Files
fparkan/docs/reference/environment-effects.md
T
Valentin Popov e8942caa23
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
fix(render): preserve object-local point lights on models
2026-11-01 13:40:12 +04:00

40 KiB
Raw Blame History

Эффекты окружения

Погода в Parkan состоит из двух связанных частей. Файл sky.ske задаёт расписание: когда начинается дождь или снег, как меняются интенсивность и цвет, какие имена ресурсов принадлежат активному интервалу. Таблица sky.wea задаёт материалы, которыми эти осадки рисуются. fparkan-fx::environment соединяет оба входа с текущей камерой и возвращает один EnvironmentFrame.

sky.ske
  -> AtmosphereFrame: время, цвет, интенсивность, ссылки на ресурсы
sky.wea
  -> SkyMaterials: имена материалов строк 7 (снег) и 8 (дождь)
камера + EnvironmentSystem
  -> EnvironmentFrame: мировые частицы, экранные квадраты, гром и дождевой loop

Модуль не владеет графическим устройством или звуковой картой. Renderer разрешает имя материала и отправляет геометрию в прозрачный проход, а audio backend разрешает архив и имя звука. Это позволяет одной и той же модели погоды работать в preview и в игровом цикле.

Цвета неба, ambient lighting и fog renderer-а используют разные поля того же sky.ske sample. SkySample::packed[1] задаёт глобальный lighting floor; CShade объединяет его с material lighting по поканальному максимуму. D3DRS_FOGCOLOR (0x22) зависит от направления камеры и интерполируется из SkySample::colors[0..4] по native sky heading. Значения sky.values[0..1] продолжают задавать fog start/end, масштабируемые на 700.

Входы и границы

Для расписания используется такой вызов:

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 от владельца кадра.

Объём и движение осадков

Исходный эмиттер хранит прямоугольный объём в координатах камеры. Его эталонные параметры таковы:

near = 2
far  = 50
half_angle_x = 0.65
half_angle_y = 0.4875

Для эталонной камеры:

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 пересчитывают две боковые грани по той же глубине:

half_y = tan(vertical_fov / 2) * far
half_x = half_y * aspect_ratio

Количество частиц зависит от объёма и интенсивности. Вспомогательная формула использует округление FISTP к ближайшему чётному целому:

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 по-прежнему сбрасывает его на голову.

Скорости не принадлежат системе координат камеры:

rain = [ 0.5, 0, -60 ]
snow = [ 0.5, 0,  -4 ]

Это мировые векторы. Поворот камеры меняет область появления, wrap и проекцию, но не вращает уже движущуюся каплю вокруг наблюдателя. В EnvironmentPrimitive::Particle position, velocity, world_head и world_tail имеют мировые координаты.

Размер частиц состоит из коэффициента класса и масштаба погодного объекта:

rain_size = scalar * 0.0065
snow_size = scalar * 0.0195

scalar вычисляется из той же проекции, которую передаёт владелец кадра:

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 сам решает, как загрузить этот квадрат и как смешать его материал.

Точечный свет из 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-битного диапазона вычисляется задержка:

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:

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. Отсутствие устройства вывода не должно останавливать симуляцию.

Звуки Control

Команда Op2 может принадлежать не погоде, а экземпляру эффекта на узле модели. fparkan-fx::ambient_sound хранит её временное окно и предыдущее значение фазы. В режиме 0 разовый звук начинается при строгом пересечении начала окна: previous <= start && start < phase, если фаза движется вперёд. Значение phase == 1 для этой проверки заменяется нулём и подготавливает следующий цикл. Режим 2 включает loop на обеих границах окна; внутри окна уже работающий источник обновляется, а не запускается заново. Выход за окно останавливает его.

Позиция интерполируется между двумя локальными точками и проходит через ту же матрицу владельца, что и графические команды. Минимальная и максимальная дистанции остаются постоянными; частота интерполируется по фазе окна. Каждая команда получает отдельный источник звука, даже если ресурс совпадает с ресурсом соседней команды. Дождь сохраняет свой loop. Перемещение слушателя, потеря фокуса и завершение приложения затрагивают все эти источники.

