22 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 и в игровом цикле.
Входы и границы
Для расписания используется такой вызов:
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 точка снова переводится в мир, а её хвост сбрасывается на голову.
Скорости не принадлежат системе координат камеры:
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 сам решает,
как загрузить этот квадрат и как смешать его материал.
Молния и звуковые события
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.
Отсутствие устройства вывода не должно останавливать симуляцию.
Цвет солнца
Солнце берёт цвет спрайта и прозрачность бликов из 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 с native XYZRHW-позицией. При положительной высоте
viewport Vulkan пиксели переводятся в clip-space так:
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 ещё
не реализован.
Для 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.