# `$.blend` и `$.viewport` — смешивание и render target

Подсистема закрывает две задачи из аудита API:

* **режимы смешивания** спрайтов и треугольников — `alpha`, `add`, `multiply`,
  `none` (аналог `CanvasItem.blend_mode` в Godot);
* **render target / подвьюпорт** — рисование в offscreen-текстуру (мини-карта,
  портал, превью). **Поддержан**: `$.viewport.bind(id)` привязывает текстуру на
  кадр, `$.viewport.sprite(id)` отдаёт спрайт прошлого кадра, а рисовать его
  нужно через `$.gfx.draw.sprite(...)` — подробности в §3.

```js
$.ready(() => {
    // Режим по умолчанию для всего, у чего не задан node.blend_mode.
    $.blend('alpha');

    $('<player>', { id: 'hero' }).at(200, 300).blend('alpha').appendTo($.world);
    $('<rect>', { id: 'glow' }).at(400, 300).size(120, 120)
        .color('#ff8844aa').blend('add').appendTo($.world);
    $('<rect>', { id: 'shadow' }).at(600, 300).size(120, 120)
        .color('#556677').blend('multiply').appendTo($.world);
    $('<rect>', { id: 'mask' }).at(600, 450).size(120, 60)
        .color('#ffffff').blend('none').appendTo($.world);
});
```

---

## 1. Режимы смешивания

| Режим | Формула | Смысл |
|---|---|---|
| `alpha` | `src * src.a + dst * (1 - src.a)` | обычная прозрачность, **по умолчанию** |
| `add` | `src + dst` | свет, вспышки, огонь, лучи |
| `multiply` | `src * dst` | затемнение, цветные линзы |
| `none` | `src` | запись поверх без смешивания: маска, трафарет |

Порядок режимов зафиксирован в трёх местах и **обязан совпадать**: `R2DBlendMode`
в `src/render.h`, массив конвейеров `R2DRenderer.pipelines` в `src/render.c` и
`BLEND_NAMES` в `src/highlevel/render.js` (`alpha=0, add=1, multiply=2, none=3`).
Индекс режима — это индекс конвейера.

### Как выбирается режим

Приоритет ровно один — как у `.blend()` на узле:

1. `node.blend_mode`, если он задан (`.blend('add')` на узле, `{ blend: 'add' }`
   в декларации тега, поле `blend_mode` в снимке);
2. иначе — режим по умолчанию из `$.blend(name)` / `$.gfx.blend(name)`.

Отрисовщики подсистем (`$.gfx.push.sprite(..., blend)`) могут передать режим
явно или положиться на общий. Для залитых прямоугольников и линий есть
`$.gfx.white` — id белого спрайта 1×1 (его тонируют цветом в `push.sprite`).

### `$.blend(name)`

```js
$.blend();          // → 'alpha' — текущий режим по умолчанию
$.blend('add');     // → 'add'   — поставить режим
$.blend('screen');  // → прежний — неизвестное имя, предупреждение в лог
```

Без аргумента — геттер. С аргументом — сеттер и **тонкая обёртка** над
`$.gfx.blend(name)`: вся валидация и хранение значения живут в `render.js`,
здесь дублирования нет. Неизвестное имя не меняет текущий режим и один раз
пишет предупреждение со списком доступных.

`node.blend_mode` выставляется методом узла `.blend(mode)` в `api.js`;
неизвестное имя там откатывается в `alpha`.

---

## 2. Сторона C: конвейеры и пакеты

В `r2d_render_init()` создаётся **по конвейеру на каждый режим** —
`SDL_GPUGraphicsPipeline *pipelines[R2D_BLEND_COUNT]`. Отличаются они только
`blend_state` цветового таргета; вершинный вход, шейдеры, растеризация и
формат цели у всех общие.

| Режим | `enable_blend` | Цвет (src / dst / op) | Альфа (src / dst / op) |
|---|---|---|---|
| `alpha` | `true` | `SRC_ALPHA` / `ONE_MINUS_SRC_ALPHA` / `ADD` | `ONE` / `ONE_MINUS_SRC_ALPHA` / `ADD` |
| `add` | `true` | `ONE` / `ONE` / `ADD` | `ONE` / `ONE` / `ADD` |
| `multiply` | `true` | `DST_COLOR` / `ZERO` / `ADD` | `DST_ALPHA` / `ZERO` / `ADD` |
| `none` | `false` | — | — |

Значения для `alpha` в точности прежние, поэтому старые игры рисуются ровно
как раньше.

### `engine.submitSprites(transforms, colors, count?, blend?)`

Необязательный **четвёртый** аргумент — строка режима. Он относится ко **всему
пакету**: батч рисуется одним конвейером, поэтому `render.js` сам режет кадр на
непрерывные участки с одинаковым режимом и делает несколько `submitSprites`.
Порядок спрайтов при этом не меняется — режим переключается только там, где
он реально сменился.