Эти ветви восстановлены в GOG Effect.dll (сборка из таблицы бинарников): init RVA 0x12D10, update 0x12EB0. Живой loop sounds.lib:f_defl.wav передаёт в звуковой backend диапазон 12/80, частоту 1 и мировую позицию из матрицы владельца. В режиме 2 признак loop берётся из первого DWORD body, а не из command flag.

Обычное создание эффекта Control сохраняет time mode заголовка и задаёт постоянную фазу нулём. Raw selector строки служит ID экземпляра, а не номером режима времени. Поэтому f_comp_sfx с time mode 0 и началом окна 0.001 остаётся тихим до управляющей команды Control. Preview такие команды ещё не исполняет. Периодические звуки с time mode 2 уже следуют общим часам и текущей позе узла. render_coverage control_op2_supported считает подготовленные команды, а control_sound_start/stop показывает фактические переходы.

Неизвестные backend modes 1/3, ненулевое поле body+76, неподдержанное время или привязка владельца пропускаются с render_coverage control_op2_unsupported. Они не заменяются разовым звуком или произвольным loop.

Цвет солнца

Солнце берёт цвет спрайта и прозрачность бликов из palette[10] расписания sky.ske; palette[9] — чёрный specular-компонент. Вторичный направленный свет использует palette[14]. При выборке расписания это выражено так:

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 уже в его пикселях. Terrain.dll VA 0x1004AA40 передаёт эту геометрию через shade callback; native FVF 0x1c4 задаёт XYZRHW | DIFFUSE | SPECULAR | TEX1. Конвертер 0x10020700 сохраняет входные x, y и z, а в RHW записывает 1.0; исходный z равен 0.999. Callback включает depth test и выключает запись depth, поэтому более близкая поверхность может закрыть спрайт, а сам спрайт не меняет глубину для последующих слоёв.

В Vulkan ряды 3 и 4 сохраняют этот contract отдельным bit 8 material mode. CPU каждый кадр преобразует viewport-пиксели к clip space текущего размера swapchain:

position = [
    pixel[0] * (2.0 / width) - 1.0,
    pixel[1] * (2.0 / height) - 1.0,
    0.999,
];

Положительная высота Vulkan viewport отображает эти координаты в тот же top-down экран. Vertex shader пропускает мировую матрицу только для этого режима:

vec4 clip_position = screen_tl
    ? vec4(in_position, 1.0)
    : frame.clip_from_world * vec4(in_position, 1.0);

Материал остаётся unlit, fog factor равен нулю. Для мировой геометрии оригинал задаёт viewport MinZ = 0.1, MaxZ = 0.99: после отсечения проекционная глубина отображается в этот диапазон:

world_depth = 0.1 + (0.99 - 0.1) * (clip_z / clip_w)

Даже дальняя гора получает глубину не больше 0.99 и закрывает экранный диск с z = 0.999. Если оставить мировой диапазон 0–1, при near 0.5 и far 700 поверхность дальше примерно 292 единиц окажется позади диска: солнце начнёт просвечивать сквозь неё при движении камеры. Одного переноса нативного z в экранные вершины недостаточно.

В Vulkan диапазон задаётся динамическим viewport отдельно для мировых пакетов; экранные TL и фоновые sky-пакеты сохраняют диапазон 0–1. Матрица проекции и плоскости отсечения при этом не меняются. Константы мирового профиля записывает GOG Terrain.dll RVA 0x27422–0x27452 из таблицы 0x65088/0x6508C/0x650A0/0x650A4 (near, far, MinZ, MaxZ). GOG Ngi32.dll RVA 0x7170, участок 0x71E7–0x723C, копирует MinZ/MaxZ в renderer +0x30/+0x34 и передаёт их IDirect3DDevice7::SetViewport; сборки указаны в таблице бинарников. Нативные значения этих двух полей имеют биты 0x3DCCCCCD и 0x3F7D70A4.

Sky-background bit 4 продолжает помещать слои на дальнюю плоскость. Отдельные flare rows 5 и 6 пока используют world-space billboard builder: для них не подтверждён этот же native TL путь.

Эффекты Control

Погодное расписание sky.ske — только один источник эффектов. У объектов миссии таблица Control (.ctl) связывает строки с FXID-командами. Текущий preview выбирает начальную группу Control и исполняет только строки без predicate, chain и state gate. Условия игровых событий и переходы между группами принадлежат полному игровому Control runtime и здесь не моделируются.

