# Эффекты окружения Погода в 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 точка снова переводится в мир, а её хвост сбрасывается на голову. Скорости не принадлежат системе координат камеры: ```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 сам решает, как загрузить этот квадрат и как смешать его материал. ## Точечный свет из 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. Отсутствие устройства вывода не должно останавливать симуляцию. ## Цвет солнца Солнце берёт цвет спрайта и прозрачность бликов из `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 ещё не реализован. Для 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#погода-осадки-и-звуковые-события).