Без четвёртого аргумента поведение прежнее (`alpha`). Неизвестная строка один
раз ругается в лог и трактуется как `alpha`.

### `engine.submitTriangles(vertices, count?, blend?)`

Треугольники тоже принимают третьим аргументом имя режима. Каждый вызов
запоминается отдельным диапазоном (структура `R2DTriBatch`): при выводе
треугольники идут после спрайтов, каждый диапазон со своим конвейером.

Режим смешивания у треугольника задаётся на вызов:
`$.gfx.push.triangle(x1, y1, x2, y2, x3, y3, color, blend)`; без режима — `alpha`.

### Отрисовка

`r2d_render_draw()` идёт по командам и объединяет соседние в один draw call,
пока не сменится **текстура или режим смешивания**. При смене режима
привязывается соответствующий конвейер (и заново — вершинный/индексный
буферы). Треугольники рисуются последними тем же способом.

Реализация биндингов — в `src/render.c`; `script.c` не правится:
`r2d_render_register_js()` вызывается последним при сборке `engine` и
переопределяет `submitSprites`/`submitTriangles`, добавляя аргумент режима.

---

## 3. `$.viewport` — render target

Render target **работает**: кадр можно увести в offscreen-текстуру и нарисовать
её на экране — так делают мини-карту, портал и превью.

| Метод | Поведение |
|---|---|
| `$.viewport.supported` | `true`, когда сборка собрана с render target |
| `$.viewport.create(w, h)` | создать текстуру, вернуть `id` (или `null`) |
| `$.viewport.destroy(id)` | удалить текстуру (связывание снимается само) |
| `$.viewport.size(id)` | `{ w, h }` или `null` |
| `$.viewport.bind(id)` / `unbind()` / `bound()` | связать кадр с текстурой / вернуть в swapchain / что связано |
| `$.viewport.sprite(id)` | спрайт **прошлого** кадра (его рисует игра) |
| `$.viewport.draw(id, x, y, w, h, opts)` | нарисовать прошлый кадр как спрайт |
| `$.viewport.count()` | сколько текстур создано |

Без render target в сборке (`supported === false`) методы возвращают
безопасные значения: `create` → `null`, `bind`/`destroy` → `false`, `size` →
`null`, а в журнал уходит строка с причиной. Ни один вызов не бросает
исключение: игра на слабой сборке продолжает работать.

### 3.0. Свет: узел `<light>` и `$.gfx.draw.glow`

Свет рисуется радиальным градиентом: цвет и альфа заданы на каждой вершине,
поэтому пятно гладкое на любом радиусе (раньше кольца были видны полосами).

```js
// Источник света: обычно аддитивно, чтобы складывался с другими.
$('<light>', { radius: 235, intensity: 1, color: '#ffbe73', falloff: 2.2 })
    .at(x, y).blend('add').alpha(0.5).appendTo($.world);

// Разовое свечение в мировых координатах (вспышка, аура, блик).
$.gfx.draw.glow(x, y, 160, '#ffd9a0', { blend: 'add', falloff: 2.4 });
```

| Поле | Значение |
|---|---|
| `radius` | радиус пятна в пикселях мира |
| `intensity` | множитель яркости, 0..2 |
| `color` | цвет света |
| `falloff` | степень затухания к краю (2 по умолчанию) |
| `inner` | доля радиуса, где яркость ещё полная |
| `segments`, `rings` | плотность сетки градиента (26 и 7 по умолчанию) |
| `.alpha()` | общая прозрачность источника |
| `.blend('add')` | аддитивное смешивание — обычный выбор для света |

Важно про `add`: альфа вершины ослабляет вклад (`SRC_ALPHA, ONE`). До этой
правки режим складывал цвет как есть, и полупрозрачное свечение выжигало кадр
в белое.

Теней у `<light>` раньше не было: полигоны видимости жили отдельно
(`engine.light.visibility`, демо `light`). Теперь свет умеет тени, конус и
площадной источник — см. §3.0.1–3.0.3.

### 3.0.1. Тени, конус и площадной свет (стиль Candle)

Свет с `.shadows(true)` перестаёт быть плоским пятном: из центра выпускаются
лучи, каждый упирается в ближайшее препятствие, и по этим расстояниям строится
концентрический веер. Градиент остаётся мягким, а кромка тени — резкой.

```js
// Препятствия: коробки, отрезки, готовая геометрия тайлмапа.
$.gfx.light.occluders([
    { x: 400, y: 200, w: 32, h: 200 },   // x/y — левый верхний угол
    { cx: 700, cy: 300, w: 40, h: 40 },  // или центр
    [100, 500, 300, 500],                // или отрезок [x1,y1,x2,y2]
]);

$('<light>', { radius: 320, color: '#ffd9a0' })
    .at(200, 300).blend('add')
    .shadows(true)          // тени от препятствий
    .cone(70, 0.3)          // конус 70°, растушёвка кромки 30 % полуугла
    .rotate(0)              // направление конуса — угол узла (0° = вправо)
    .occluders([{ x: 380, y: 280, w: 24, h: 60 }])   // свои препятствия
    .flicker(0.18, 9)       // дрожание, как у свечи
    .appendTo($.world);
```

