38 KiB
Эффекты окружения
Погода в 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 оставляет этот множитель явным для
владельца, который хранит собственную плотность.
Порядок обновления частицы важен:
- при создании три 15-битных значения детерминированного генератора заполняют локальный объём;
- локальная точка переводится через основу камеры в мировую позицию;
- мировая позиция интегрируется с мировым вектором скорости;
- для проверки границ позиция временно переводится обратно в локальные координаты, и к каждой оси применяется floor-based wrap;
- после 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 входят в общий
diffuse light set. Записи с этим битом сохраняют native flags для специальных
путей и projected shadows, но не добавляются в общий diffuse список.
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-команды используют общий preview clock и ограниченный набор time modes:
mode 0 оставляет фазу равной нулю, mode 1 вычисляет elapsed / duration без
зажатия, mode 2 повторяет цикл. Header flags 0x200 и 0x20 преобразуют эту
фазу; command path 0x8120 зажимает результат только после преобразований.
В эталонной 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, связанное с header gate и фактической FX phase, пока не
моделируется. 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.