Type 4 row задаёт локальную basis эффекта через три точки CTL point list. Для валидной привязки первая точка также задаёт owner node. Путь MSH выбирает позу этого узла из текущей pose hierarchy; для внешнего mounted-компонента к ней добавляется поза socket. После этого применяются object transform, header scale и локальная матрица FX. Подробнее о порядке матриц см. описание MSH.

Renderer preview поддерживает FXID-команды Op3 modes 0–2, Op4, Op7, Op8 и Op9. Op3 mode0 строит camera-facing поверхность из анимируемых локальных позиции и масштаба; Op7/Op8 обновляют отдельные элементы particle pool; Op9 создаёт индексированную полусферическую сетку. Op1 вычисляется отдельно как список point lights: selector 1 без flags & 0x80000000 освещает мир, а с этим битом — собственное строение или юнит. Локальный источник нельзя удалять из освещения MSH вместе с исключением из общего списка рельефа. Preview сохраняет исходные flags для projected shadows и передаёт в renderer область действия корневого объекта. Область ноль означает общий свет; у локального источника и диапазонов его модели одинаковый ненулевой идентификатор.

Body discriminator Op3 читается из первого DWORD FxCommand.raw_body; offsets в evaluator-е относительны к данным после четырёхбайтового command word. Mode 1 добавляет анимируемую ось к camera-facing basis, mode 2 использует native D110 basis. Для modes 0 и 1 preview предполагает render context 0, который проходит общий gate Effect.dll 0x106C0; их API не выбирает другой context. Evaluator mode 2 принимает индекс context и учитывает gate при расчёте active.

У Op3 mode 1 ось задаёт длину прямоугольника, а не его нормаль. Для локального вектора к камере v и анимируемой оси a столбцы базиса имеют порядок [a, u, w], где u = normalize(v × a), w = a × u. Плоскость прямоугольника — XY, поэтому первая сторона идёт вдоль авторской оси, а вторая поворачивается к камере. Это сохраняет соединение между точками локатора при движении камеры. Если поставить ось в третий столбец, прямоугольник займёт поперечную плоскость и связь превратится в полоску или небольшой диск.

Порядок подтверждён аргументами конструктора матрицы в GOG Effect.dll (сборка из таблицы бинарников, SHA-256 386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789): RVA 0x93D0, ветка mode 1 с 0x9739, сборка столбцов 0x97F6–0x983C. Например, a = (1, 0, 0) и v = (2, 1, 0) дают столбцы (1, 0, 0), (0, 0, -1), (0, 1, 0). При нативном вызове длины последних двух столбцов могут немного отличаться от единицы из-за приближённой нормализации выбранного g_FastProc; порядок осей от этого не меняется.

У сооружения s_tree_58 в миссии «Конструктор» FXID tree_ray_58 задаёт два слоя жёлтого луча: команды Op3 mode 1 с ресурсами defl_y_add и defl_y1_add. Их геометрию сверили с CPU-вызовом GOG Effect.dll 0x93D0. На семи временных фазах и трёх направлениях камеры для каждой команды (42 выборки) вершины совпали с Rust evaluator с максимальной покомпонентной разницей 0.0001373291. Оракул получает уже выбранные Rust позицию, ось и масштаб, а world-camera переводится в cameraLocal тем же component-wise projection, что и native FastProc + 0x68. Поэтому это подтверждает только геометрию Type 1 helper-а на этих входах: обновление и расписание эффекта, UV/material и пиксельное совпадение здесь не проверялись.

Частицы 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 состояние и не воспроизводят общий порядок вызовов.

f_gener_ball evaluator покрывает Op4 mode 0 с положительным знаменателем в body+0xC4 и Op9 mode 2. Для Op9 selectors body+0xC4 со значениями 0, 1 и 2 отображаются в native flag bases 3, 5 и 6. Нулевой body+0xC8 проецирует UV на XY, ненулевой выбирает статические quad/cap координаты. Op4 вычисляет локальное расстояние камеры тем же component-wise projection, что использует native path.