| Метод / поле | Значение |
|---|---|
| `.shadows(on)` | тени от препятствий; по умолчанию выключены |
| `.cone(deg, soft)` | угол конуса в градусах (0 — полный круг) и растушёвка кромки 0..1 |
| `.occluders(list)` | препятствия только этого источника (плюс общий реестр) |
| `.flicker(amount, speed)` | детерминированное мерцание, доля яркости и скорость |
| `.shadowSoft(rays)` | мягкая кромка тени: 1 — резкая (как было), 2..8 — подлучи и полутень |
| `.punch(on)` | рисовать свет поверх тумана и темноты (§3.0.3) |
| `angle` / `.rotate(deg)` | направление конуса |

Площадной источник — отдельный тег: свет идёт не из точки, а из полосы (окно,
лампа дневного света, костёр), поэтому тени считаются из нескольких точек.

```js
$('<lightarea>', { radius: 150, intensity: 0.9, color: '#88bbff',
                   samples: 4, shadows: true })
    .at(620, 480).size(220, 12).blend('add').appendTo($.world);
```

| Поле | Значение |
|---|---|
| `radius`, `intensity`, `color`, `falloff`, `inner` | как у `<light>` |
| `samples` | сколько точек вдоль площадки (1..8, по умолчанию 3) |
| `shadows` | считать тени из каждой точки |
| `core` | рисовать саму полосу источника (`true` по умолчанию) |
| `.size(w, h)` | размер площадки; свет идёт вдоль длинной стороны |

Реестр препятствий и утилиты:

| Вызов | Назначение |
|---|---|
| `$.gfx.light.occluders(list)` | заменить реестр; возвращает число отрезков |
| `$.gfx.light.addOccluders(list)` | добавить к реестру |
| `$.gfx.light.clearOccluders()` | очистить |
| `$.gfx.light.count()` / `.segments()` | сколько отрезков / копия списка |
| `$.gfx.light.tiles(cols, rows, isSolid, { cell, x, y })` | рёбра непроходимых тайлов: внутренние рёбра стен пропускаются |
| `$.gfx.light.polygon(x, y)` | точный полигон видимости из C (`engine.light.visibility`) |
| `$.gfx.light.debug(true)` | показать препятствия красными отрезками |
| `$.gfx.light.ambient({ level, color })` | темнота поверх кадра; свет с `.punch(true)` её прорезает (§3.0.3) |
| `$.gfx.light.ambient.off()` / `.params()` / `.on()` | выключить / параметры / включена ли |
| `$.gfx.light.map({ on, intensity, soft })` | световая карта: свет в отдельной текстуре (§3.0.5) |
| `$.gfx.light.mapSupported()` | доступна ли карта света в этой сборке |
| `$.gfx.light.stats()` / `.resetStats()` | счётчики за кадр: посчитано границ, из кэша, отрезков в радиус (§3.0.4) |

### 3.0.2. Туман

Два способа: узел `<fog>` (прямоугольник в мире) и экранный слой
`$.gfx.fog({...})`. Оба рисуются дрейфующими полосами с мягкими краями; туман
идёт поверх сцены, но под интерфейсом.

```js
$('<fog>', { color: '#8899bb', density: 0.4, layers: 4 })
    .at(400, 300).size(800, 600).appendTo($.world);

$.gfx.fog({ color: '#8899bb', density: 0.25, layers: 5, ground: 0.6 });
$.gfx.fog.off();              // выключить экранный туман
$.gfx.fog.params();           // текущие параметры (или null)
```

| Поле | Значение |
|---|---|
| `color`, `density` | цвет и плотность (0..1); альфа узла умножает плотность |
| `layers` | число полос (1..12): больше — мягче, но дороже |
| `speed`, `amp` | скорость и размах вертикального дрейфа |
| `thickness` | толщина полосы относительно шага |
| `ground` | приземность: 0 — ровная дымка, 1 — гуще внизу |

Свет с `.punch(true)` рисуется **после** тумана: фонарь «прорезает» дымку,
а не тонет в ней. Порядок внутри кадра: сцена → `<fog>`-узлы → экранный
`$.gfx.fog()` → темнота (§3.0.3) → свет с `.punch(true)` → примитивы
`$.gfx.draw.*`.

### 3.0.3. Темнота: `$.gfx.light.ambient`

Свет в движке аддитивный: им можно только добавить яркости, а сделать темнее
(ночь, подвал, пещера) — нельзя. Для этого отдельный слой: multiply по всему
кадру, который рисуется после мира, тумана и свечения фонарей.

```js
$.gfx.light.ambient({ level: 0.65, color: '#0a1020' });  // сумерки
$.gfx.light.ambient({ level: 0.9 });                     // почти ночь
$.gfx.light.ambient.params();   // { level, color } или null
$.gfx.light.ambient.off();
```

