Files
fparkan/docs/reference/environment-effects.md
T

349 lines
24 KiB
Markdown
Raw Normal View History

# Эффекты окружения
Погода в Parkan состоит из двух связанных частей. Файл `sky.ske` задаёт
расписание: когда начинается дождь или снег, как меняются интенсивность и цвет,
какие имена ресурсов принадлежат активному интервалу. Таблица `sky.wea` задаёт
материалы, которыми эти осадки рисуются. `fparkan-fx::environment` соединяет
оба входа с текущей камерой и возвращает один `EnvironmentFrame`.
```text
sky.ske
-> AtmosphereFrame: время, цвет, интенсивность, ссылки на ресурсы
sky.wea
-> SkyMaterials: имена материалов строк 7 (снег) и 8 (дождь)
камера + EnvironmentSystem
-> EnvironmentFrame: мировые частицы, экранные квадраты, гром и дождевой loop
```
Модуль не владеет графическим устройством или звуковой картой. Renderer
разрешает имя материала и отправляет геометрию в прозрачный проход, а audio
backend разрешает архив и имя звука. Это позволяет одной и той же модели
погоды работать в preview и в игровом цикле.
## Входы и границы
Для расписания используется такой вызов:
```rust
let frame = environment.update_atmosphere_with_materials(
dt_seconds,
&atmosphere_frame,
&sky_materials,
camera,
);
```
`AtmosphereFrame` передаёт ссылки из стартового ключа активного интервала.
Для дождя первая ссылка используется как звук фонового loop. Строка вида
`archive/name` разделяется на архив и имя. У простой строки `name` архив
остаётся пустым: звуковой владелец подставляет библиотеку, выбранную миссией.
Имя не заменяется глобальным именем из AutoDemo.
`SkyMaterials` хранит разобранную таблицу, поэтому строки 7 и 8 являются
положением в формате исходной игры, а не зашитыми названиями. В AutoDemo там
находятся `SNOWFLAKE` и `RAIN_DROP`; миссия может передать другие имена. Если
активной погоде не назначен материал, частицы для неё не создаются.
На входной границе конечные значения приводятся к безопасному диапазону:
`dt_seconds` и интенсивность неотрицательны, интенсивность и каждый компонент
цвета ограничены единицей, а нечисловые значения заменяются нулём. Камера
получает конечную ортонормированную основу, FOV, aspect ratio и размер
viewport от владельца кадра.
## Объём и движение осадков
Исходный эмиттер хранит прямоугольный объём в координатах камеры. Его
эталонные параметры таковы:
```text
near = 2
far = 50
half_angle_x = 0.65
half_angle_y = 0.4875
```
Для эталонной камеры:
```text
half_x = tan(half_angle_x) * far
half_y = tan(half_angle_y) * far
origin = [near, -half_x, -half_y]
extent = [far - near, 2 * half_x, 2 * half_y]
```
Текущий FOV и aspect ratio пересчитывают две боковые грани по той же глубине:
```text
half_y = tan(vertical_fov / 2) * far
half_x = half_y * aspect_ratio
```
Количество частиц зависит от объёма и интенсивности. Вспомогательная
формула использует округление FISTP к ближайшему чётному целому:
```text
N = round_even(
clamp(current_volume / reference_volume, 0, 1)
* density
* intensity
* 1000
)
```
Текущий эмиттер передаёт `density = 1`. Публичный
`PrecipitationVolume::particle_count_for` оставляет этот множитель явным для
владельца, который хранит собственную плотность.
Порядок обновления частицы важен:
1. при создании три 15-битных значения детерминированного генератора
заполняют локальный объём;
2. локальная точка переводится через основу камеры в мировую позицию;
3. мировая позиция интегрируется с мировым вектором скорости;
4. для проверки границ позиция временно переводится обратно в локальные
координаты, и к каждой оси применяется floor-based wrap;
5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову.
Повторное обновление на том же времени имеет нулевой `dt`: позиция капли и
сохранённое начало хвоста остаются прежними. Хвост продвигается только при
положительном шаге; wrap по-прежнему сбрасывает его на голову.
Скорости не принадлежат системе координат камеры:
```text
rain = [ 0.5, 0, -60 ]
snow = [ 0.5, 0, -4 ]
```
Это мировые векторы. Поворот камеры меняет область появления, wrap и
проекцию, но не вращает уже движущуюся каплю вокруг наблюдателя. В
`EnvironmentPrimitive::Particle` `position`, `velocity`, `world_head` и
`world_tail` имеют мировые координаты.
Размер частиц состоит из коэффициента класса и масштаба погодного объекта:
```text
rain_size = scalar * 0.0065
snow_size = scalar * 0.0195
```
`scalar` вычисляется из той же проекции, которую передаёт владелец кадра:
```text
horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
scalar = viewport_width / horizontal_fov
```
Это соответствует запросу размера native camera (`Terrain` primary vtable
slot `+6c`): ширина `RECT` делится на горизонтальный FOV в радианах. Поэтому
`Camera::with_projection(...).with_viewport([width, height])` должен получать
реальный viewport каждого кадра; отдельного setter-а масштаба нет.
`ScreenBillboard` содержит мировые голову и хвост, их проекцию и четыре NDC
угла с UV. `half_size` остаётся native-величиной в пикселях, а обе глубины
хранятся отдельно; renderer не смешивает эти единицы. Дождь использует
предыдущую мировую голову как хвост и строит полосу перпендикулярно экранному
направлению движения. Снег остаётся квадратом в текущей голове, использует
исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает,
как загрузить этот квадрат и как смешать его материал.
2026-10-18 09:54:32 +04:00
## Точечный свет из opcode 1
Preview вычисляет generic Op1 отдельно от визуальных команд. Переданный
владельцем `command_time` сравнивается с началом и концом интервала из body
opcode 1; обе границы включены, а коэффициент выборки равен
`(command_time - start) / (end - start)`. Заголовочная длительность FXID не
заменяет этот интервал. Для нулевой ширины адаптер не создаёт кадр света,
поскольку у команды нет конечного коэффициента интерполяции.
Позиция, направление, цвет и range интерполируются по концам команды.
Позиция и направление переводятся через основу родителя; направление после
этого нормализуется, а нулевой вектор остаётся нулевым. Range сохраняет
авторское значение без ограничения снизу. Типы Op1 `1`, `2`, `5`, `6` и `7`
выбирают point-light selector `1`; их native flags сохраняются. Типы `3` и
`4` выбирают другие классы записей LightManager и не попадают в generic список
точечного света. У `LightningEffect` выборка тех же полей использует отдельные
ограничение по длительности FXID и правила безопасной обработки.
## Молния и звуковые события
`decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и
2, первые четыре числа opcode 1 и полный body opcode 1. Имена не подменяются
глобальным whitelist-ом: материал и звук принадлежат выбранному FXID.
`LightningEffect` устанавливается через `set_lightning_effect`; без него
погодный таймер не создаёт визуал или звук.
Активный объект использует следующий таймер. При интенсивности `I` сначала
применяется `min(I, 0.95)`, затем для случайного `U` из 15-битного диапазона
вычисляется задержка:
```text
delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
```
После достижения срока выбираются X и Y из переданных `LightningBounds`, а Z
берётся без изменения. Если границы не заданы, используется позиция камеры.
Следующая попытка разрешена через 6000 миллисекунд. Длительность уже
созданного FX берётся из его заголовка. Renderer получает native descriptor
`[40, 40, 600]`, начало эффекта на `sampled_z + 300`; при нулевом локальном
смещении концы quad находятся на `sampled_z` и `sampled_z + 600`. Opcode 3
задаёт материал, локальное смещение, scale и lifetime visual quad. Opcode 1
вычисляется отдельно как динамический point light и не задаёт размеры или UV
quad.
CPU не подменяет numeric поля шириной или линейным lifetime fade.
Звуки возвращаются рядом с графикой как `SoundEvent`:
```text
StartLoop -> начать дождевой loop
SetLoopVolume -> обновить его громкость при интерполяции погоды
StopLoop -> остановить loop
OneShot -> воспроизвести разовый звук молнии
```
Каждое событие содержит архив, имя, мировую позицию и линейную громкость.
Для FX-звука также передаются native `min_distance`, `max_distance` и
`frequency_ratio`; у дождевого loop используются `0`, бесконечность и `1`.
Событие `OneShot` молнии использует ту же случайно выбранную позицию, что и
визуальный эффект, а его диапазон и частота читаются из opcode 2: в
поставленном `env_lightning` это `100`, `1500` и `1`. Audio backend загружает
объявленный архив и запись лениво, кэширует проверенный sample, создаёт
отдельный spatial source, применяет DirectSound range/pan и текущий listener.
Отсутствие устройства вывода не должно останавливать симуляцию.
2026-10-18 05:48:38 +04:00
## Цвет солнца
Солнце берёт цвет спрайта и прозрачность бликов из `palette[10]` расписания
`sky.ske`; `palette[9]` — чёрный specular-компонент. Вторичный направленный
свет использует `palette[14]`. При выборке расписания это выражено так:
```rust
packed: [key.colors[10], key.colors[14]],
```
Этот порядок соответствует живому GOG-замеру: `CSun+0x95c` содержал
`0x41FF3C00` (интерполяция `palette[10]`), `+0x960` — `0xFF000000`
(`palette[9]`), а `+0x964` — вторичный свет из `palette[14]`. В `Terrain.dll`
по VA `0x1004AA92..0x1004AAAB` цвет в `+0x95c` обновляется, только если
установлен бит 0 результата sky-query; иначе остаётся прежнее значение.
`AtmosphereFrame` пока не несёт этот бит обновления, поэтому sampler
выдаёт интерполированный цвет при каждом запросе.
## Экранная геометрия солнца и луны
`CSun::Render` сначала переводит направление небесного тела в пиксели текущего
viewport, затем собирает quad с native XYZRHW-позицией. При положительной высоте
viewport Vulkan пиксели переводятся в clip-space так:
```rust
position = [
pixel[0] * (2.0 / width) - 1.0,
pixel[1] * (2.0 / height) - 1.0,
0.999,
];
```
Vertex shader пропускает `clip_from_world` для строк солнца и луны. CPU каждый
кадр пересчитывает центр и размер quad по текущему viewport; старые пиксельные
координаты не переносятся на новый размер. World-space billboards бликов
остаются отдельным путём. Материал экранного quad не освещается, vertex fog
отключён, а sky far-depth bit к нему не применяется.
Native `z = 0.999` не означает, что спрайт гарантированно лежит за любой
поверхностью. Vulkan использует `LESS_OR_EQUAL` и не записывает depth для
прозрачного ряда. При near `0.5`, far `700` мировая поверхность на расстоянии
`320` получает depth
`700 / (700 - 0.5) * (1 - 0.5 / 320) ≈ 0.999151`; поэтому экранный `0.999`
чуть ближе этой поверхности и проходит depth test перед ней. Это фиксированное
native значение сохраняется для совместимости, но само по себе не обещает
окклюзию солнца всей геометрией местности.
## Геометрия визуальных FX-команд
`fparkan-fx::fx_visual` формирует кадры для двух подтверждённых вариантов
`f_gener_ball`. Op4 принимает mode 0 с положительным знаменателем в `body+0xC4`
и выдаёт camera-facing quad; расстояние камеры считается через native
component-wise projection родительской матрицы. Op9 принимает mode 2:
селектор `body+0xC4` со значением 0, 1 или 2 отображается в native flag base
3, 5 или 6 и выбирает одну из трёх сеток; категория render flags выбирает её
детализацию. `body+0xC8` выбирает UV path:
нулевое значение проецирует позицию вершины на XY, ненулевое использует
фиксированные координаты quad и cap. Кадр сохраняет MAT0 resource, фазу,
opacity, вершины и triangle indices для renderer.
Evaluator получает готовые command body, phase, camera point и внешнюю
матрицу. Выбор Control row, manager gates, owner attachment, header jitter и
global RNG остаются задачей владельца. `fparkan-game` пока использует для Op9
категорию 0 с наибольшей детализацией; выбор категории по projected size ещё
не реализован.
2026-10-18 08:39:37 +04:00
Для Op3 поддерживаются body-типы 0, 1 и 2. Поля отсчитываются от
`FxCommand.raw_body`, то есть после 4-байтового command word. Типы 0 и 1
используют общие position/scale/phase/opacity tracks и camera-facing основу;
тип 1 и тип 2 также интерполируют ось, а тип 2 строит basis через native
D110-порядок. Все три evaluator-а принимают уже собранную матрицу владельца и
не выбирают Control row или attachment. Текущий путь для типов 0 и 1
предполагает native render context 0: `Effect.dll` проверяет общий gate
`context + 0x60 < 8`, а API этих evaluator-ов не выбирает другой context.
Evaluator типа 2 принимает индекс context явно и учитывает этот gate в `active`.
## Частицы Op7
`fparkan-fx::op7` вычисляет фиксированный набор частиц Op7 по body команды,
фазе эффекта, матрице владельца, позиции камеры и render context. После
инициализации состояние команды сохраняет случайно изменённые треки позиции
и масштаба. Body задаёт время групп, opacity, фазу материала и вертикальное
ускорение; последнее масштабируется переданным world scalar и применяется к
копии матрицы владельца каждой активной группы. Текущий preview
`fparkan-game` передаёт `world_scalar = 10.0`; чтение действующей настройки
мира пока не подключено.
Часы активной группы обновляются до применения detail cutoff отдельной
частицы. Cutoff сравнивает ordinal render context с постоянным порогом слота.
`Effect.dll` `0x1300` использует тот же порядок, поэтому скрытие слота не
останавливает часы его активной группы. Поток частиц последовательно задаёт
случайные изменения позиции и масштаба; для порогов видимости используется
отдельный поток. Шаг LFSR совпадает с нативным переходом двух половин, однако
семена отдельных экземпляров лишь детерминированно заменяют process-global
состояние и не воспроизводят общий порядок вызовов.
## Частицы Op8
`fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт
quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная
фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события;
сравнение строгое (`schedule_base + interval < update_clock`). Все события
catch-up в одном обновлении проверяются по тому же нормализованному интервалу;
после события нативный код продвигает базу расписания интервалом, вычисленным
по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу.
Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который
нативный unordered-путь сравнения ограничивает нулём.
Нулевая база расписания служит признаком начального состояния: обновление
сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если
первое время равно нулю, следующее обновление повторно захватывает опору. При
событии скорость опоры считается от сохранённой позиции до текущей за время
от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`.
Обновление без событий не переносит сохранённую опору к текущему владельцу.
Откат часов корректирует только базу расписания до текущего времени: слоты,
опора и три потока случайных
чисел не сбрасываются.
Evaluator принимает отдельные начальные состояния для эмиттера, знаковых
отклонений и материала. Переход каждого потока совпадает с общим native LFSR,
но эти локальные seed не воспроизводят порядок process-global вызовов игры.
Предел 100000 событий catch-up — защитная граница evaluator-а; соответствующего
лимита в исследованном native цикле не обнаружено.
## Что остаётся за владельцем
`EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы
или звуки и не выполняет финальный GPU pass. Владелец должен передать основу
камеры, projection, реальный viewport, `SkyMaterials` и декодированный FXID.
Из projection и viewport модуль сам получает native scalar осадков.
CPU-контракт проверяет движение, wrap, количество, время молнии и порядок
звуковых переходов. Он не заявляет совместимость с нераскрытой семантикой
всех FX opcode, с точным native RNG sequence или с конкретным mixer/device
backend. Подробное описание связи с listener и mission audio находится в
[томе VI](../tomes/06-behavior.md#погода-осадки-и-звуковые-события).