FX-команды одной Control-строки используют общие часы экземпляра fparkan_fx::clock::FxInstanceClock. Время хранится в миллисекундах f32, отдельно от длительности документа: mode 0 возвращает постоянную фазу (при создании ноль), mode 1 — (now − origin) / duration, mode 2 повторяет цикл, mode 3 возвращает 1 − (now − origin) / duration. Header flags 0x200 и 0x20 преобразуют незажатую фазу; command path 0x8120 зажимает результат только после преобразований. Вызов set_time меняет начало отсчёта и режим экземпляра, сохраняя режим документа. Запрос перезапуска (Effect.dll RVA 0x7B40, instance bit 0x20) срабатывает после конца текущего времени жизни: следующий update начинает новый цикл в своём времени и возвращает режим документа. Это отдельный флаг, не header 0x20, складывающий фазу треугольником. Правила режимов, gates 0x80/0x100 и установления времени сверены вызовом эталонной DLL с x87 CW 0x007F; отложенный переход закреплён Rust-тестом по ветви update 0x8120. Все экземпляры статического preview пока создаются в момент ноль; геометрия, свет и звук получают одну фазу на обновление сцены. Команды Control ещё не меняют эти часы, а нативная очередь обновления с шагом 50 ms не моделируется. В эталонной Effect.dll RVA 0x5C60 режимы 4–17 читают состояние владельца или провайдера; режим 14 тоже входит в эту группу. Пропуски при расширении выбранных начальных Control-строк учитываются по командам: control_fx_requires_game_state для этих режимов и control_fx_unsupported для остальных непреобразованных команд. Диагностика сохраняет причину, ресурс, объект, компонент, строку и номер команды. control_fx_invalid_documents отдельно считает неразобранные FXID, где число команд неизвестно. Счётчики суммируются по компонентам миссии; они не заменяют учёт ещё не выбранных Control-групп и условных строк в фазе G. Effect renderer проверяет header bit 0x800 только при render selector 0; native AniMesh component path был захвачен с selector 1, поэтому preview использует selector 1 и не подавляет такие visual commands. Этот capture также показал 0xD00 FX entries в 53-entry manager. Динамическое состояние manager A8 пока заменено нулём; экземпляры с gates 0x80/0x100 считаются в control_fx_manager_flag_stub. Header bit 0x10 относится к activation lifecycle и сам по себе не отключает draw. Header jitter и random shift хранятся один раз на Control row и совместно применяются к его sibling-командам; отдельный детерминированный поток каждой строки сохраняет эту связь, но не повторяет порядок глобального native RNG. Неразобранные группы, event predicates и неразрешённые owner attachments пропускаются.

Для visual FX material берётся из resource reference команды. MAT0 выбирает фазу по normalized FX phase, независимо от wall-clock анимации world materials. Command opacity передаётся vertex alpha, а FX MAT0 alpha не умножает её второй раз; texture alpha продолжает участвовать в shader. Текущая preview-ветка использует SourceAlpha и read-only depth state и допускает только MAT0-фазы, проходящие локальный predicate additive-RGB approximation. Семантика native flag40 и directional-light ветки остаётся открытой.

Generated Op9 mesh разделяется на отдельный draw range для каждого native quad и cap. Каждый range получает ключ по камере до первой индексированной вершины, в порядке убывания расстояния. Равные ключи используют midpoint insertion из native queue, поэтому их порядок не является стабильным по первоначальному времени поступления. Preview использует category 0 как highest-detail mesh; projected-size LOD и полное визуальное совпадение размера и формы пока не реализованы.

Частицы Op8

fparkan-fx::smoke разбирает body Op8, хранит кольцевой пул команды и выдаёт quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события; сравнение строгое (schedule_base + interval < update_clock). Все события catch-up в одном обновлении проверяются по тому же нормализованному интервалу; после события нативный код продвигает базу расписания интервалом, вычисленным по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу. Когда фаза равна единственной границе окна, деление 0/0 даёт NaN, который нативный unordered-путь сравнения ограничивает нулём.

Нулевая база расписания служит признаком начального состояния: обновление сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если первое время равно нулю, следующее обновление повторно захватывает опору. При событии скорость опоры считается от сохранённой позиции до текущей за время от базы расписания; слот смещает сохранённую опору на скорость * due_interval. При нулевой скорости оригинал подставляет ось X владельца и для направления, и для этого смещения. Обновление без событий не переносит сохранённую опору к текущему владельцу. Откат часов корректирует только базу расписания до текущего времени: слоты, опора и три потока случайных чисел не сбрасываются.

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.