| Поле | Значение |
|---|---|
| `level` | 0 — не гасит ничего, 1 — кадр умножается ровно на `color` |
| `color` | цвет темноты (по умолчанию чёрный); синеватый даёт «ночь» |

Свет с `.punch(true)` рисуется **после** темноты, поэтому фонарь её прорезает —
это и есть ночной город: всё в полутени, а лампы светят в полную силу.

### 3.0.4. Производительность: кэш границ и отсечение

Границы теней зависят от положения источника, веера, набора препятствий и
версии реестра — но **не от камеры**. Поэтому узел кэширует посчитанные
границы: статический фонарь считается один раз, а не каждый кадр. Площадной
свет кэширует каждую свою точку отдельно, а `.shadowSoft(n)` на статическом
свете тоже считается один раз.

Препятствия разложены по клеткам (128 единиц мира), и свету достаются только
те отрезки, что попали в его радиус, — а не весь реестр уровня. Индекс
перестраивается только при изменении реестра.

```js
$.gfx.light.stats();
// { built, cached, culled, considered, rays, cells, occluders } — за кадр
$.gfx.light.resetStats();
```

`built` — сколько границ реально посчитано за кадр, `cached` — сколько взято
готовыми, `culled`/`considered` — сколько отрезков дошло до света из всех, что
лежат в реестре. У прогретой статичной сцены `built` должен быть нулём.

**Ограничения.** Тени считаются трассировкой лучей по отрезкам в JS, а не
точным полигоном видимости: на 96 образцах и сотнях отрезков это заметно, но
предсказуемо. Кромка тени по умолчанию резкая, `.shadowSoft(n, deg)` даёт
полутень ценой n-кратного числа лучей. Свет и туман — треугольники, а они в
движке рисуются после спрайтов кадра и **до** интерфейса (HUD не
засвечивается и не затемняется темнотой). Дрожание (`flicker`)
детерминировано от игрового времени, поэтому повторяется в `--fixed-dt`
прогоне.

### 3.0.5. Световая карта: `$.gfx.light.map`

Свет — аддитивные треугольники, и если рисовать их прямо в сцену, порядок света
начинает зависеть от порядка сцены. Световая карта это снимает: свет копится в
отдельной текстуре половинного разрешения и накладывается на сцену **одним**
полноэкранным проходом.

```js
$.gfx.light.map({ on: true, intensity: 1, soft: 2 });  // включить
$.gfx.light.map({ soft: 0 });                          // без размытия
$.gfx.light.map();        // { on, intensity, soft } — текущее состояние
$.gfx.light.map({ on: false });
$.gfx.light.mapSupported();   // есть ли карта света в сборке
```

| Поле | Значение |
|---|---|
| `on` | включена ли карта света (по умолчанию выключена) |
| `intensity` | множитель силы света в композите (1 — как раньше) |
| `soft` | сила размытия карты: 0 — только половинное разрешение, 2..4 — мягкая кромка |

Что это меняет:

- **Порядок.** Свет всегда ложится поверх мира, тумана и темноты (§3.0.3), до
  интерфейса. `.punch(true)` при включённой карте не нужен — он становится
  no-op.
- **Кромка.** Половинное разрешение с линейной фильтрацией само даёт мягкость,
  а `soft` добавляет к ней размытие: полутень получается без `.shadowSoft()` и
  без лишних лучей.
- **Пиксели.** Картинка остаётся прежней: на тестовой сцене яркость освещённой
  точки 207 до и после включения.

Порядок проходов в кадре: свет → текстура света (+ размытие) → проход сцены →
композит света → интерфейс. В render target игры (см. §3.2) карта света не
применяется: формат чужой цели может не совпасть с полноэкранными конвейерами,
поэтому свет в этом случае рисуется прямо в сцену, как раньше.

**Ограничения.** Световая карта стоит одного дополнительного прохода и двух
текстур половинного разрешения; при `soft > 0` добавляются ещё два прохода
размытия. Свет в карте считается по пикселям, а не по объектам, поэтому
наложение света на свет в полупрозрачных местах складывается так же аддитивно,
как и раньше.

### 3.1. Пост-обработка: offscreen-проход

С появлением `$.gfx.post` в движке появился offscreen-проход: движок рисует
сцену в текстуру формата swapchain, а на экран накладывает её полноэкранным
проходом с эффектами.

```
main.c (когда пост включён):
    pass 1: сцена  → offscreen-текстура (формат swapchain)
    pass 2: bloom  → текстура половинного разрешения (порог яркости)
    pass 3: bloom  → размытие по горизонтали
    pass 4: bloom  → размытие по вертикали
    pass 5: post   → swapchain (полноэкранный треугольник + эффекты,
                    свечение берётся из готовой размытой текстуры)
            UI     → swapchain (HUD метится engine.markUI и не затемняется)
```

Проходы свечения идут, только когда `glow > 0` и пост включён: без свечения
кадр стоит те же два прохода, что и раньше.

