aboutsummaryrefslogtreecommitdiff
path: root/docs/reference/environment-effects.md
blob: 29d85f929e2e006558ed732981e67de585c2c6b3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# Эффекты окружения

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

## Молния и звуковые события

`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#погода-осадки-и-звуковые-события).