Files
fparkan/docs/reference/environment-effects.md
T
Valentin Popov c77c078efe
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
fix: preserve native sky sprite screen depth
2026-10-18 07:28:16 +04:00

15 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 и в игровом цикле.

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

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

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 точка снова переводится в мир, а её хвост сбрасывается на голову.

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

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 значение сохраняется для совместимости, но само по себе не обещает окклюзию солнца всей геометрией местности.

Что остаётся за владельцем

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.