| Метод | Назначение |
|---|---|
| `$.gfx.post({...})` | задать параметры и включить пост |
| `$.gfx.post()` | текущие параметры (или `null`, если сборка без поста) |
| `$.gfx.postOff()` | выключить пост (кадр идёт прямо в swapchain) |
| `$.gfx.postSupported()` | поднялся ли пайплайн поста |
| `$.gfx.postPreset(name, {ms})` | готовый набор камерных эффектов |
| `$.gfx.postPresets()` | список имён пресетов |

Параметры: `glow` (сила свечения), `bloom_threshold` (порог яркости для него,
по умолчанию `0.75`), `bloom_radius` (толщина ореола, по умолчанию `1`),
`vignette` (затемнение краёв), `chromatic` (расхождение каналов), `grain`
(зерно), `scanline` (скан-линии) и линза — `lens`, `centerX`, `centerY`,
`radius` (экранное искажение UV по полю `~1/r²`). Линза и хроматика — основа
«чёрной дыры» и взрывной волны.

**Свечение — честный bloom**: яркий проход (порог с мягким коленом) в
половинном разрешении, затем два разделяемых размытия и композит. Раньше это
были восемь выборок в одном проходе: ореол не размывался, поэтому свет
выглядел как контур. Признак «свечение посчиталось проходами» виден из
`engine.getPost().bloom_ready` и `engine.renderInfo()`
(`{ post, bloom, bloom_ready, bloom_w, bloom_h, scene_w, scene_h, passes, draws }`).

```js
$.gfx.post({ vignette: 0.3, glow: 0.25 });          // атмосфера
$.gfx.post({ lens: 1.1, centerX: 0.5, centerY: 0.5 });  // воронка
$.gfx.postOff();
```

**Камерные пресеты** — это те же параметры, собранные в наборы: от тёплого
«мультика» до хоррора. Переключение может быть мгновенным или плавным
(`{ ms: 800 }` — переход длится указанное время и считается в кадре).

| Пресет | Что делает |
|---|---|
| `adventure` | тёплая сочная картинка, мягкое свечение — «как в хорошем аниме» |
| `forest_night` | холодная ночь, вигнетка, зерно |
| `horror` | почти ч/б, контраст, зерно, скан-линии, кровь по краям |
| `bloodmoon` | хоррор, залитый красным |
| `retro` | постеризация и скан-линии |
| `noir` | чёрно-белый с контрастом |
| `dream` | сильное свечение, мягкий контраст |
| `neutral` | всё по нулям |

```js
$.gfx.postPreset('forest_night');                 // мгновенно
$.gfx.postPreset('bloodmoon', { ms: 90 });        // рывок на удар
$.gfx.postPreset('forest_night', { ms: 900 });    // плавный возврат
```

Параметры кадра (`$.gfx.post({...})`): `glow`, `bloomThreshold`, `bloomRadius`,
`vignette`, `chromatic`, `grain`, `scanline`, `lens` + `centerX`/`centerY`/`radius`,
`posterize` (уровни квантования цвета), `saturation`, `contrast`, `brightness`,
`tint: [r, g, b]` и `tintAmount` (сдвиг оттенка), `blood` (красная пелена по
краям — для урона).

Важные детали:

* свечение — **отдельные проходы** (bright-pass + два размытия); если буферы
  не создались, движок откатывается на прежний однопроходный вариант с восемью
  выборками и сообщает об этом в журнал;
* переход между пресетами идёт по кадру, а `$.gfx.post()` поверх пресета
  трогает только те поля, что игра передала (так вспышка выстрела живёт
  поверх любого пресета);

---

### 3.2. Render target игры

С 0.2 у игры есть своя offscreen-текстура: `$.viewport`. Кадр рисуется в неё,
а на экран движок показывает его блитом.

```
main.c (когда viewport связан):
    pass 1: сцена  → текстура viewport'а (target)
    pass 2: блит   → swapchain (кадр на экране)
    pass 3: блит   → текстура истории (её игра читает в следующем кадре)
            UI     → swapchain (поверх кадра)
```

| Метод | Назначение |
|---|---|
| `$.viewport.supported` | поддержан ли render target в сборке |
| `$.viewport.create(w, h)` | создать текстуру, → `id` или `null` |
| `$.viewport.destroy(id)` | удалить текстуру |
| `$.viewport.size(id)` | `{ w, h }` |
| `$.viewport.bind(id)` | рисовать кадр в эту текстуру (`bind(null)` — вернуть) |
| `$.viewport.bound()` | какая текстура связана сейчас (`id` или `null`) |
| `$.viewport.sprite(id)` | спрайт **прошлого** кадра — его игра рисует сама |
| `$.viewport.draw(id, x, y, w, h, opts)` | нарисовать прошлый кадр как спрайт |
| `$.viewport.count()` | сколько текстур создано |

```js
const trail = $.viewport.create(800, 600);
$.ready(() => {
    $.viewport.bind(trail);                    // каждый кадр заново: привязка на кадр
    $.gfx.draw.sprite($.viewport.sprite(trail), 0, 0, 800, 600, { alpha: 0.9 });
    // ... обычная сцена
});
```

Почему текстур две (target и history): если рисовать в ту же текстуру, из
которой читаешь, получается неопределённый результат — на GPU это запрещено.
Движок после кадра копирует target в history, поэтому игра всегда читает
прошлый кадр, а рисует в текущий.

Ограничения (честно):

* это **цель всего кадра**, а не произвольный проход посреди кадра: начать
  второй render pass из JS посреди списка команд нельзя (кадр рисуется одним
  проходом, а проходы открывает main.c);
* пока viewport связан, **пост-обработка не применяется** — кадр показывается
  блитом как есть;
* размер текстуры не обязан совпадать с окном: блит растягивает её на экран;
* `begin/end` и `capture` из прежней заглушки остались неподдержанными: их
  семантика — «нарисовать кусок сцены в текстуру», а это и есть тот самый
  второй проход посреди кадра.

---

### 3.3. Свои шейдеры узла

`.shader()` понимает не только встроенные эффекты: `$.gfx.defineShader(имя,
исходник)` компилирует фрагментный шейдер прямо в игре.

```
исходник игрока
      │  + шапка движка (#version, привязки)
      ▼
   glslang ──► SPIR-V ──► spirv-cross ──► MSL (для Metal)
      │                        │
      └────────┬───────────────┘
               ▼
     SDL_CreateGPUShader + конвейеры на все 4 режима смешивания
```

Компиляторы (`glslang` и `SPIRV-Cross`) уже собираются как зависимости проекта
— ими же `cmake/Shaders.cmake` собирает встроенные шейдеры на этапе сборки;
в рантайме те же библиотеки линкуются в движок. Сборка без них —
`-DR2D_ENABLE_LIVE_SHADERS=OFF`: тогда `$.gfx.shadersSupported()` вернёт
`false`, а `.shader()` останется только со встроенными эффектами.

Контракт шейдера фиксирован (шапка — `$.gfx.shaderPreamble()`):

| Что | Где | Зачем |
|---|---|---|
| `sampler2D u_texture` | set 2, binding 0 | текстура спрайта узла |
| `NodeParams { vec4 p; vec4 c; } u` | set 3, binding 0 | `u.p = (0, p1, p2, p3)`, `u.c` — цвет из `.shader(имя, { color })` |
| `v_texcoord`, `v_color` | location 0, 1 | UV внутри спрайта и цвет узла |
| `o_color` | location 0 | результат |

```js
$.gfx.defineShader('heat', `
    void main() {
        vec4 c = texture(u_texture, v_texcoord) * v_color;
        float w = sin(v_texcoord.y * u.p.y + u.p.z) * u.p.x;
        o_color = texture(u_texture, v_texcoord + vec2(w, 0.0)) * v_color;
    }`);
$('#lava').shader('heat', { p1: 0.01, p2: 30, p3: $.time.now() * 2 });
```

Ограничения честные:

* компилируется только фрагментный шейдер: вершинный общий (спрайтовый
  конвейер), у шейдера один сэмплер и один блок параметров;
* DXIL (Windows/D3D12) не генерируется — нужен DXC, поэтому на D3D12 свой
  шейдер не создастся, и об этом будет строка в журнале;
* компиляция синхронная и занимает десятки миллисекунд: регистрируйте шейдеры
  при загрузке уровня или экрана, а не в игровом цикле;
* ошибка компиляции возвращает `false` и текст glslang в `$.gfx.shaderError()`
  — игра может показать его прямо на экране.

---

### 3.4.1. Порядок внутри интерфейса: подложка → подпись

В ui-слое текст рисуется **поверх подложек** — независимо от того, в каком
порядке узлы созданы:

```js
$('<ui.panel>', { x: 640, y: 360, w: 500, h: 160, color: '#0b0d10e0' }).appendTo($.ui);
$('<ui.label>', { x: 640, y: 360, text: 'Убит', size: 40, align: 'center' }).appendTo($.ui);
// «Убит» будет виден: подпись ложится поверх панели.
```

Так было не всегда. Подложки (`ui.panel`, `ui.bar`, `ui.button`, `ui.image`)
копятся в батче спрайтов и уходят в C одним пакетом в конце слоя, а
`ui.label` рисовался сразу — то есть **раньше** своей подложки, и она его
закрашивала. В HUD это не замечалось: подписи стояли вне панели. Всплыло на
экране исхода в игре: узлы есть, `visible = true`, а на кадре пусто
(проверка: tests/agent/highlevel_ui_text_over_panel_test.py).

Теперь текст ui-слоя откладывается и выполняется после `submitSprites()`, в
том же ui-диапазоне, — поэтому HUD по-прежнему рисуется поверх
пост-обработки.


## 4. Как устроен пользовательский render target

Render pass открывает `main.c`, и `r2d_render_draw(renderer, pass)` получает его
уже открытым — но привязанная игра текстура имеет приоритет над пост-обработкой:
кадр уходит в неё, а на экран попадает отдельным блитом
(`r2d_render_viewport_present`), при этом HUD рисуется в проходе поверх.

```
main.c:
    r2d_render_upload(renderer, cmd)              // copy pass — заливка VB/IB
    target.texture = swapchain
    pass = SDL_BeginGPURenderPass(cmd, &target, 1, NULL)
        r2d_render_draw(renderer, pass)           // спрайты и треугольники
        r2d_debug_ui_draw(debug, cmd, pass)
    SDL_EndGPURenderPass(pass)
```

Отсюда три ограничения: (1) вложить новый render pass в уже открытый нельзя;
(2) смена цели — это не смена viewport, а новый `SDL_BeginGPURenderPass` с
другой текстурой; (3) команды `viewport.begin()/end()` приходят из JS во время
сборки кадра, когда буфер команд и проход уже заняты.

Чтобы сделать честно, нужно (следующим шагом, с правкой `main.c` — это
интеграционная точка, не файл подсистемы):

1. **Offscreen-текстуры.** Добавить в `R2DRenderer` пул текстур с
   `SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER` (сейчас
   `r2d__create_texture` умеет только `SAMPLER`). Формат — как у swapchain,
   тогда существующие конвейеры переиспользуются; иначе понадобятся отдельные
   конвейеры на формат.
2. **Список команд вместо прямых вызовов.** `viewport.begin(id)` /
   `end()` / `draw(...)` из JS должны не открывать проход сразу, а записывать
   команды в массив рендерера: `{ target, clear, диапазон батча }`. Тогда
   `submitSprites`/`submitTriangles` получают ещё и «текущую цель», и
   `r2d_render_draw` режет батч не только по текстуре/режиму, но и по цели.
3. **Пред-проход до swapchain.** Новый вызов уровня `main.c`
   (`r2d_render_draw_targets(renderer, cmd)`), который **до** основного
   `SDL_BeginGPURenderPass` проходит по списку целей: для каждой —
   `SDL_BeginGPURenderPass` по offscreen-текстуре, заливка нужного диапазона
   батча, `SDL_EndGPURenderPass`. Порядок: сначала все offscreen-проходы,
   потом основной (порталы могут ссылаться друг на друга — нужен порядок
   зависимостей).
4. **Регистрация результата как спрайта.** `$.viewport.draw(id, x, y, w, h, alpha)`
   рисует текстуру буфера обычным спрайтом: завести `R2DTexture`/`R2DSprite`
   поверх offscreen-текстуры (sampler-биндинг) и добавить команду в батч.
5. **`capture(id)`.** Чтение пикселей — это `SDL_DownloadFromGPUTexture` +
   transfer-буфер + fence после отправки кадра. Синхронно в том же кадре не
   выйдет, поэтому контракт должен быть «снимок прошлого кадра» либо
   `await`-хелпер; это отдельное решение по API.
6. **Согласовать с `r2d_gui_render` (RmlUi).** Они открывают собственные проходы
   после основного; offscreen-проходы обязаны идти до них.

Когда это будет сделано, `$.viewport` из подсистемы станет тонкой обёрткой над
`engine.viewport` (проверка `supported === true` уже стоит в `viewport.js`) —
менять её интерфейс не придётся.

---

## 5. Фильтрация спрайтов

```js
$.gfx.filter(true);    // линейная: сглаженный масштаб
$.gfx.filter(false);   // nearest: пиксель-арт (по умолчанию)
$.gfx.filter();        // текущий режим
```

По умолчанию спрайты берутся с фильтром **nearest** — это то, что нужно
пиксель-арту: при увеличении пиксели остаются квадратными. Линейная фильтрация
сглаживает края; она нужна, когда картинка масштабируется сильно (крупные
спрайты, зум камеры, растянутые панели) или когда спрайт — не пиксель-арт.

Режим **глобальный**: он выбирает сэмплер для прохода отрисовки. Смена режима
разрывает участок склейки команд, поэтому переключать его на каждом узле — плохая
идея; ставьте один режим на кадр.

## 5.1. Обрезка (scissor)

```js
$.gfx.clip(0, 0, 400, 300);      // обрезать всё, что рисуется дальше
$.gfx.draw.rect(...);            // попадёт внутрь обрезки
$.gfx.clipOff();                 // снять

$('#panel').clip({ x: 400, y: 100, w: 200, h: 150 });   // обрезать узел
$('#panel').clip(true);          // по своей коробке
$('#panel').clip(false);         // снять
```

Обрезка — это **scissor** (`SDL_SetGPUScissor`), то есть обрезка пикселей, а не
геометрии. До неё в движке не было **ни одной** обрезки, из-за чего не работали
прокрутка списка, портрет в рамке и миникарта.

**Обрезка действует на КОМАНДУ, а не на кадр.** Батч кадра один, но каждый
спрайт помнит свой прямоугольник (`R2DDrawCmd.clip`), и `submitSprites` рвёт
отправку по клипу, выставляя scissor перед участком. Поэтому разные узлы одного
кадра обрезаются **по-разному** — это проверено тестом.

**Где вызывать `$.gfx.clip`.** Клип ставится в `$.render(fn)` или
`scene.render` — они идут **до** сбора кадра. Сброс обрезок стоит в начале кадра
отрисовки (в `setRender`), а **не** в `_render`: иначе клип, поставленный игрой
перед отрисовкой, стирался бы перед самым рисованием.

**Обрезка узла не наследуется детьми.** Дети — отдельные узлы, и клип
возвращается как был сразу после узла (поэтому сосед не обрезается). Для
контейнера прокрутки поставьте обрезку каждому ребёнку — или используйте
`<ui.scroll>`, у которого обрезка своя.

| Вызов | Смысл |
|---|---|
| `$.gfx.clip(x, y, w, h)` | обрезать прямоугольником экрана |
| `$.gfx.clip({x, y, w, h})` | то же объектом |
| `$.gfx.clip(node)` | по экранному прямоугольнику узла (главная камера) |
| `$.gfx.clipOff()` | снять |
| `$.gfx.clipRect()` | действующая обрезка или `null` |
| `$.gfx.clipCount()` | сколько разных обрезок в кадре |
| узел `.clip(...)` | обрезка одного узла |

Нулевой или отрицательный размер = снятие обрезки. Больше 256 разных обрезок за
кадр — лишние игнорируются с предупреждением в журнал (кадр не роняется).

---

## 5.2. Наследование от родителя

Дети в `$` — отдельные узлы **плоского** реестра, поэтому раньше скрытый
контейнер не скрывал содержимое, а прозрачность родителя на детей не влияла:
гасишь панель — надписи остаются. Теперь эффективные значения считаются по
цепочке `parent_node`:

| Что | Как считается |
|---|---|
| видимость | скрыт ЛЮБОЙ предок → не виден никто из его детей |
| прозрачность | произведение `alpha` по цепочке: 0.5 × 0.5 = 0.25 |
| глубина | складывается с родителем, если узел помечен `.depthRelative(true)` |

```js
$('#panel').hide();               // исчезнет и содержимое
$('#panel').alpha(0.5);           // содержимое станет полупрозрачным
$('#hud').depthRelative(true).depth(10);   // поднять всю панель на 10
```

Глубина по умолчанию **абсолютная** — как было: узел сравнивается с другими по
своему `depth`. Относительная нужна, чтобы поднять контейнер одним вызовом, не
пересчитывая детей: их глубина сложится с родительской.

Отладка: `$.gfx.effectiveAlpha(node)`, `$.gfx.effectiveVisible(node)`,
`$.gfx.effectiveDepth(node)`, а у узла — `.effectiveAlpha()`,
`.effectivelyVisible()`, `.effectiveDepth()`.

**Считается на ходу, без кэша**: цепочки короткие, а кэш пришлось бы сбрасывать
при каждом изменении любого предка.

## 6. Ограничения

* Режим смешивания — свойство **пакета**, а не отдельного спрайта: JS группирует
  подряд идущие спрайты, из-за чего смена режима добавляет draw call. Для
  «шахматного» чередования режимов пакетов будет много — группируйте сами.
* `none` пишет цвет без смешивания, включая альфу: прозрачные пиксели
  источника затирают назначение. Это осознанное определение режима.
* Треугольники, как и раньше, рисуются **после** спрайтов кадра (свет поверх
  сцены), поэтому их режим не влияет на порядок относительно спрайтов.
* Render target поддержан, но привязанная текстура отключает пост-обработку —
  её считали бы по чужой текстуре (см. §3–4).

---

## 7. Тесты

| Что | Файл | Запуск |
|---|---|---|
| Нормализация режимов, приоритет `node.blend_mode`, нарезка на участки, ошибка render target | `tests/js/viewport_test.mjs` | `build/_deps/quickjs-build/qjs tests/js/viewport_test.mjs` |
| Кадр со всеми четырьмя режимами рисуется, `$.viewport` объясняет отказ | `tests/agent/highlevel_render_test.py`, фикстура `tests/fixtures/render/` | `python3 tests/agent/highlevel_render_test.py` (после сборки) |

---

## 8. Файлы

| Файл | Что там |
|---|---|
| `src/render.h` | `R2DBlendMode`, `pipelines[]`, поля `blend` у команд и диапазонов треугольников |
| `src/render.c` | конвейеры по режимам, `blend_state`, переключение в `r2d_render_draw`, биндинги `submitSprites`/`submitTriangles`, обрезка (scissor) и `engine.viewport` |
| `src/highlevel/viewport.js` | `$.blend`, `$.viewport`, чистые хелперы `normalizeBlend`/`nodeBlendMode`/`resolveBlend`/`blendRuns` |
| `tests/fixtures/render/` | фикстура агентского теста |
