Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4a7899af | |||
| 65dd84646a | |||
| ed2db8523b | |||
| 67a2a523b4 | |||
| a806635681 | |||
| 0092ec64a9 | |||
| e08fc65ac8 |
@@ -28,6 +28,13 @@ jobs:
|
||||
# падало по таймауту, а сохранение вешало «Complete job» на пару минут. Инструмент собирается заново.
|
||||
- name: Форк Horizon
|
||||
run: nice -n 19 python3 tools/airdef.py fork
|
||||
# Чистая симуляция тестируется нативно (хост-таргет раннера, без WASM): регрессии из sim/regress.rs
|
||||
# и сценарии tests/scenarios.rs (NaN, налёт кончается, ТЗМ не зависают). Нужен форк: SDK подключён путём.
|
||||
- name: Тесты мода
|
||||
working-directory: airdef-mod
|
||||
run: |
|
||||
HOST=$(rustc -vV | sed -n 's/^host: //p')
|
||||
nice -n 19 cargo test --target "$HOST"
|
||||
- name: Мод
|
||||
run: nice -n 19 python3 tools/airdef.py mod
|
||||
- name: Релиз
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
## Запуск
|
||||
|
||||
Пошаговый гайд для Windows и macOS (готовая сборка из релиза или сборка из исходников): [docs/INSTALL.md](docs/INSTALL.md).
|
||||
Устройство HD-рендера, его статус и план доработки: [docs/HD-RENDERER.md](docs/HD-RENDERER.md).
|
||||
|
||||
Коротко, из исходников:
|
||||
|
||||
@@ -23,9 +24,10 @@ python3 tools/airdef.py play # запустить (аргументы игр
|
||||
(`tools/gen_sounds.py`, нужны numpy и ffmpeg), собирает и ставит мод. Сгенерированные файлы лежат в репозитории,
|
||||
так что для обычной сборки нужен только Rust.
|
||||
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает мод и игру для Linux, а пуш в `main` публикует релиз
|
||||
`build-N`; `.gitea/workflows/desktop.yml` (запуск вручную с тегом релиза) собирает игру для macOS и Windows на
|
||||
раннерах с метками `macos-latest` / `windows-latest` и дописывает архивы в релиз.
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его
|
||||
релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml`
|
||||
(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом
|
||||
готового релиза и дописывают свои архивы в него.
|
||||
|
||||
## Как играть
|
||||
|
||||
@@ -130,7 +132,7 @@ API мода `render.set-lights` (до 512, плавно интерполиру
|
||||
перехватчиков (жёлтые), сбития (x), прилёты, шкала времени с событиями; время идёт само, колесо мыши перематывает;
|
||||
- **повтор момента** — клик по событию в списке: «призраки» дронов и ракет снова летят в мире, камера идёт за
|
||||
целью, потом за перехватчиком (или смотрит из пушки, со стороны на прилёт), в момент сбития время замедляется;
|
||||
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — скорость (×0.5–×16);
|
||||
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — перемотка, стрелки — скорость;
|
||||
- **лучшие моменты** — нарезка до 8 самых интересных событий (перехват баллистики и гиперзвука, поражения
|
||||
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "airdef:block/amraam_missile"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "airdef:block/irist_missile"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
"block.airdef.geran3": "Герань-3",
|
||||
"block.airdef.geran3_flame": "Факел",
|
||||
"block.airdef.pac3": "PAC-3 MSE",
|
||||
"block.airdef.irist_missile": "IRIS-T SLM",
|
||||
"block.airdef.amraam_missile": "AMRAAM",
|
||||
"block.airdef.flame": "Факел",
|
||||
"block.airdef.patriot_launcher": "ПУ M903",
|
||||
"block.airdef.patriot_truck": "Тягач",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"block.airdef.geran3": "Герань-3",
|
||||
"block.airdef.geran3_flame": "Факел",
|
||||
"block.airdef.pac3": "PAC-3 MSE",
|
||||
"block.airdef.irist_missile": "IRIS-T SLM",
|
||||
"block.airdef.amraam_missile": "AMRAAM",
|
||||
"block.airdef.flame": "Факел",
|
||||
"block.airdef.patriot_launcher": "ПУ M903",
|
||||
"block.airdef.patriot_truck": "Тягач",
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"nose_dark": "airdef:block/nose_dark",
|
||||
"white": "airdef:block/white",
|
||||
"nozzle": "airdef:block/nozzle",
|
||||
"particle": "airdef:block/nose_dark"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
7.333333333333333,
|
||||
7.333333333333333,
|
||||
-16.0
|
||||
],
|
||||
"to": [
|
||||
8.666666666666666,
|
||||
8.666666666666666,
|
||||
-13.666666666666666
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
-13.666666666666666
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
-11.333333333333332
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.666666666666666,
|
||||
6.666666666666666,
|
||||
-11.333333333333332
|
||||
],
|
||||
"to": [
|
||||
9.333333333333334,
|
||||
9.333333333333334,
|
||||
-9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333332,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333332,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.333333333333332
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.333333333333332
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.5,
|
||||
6.5,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
9.5,
|
||||
9.5,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
1.0,
|
||||
7.6,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
15.0,
|
||||
8.4,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
1.0,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
15.0,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
4.0,
|
||||
7.6,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
12.0,
|
||||
8.4,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
4.0,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
12.0,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
26.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
28.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"white": "airdef:block/white",
|
||||
"missile_grey": "airdef:block/missile_grey",
|
||||
"nozzle": "airdef:block/nozzle",
|
||||
"particle": "airdef:block/white"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
7.333333333333333,
|
||||
7.333333333333333,
|
||||
-16.0
|
||||
],
|
||||
"to": [
|
||||
8.666666666666666,
|
||||
8.666666666666666,
|
||||
-14.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
-14.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
-12.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.666666666666666,
|
||||
6.666666666666666,
|
||||
-12.0
|
||||
],
|
||||
"to": [
|
||||
9.333333333333334,
|
||||
9.333333333333334,
|
||||
-10.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.5,
|
||||
6.5,
|
||||
-10.0
|
||||
],
|
||||
"to": [
|
||||
9.5,
|
||||
9.5,
|
||||
24.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
3.0,
|
||||
7.6,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
13.0,
|
||||
8.4,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
3.0,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
13.0,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
2.0,
|
||||
7.6,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
14.0,
|
||||
8.4,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
2.0,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
14.0,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
24.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
28.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
4.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
4.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,59 @@
|
||||
//! Rough cost of handing 2000 model instances to the game each tick: allocations and bytes the mod
|
||||
//! builds (counting allocator) and the bytes the canonical ABI then copies across (wasm32 layout:
|
||||
//! `model-instance` 64 bytes + its name, `model-instance-id` 56 bytes).
|
||||
//! Run: `cargo run --release --example emit_cost`.
|
||||
use horizon_mod_sdk::render::{ModelInstance, ModelInstanceId};
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
|
||||
|
||||
static COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static BYTES: AtomicUsize = AtomicUsize::new(0);
|
||||
struct Counting;
|
||||
unsafe impl GlobalAlloc for Counting {
|
||||
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
||||
COUNT.fetch_add(1, Relaxed);
|
||||
BYTES.fetch_add(l.size(), Relaxed);
|
||||
unsafe { System.alloc(l) }
|
||||
}
|
||||
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
||||
unsafe { System.dealloc(p, l) }
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static A: Counting = Counting;
|
||||
|
||||
const NAMES: [&str; 4] = ["airdef:shahed", "airdef:geran_2", "airdef:iris_t", "airdef:explosion_core"];
|
||||
|
||||
fn measure(f: impl FnOnce()) -> (usize, usize) {
|
||||
let (c, b) = (COUNT.load(Relaxed), BYTES.load(Relaxed));
|
||||
f();
|
||||
(COUNT.load(Relaxed) - c, BYTES.load(Relaxed) - b)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
const N: usize = 2000;
|
||||
let (mut old, mut by_id): (Vec<ModelInstance>, Vec<ModelInstanceId>) = (Vec::new(), Vec::new());
|
||||
by_id.reserve(N);
|
||||
let inst = |i: usize| (i as u32, NAMES[i % 4], i as f64);
|
||||
let wire_old: usize = (0..N).map(|i| 64 + NAMES[i % 4].len()).sum();
|
||||
let (c, b) = measure(|| {
|
||||
old = (0..N)
|
||||
.map(|i| {
|
||||
let (id, n, x) = inst(i);
|
||||
ModelInstance { id, model: n.to_string(), x, y: x, z: x, rotation: (0., 0., 0., 1.), scale: 1., glow: false }
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
println!("old : {c} allocations, {b} bytes built per tick; {wire_old} bytes across, {N} strings lowered");
|
||||
drop(old);
|
||||
for tick in 0..2 {
|
||||
let (c, b) = measure(|| {
|
||||
by_id.clear();
|
||||
for i in 0..N {
|
||||
let (id, _, x) = inst(i);
|
||||
by_id.push(ModelInstanceId { id, model: (i % 4) as u32, x, y: x, z: x, rotation: (0., 0., 0., 1.), scale: 1., glow: false });
|
||||
}
|
||||
});
|
||||
println!("new : tick {tick}: {c} allocations, {b} bytes built; {} bytes across, no strings", N * 56);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Headless driver for the pure simulation (`airdef::sim`): builds a scenario, steps the
|
||||
//! simulation like the mod's tick does (answering ground queries from a height function),
|
||||
//! and measures. Shared by the `sim_run` example and `tests/scenarios.rs`.
|
||||
//!
|
||||
//! Native timings say nothing about the WASM build or the GPU; use them to compare runs of the
|
||||
//! same machine.
|
||||
#![allow(dead_code)]
|
||||
use airdef::sim::{Aim, Approach, DroneKind, Ground, Out, RaidOrder, Sam, SamKind, Sim};
|
||||
use glam::DVec3;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Height of the land under a column (what `render.ground` would answer).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Terrain {
|
||||
Flat(i32),
|
||||
/// Smooth hills: `base + amp * (sin(x/period) + cos(z/period)) / 2`.
|
||||
Hills { base: i32, amp: f64, period: f64 },
|
||||
}
|
||||
|
||||
impl Terrain {
|
||||
pub fn height(self, x: i32, z: i32) -> i32 {
|
||||
match self {
|
||||
Terrain::Flat(h) => h,
|
||||
Terrain::Hills { base, amp, period } => {
|
||||
base + (amp * ((f64::from(x) / period).sin() + (f64::from(z) / period).cos()) / 2.0).round() as i32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RaidSpec {
|
||||
pub kinds: Vec<(DroneKind, u32)>,
|
||||
pub approach: Approach,
|
||||
pub aim: Aim,
|
||||
pub dist: f64,
|
||||
/// The tick the raid is ordered at (a raid waits for the previous one to be sent).
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Unit {
|
||||
pub kind: SamKind,
|
||||
pub pos: DVec3,
|
||||
pub heading: f64,
|
||||
/// Starts with an empty magazine, waiting for a reload (logistics).
|
||||
pub empty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Scenario {
|
||||
pub name: String,
|
||||
pub seed: u64,
|
||||
pub units: Vec<Unit>,
|
||||
pub objects: Vec<DVec3>,
|
||||
pub raids: Vec<RaidSpec>,
|
||||
pub terrain: Terrain,
|
||||
/// Hard limit of ticks (20 a second).
|
||||
pub ticks: u64,
|
||||
/// Stop as soon as every raid has ended.
|
||||
pub stop_at_end: bool,
|
||||
pub ew: bool,
|
||||
/// Ticks a truck on a job may stand still before it counts as stuck.
|
||||
pub stuck_after: u64,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Scenario {
|
||||
name: name.into(),
|
||||
seed: 1,
|
||||
units: Vec::new(),
|
||||
objects: vec![DVec3::new(0.0, 70.0, 0.0)],
|
||||
raids: Vec::new(),
|
||||
terrain: Terrain::Flat(70),
|
||||
ticks: 20 * 60 * 10,
|
||||
stop_at_end: true,
|
||||
ew: false,
|
||||
stuck_after: 600,
|
||||
}
|
||||
}
|
||||
pub fn unit(&mut self, kind: SamKind, x: f64, z: f64) -> &mut Unit {
|
||||
self.units.push(Unit { kind, pos: DVec3::new(x, 70.0, z), heading: 0.0, empty: false });
|
||||
self.units.last_mut().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Report {
|
||||
pub name: String,
|
||||
pub seed: u64,
|
||||
pub ticks_run: u64,
|
||||
pub units: usize,
|
||||
pub launched: u32,
|
||||
pub kills: u32,
|
||||
pub hits: u32,
|
||||
/// Missiles fired by air defence (sum of the systems' `fired`).
|
||||
pub fired: u32,
|
||||
pub ground_queries: u64,
|
||||
pub ground_batches: u64,
|
||||
pub tick_p50: Duration,
|
||||
pub tick_p95: Duration,
|
||||
pub tick_max: Duration,
|
||||
pub tick_total: Duration,
|
||||
/// Time spent in ticks that began with a raid being planned.
|
||||
pub planning: Duration,
|
||||
pub planning_ticks: u64,
|
||||
pub nan_positions: u64,
|
||||
/// Ids of trucks that stood on a job without progress for too long.
|
||||
pub stuck_trucks: Vec<u32>,
|
||||
/// Truck-ticks spent on a job or driving (shows the logistics actually ran).
|
||||
pub truck_busy_ticks: u64,
|
||||
pub raids_ordered: usize,
|
||||
pub raids_ended: usize,
|
||||
pub all_ended: bool,
|
||||
}
|
||||
|
||||
fn finite(v: DVec3) -> bool {
|
||||
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
|
||||
}
|
||||
|
||||
pub fn run(sc: &Scenario) -> Report {
|
||||
let mut sim = Sim::new(sc.seed);
|
||||
sim.damage = false; // explosions would change the land the height function describes
|
||||
for u in &sc.units {
|
||||
let id = sim.id();
|
||||
let mut s = Sam::new(id, u.kind, u.pos, u.heading);
|
||||
if u.empty {
|
||||
s.empty = true;
|
||||
s.missiles = 0;
|
||||
}
|
||||
sim.sams.push(s);
|
||||
}
|
||||
sim.targets = sc.objects.clone();
|
||||
sim.target_health = vec![100.0; sc.objects.len()];
|
||||
if sc.ew {
|
||||
sim.set_ew(true);
|
||||
}
|
||||
let mut ground = Ground::default();
|
||||
let mut pending: Vec<(i32, i32, i32)> = Vec::new();
|
||||
let mut r = Report { name: sc.name.clone(), seed: sc.seed, units: sc.units.len(), ..Default::default() };
|
||||
let mut times: Vec<Duration> = Vec::with_capacity(sc.ticks as usize);
|
||||
let mut ordered = vec![false; sc.raids.len()];
|
||||
let mut launched_before = vec![0u32; sc.raids.len()];
|
||||
let mut ended = vec![false; sc.raids.len()];
|
||||
// truck id -> (last position, ticks standing while busy)
|
||||
let mut standing: HashMap<u32, (DVec3, u64)> = HashMap::new();
|
||||
let mut out = Out::default();
|
||||
for now in 1..=sc.ticks {
|
||||
// Raids come in order: one is ordered once the previous one has been sent.
|
||||
for (i, spec) in sc.raids.iter().enumerate() {
|
||||
if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) {
|
||||
let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim };
|
||||
launched_before[i] = sim.launched;
|
||||
sim.order_raid(&order, &sc.objects, &mut out);
|
||||
ordered[i] = true;
|
||||
}
|
||||
}
|
||||
ground.answer(std::mem::take(&mut pending), now);
|
||||
let planning = sim.planning();
|
||||
out = Out::default();
|
||||
let t = Instant::now();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let wanted = ground.take_wanted();
|
||||
let dt = t.elapsed();
|
||||
times.push(dt);
|
||||
if planning {
|
||||
r.planning += dt;
|
||||
r.planning_ticks += 1;
|
||||
}
|
||||
if !wanted.is_empty() {
|
||||
r.ground_batches += 1;
|
||||
r.ground_queries += wanted.len() as u64;
|
||||
pending = wanted.into_iter().map(|(x, z)| (x, z, sc.terrain.height(x, z))).collect();
|
||||
}
|
||||
r.ticks_run = now;
|
||||
// NaN check on everything with a position.
|
||||
let bad = sim.drones.iter().filter(|d| !finite(d.pos) || !finite(d.vel)).count()
|
||||
+ sim.missiles.iter().filter(|m| !finite(m.pos)).count()
|
||||
+ sim.sams.iter().filter(|s| !finite(s.pos)).count()
|
||||
+ sim.blasts.iter().filter(|b| !finite(b.at)).count()
|
||||
+ out.models.iter().filter(|m| !finite(m.at)).count();
|
||||
r.nan_positions += bad as u64;
|
||||
// Trucks with a job or on the move, standing still.
|
||||
for s in sim.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()) {
|
||||
let busy = s.moving() || s.job().is_some();
|
||||
r.truck_busy_ticks += u64::from(busy);
|
||||
let e = standing.entry(s.id).or_insert((s.pos, 0));
|
||||
if !busy || e.0.distance(s.pos) > 0.05 {
|
||||
*e = (s.pos, 0);
|
||||
} else {
|
||||
e.1 += 1;
|
||||
if e.1 == sc.stuck_after && !r.stuck_trucks.contains(&s.id) {
|
||||
r.stuck_trucks.push(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A raid has ended when it was ordered, planned, flown and nothing is left in the air.
|
||||
let quiet = !sim.planning() && sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()) && sim.missiles.is_empty();
|
||||
for i in 0..sc.raids.len() {
|
||||
if ordered[i] && !ended[i] && quiet && sim.launched > launched_before[i] {
|
||||
ended[i] = true;
|
||||
}
|
||||
}
|
||||
if sc.stop_at_end && ended.iter().all(|e| *e) && !sc.raids.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
r.launched = sim.launched;
|
||||
r.kills = sim.kills;
|
||||
r.hits = sim.hits;
|
||||
r.fired = sim.sams.iter().map(|s| s.fired).sum();
|
||||
r.raids_ordered = ordered.iter().filter(|o| **o).count();
|
||||
r.raids_ended = ended.iter().filter(|o| **o).count();
|
||||
r.all_ended = r.raids_ended == sc.raids.len();
|
||||
r.tick_total = times.iter().sum();
|
||||
times.sort();
|
||||
if !times.is_empty() {
|
||||
let at = |q: f64| times[((times.len() - 1) as f64 * q).round() as usize];
|
||||
r.tick_p50 = at(0.5);
|
||||
r.tick_p95 = at(0.95);
|
||||
r.tick_max = *times.last().unwrap();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn ms(d: Duration) -> f64 {
|
||||
d.as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Problems that fail a scenario: empty when it is healthy.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut p = Vec::new();
|
||||
if self.nan_positions > 0 {
|
||||
p.push(format!("{} non-finite positions", self.nan_positions));
|
||||
}
|
||||
if !self.stuck_trucks.is_empty() {
|
||||
p.push(format!("stuck trucks: {:?}", self.stuck_trucks));
|
||||
}
|
||||
if !self.all_ended {
|
||||
p.push(format!("{} of {} raids ended in {} ticks", self.raids_ended, self.raids_ended.max(self.raids_ordered), self.ticks_run));
|
||||
}
|
||||
p
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
format!(
|
||||
"scenario {name} (seed {seed}): {units} units, {ticks} ticks ({secs:.0} s of game time)\n\
|
||||
raids: {ro} ordered, {re} ended\n\
|
||||
launched {l}, shot down {k}, got through {h}, air-defence missiles fired {f}\n\
|
||||
tick time ms: p50 {p50:.3} p95 {p95:.3} max {max:.3} total {tot:.0}\n\
|
||||
planning: {pl:.1} ms over {pt} ticks\n\
|
||||
ground queries: {gq} in {gb} batches\n\
|
||||
non-finite positions: {nan}\n\
|
||||
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n",
|
||||
name = self.name,
|
||||
seed = self.seed,
|
||||
units = self.units,
|
||||
ticks = self.ticks_run,
|
||||
secs = self.ticks_run as f64 / 20.0,
|
||||
ro = self.raids_ordered,
|
||||
re = self.raids_ended,
|
||||
l = self.launched,
|
||||
k = self.kills,
|
||||
h = self.hits,
|
||||
f = self.fired,
|
||||
p50 = ms(self.tick_p50),
|
||||
p95 = ms(self.tick_p95),
|
||||
max = ms(self.tick_max),
|
||||
tot = ms(self.tick_total),
|
||||
pl = ms(self.planning),
|
||||
pt = self.planning_ticks,
|
||||
gq = self.ground_queries,
|
||||
gb = self.ground_batches,
|
||||
nan = self.nan_positions,
|
||||
stuck = self.stuck_trucks,
|
||||
busy = self.truck_busy_ticks,
|
||||
)
|
||||
}
|
||||
pub fn json(&self) -> String {
|
||||
format!(
|
||||
"{{\"scenario\":{:?},\"seed\":{},\"units\":{},\"ticks\":{},\"raids_ordered\":{},\"raids_ended\":{},\
|
||||
\"launched\":{},\"kills\":{},\"hits\":{},\"fired\":{},\
|
||||
\"tick_ms\":{{\"p50\":{:.4},\"p95\":{:.4},\"max\":{:.4},\"total\":{:.2}}},\
|
||||
\"planning_ms\":{:.2},\"planning_ticks\":{},\"ground_queries\":{},\"ground_batches\":{},\
|
||||
\"nan_positions\":{},\"stuck_trucks\":{:?},\"truck_busy_ticks\":{}}}",
|
||||
self.name,
|
||||
self.seed,
|
||||
self.units,
|
||||
self.ticks_run,
|
||||
self.raids_ordered,
|
||||
self.raids_ended,
|
||||
self.launched,
|
||||
self.kills,
|
||||
self.hits,
|
||||
self.fired,
|
||||
ms(self.tick_p50),
|
||||
ms(self.tick_p95),
|
||||
ms(self.tick_max),
|
||||
ms(self.tick_total),
|
||||
ms(self.planning),
|
||||
self.planning_ticks,
|
||||
self.ground_queries,
|
||||
self.ground_batches,
|
||||
self.nan_positions,
|
||||
self.stuck_trucks,
|
||||
self.truck_busy_ticks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ready-made scenarios
|
||||
|
||||
fn ring(sc: &mut Scenario, kinds: &[SamKind], count: usize, r0: f64, r1: f64) {
|
||||
for i in 0..count {
|
||||
let a = i as f64 * 2.399_963; // golden angle: an even spread without randomness
|
||||
let r = r0 + (r1 - r0) * (i as f64 / count.max(1) as f64);
|
||||
let kind = kinds[i % kinds.len()];
|
||||
sc.unit(kind, a.sin() * r, a.cos() * r).heading = a;
|
||||
}
|
||||
}
|
||||
|
||||
/// A big smart raid on 61 systems.
|
||||
pub fn big_smart() -> Scenario {
|
||||
let mut sc = Scenario::new("big-smart");
|
||||
sc.terrain = Terrain::Hills { base: 70, amp: 14.0, period: 90.0 };
|
||||
for (x, z) in [(0.0, 0.0), (120.0, 80.0), (-150.0, -60.0)] {
|
||||
sc.objects.push(DVec3::new(x, 70.0, z));
|
||||
}
|
||||
ring(&mut sc, &[SamKind::Radar], 6, 150.0, 500.0);
|
||||
ring(&mut sc, &[SamKind::Ew], 2, 100.0, 300.0);
|
||||
ring(&mut sc, &SamKind::ARMED, 53, 60.0, 450.0);
|
||||
assert_eq!(sc.units.len(), 61);
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![
|
||||
(DroneKind::Gerbera, 8),
|
||||
(DroneKind::Shahed, 12),
|
||||
(DroneKind::Geran3, 4),
|
||||
(DroneKind::Lancet, 4),
|
||||
(DroneKind::Kh101, 4),
|
||||
(DroneKind::Kalibr, 4),
|
||||
(DroneKind::Iskander, 2),
|
||||
],
|
||||
approach: Approach::AllSides,
|
||||
aim: Aim::Smart,
|
||||
dist: 500.0,
|
||||
at: 1,
|
||||
});
|
||||
sc.ticks = 20 * 60 * 8;
|
||||
sc
|
||||
}
|
||||
|
||||
/// Trucks and a depot supplying systems that start with empty magazines, under a raid.
|
||||
pub fn logistics() -> Scenario {
|
||||
let mut sc = Scenario::new("logistics");
|
||||
sc.unit(SamKind::Radar, 0.0, -40.0);
|
||||
sc.unit(SamKind::Depot, 300.0, 300.0);
|
||||
for (x, z) in [(310.0, 320.0), (290.0, 330.0)] {
|
||||
sc.unit(SamKind::Tzm, x, z);
|
||||
}
|
||||
sc.unit(SamKind::Tzm, 280.0, 280.0);
|
||||
for (i, kind) in [SamKind::Patriot, SamKind::Tor, SamKind::Buk, SamKind::Nasams, SamKind::Pantsir, SamKind::S400].into_iter().enumerate() {
|
||||
let a = i as f64 * 1.05;
|
||||
let u = sc.unit(kind, a.sin() * 120.0, a.cos() * 120.0);
|
||||
// Every other system starts out of missiles: the trucks have work from the first second.
|
||||
u.empty = i % 2 == 0;
|
||||
}
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![(DroneKind::Shahed, 16), (DroneKind::Gerbera, 8), (DroneKind::Kh101, 3)],
|
||||
approach: Approach::TwoSectors,
|
||||
aim: Aim::Objects,
|
||||
dist: 400.0,
|
||||
at: 1,
|
||||
});
|
||||
sc.ticks = 20 * 60 * 6;
|
||||
sc
|
||||
}
|
||||
|
||||
/// EW stations on and a smart enemy that adapts over three raids.
|
||||
pub fn ew_adaptive() -> Scenario {
|
||||
let mut sc = Scenario::new("ew-adaptive");
|
||||
sc.ew = true;
|
||||
sc.unit(SamKind::Radar, 0.0, 0.0);
|
||||
sc.unit(SamKind::Radar, 200.0, -200.0);
|
||||
sc.unit(SamKind::Ew, 80.0, 0.0);
|
||||
sc.unit(SamKind::Ew, -80.0, 60.0);
|
||||
for (i, kind) in [SamKind::Pantsir, SamKind::Gepard, SamKind::Tor, SamKind::Patriot, SamKind::Buk, SamKind::Gepard].into_iter().enumerate() {
|
||||
let a = i as f64 * 1.05;
|
||||
sc.unit(kind, a.sin() * 150.0, a.cos() * 150.0);
|
||||
}
|
||||
for at in [1, 1, 1] {
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![(DroneKind::Gerbera, 6), (DroneKind::Shahed, 10), (DroneKind::Kh101, 4), (DroneKind::Kalibr, 3)],
|
||||
approach: Approach::AllSides,
|
||||
aim: Aim::Smart,
|
||||
dist: 450.0,
|
||||
at,
|
||||
});
|
||||
}
|
||||
sc.ticks = 20 * 60 * 12;
|
||||
sc
|
||||
}
|
||||
|
||||
pub fn preset(name: &str) -> Option<Scenario> {
|
||||
match name {
|
||||
"big-smart" => Some(big_smart()),
|
||||
"logistics" => Some(logistics()),
|
||||
"ew-adaptive" => Some(ew_adaptive()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub const PRESETS: [&str; 3] = ["big-smart", "logistics", "ew-adaptive"];
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Native headless runner for the simulation.
|
||||
//!
|
||||
//! cargo run --release --example sim_run -- [options]
|
||||
//!
|
||||
//! Options (all optional; a preset gives the defaults, the others override it):
|
||||
//! --scenario NAME big-smart | logistics | ew-adaptive (default big-smart)
|
||||
//! --seed N random seed
|
||||
//! --layout FILE systems from a text file, replacing the preset's; one per line:
|
||||
//! `KIND X Z [HEADING_RAD] [empty]` (`#` starts a comment)
|
||||
//! KIND is a system key (patriot, tor, radar, depot, tzm, ew, ...)
|
||||
//! --raid SPEC replaces the preset's raids; repeatable. SPEC is
|
||||
//! `kind=count,kind=count[;dist=500][;aim=smart|objects|sams][;approach=sector|two|all|waves][;at=TICK]`
|
||||
//! --terrain T flat[:HEIGHT] | hills[:BASE:AMP:PERIOD]
|
||||
//! --ticks N tick limit (20 a second)
|
||||
//! --run-on do not stop when the raids have ended
|
||||
//! --ew / --no-ew EW stations on or off
|
||||
//! --json one JSON object instead of text
|
||||
//! --list list scenarios, system and drone kinds
|
||||
//! Exit code 1 when the run has problems (NaN positions, stuck trucks, a raid that never ended).
|
||||
//! Native timings are for comparing runs on one machine, not an estimate of WASM/GPU cost.
|
||||
mod harness;
|
||||
use airdef::sim::{Aim, Approach, DroneKind, SamKind};
|
||||
use glam::DVec3;
|
||||
use harness::*;
|
||||
|
||||
fn die(msg: &str) -> ! {
|
||||
eprintln!("sim_run: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn parse_raid(spec: &str) -> RaidSpec {
|
||||
let mut r = RaidSpec { kinds: Vec::new(), approach: Approach::Sector, aim: Aim::Smart, dist: 400.0, at: 1 };
|
||||
for (i, part) in spec.split(';').enumerate() {
|
||||
if i == 0 {
|
||||
for kc in part.split(',').filter(|s| !s.is_empty()) {
|
||||
let (k, c) = kc.split_once('=').unwrap_or_else(|| die(&format!("raid kind `{kc}` needs =count")));
|
||||
let kind = DroneKind::from_key(k).unwrap_or_else(|| die(&format!("unknown drone kind `{k}` (see --list)")));
|
||||
r.kinds.push((kind, c.parse().unwrap_or_else(|_| die(&format!("bad count `{c}`")))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (k, v) = part.split_once('=').unwrap_or_else(|| die(&format!("bad raid option `{part}`")));
|
||||
match k {
|
||||
"dist" => r.dist = v.parse().unwrap_or_else(|_| die("bad dist")),
|
||||
"at" => r.at = v.parse().unwrap_or_else(|_| die("bad at")),
|
||||
"aim" => {
|
||||
r.aim = match v {
|
||||
"smart" => Aim::Smart,
|
||||
"objects" => Aim::Objects,
|
||||
"sams" => Aim::ObjectsAndSams,
|
||||
_ => die("aim: smart | objects | sams"),
|
||||
}
|
||||
}
|
||||
"approach" => {
|
||||
r.approach = match v {
|
||||
"sector" => Approach::Sector,
|
||||
"two" => Approach::TwoSectors,
|
||||
"all" => Approach::AllSides,
|
||||
"waves" => Approach::Waves,
|
||||
_ => die("approach: sector | two | all | waves"),
|
||||
}
|
||||
}
|
||||
_ => die(&format!("unknown raid option `{k}`")),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn parse_terrain(t: &str) -> Terrain {
|
||||
let n: Vec<&str> = t.split(':').collect();
|
||||
let num = |i: usize, d: f64| n.get(i).map_or(d, |s| s.parse().unwrap_or_else(|_| die("bad terrain number")));
|
||||
match n[0] {
|
||||
"flat" => Terrain::Flat(num(1, 70.0) as i32),
|
||||
"hills" => Terrain::Hills { base: num(1, 70.0) as i32, amp: num(2, 14.0), period: num(3, 90.0) },
|
||||
_ => die("terrain: flat[:H] | hills[:BASE:AMP:PERIOD]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_layout(path: &str) -> Vec<Unit> {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_else(|e| die(&format!("{path}: {e}")));
|
||||
let mut units = Vec::new();
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let bad = || die(&format!("{path}:{}: expected `KIND X Z [HEADING] [empty]`", n + 1));
|
||||
if w.len() < 3 {
|
||||
bad();
|
||||
}
|
||||
let kind = SamKind::from_key(w[0]).unwrap_or_else(|| die(&format!("{path}:{}: unknown system `{}`", n + 1, w[0])));
|
||||
let f = |s: &str| s.parse::<f64>().unwrap_or_else(|_| bad());
|
||||
let heading = w.get(3).filter(|s| **s != "empty").map_or(0.0, |s| f(s));
|
||||
units.push(Unit { kind, pos: DVec3::new(f(w[1]), 70.0, f(w[2])), heading, empty: w.contains(&"empty") });
|
||||
}
|
||||
units
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut name = "big-smart".to_string();
|
||||
let mut i = 0;
|
||||
// The scenario first, so the other options override it whatever their order.
|
||||
while i < args.len() {
|
||||
if args[i] == "--scenario" {
|
||||
name = args.get(i + 1).cloned().unwrap_or_else(|| die("--scenario needs a name"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut sc = preset(&name).unwrap_or_else(|| die(&format!("unknown scenario `{name}`; one of {PRESETS:?}")));
|
||||
let (mut json, mut raids) = (false, Vec::new());
|
||||
let mut it = args.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
let mut value = || it.next().cloned().unwrap_or_else(|| die(&format!("{arg} needs a value")));
|
||||
match arg.as_str() {
|
||||
"--scenario" => {
|
||||
value();
|
||||
}
|
||||
"--seed" => sc.seed = value().parse().unwrap_or_else(|_| die("bad seed")),
|
||||
"--ticks" => sc.ticks = value().parse().unwrap_or_else(|_| die("bad ticks")),
|
||||
"--layout" => sc.units = parse_layout(&value()),
|
||||
"--raid" => raids.push(parse_raid(&value())),
|
||||
"--terrain" => sc.terrain = parse_terrain(&value()),
|
||||
"--run-on" => sc.stop_at_end = false,
|
||||
"--ew" => sc.ew = true,
|
||||
"--no-ew" => sc.ew = false,
|
||||
"--json" => json = true,
|
||||
"--list" => {
|
||||
println!("scenarios: {PRESETS:?}");
|
||||
println!("systems: {:?}", SamKind::ALL.iter().map(|k| k.key()).collect::<Vec<_>>());
|
||||
println!("drones: {:?}", DroneKind::ALL.iter().map(|k| k.spec().key).collect::<Vec<_>>());
|
||||
return;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("{}", include_str!("main.rs").lines().take_while(|l| l.starts_with("//")).map(|l| l.trim_start_matches("//!").trim_start_matches("//").to_string() + "\n").collect::<String>());
|
||||
return;
|
||||
}
|
||||
other => die(&format!("unknown option `{other}` (try --help)")),
|
||||
}
|
||||
}
|
||||
if !raids.is_empty() {
|
||||
sc.raids = raids;
|
||||
}
|
||||
let r = run(&sc);
|
||||
if json {
|
||||
println!("{}", r.json());
|
||||
} else {
|
||||
print!("{}", r.text());
|
||||
}
|
||||
let problems = r.problems();
|
||||
if !problems.is_empty() {
|
||||
eprintln!("PROBLEMS: {}", problems.join("; "));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,26 @@ fn screen_radius(world: vec3<f32>, radius: f32) -> f32 {
|
||||
return length((b.xy - a.xy) * frame.resolution.xy);
|
||||
}
|
||||
|
||||
// The clip-space position of a world point (what `project` starts from).
|
||||
fn clip_of(world: vec3<f32>) -> vec4<f32> {
|
||||
return frame.view_proj * vec4<f32>(world - frame.camera.xyz, 1.0);
|
||||
}
|
||||
|
||||
// A clip position on the screen in uv.
|
||||
fn clip_uv(c: vec4<f32>) -> vec2<f32> {
|
||||
let ndc = c.xy / max(abs(c.w), 1e-5);
|
||||
return vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
}
|
||||
|
||||
// `screen_radius` for a point whose clip position is known (the projection is linear,
|
||||
// so the offset to the right is added in clip space): saves two projections and a matrix
|
||||
// product for every effect at every pixel.
|
||||
fn screen_radius_clip(c0: vec4<f32>, radius: f32) -> f32 {
|
||||
let right = frame.view_inverse[0].xyz;
|
||||
let cb = c0 + frame.view_proj * vec4<f32>(right * radius, 0.0);
|
||||
return length((clip_uv(cb) - clip_uv(c0)) * frame.resolution.xy);
|
||||
}
|
||||
|
||||
fn now() -> f32 {
|
||||
return frame.params[0].x + frame.time.y;
|
||||
}
|
||||
@@ -86,22 +106,27 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 { continue; }
|
||||
// Past both windows (the ring's second, the haze over a hot ball) it bends nothing.
|
||||
let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y);
|
||||
if age >= 24.0 && heat <= 0.0 { continue; }
|
||||
let centre = a.xyz;
|
||||
let p = project(centre);
|
||||
if p.z <= 0.5 { continue; }
|
||||
let to = px - p.xy * frame.resolution.xy;
|
||||
let c0 = clip_of(centre);
|
||||
if c0.w <= 0.5 { continue; }
|
||||
let to = px - clip_uv(c0) * frame.resolution.xy;
|
||||
let dist = length(to);
|
||||
// The shock: a ring racing out, fading in a second.
|
||||
if age < 24.0 {
|
||||
let ring = screen_radius(centre, age * 3.2 * (0.6 + a.w * 0.1));
|
||||
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
|
||||
let width = 6.0 + ring * 0.08;
|
||||
let x = (dist - ring) / width;
|
||||
let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0;
|
||||
offset += normalize(to + vec2<f32>(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw;
|
||||
// exp(-x * x) * x is below 1e-8 past six widths.
|
||||
if abs(x) < 6.0 {
|
||||
let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0;
|
||||
offset += normalize(to + vec2<f32>(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw;
|
||||
}
|
||||
}
|
||||
// Heat haze over a hot fireball.
|
||||
let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y);
|
||||
let r = screen_radius(centre, a.w * 2.0);
|
||||
let r = screen_radius_clip(c0, a.w * 2.0);
|
||||
if heat > 0.0 && dist < r {
|
||||
let n = noise3(vec3<f32>(px * 0.04, t_now * 0.35)) - 0.5;
|
||||
offset += vec2<f32>(n, -abs(n)) * heat * (1.0 - dist / r) * 6.0 * frame.resolution.zw;
|
||||
@@ -149,8 +174,23 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 { continue; }
|
||||
let life = b.y;
|
||||
// Burnt out: no density and no fire light.
|
||||
if age >= life { continue; }
|
||||
let k = clamp(age / life, 0.0, 1.0);
|
||||
let size = a.w;
|
||||
// The ball and its smoke stay inside 4.5 sizes of the blast (radius 2.28 sizes, risen
|
||||
// 2.2 sizes): rays that miss that sphere cannot reach the volume. Only the
|
||||
// pixels' own direction is needed, no volume maths.
|
||||
let rel = a.xyz - frame.camera.xyz;
|
||||
let reach = size * 4.5 + 0.1;
|
||||
let cc = dot(rel, rel);
|
||||
let along = dot(rel, dir);
|
||||
if cc > reach * reach && (along < 0.0 || cc - along * along > reach * reach) {
|
||||
// Out of reach of the volume; the fire's glow still counts.
|
||||
let h0 = 1.0 - smoothstep(0.05, 0.55, k);
|
||||
glow += vec3<f32>(1.0, 0.5, 0.15) * h0 * h0 * size * 0.05 / (1.0 + length(rel + vec3<f32>(0.0, k * k * size * 2.2, 0.0)) * 0.03);
|
||||
continue;
|
||||
}
|
||||
let seed = vec3<f32>(a.x * 0.37, a.y * 0.11, a.z * 0.53);
|
||||
// The ball swells fast, then rises and spreads as smoke.
|
||||
let radius = size * (0.3 + 0.9 * (1.0 - exp(-age / 3.0))) * (1.0 + k * 0.9);
|
||||
@@ -195,13 +235,20 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 || age > 10.0 { continue; }
|
||||
let p = project(a.xyz);
|
||||
if p.z <= 0.5 { continue; }
|
||||
let visible = select(0.25, 1.0, scene_distance(p.xy) > p.z - a.w);
|
||||
let c0 = clip_of(a.xyz);
|
||||
if c0.w <= 0.5 { continue; }
|
||||
let p = vec3<f32>(clip_uv(c0), c0.w);
|
||||
// Ray distance on both sides: `p.z` is the depth along the view axis, which is shorter
|
||||
// than the distance along the ray towards the screen edge.
|
||||
let dist = length(a.xyz - frame.camera.xyz);
|
||||
let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w);
|
||||
let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible;
|
||||
let r = screen_radius(a.xyz, a.w * 2.5) + 20.0;
|
||||
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
|
||||
let dd = length(px - p.xy * frame.resolution.xy) / r;
|
||||
color += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25);
|
||||
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
|
||||
if dd < 9.0 {
|
||||
color += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25);
|
||||
}
|
||||
color += vec3<f32>(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01);
|
||||
}
|
||||
|
||||
@@ -209,11 +256,15 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
for (var j = 0; j < lights; j++) {
|
||||
let a = frame.params[LIGHT_BASE + 2 * j];
|
||||
let b = frame.params[LIGHT_BASE + 2 * j + 1];
|
||||
let p = project(a.xyz);
|
||||
if p.z <= 0.3 { continue; }
|
||||
if scene_distance(p.xy) < p.z - a.w * 1.5 { continue; }
|
||||
let r = screen_radius(a.xyz, a.w) + 3.0;
|
||||
let dd = length(px - p.xy * frame.resolution.xy) / r;
|
||||
let c0 = clip_of(a.xyz);
|
||||
if c0.w <= 0.3 { continue; }
|
||||
let p = clip_uv(c0);
|
||||
let r = screen_radius_clip(c0, a.w) + 3.0;
|
||||
let dd = length(px - p * frame.resolution.xy) / r;
|
||||
// Far from the glow (intensity at most 2): its tail is below 5e-4, under 8 bits.
|
||||
// This is checked before the depth lookup, which most pixels then never make.
|
||||
if dd > 6.0 { continue; }
|
||||
if scene_distance(p) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; }
|
||||
let flicker = 0.85 + 0.15 * noise3(vec3<f32>(a.xyz * 0.7 + vec3<f32>(t_now * 1.3)));
|
||||
color += b.rgb * b.w * flicker * (exp(-dd * dd * 4.0) * 1.2 + exp(-dd * 1.2) * 0.18);
|
||||
}
|
||||
|
||||
+148
-46
@@ -7,7 +7,7 @@ use horizon_mod_sdk::{
|
||||
Mod, Registration, chat, command, game,
|
||||
hud::{self, Corner},
|
||||
input::{self, Event},
|
||||
render::{self, Lighting, ModelInstance, ParticleSpawn, PointLight},
|
||||
render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget},
|
||||
};
|
||||
@@ -16,7 +16,8 @@ mod monitor;
|
||||
mod panel;
|
||||
mod radar;
|
||||
mod replay;
|
||||
mod sim;
|
||||
pub mod sim;
|
||||
mod scenario;
|
||||
mod wave;
|
||||
use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim};
|
||||
|
||||
@@ -56,6 +57,9 @@ struct AirDef {
|
||||
sim: Sim,
|
||||
ground: Ground,
|
||||
dirty: bool,
|
||||
/// Wall-clock-free pacing of autosaves: the tick before which none is written (at most one a
|
||||
/// second while guns fire, and a pause after a failed write).
|
||||
next_save: u64,
|
||||
hud_shown: bool,
|
||||
/// The system the monitor shows (K cycles), none: hidden.
|
||||
monitor: Option<usize>,
|
||||
@@ -82,8 +86,23 @@ struct AirDef {
|
||||
menu: Option<Page>,
|
||||
/// Where the player is, for the sounds' delay and far variants.
|
||||
listener: DVec3,
|
||||
/// Sounds on their way to the player at the speed of sound: (tick, event, at, pitch).
|
||||
pending: Vec<(u64, String, DVec3, f32)>,
|
||||
/// Sounds on their way to the player at the speed of sound.
|
||||
pending: Vec<PendingSound>,
|
||||
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
|
||||
/// a name the game doesn't know.
|
||||
model_ids: std::collections::HashMap<&'static str, Option<u32>>,
|
||||
model_generation: u32,
|
||||
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
|
||||
by_id: Vec<ModelInstanceId>,
|
||||
}
|
||||
|
||||
/// A sound heard later: when, what, where, and how loud and high it was.
|
||||
struct PendingSound {
|
||||
due: u64,
|
||||
name: String,
|
||||
at: DVec3,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
}
|
||||
|
||||
/// Blocks a tick the sound travels (343 m/s).
|
||||
@@ -307,7 +326,8 @@ impl AirDef {
|
||||
let size = b.size;
|
||||
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
|
||||
let flash = (-age * 0.35).exp() * 4.0;
|
||||
let id = 0x6100_0000 ^ (b.start as u32).wrapping_mul(31) ^ (b.at.x as i32 as u32).wrapping_mul(7);
|
||||
// Its own id range: the models' and the systems' ids are small numbers.
|
||||
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
|
||||
add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
|
||||
}
|
||||
for s in &self.sim.sams {
|
||||
@@ -352,7 +372,14 @@ impl AirDef {
|
||||
let mut p = vec![0.0f32; 4];
|
||||
p[0] = self.sim.tick as f32;
|
||||
p[3] = if self.hd { 1.0 } else { 0.0 };
|
||||
let blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
|
||||
let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
|
||||
// The shader composites the volumes front to back with one shared transmittance, so
|
||||
// the nearest to the player goes first. This is exact for volumes that do not
|
||||
// overlap; where two intersect, no order by centre is right along every ray.
|
||||
if let Some(pl) = player() {
|
||||
let e = eye(&pl);
|
||||
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
|
||||
}
|
||||
p[1] = blasts.len() as f32;
|
||||
for b in &blasts {
|
||||
p.extend([b.at.x as f32, b.at.y as f32, b.at.z as f32, b.size]);
|
||||
@@ -384,6 +411,38 @@ impl AirDef {
|
||||
p
|
||||
}
|
||||
|
||||
/// The models to the game by handle: each name crosses once, the buffer stays allocated.
|
||||
fn send_models(&mut self, models: &[sim::Model]) {
|
||||
let generation = render::model_generation();
|
||||
if generation != self.model_generation {
|
||||
// The game's registry changed (or this is the first tick): ask again.
|
||||
self.model_generation = generation;
|
||||
self.model_ids.clear();
|
||||
}
|
||||
self.by_id.clear();
|
||||
for m in models {
|
||||
let ids = &mut self.model_ids;
|
||||
let handle = *ids.entry(m.name).or_insert_with(|| render::model_id(m.name));
|
||||
// A name the game's registry doesn't have draws nothing, by name too.
|
||||
let Some(model) = handle else { continue };
|
||||
let r = m.rotation.as_quat();
|
||||
self.by_id.push(ModelInstanceId {
|
||||
id: m.id,
|
||||
model,
|
||||
x: m.at.x,
|
||||
y: m.at.y,
|
||||
z: m.at.z,
|
||||
rotation: (r.x, r.y, r.z, r.w),
|
||||
scale: m.scale,
|
||||
glow: m.glow,
|
||||
});
|
||||
}
|
||||
if !render::set_models_by_id(generation, &self.by_id) {
|
||||
// The registry changed between the two calls: ask again next tick.
|
||||
self.model_generation = u32::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, out: Out) {
|
||||
if self.sim.shader {
|
||||
render::set_params(&self.shader_params(&out));
|
||||
@@ -391,25 +450,8 @@ impl AirDef {
|
||||
if self.hd {
|
||||
render::set_lights(&self.lights(&out));
|
||||
}
|
||||
let models: Vec<ModelInstance> = out
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let r = m.rotation.as_quat();
|
||||
ModelInstance {
|
||||
id: m.id,
|
||||
model: m.name.to_string(),
|
||||
x: m.at.x,
|
||||
y: m.at.y,
|
||||
z: m.at.z,
|
||||
rotation: (r.x, r.y, r.z, r.w),
|
||||
scale: m.scale,
|
||||
glow: m.glow,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !models.is_empty() || self.sim.tick % 20 == 0 {
|
||||
render::set_models(&models);
|
||||
if !out.models.is_empty() || self.sim.tick % 20 == 0 {
|
||||
self.send_models(&out.models);
|
||||
}
|
||||
if !out.particles.is_empty() {
|
||||
let spawns: Vec<ParticleSpawn> = out
|
||||
@@ -441,7 +483,7 @@ impl AirDef {
|
||||
if delay == 0 {
|
||||
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
|
||||
} else if self.pending.len() < 256 {
|
||||
self.pending.push((now + delay, name, at, pitch));
|
||||
self.pending.push(PendingSound { due: now + delay, name, at, volume, pitch });
|
||||
}
|
||||
} else {
|
||||
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
|
||||
@@ -449,9 +491,9 @@ impl AirDef {
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.pending.len() {
|
||||
if self.pending[i].0 <= now {
|
||||
let (_, name, at, pitch) = self.pending.swap_remove(i);
|
||||
render::play_sound(&name, at.x, at.y, at.z, 1.0, pitch);
|
||||
if self.pending[i].due <= now {
|
||||
let s = self.pending.swap_remove(i);
|
||||
render::play_sound(&s.name, s.at.x, s.at.y, s.at.z, s.volume, s.pitch);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -485,12 +527,20 @@ impl AirDef {
|
||||
|
||||
// ------------------------------------------------ saving
|
||||
|
||||
fn save(&mut self) {
|
||||
/// The state as text, lines of words: the autosave's format, and the body of a scenario.
|
||||
/// `fresh`: every system and object whole, magazines full (a scenario to replay from the start).
|
||||
fn state_text(&self, fresh: bool, view: bool) -> String {
|
||||
let mut text = String::new();
|
||||
for s in &self.sim.sams {
|
||||
// Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks
|
||||
// left) and where it was driving, if anywhere.
|
||||
let dest = s.dest.map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z));
|
||||
let dest = s.dest.filter(|_| !fresh).map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z));
|
||||
let new = Sam::new(0, s.kind, s.pos, s.heading);
|
||||
let (health, cargo, missiles, bursts, empty, reloading) = if fresh {
|
||||
(new.health, new.cargo, new.missiles, new.bursts, false, 0)
|
||||
} else {
|
||||
(s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading)
|
||||
};
|
||||
text += &format!(
|
||||
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n",
|
||||
s.kind.key(),
|
||||
@@ -499,28 +549,42 @@ impl AirDef {
|
||||
s.pos.z,
|
||||
s.heading,
|
||||
s.roles,
|
||||
s.health,
|
||||
s.cargo,
|
||||
s.missiles,
|
||||
s.bursts,
|
||||
u8::from(s.empty),
|
||||
s.reloading,
|
||||
health,
|
||||
cargo,
|
||||
missiles,
|
||||
bursts,
|
||||
u8::from(empty),
|
||||
reloading,
|
||||
);
|
||||
}
|
||||
for (i, t) in self.sim.targets.iter().enumerate() {
|
||||
let h = self.sim.target_health.get(i).copied().unwrap_or(100.0);
|
||||
let h = if fresh { 100.0 } else { self.sim.target_health.get(i).copied().unwrap_or(100.0) };
|
||||
text += &format!("target {} {} {} {h}\n", t.x, t.y, t.z);
|
||||
}
|
||||
text += &format!("damage {}\n", self.sim.damage);
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
if view {
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
}
|
||||
for (kind, roles) in &self.sim.roles {
|
||||
text += &format!("roles {} {}\n", kind.key(), roles);
|
||||
}
|
||||
if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) {
|
||||
horizon_mod_sdk::warn(&format!("could not save: {e}"));
|
||||
text
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let text = self.state_text(false, true);
|
||||
// Clean only once written: a failed write is tried again after a pause.
|
||||
match storage::set(&self.state_key(), text.as_bytes()) {
|
||||
Ok(()) => {
|
||||
self.dirty = false;
|
||||
self.next_save = self.sim.tick + 20;
|
||||
}
|
||||
Err(e) => {
|
||||
horizon_mod_sdk::warn(&format!("could not save: {e}"));
|
||||
self.next_save = self.sim.tick + 200;
|
||||
}
|
||||
}
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
fn load(&mut self) {
|
||||
@@ -529,7 +593,12 @@ impl AirDef {
|
||||
|
||||
fn load_from(&mut self, key: &str) {
|
||||
let Some(bytes) = storage::get(key) else { return };
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
self.apply_state(&String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
|
||||
/// Adds the state in `text` (`state_text`'s lines; unknown lines are skipped, so older and
|
||||
/// newer files read alike).
|
||||
fn apply_state(&mut self, text: &str) {
|
||||
for line in text.lines() {
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let num = |i: usize| w.get(i).and_then(|v| v.parse::<f64>().ok());
|
||||
@@ -574,6 +643,13 @@ impl AirDef {
|
||||
}
|
||||
}
|
||||
Some(&"damage") => self.sim.damage = w.get(1) != Some(&"false"),
|
||||
// A scenario's: the random generator's state, and the wave menu's raid.
|
||||
Some(&"seed") => {
|
||||
if let Some(v) = w.get(1).and_then(|v| v.parse::<u64>().ok()) {
|
||||
self.sim.rng = sim::Rng::new(v);
|
||||
}
|
||||
}
|
||||
Some(&"wave") => self.wave.parse(&w[1..]),
|
||||
Some(&"far") => self.far = w.get(1) != Some(&"false"),
|
||||
Some(&"effects") => {
|
||||
let shader = w.get(1) != Some(&"false");
|
||||
@@ -635,11 +711,18 @@ impl AirDef {
|
||||
let shader = self.sim.shader;
|
||||
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
|
||||
self.sim = Sim::new(seed);
|
||||
self.next_save = 0;
|
||||
self.sim.shader = shader;
|
||||
self.ground = Ground::default();
|
||||
self.monitor = None;
|
||||
self.grid = false;
|
||||
self.grid_ids.clear();
|
||||
// The last world's transient state: its sounds were due on its own
|
||||
// clock, its picked unit may have another meaning in this one, and
|
||||
// its models would stay until the next full model list.
|
||||
self.pending.clear();
|
||||
self.moving = None;
|
||||
render::set_models(&[]);
|
||||
self.far = true;
|
||||
self.world = Some(world);
|
||||
self.load();
|
||||
@@ -876,7 +959,7 @@ impl AirDef {
|
||||
}
|
||||
}
|
||||
|
||||
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
|
||||
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
|
||||
|
||||
impl Mod for AirDef {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
@@ -885,6 +968,7 @@ impl Mod for AirDef {
|
||||
sim: Sim::new(seed),
|
||||
ground: Ground::default(),
|
||||
dirty: false,
|
||||
next_save: 0,
|
||||
hud_shown: false,
|
||||
monitor: None,
|
||||
monitor_shown: false,
|
||||
@@ -900,6 +984,9 @@ impl Mod for AirDef {
|
||||
menu: None,
|
||||
listener: DVec3::ZERO,
|
||||
pending: Vec::new(),
|
||||
model_ids: std::collections::HashMap::new(),
|
||||
model_generation: u32::MAX,
|
||||
by_id: Vec::new(),
|
||||
};
|
||||
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
|
||||
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
|
||||
@@ -1044,7 +1131,7 @@ impl Mod for AirDef {
|
||||
self.health_mark = mark;
|
||||
self.dirty = true;
|
||||
}
|
||||
if self.dirty {
|
||||
if self.dirty && self.sim.tick >= self.next_save {
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
@@ -1251,6 +1338,21 @@ impl Mod for AirDef {
|
||||
}
|
||||
None
|
||||
}
|
||||
["scenario", rest @ ..] => Some(self.scenario_command(rest)?),
|
||||
["why", rest @ ..] => {
|
||||
// The system n of the summary, else the monitored one.
|
||||
let i = match rest.first() {
|
||||
Some(n) => n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).ok_or("/airdef why <номер>")?,
|
||||
None => self.monitor.ok_or("/airdef why <номер> (или откройте монитор K)")?,
|
||||
};
|
||||
if i >= self.sim.sams.len() {
|
||||
return Err("Нет такой техники".into());
|
||||
}
|
||||
for c in self.sim.why(i) {
|
||||
say(&format!("{} {}", if c.ok { "+" } else { "-" }, c.text));
|
||||
}
|
||||
None
|
||||
}
|
||||
["raid"] => {
|
||||
self.launch_wave(&mut out)?;
|
||||
None
|
||||
|
||||
@@ -25,11 +25,22 @@ fn fit(t: String, w: f32) -> String {
|
||||
if render::text_width(&t) <= w {
|
||||
return t;
|
||||
}
|
||||
let mut cut: String = t;
|
||||
while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w {
|
||||
cut.pop();
|
||||
// The width grows with the prefix: bisect over its char starts, so a long
|
||||
// label costs a few host calls rather than one per dropped char.
|
||||
let starts: Vec<usize> = t.char_indices().map(|(i, _)| i).collect();
|
||||
let (mut lo, mut hi) = (0, starts.len());
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if render::text_width(&format!("{}…", &t[..starts[mid]])) <= w {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
format!("{cut}…")
|
||||
if lo == 0 {
|
||||
return "…".into();
|
||||
}
|
||||
format!("{}…", &t[..starts[lo - 1]])
|
||||
}
|
||||
|
||||
fn argb(rgb: u32, a: u32) -> u32 {
|
||||
@@ -101,7 +112,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
s.push(quad(p(a0, r - 0.4), p(a0, r + 0.4), p(a1, r + 0.4), p(a1, r - 0.4), 0x9040FF60));
|
||||
}
|
||||
}
|
||||
let reach = sam.kind.range();
|
||||
// A system that only fires (no radar) has no reach: a radar's sensor
|
||||
// range stands in, and nothing is divided by zero.
|
||||
let reach = match sam.kind.range() {
|
||||
r if r > 0.0 => r,
|
||||
_ => spec.sensor,
|
||||
};
|
||||
let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 };
|
||||
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
|
||||
for k in 0..6 {
|
||||
let a1 = sweep - k as f32 * 0.09;
|
||||
@@ -115,11 +132,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
|
||||
let rel = d.pos - sam.pos;
|
||||
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
|
||||
if flat > reach * 1.25 {
|
||||
if reach <= 0.0 || flat > reach * 1.25 {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
let k = (rr as f64 / (reach * 1.25)) as f32;
|
||||
let k = scale as f32;
|
||||
let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
|
||||
let allowed = sam.roles & d.kind.spec().class.bit() != 0;
|
||||
let color = if Some(d.id) == target.map(|t| t.id) {
|
||||
@@ -213,6 +230,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
|
||||
let mut s = Vec::new();
|
||||
cell(sim, index, 4.0, 4.0, 300.0, 150.0, &mut s);
|
||||
// Why it fires or doesn't: the conditions the simulation decides by, worked out now for this one.
|
||||
let checks = sim.why(index);
|
||||
s.push(fill(4.0, 156.0, 300.0, 4.0 + 10.0 * checks.len() as f32, 0xA0080C0A));
|
||||
for (k, c) in checks.iter().enumerate() {
|
||||
let color = if c.ok { 0xFF80FF90 } else { 0xFFFF9060 };
|
||||
s.push(text(fit(format!("{} {}", if c.ok { "+" } else { "-" }, c.text), 294.0), 7.0, 158.0 + 10.0 * k as f32, color));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -10,6 +10,8 @@ const CYAN: u32 = 0x9FE0FF;
|
||||
const GREY: u32 = 0xB0B0B0;
|
||||
const YELLOW: u32 = 0xFFE070;
|
||||
const GREEN: u32 = 0x80FF90;
|
||||
/// The most lines the summary holds (the HUD's room).
|
||||
const MAX_LINES: usize = 16;
|
||||
|
||||
/// Metres (a block is a metre) as a short distance.
|
||||
pub fn distance(m: f64) -> String {
|
||||
@@ -77,7 +79,15 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
if !sim.missiles.is_empty() {
|
||||
lines.push(line(format!("Перехватчиков в полёте: {}", sim.missiles.len()), YELLOW));
|
||||
}
|
||||
for sam in sim.sams.iter().take(8) {
|
||||
// The score is the last line and always shown: the unit list gets what
|
||||
// room is left, and cuts its own tail.
|
||||
let room = (MAX_LINES - 1).saturating_sub(lines.len());
|
||||
let total = sim.sams.len();
|
||||
let mut shown = total.min(8);
|
||||
if shown + usize::from(total > 8) > room {
|
||||
shown = room.saturating_sub(1);
|
||||
}
|
||||
for sam in sim.sams.iter().take(shown) {
|
||||
let mut text = if sam.kind.armed() {
|
||||
format!("{} [{}]", sam.status(), crate::sim::roles_text(sam.roles))
|
||||
} else {
|
||||
@@ -91,15 +101,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
}
|
||||
lines.push(line(text, CYAN));
|
||||
}
|
||||
if sim.sams.len() > 8 {
|
||||
lines.push(line(format!("…и ещё {} комплексов", sim.sams.len() - 8), CYAN));
|
||||
if total > shown && room > 0 {
|
||||
lines.push(line(format!("…и ещё {} комплексов", total - shown), CYAN));
|
||||
}
|
||||
// Room for the score whatever came before.
|
||||
lines.truncate(MAX_LINES - 1);
|
||||
let real = sim.launched.saturating_sub(0);
|
||||
let rate = if real > 0 { sim.kills as f64 * 100.0 / real as f64 } else { 0.0 };
|
||||
lines.push(line(
|
||||
format!("Сбито {} из {} ({rate:.0}%) · прорвалось {}", sim.kills, sim.launched, sim.hits),
|
||||
GREY,
|
||||
));
|
||||
lines.truncate(16);
|
||||
lines
|
||||
}
|
||||
|
||||
+68
-35
@@ -5,7 +5,8 @@
|
||||
//! behind the drone, then behind the interceptor or from the side — slowing
|
||||
//! time round the moment itself.
|
||||
use crate::panel::distance;
|
||||
use crate::sim::record::{EventKind, Recording, What};
|
||||
use crate::sim::record::{EventKind, Recording, STEP, What};
|
||||
use std::cell::Cell;
|
||||
use crate::sim::{Class, DroneKind, Out, Sim};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
|
||||
@@ -55,6 +56,8 @@ pub struct Player {
|
||||
/// Ticks the controls stay on screen.
|
||||
help: u32,
|
||||
pub done: bool,
|
||||
/// The map's area, worked out on the first map frame.
|
||||
area: Cell<Option<[f64; 4]>>,
|
||||
}
|
||||
|
||||
/// What the replay's keys do.
|
||||
@@ -201,6 +204,7 @@ impl Player {
|
||||
};
|
||||
let _ = sim;
|
||||
Player {
|
||||
area: Cell::new(None),
|
||||
rec,
|
||||
mode,
|
||||
t: start,
|
||||
@@ -581,34 +585,16 @@ impl Player {
|
||||
fn map(&self, rec: &Recording, gw: f32, gh: f32) -> Vec<Shape> {
|
||||
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xF0060A08)];
|
||||
let o = rec.origin;
|
||||
// The area: everything that flew, but not far beyond the systems.
|
||||
let mut lo = (f64::MAX, f64::MAX);
|
||||
let mut hi = (f64::MIN, f64::MIN);
|
||||
let mut grow = |p: DVec3| {
|
||||
lo = (lo.0.min(p.x), lo.1.min(p.z));
|
||||
hi = (hi.0.max(p.x), hi.1.max(p.z));
|
||||
// The area is the same every tick: worked out once for the recording.
|
||||
let [l0, l1, h0, h1] = match self.area.get() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let a = area(rec, o);
|
||||
self.area.set(Some(a));
|
||||
a
|
||||
}
|
||||
};
|
||||
for tr in &rec.tracks {
|
||||
if let What::Drone(k) = tr.what
|
||||
&& k.spec().ballistic.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in tr.path(o, 20, rec.len as f64) {
|
||||
grow(p);
|
||||
}
|
||||
}
|
||||
for sam in &rec.sams {
|
||||
grow(sam.2 - DVec3::splat(60.0));
|
||||
grow(sam.2 + DVec3::splat(60.0));
|
||||
}
|
||||
for p in &rec.objects {
|
||||
grow(*p);
|
||||
}
|
||||
if lo.0 > hi.0 {
|
||||
lo = (o.x - 200.0, o.z - 200.0);
|
||||
hi = (o.x + 200.0, o.z + 200.0);
|
||||
}
|
||||
let (lo, hi) = ((l0, l1), (h0, h1));
|
||||
let (top, bottom) = (20.0f32, 34.0f32);
|
||||
let (aw, ah) = (gw - 16.0, gh - top - bottom);
|
||||
let k = (aw as f64 / (hi.0 - lo.0).max(1.0)).min(ah as f64 / (hi.1 - lo.1).max(1.0));
|
||||
@@ -638,7 +624,9 @@ impl Player {
|
||||
let (x, y) = at(*p);
|
||||
s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFFFFF));
|
||||
}
|
||||
// Tracks up to now: a budget of segments shared out.
|
||||
// Tracks up to now: a budget of segments shared out. The stride is
|
||||
// worked out from the points there are, so only the segments drawn
|
||||
// are ever copied.
|
||||
let n = rec.tracks.len().max(1);
|
||||
let per = (640 / n).clamp(2, 14);
|
||||
for tr in &rec.tracks {
|
||||
@@ -647,16 +635,21 @@ impl Player {
|
||||
What::Drone(_) => (0x90FF4040, 0xFFFF6060),
|
||||
What::Missile(..) => (0x90FFD040, 0xFFFFE070),
|
||||
};
|
||||
let pts = tr.path(o, 1, self.t);
|
||||
let upto = ((self.t - tr.first as f64) / STEP as f64).floor();
|
||||
if upto < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let last = ((tr.last() - tr.first) / STEP) as f64;
|
||||
let count = upto.min(last) as usize + 1;
|
||||
if count < 2 {
|
||||
continue;
|
||||
}
|
||||
let pts = tr.path(o, count.div_ceil(per).max(1), self.t);
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let every = pts.len().div_ceil(per).max(1);
|
||||
let mut prev = at(pts[0]);
|
||||
for (i, p) in pts.iter().enumerate().skip(1) {
|
||||
if i % every != 0 && i != pts.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
for p in pts.iter().skip(1) {
|
||||
let q = at(*p);
|
||||
if inside(prev) && inside(q) {
|
||||
s.push(line(prev, q, 1.0, color));
|
||||
@@ -699,6 +692,11 @@ impl Player {
|
||||
}
|
||||
let keys = format!("{KEYS_HELP} · колесо: время");
|
||||
s.push(text(keys, gw / 2.0, gh - 12.0, 0xC0FFFFFF, true));
|
||||
// What went wrong for the air defence, as counted during the raid.
|
||||
let tally = rec.tally.text();
|
||||
if !tally.is_empty() && horizon_mod_sdk::render::text_width(&tally) < gw - 24.0 - lw {
|
||||
s.push(text(tally, 8.0, 6.0 + 11.0, 0xB0FFC080, false));
|
||||
}
|
||||
let (tx, ty, tw) = (8.0, gh - bottom + 6.0, gw - 16.0);
|
||||
s.push(fill(tx, ty, tw, 3.0, 0x60FFFFFF));
|
||||
let len = rec.len.max(1) as f32;
|
||||
@@ -718,6 +716,41 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ground the map shows, as [lo x, lo z, hi x, hi z]: everything that
|
||||
/// flew (not the ballistic falls), with the systems and objects, or round the
|
||||
/// origin when nothing did.
|
||||
fn area(rec: &Recording, o: DVec3) -> [f64; 4] {
|
||||
let mut lo = (f64::MAX, f64::MAX);
|
||||
let mut hi = (f64::MIN, f64::MIN);
|
||||
let mut grow = |p: DVec3| {
|
||||
lo = (lo.0.min(p.x), lo.1.min(p.z));
|
||||
hi = (hi.0.max(p.x), hi.1.max(p.z));
|
||||
};
|
||||
for tr in &rec.tracks {
|
||||
if let What::Drone(k) = tr.what
|
||||
&& k.spec().ballistic.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in tr.path(o, 20, rec.len as f64) {
|
||||
grow(p);
|
||||
}
|
||||
}
|
||||
// The systems, with some room round them.
|
||||
for sam in &rec.sams {
|
||||
grow(sam.2 - DVec3::splat(60.0));
|
||||
grow(sam.2 + DVec3::splat(60.0));
|
||||
}
|
||||
for p in &rec.objects {
|
||||
grow(*p);
|
||||
}
|
||||
if lo.0 > hi.0 {
|
||||
lo = (o.x - 200.0, o.z - 200.0);
|
||||
hi = (o.x + 200.0, o.z + 200.0);
|
||||
}
|
||||
[lo.0, lo.1, hi.0, hi.1]
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn event_clip(rec: &Recording, i: usize) -> Mode {
|
||||
Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false }
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Named scenarios: a snapshot of the layout (systems, objects), the wave menu's raid and the
|
||||
//! random generator, kept under a name and loaded back later. The body is the autosave's text
|
||||
//! (`state_text`); a header names the format's version and the world it was made in.
|
||||
use super::*;
|
||||
|
||||
/// The format this build writes and reads.
|
||||
const VERSION: u32 = 1;
|
||||
/// Names of the saved scenarios, one a line.
|
||||
const INDEX: &str = "scenarios";
|
||||
|
||||
fn key(name: &str) -> String {
|
||||
format!("scn-{name}")
|
||||
}
|
||||
|
||||
fn valid(name: &str) -> Result<String, String> {
|
||||
let n = name.to_lowercase();
|
||||
if n.is_empty() || n.len() > 32 || !n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err("Имя сценария: до 32 символов, латиница, цифры, - и _".into());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn names() -> Vec<String> {
|
||||
storage::get(INDEX).map(|b| String::from_utf8_lossy(&b).lines().map(str::to_string).filter(|l| !l.is_empty()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl AirDef {
|
||||
/// The scenario as text.
|
||||
fn scenario_text(&self, name: &str, fresh: bool) -> String {
|
||||
let mut text = format!("scenario {VERSION}\nname {name}\nworld {}\n", self.state_key());
|
||||
text += &self.state_text(fresh, false);
|
||||
text += &format!("seed {}\nwave {}\n", self.sim.rng.state(), self.wave.text());
|
||||
text
|
||||
}
|
||||
|
||||
/// Replaces the world's layout with the scenario `text`; nothing changes if it is refused.
|
||||
fn apply_scenario(&mut self, text: &str, force: bool) -> Result<(), String> {
|
||||
let mut lines = text.lines();
|
||||
let version = lines
|
||||
.next()
|
||||
.and_then(|l| l.strip_prefix("scenario "))
|
||||
.ok_or("Это не сценарий (нет строки версии)")?
|
||||
.trim();
|
||||
match version.parse::<u32>() {
|
||||
Ok(VERSION) => {}
|
||||
_ => return Err(format!("Сценарий формата {version}: эта версия мода понимает только формат {VERSION}")),
|
||||
}
|
||||
let world = text.lines().find_map(|l| l.strip_prefix("world ")).unwrap_or("").trim();
|
||||
if !force && world != self.state_key() {
|
||||
return Err("Сценарий сделан в другом мире: координаты могут не подойти (загрузить всё равно: добавьте force)".into());
|
||||
}
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
}
|
||||
// Everything of the last layout goes: its systems with their jobs and targets, the sky,
|
||||
// the cook-offs to come; ids are made anew with the new systems.
|
||||
self.sim.clear();
|
||||
self.sim.sams.clear();
|
||||
self.sim.targets.clear();
|
||||
self.sim.target_health.clear();
|
||||
self.sim.roles.clear();
|
||||
self.sim.kills = 0;
|
||||
self.sim.hits = 0;
|
||||
self.sim.launched = 0;
|
||||
self.monitor = None;
|
||||
self.grid_ids.clear();
|
||||
self.moving = None;
|
||||
self.pending.clear();
|
||||
render::set_models(&[]);
|
||||
self.apply_state(text);
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/airdef scenario save|load|list|delete`.
|
||||
pub(crate) fn scenario_command(&mut self, args: &[&str]) -> Result<String, String> {
|
||||
match args {
|
||||
["save", name, flags @ ..] => {
|
||||
let name = valid(name)?;
|
||||
let fresh = flags.contains(&"fresh");
|
||||
let text = self.scenario_text(&name, fresh);
|
||||
storage::set(&key(&name), text.as_bytes()).map_err(|e| format!("Не удалось сохранить: {e}"))?;
|
||||
let mut all = names();
|
||||
if !all.contains(&name) {
|
||||
all.push(name.clone());
|
||||
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
|
||||
}
|
||||
Ok(format!(
|
||||
"Сценарий «{name}» сохранён: комплексов {}, объектов {}{}",
|
||||
self.sim.sams.len(),
|
||||
self.sim.targets.len(),
|
||||
if fresh { " (целые, с полным боезапасом)" } else { "" }
|
||||
))
|
||||
}
|
||||
["load", name, flags @ ..] => {
|
||||
let name = valid(name)?;
|
||||
let bytes = storage::get(&key(&name)).ok_or(format!("Нет сценария «{name}» (/airdef scenario list)"))?;
|
||||
self.apply_scenario(&String::from_utf8_lossy(&bytes), flags.contains(&"force"))?;
|
||||
Ok(format!("Сценарий «{name}» загружен: комплексов {}, объектов {}", self.sim.sams.len(), self.sim.targets.len()))
|
||||
}
|
||||
["list"] => {
|
||||
let all = names();
|
||||
if all.is_empty() {
|
||||
return Ok("Сценариев нет: /airdef scenario save <имя>".into());
|
||||
}
|
||||
Ok(format!("Сценарии: {}", all.join(", ")))
|
||||
}
|
||||
["delete", name] => {
|
||||
let name = valid(name)?;
|
||||
let mut all = names();
|
||||
if !all.contains(&name) {
|
||||
return Err(format!("Нет сценария «{name}»"));
|
||||
}
|
||||
all.retain(|n| *n != name);
|
||||
storage::delete(&key(&name));
|
||||
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
|
||||
Ok(format!("Сценарий «{name}» удалён"))
|
||||
}
|
||||
_ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,8 @@ impl Sim {
|
||||
for h in &mut self.target_health {
|
||||
*h = 100.0;
|
||||
}
|
||||
// The wrecks' ammunition still to cook off goes with them.
|
||||
self.later.clear();
|
||||
n
|
||||
}
|
||||
|
||||
|
||||
@@ -159,8 +159,10 @@ impl Sim {
|
||||
return;
|
||||
}
|
||||
let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1;
|
||||
// The drones sent at a new target: (waiting in the queue, index), to get a new route.
|
||||
let mut retargeted: Vec<(bool, usize)> = Vec::new();
|
||||
let mut n = 0usize;
|
||||
for (_, d) in self.queue.iter_mut().filter(|(_, d)| d.smart) {
|
||||
for (qi, (_, d)) in self.queue.iter_mut().enumerate().filter(|(_, (_, d))| d.smart) {
|
||||
let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon);
|
||||
if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) {
|
||||
n += 1;
|
||||
@@ -173,18 +175,80 @@ impl Sim {
|
||||
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
|
||||
d.target = to;
|
||||
d.route.clear();
|
||||
retargeted.push((true, qi));
|
||||
n += 1;
|
||||
}
|
||||
// In flight: a target system destroyed — the next known one, or keep going.
|
||||
for d in self.drones.iter_mut().filter(|d| d.alive() && d.smart) {
|
||||
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
|
||||
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0);
|
||||
if aimed_at_wreck {
|
||||
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
|
||||
if let Some((_, at)) = next {
|
||||
d.target = *at;
|
||||
d.route.clear();
|
||||
retargeted.push((false, di));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.replan_routes(&retargeted);
|
||||
}
|
||||
|
||||
/// Routes round the known defences for drones that were just sent at a new target, from where they are
|
||||
/// (or wait). One grid and one set of prices cover them all, and the work is the planner's share for a
|
||||
/// tick: those left over fly straight, as they did before there was a replan. A Lancet's strike on a
|
||||
/// unit and the ballistic missiles go straight on purpose, as in the raid's own plan.
|
||||
fn replan_routes(&mut self, jobs: &[(bool, usize)]) {
|
||||
let drone = |s: &Sim, (queued, i): (bool, usize)| if queued { s.queue[i].1.clone() } else { s.drones[i].clone() };
|
||||
let jobs: Vec<(bool, usize)> = jobs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&j| {
|
||||
let d = drone(self, j);
|
||||
let spec = d.kind.spec();
|
||||
d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet
|
||||
})
|
||||
.collect();
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
// One grid over all their starts and goals, with room round them to go by.
|
||||
let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
|
||||
for &j in &jobs {
|
||||
let d = drone(self, j);
|
||||
for p in [d.pos, d.target] {
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
}
|
||||
}
|
||||
let span = (hi.x - lo.x).max(hi.z - lo.z) / 2.0 + 450.0;
|
||||
let cell = raid::CELL.max(2.0 * span / raid::CELLS as f64);
|
||||
let centre = (lo + hi) / 2.0;
|
||||
let grid = raid::Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 };
|
||||
let mut prices: HashMap<DroneKind, Vec<f32>> = HashMap::new();
|
||||
let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec<DVec3>> = HashMap::new();
|
||||
let mut work = 0usize;
|
||||
for j in jobs {
|
||||
let d = drone(self, j);
|
||||
if work >= raid::WORK_PER_TICK {
|
||||
break;
|
||||
}
|
||||
let key = (d.kind, grid.index(d.pos), grid.index(d.target));
|
||||
let route = match routes.get(&key) {
|
||||
Some(r) => r.clone(),
|
||||
None => {
|
||||
if !prices.contains_key(&d.kind) {
|
||||
let (p, cost) = self.price_grid(&grid, d.kind);
|
||||
work += cost;
|
||||
prices.insert(d.kind, p);
|
||||
}
|
||||
let (r, cost) = raid::plan_route(&grid, &prices[&d.kind], d.pos, d.target);
|
||||
work += cost;
|
||||
routes.insert(key, r.clone());
|
||||
r
|
||||
}
|
||||
};
|
||||
let d = if j.0 { &mut self.queue[j.1].1 } else { &mut self.drones[j.1] };
|
||||
d.route = route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ impl Sim {
|
||||
self.reload_now(i, out);
|
||||
}
|
||||
}
|
||||
// A destroyed truck drops its job, for another to take.
|
||||
for s in self.sams.iter_mut().filter(|s| s.kind == SamKind::Tzm && !s.alive()) {
|
||||
s.job = None;
|
||||
}
|
||||
// The trucks: a job each, when idle.
|
||||
for t in 0..self.sams.len() {
|
||||
let truck = &self.sams[t];
|
||||
@@ -66,13 +70,17 @@ impl Sim {
|
||||
}
|
||||
self.sams[t].job = None;
|
||||
} else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) {
|
||||
continue; // still on its way (a new order follows when it arrives)
|
||||
// Stopped short of it (it drove off meanwhile): on to where it stands now.
|
||||
let to = self.sams[j].pos;
|
||||
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
|
||||
self.order_move(t, to + side);
|
||||
continue;
|
||||
} else {
|
||||
self.sams[t].job = None;
|
||||
}
|
||||
}
|
||||
// A new job: the nearest waiting system no other truck is driving to, or a depot to load at.
|
||||
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm).filter_map(|s| s.job).collect();
|
||||
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()).filter_map(|s| s.job).collect();
|
||||
let next = if self.sams[t].cargo > 0 {
|
||||
self.sams
|
||||
.iter()
|
||||
|
||||
+217
-86
@@ -18,6 +18,9 @@ mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
mod units;
|
||||
mod why;
|
||||
#[cfg(test)]
|
||||
mod regress;
|
||||
pub use ew::EW_RADIUS;
|
||||
pub use units::{Family, SamKind};
|
||||
use units::Muzzle;
|
||||
@@ -52,6 +55,8 @@ pub struct Particle {
|
||||
/// A blast the post-processing shader draws as a fireball, flash and shock.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Blast {
|
||||
/// Never reused while the simulation lives: the HD light keeps its identity through the blast's life.
|
||||
pub id: u32,
|
||||
pub at: DVec3,
|
||||
/// Fireball radius in blocks.
|
||||
pub size: f32,
|
||||
@@ -108,6 +113,10 @@ impl Rng {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self(seed | 1)
|
||||
}
|
||||
/// The generator's state: `new(state())` continues the same sequence.
|
||||
pub fn state(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
pub fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
@@ -161,6 +170,33 @@ fn slew(from: f64, to: f64, rate: f64) -> f64 {
|
||||
from + angle_diff(from, to).clamp(-rate, rate)
|
||||
}
|
||||
|
||||
/// One axis of a drive: speeds up towards `err` (the angle still to turn), brakes in time to stop on it,
|
||||
/// and returns the turn this tick. Arriving takes the last bit at once, so there is no overshoot or jitter.
|
||||
fn drive(vel: &mut f64, err: f64, speed: f64, accel: f64) -> f64 {
|
||||
// The fastest it may go and still stop within what is left.
|
||||
let fastest = (2.0 * accel * err.abs()).sqrt().min(speed);
|
||||
let want = fastest.copysign(err);
|
||||
*vel = (*vel + (want - *vel).clamp(-accel, accel)).clamp(-speed, speed);
|
||||
if vel.abs() >= err.abs() && vel.signum() == err.signum() {
|
||||
*vel = 0.0;
|
||||
return err;
|
||||
}
|
||||
*vel
|
||||
}
|
||||
|
||||
/// Moves one system's claim from the drone it had to the one it has, in a count of claims per drone.
|
||||
fn retarget(counts: &mut HashMap<u32, u32>, from: Option<u32>, to: Option<u32>) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = from.and_then(|id| counts.get_mut(&id)) {
|
||||
*n = n.saturating_sub(1);
|
||||
}
|
||||
if let Some(id) = to {
|
||||
*counts.entry(id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn angle_diff(from: f64, to: f64) -> f64 {
|
||||
(to - from + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
|
||||
}
|
||||
@@ -173,8 +209,13 @@ pub struct Ground {
|
||||
known: HashMap<(i32, i32), (i32, u64)>,
|
||||
wanted: Vec<(i32, i32)>,
|
||||
asked: std::collections::HashSet<(i32, i32)>,
|
||||
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
|
||||
swept: u64,
|
||||
}
|
||||
|
||||
/// Ticks between full clean-ups of a big ground cache.
|
||||
const SWEEP_EVERY: u64 = 200;
|
||||
|
||||
impl Ground {
|
||||
/// One above the highest solid block, if known; the column is asked for
|
||||
/// when it isn't (or is old).
|
||||
@@ -182,7 +223,9 @@ impl Ground {
|
||||
let key = (p.x.floor() as i32, p.z.floor() as i32);
|
||||
match self.known.get(&key) {
|
||||
Some(&(y, when)) => {
|
||||
if now.saturating_sub(when) > 200 && self.wanted.len() < 256 && self.asked.insert(key) {
|
||||
// Refreshes of known columns take at most a quarter of the questions: the rest
|
||||
// stay free for columns never seen, or those would starve.
|
||||
if now.saturating_sub(when) > 200 && self.wanted.len() < 64 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
(y > -64).then_some(f64::from(y))
|
||||
@@ -199,8 +242,9 @@ impl Ground {
|
||||
for (x, z, y) in answers {
|
||||
self.known.insert((x, z), (y, now));
|
||||
}
|
||||
if self.known.len() > 300_000 {
|
||||
self.known.retain(|_, (_, when)| now - *when < 2000);
|
||||
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
|
||||
self.swept = now;
|
||||
self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000);
|
||||
}
|
||||
}
|
||||
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
|
||||
@@ -361,6 +405,17 @@ impl DroneKind {
|
||||
DroneKind::OreshnikBlock,
|
||||
DroneKind::OreshnikSub,
|
||||
];
|
||||
/// The sound of a launch from the ground: a light catapult shot, a rocket booster, a heavy ballistic launch.
|
||||
pub fn launch_sound(self) -> &'static str {
|
||||
match self {
|
||||
DroneKind::Shahed | DroneKind::Geran3 | DroneKind::Lancet | DroneKind::Gerbera => "airdef:launch.s400_eject",
|
||||
DroneKind::Kh101 | DroneKind::Kalibr | DroneKind::Kh22 => "airdef:launch.patriot",
|
||||
DroneKind::Zircon => "airdef:launch.s400",
|
||||
DroneKind::Iskander | DroneKind::Kinzhal | DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => {
|
||||
"airdef:launch.s500"
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn spec(self) -> &'static DroneSpec {
|
||||
match self {
|
||||
DroneKind::Shahed => &DroneSpec {
|
||||
@@ -779,7 +834,7 @@ impl MissileKind {
|
||||
rear: 32.0,
|
||||
},
|
||||
MissileKind::IrisT => &MissileSpec {
|
||||
model: "airdef:pac3",
|
||||
model: "airdef:irist_missile",
|
||||
speed: 7.0,
|
||||
accel: 0.5,
|
||||
turn: 0.35,
|
||||
@@ -793,7 +848,7 @@ impl MissileKind {
|
||||
rear: 28.0,
|
||||
},
|
||||
MissileKind::Amraam => &MissileSpec {
|
||||
model: "airdef:pac3",
|
||||
model: "airdef:amraam_missile",
|
||||
speed: 6.0,
|
||||
accel: 0.45,
|
||||
turn: 0.3,
|
||||
@@ -932,6 +987,9 @@ pub struct Sam {
|
||||
pub heading: f64,
|
||||
yaw: f64,
|
||||
pitch: f64,
|
||||
/// How fast the mount turns now (radians a tick); not saved, a loaded system starts at rest.
|
||||
yaw_vel: f64,
|
||||
pitch_vel: f64,
|
||||
pub missiles: u32,
|
||||
pub bursts: u32,
|
||||
cooldown: u32,
|
||||
@@ -985,6 +1043,8 @@ impl Sam {
|
||||
heading,
|
||||
yaw: heading,
|
||||
pitch: spec.rest_pitch,
|
||||
yaw_vel: 0.0,
|
||||
pitch_vel: 0.0,
|
||||
missiles: spec.missile.as_ref().map_or(0, |m| m.magazine),
|
||||
bursts: spec.gun.as_ref().map_or(0, |g| g.bursts),
|
||||
cooldown: 40,
|
||||
@@ -1024,6 +1084,21 @@ impl Sam {
|
||||
self.health > 0.0
|
||||
}
|
||||
/// How well it still works: turning, aiming, its radar's reach.
|
||||
/// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged
|
||||
/// system has the less of both speed and acceleration.
|
||||
fn aim_towards(&mut self, yaw: f64, pitch: Option<f64>) {
|
||||
let d = self.kind.spec().drive();
|
||||
let fit = self.fitness();
|
||||
self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit);
|
||||
if let Some(pitch) = pitch {
|
||||
self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit);
|
||||
}
|
||||
}
|
||||
/// The mount stops where it is (a march, a deployment: the vehicle drives it directly).
|
||||
pub(super) fn halt_mount(&mut self) {
|
||||
self.yaw_vel = 0.0;
|
||||
self.pitch_vel = 0.0;
|
||||
}
|
||||
fn fitness(&self) -> f64 {
|
||||
0.45 + 0.55 * self.health.clamp(0.0, 1.0)
|
||||
}
|
||||
@@ -1090,6 +1165,8 @@ pub struct Sim {
|
||||
fx: Vec<(u32, Fx)>,
|
||||
/// Blasts for the post-processing shader.
|
||||
pub blasts: Vec<Blast>,
|
||||
/// The next blast's id (separate from `next`, so the other units' ids stay as they were).
|
||||
blast_seq: u32,
|
||||
next: u32,
|
||||
pub rng: Rng,
|
||||
pub tick: u64,
|
||||
@@ -1118,6 +1195,8 @@ pub struct Sim {
|
||||
rec: Option<record::Recording>,
|
||||
pub replays: Vec<record::Recording>,
|
||||
recordings: u32,
|
||||
/// What went wrong for the air defence, counted for the raid report and the recordings.
|
||||
pub tally: record::Tally,
|
||||
/// What the enemy has learned.
|
||||
intel: ew::Intel,
|
||||
/// Night: guns need light (the mod sets it from the game's clock).
|
||||
@@ -1140,6 +1219,7 @@ impl Sim {
|
||||
targets: Vec::new(),
|
||||
fx: Vec::new(),
|
||||
blasts: Vec::new(),
|
||||
blast_seq: 0,
|
||||
next: 1,
|
||||
rng: Rng::new(seed),
|
||||
tick: 0,
|
||||
@@ -1158,6 +1238,7 @@ impl Sim {
|
||||
rec: None,
|
||||
replays: Vec::new(),
|
||||
recordings: 0,
|
||||
tally: record::Tally::default(),
|
||||
intel: ew::Intel::default(),
|
||||
night: false,
|
||||
flares: Vec::new(),
|
||||
@@ -1242,7 +1323,7 @@ impl Sim {
|
||||
let dir = DVec3::new(dir.x, dir.y.max(0.35), dir.z).normalize_or(DVec3::Y);
|
||||
let from = if kind.spec().ballistic.is_some() { from + DVec3::Y * 2.0 } else { from };
|
||||
self.add_drone(kind, from, dir, target);
|
||||
out.sound("airdef:launch.patriot", from, 1.0, 1.3);
|
||||
out.sound(kind.launch_sound(), from, 1.0, 1.3);
|
||||
out.particles("minecraft:large_smoke", from, 0.6, DVec3::ZERO, 0.05, 30);
|
||||
out.particles("minecraft:flame", from, 0.3, -dir * 0.2, 0.05, 12);
|
||||
}
|
||||
@@ -1308,8 +1389,8 @@ impl Sim {
|
||||
let now = self.tick;
|
||||
let mut boom: Vec<(DVec3, u32, bool)> = Vec::new();
|
||||
let mut removed: Vec<u32> = Vec::new();
|
||||
// Oreshnik pieces released this tick: kind, at, velocity, target.
|
||||
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3)> = Vec::new();
|
||||
// Oreshnik pieces released this tick: kind, at, velocity, target, the parent's sector (what they teach the enemy).
|
||||
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3, u8)> = Vec::new();
|
||||
// Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits.
|
||||
let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
|
||||
let mut splits: Vec<(u32, DVec3)> = Vec::new();
|
||||
@@ -1317,6 +1398,15 @@ impl Sim {
|
||||
d.age += 1;
|
||||
let spec = d.kind.spec();
|
||||
let below = ground.at(d.pos, now);
|
||||
// Near its aim it looks at the land there again: a platform or hilltop blown away
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood.
|
||||
if d.flight != Flight::Falling
|
||||
&& horizontal(d.target - d.pos).length() < 250.0
|
||||
&& let Some(g) = ground.at(d.target, now)
|
||||
&& g < d.target.y - 1.5
|
||||
{
|
||||
d.target.y = g;
|
||||
}
|
||||
if d.flight == Flight::Falling {
|
||||
d.vel.y -= 0.05;
|
||||
d.vel *= 0.985;
|
||||
@@ -1352,7 +1442,7 @@ impl Sim {
|
||||
let speed = d.vel.length();
|
||||
let hit = d.pos.distance(d.target) < speed.max(2.0)
|
||||
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|
||||
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0)
|
||||
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0)
|
||||
|| d.age > 4000;
|
||||
if hit {
|
||||
boom.push((d.pos, spec.warhead, false));
|
||||
@@ -1377,7 +1467,7 @@ impl Sim {
|
||||
let aim = d.target + DVec3::new(a.sin() * r, 0.0, a.cos() * r);
|
||||
// Straight down at the aim point, at the bus's speed.
|
||||
let v = (aim - d.pos).normalize_or(DVec3::NEG_Y) * 3.0;
|
||||
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim));
|
||||
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim, d.sector));
|
||||
}
|
||||
out.sound("airdef:boom.sonic", d.pos, 1.0, 0.7);
|
||||
splits.push((d.id, d.pos));
|
||||
@@ -1509,7 +1599,7 @@ impl Sim {
|
||||
self.explode(pos, warhead, small, ground, player, out);
|
||||
}
|
||||
self.drones.retain(|d| !removed.contains(&d.id));
|
||||
for (kind, pos, vel, target) in released {
|
||||
for (kind, pos, vel, target, sector) in released {
|
||||
let id = self.id();
|
||||
self.drones.push(Drone {
|
||||
id,
|
||||
@@ -1525,7 +1615,7 @@ impl Sim {
|
||||
route: Vec::new(),
|
||||
low: false,
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
sector,
|
||||
smart: false,
|
||||
});
|
||||
}
|
||||
@@ -1624,7 +1714,8 @@ impl Sim {
|
||||
}
|
||||
|
||||
fn fireball(&mut self, pos: DVec3, size: f64, life: u32) {
|
||||
self.blasts.push(Blast { at: pos, size: size as f32, start: self.tick, life: life * 9 });
|
||||
self.blast_seq = self.blast_seq.wrapping_add(1);
|
||||
self.blasts.push(Blast { id: self.blast_seq, at: pos, size: size as f32, start: self.tick, life: life * 9 });
|
||||
if self.blasts.len() > 48 {
|
||||
self.blasts.remove(0);
|
||||
}
|
||||
@@ -1688,6 +1779,12 @@ impl Sim {
|
||||
return;
|
||||
}
|
||||
let logistics = self.logistics();
|
||||
// Drones by id (none come or go while the systems act), and how many systems have each as their
|
||||
// missile target and as their gun target: counted once, then kept up to date as targets change.
|
||||
let drone_at: HashMap<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
|
||||
let (mut missile_on, mut guns_on) = why::engagement_counts(&self.sams);
|
||||
// For the raid's report: tracks dropped and systems that ran dry and wait for a truck.
|
||||
let (mut tracks_lost, mut ammo_waits) = (0u32, 0u32);
|
||||
let mut launches: Vec<(MissileKind, DVec3, DVec3, u32, u32)> = Vec::new();
|
||||
let mut gun_hits: Vec<(u32, u32)> = Vec::new();
|
||||
let mut tracers: Vec<(u32, DVec3, DVec3, u32)> = Vec::new();
|
||||
@@ -1696,8 +1793,8 @@ impl Sim {
|
||||
if !self.sams[i].alive() {
|
||||
let sam = &mut self.sams[i];
|
||||
sam.state = SamState::Destroyed;
|
||||
sam.target = None;
|
||||
sam.gun_target = None;
|
||||
retarget(&mut missile_on, sam.target.take(), None);
|
||||
retarget(&mut guns_on, sam.gun_target.take(), None);
|
||||
continue;
|
||||
}
|
||||
let fit = self.sams[i].fitness();
|
||||
@@ -1725,61 +1822,80 @@ impl Sim {
|
||||
sam.state = SamState::Search;
|
||||
// A searchlight's target is its beam's (step_lights): kept for its spot of light.
|
||||
if sam.kind != SamKind::Light {
|
||||
sam.target = None;
|
||||
retarget(&mut missile_on, sam.target.take(), None);
|
||||
}
|
||||
sam.gun_target = None;
|
||||
retarget(&mut guns_on, sam.gun_target.take(), None);
|
||||
continue;
|
||||
}
|
||||
let pos = self.sams[i].pos;
|
||||
let me = self.sams[i].id;
|
||||
let roles = self.sams[i].roles;
|
||||
let seen = self.sams[i].seen.clone();
|
||||
let own = self.sams[i].own.clone();
|
||||
let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id);
|
||||
// Interceptors a target has or will have: in flight, and other systems' launchers on it.
|
||||
let claimed = |d: &Drone| {
|
||||
d.engaged + self.sams.iter().filter(|s| s.id != me && s.target == Some(d.id)).count() as u32
|
||||
};
|
||||
// Ballistic missiles get a pair; anything else one.
|
||||
let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
|
||||
if let Some(load) = &spec.missile {
|
||||
let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos));
|
||||
let keep = self.sams[i].target.filter(|t| {
|
||||
self.drones.iter().any(|d| d.id == *t && allowed(d) && in_reach(d, 1.1))
|
||||
let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target);
|
||||
let (target, gun_target) = {
|
||||
let (seen, own) = (&self.sams[i].seen, &self.sams[i].own);
|
||||
// The conditions are `why`'s, shared with the diagnostic.
|
||||
let allowed = |d: &Drone| why::allowed(roles, seen, d);
|
||||
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
|
||||
let needed = why::needed;
|
||||
let target = spec.missile.as_ref().map(|load| {
|
||||
let in_reach = |d: &Drone, k: f64| why::in_reach(load.min, load.max, k, pos, d);
|
||||
let keep = had_target.filter(|t| {
|
||||
drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1))
|
||||
});
|
||||
keep.or_else(|| {
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
|
||||
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
|
||||
.map(|d| d.id)
|
||||
})
|
||||
});
|
||||
let target = keep.or_else(|| {
|
||||
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
|
||||
let gun_target = spec.gun.as_ref().map(|gun| {
|
||||
let on_it = |d: &Drone| {
|
||||
(guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64
|
||||
};
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
|
||||
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
|
||||
.map(|d| d.id)
|
||||
.filter(|d| why::gun_candidate(roles, seen, own, gun.track, pos, d))
|
||||
.map(|d| {
|
||||
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(d.pos.distance(pos) * busy, d.id)
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id)| id)
|
||||
});
|
||||
(target, gun_target)
|
||||
};
|
||||
if let Some(target) = target {
|
||||
// A target still in the sky that the system had and now doesn't: its track was lost.
|
||||
if let Some(h) = had_target
|
||||
&& target != Some(h)
|
||||
&& drone_at.get(&h).is_some_and(|&k| self.drones[k].alive())
|
||||
{
|
||||
tracks_lost += 1;
|
||||
}
|
||||
retarget(&mut missile_on, had_target, target);
|
||||
self.sams[i].target = target;
|
||||
}
|
||||
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
|
||||
if let Some(gun) = &spec.gun {
|
||||
let guns_on = |d: &Drone| {
|
||||
self.sams.iter().filter(|s| s.id != me && s.gun_target == Some(d.id)).count() as f64
|
||||
};
|
||||
self.sams[i].gun_target = self
|
||||
.drones
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track)
|
||||
.map(|d| {
|
||||
let busy = 1.0 + 2.0 * guns_on(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(d.pos.distance(pos) * busy, d.id)
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id)| id);
|
||||
if let Some(gun_target) = gun_target {
|
||||
retarget(&mut guns_on, had_gun, gun_target);
|
||||
self.sams[i].gun_target = gun_target;
|
||||
}
|
||||
let sam = &mut self.sams[i];
|
||||
// What the turret or launcher points at: guns first when a target is close.
|
||||
let aim = sam.gun_target.or(sam.target).and_then(|t| self.drones.iter().find(|d| d.id == t));
|
||||
let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k]));
|
||||
sam.launched = sam.launched.saturating_sub(1);
|
||||
let Some(aim) = aim else {
|
||||
// Out of missiles and nothing to shoot: reload now, guns or not.
|
||||
if spec.missile.is_some() && sam.missiles == 0 && sam.reloading == 0 && !sam.empty {
|
||||
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
|
||||
// Out of missiles or of shells, and nothing to shoot: reload now (both, as a
|
||||
// combined system's crew would), not only once both are spent.
|
||||
let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0);
|
||||
if spent && sam.reloading == 0 && !sam.empty {
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
}
|
||||
}
|
||||
sam.state = if sam.reloading > 0 {
|
||||
SamState::Reload
|
||||
@@ -1788,31 +1904,24 @@ impl Sim {
|
||||
} else {
|
||||
SamState::Search
|
||||
};
|
||||
sam.yaw = slew(sam.yaw, sam.heading, 0.03);
|
||||
sam.pitch += (spec.rest_pitch - sam.pitch) * 0.05;
|
||||
// Back to the rest position, under the drive's limits like any other turn.
|
||||
sam.aim_towards(sam.heading, Some(spec.rest_pitch));
|
||||
continue;
|
||||
};
|
||||
let (mount, _) = sam.mount();
|
||||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed);
|
||||
let to = lead - mount;
|
||||
let want_yaw = heading_of(to);
|
||||
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
|
||||
let (yaw_rate, pitch_rate) = spec.gun.as_ref().map_or((0.05, 0.05), |g| g.slew);
|
||||
let (yaw_rate, pitch_rate) = (yaw_rate * fit, pitch_rate * fit);
|
||||
let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim, lead_speed);
|
||||
let cells = matches!(spec.muzzle, Muzzle::Cells);
|
||||
match spec.family {
|
||||
// Vertical launch: no aiming (the Tor's turret turns its radar to the target).
|
||||
Family::Vertical if cells => sam.yaw = slew(sam.yaw, want_yaw, 0.15 * fit),
|
||||
Family::Vertical if cells => sam.aim_towards(want_yaw, None),
|
||||
Family::Vertical => {}
|
||||
Family::Trainable => sam.yaw = slew(sam.yaw, want_yaw, 0.12 * fit),
|
||||
_ => {
|
||||
sam.yaw = slew(sam.yaw, want_yaw, yaw_rate);
|
||||
sam.pitch = slew(sam.pitch, want_pitch, pitch_rate);
|
||||
}
|
||||
// The launcher's elevation is fixed (the missile turns after its boost): only the training is aimed.
|
||||
Family::Trainable => sam.aim_towards(want_yaw, None),
|
||||
_ => sam.aim_towards(want_yaw, Some(want_pitch)),
|
||||
}
|
||||
let aim_error = angle_diff(sam.yaw, want_yaw).abs()
|
||||
+ if spec.gun.is_some() { (want_pitch - sam.pitch).abs() } else { 0.0 };
|
||||
// How far the mount still is from the aim, over the axes this family aims.
|
||||
let aim_error = why::aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
|
||||
let vertical = spec.family == Family::Vertical;
|
||||
sam.state = if sam.reloading > 0 {
|
||||
SamState::Reload
|
||||
@@ -1831,15 +1940,20 @@ impl Sim {
|
||||
};
|
||||
// Guns.
|
||||
if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) {
|
||||
if let Some(t) = self.drones.iter().find(|d| d.id == gt) {
|
||||
if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) {
|
||||
let dist = t.pos.distance(mount);
|
||||
if aim.id == gt && aim_error < 0.1 && dist < gun.range && sam.gun_cooldown == 0 && sam.reloading == 0 && sam.bursts > 0 {
|
||||
if why::gun_blocks(sam, aim.id, gt, aim_error, dist, gun.range) == 0 {
|
||||
sam.gun_cooldown = 2;
|
||||
sam.bursts -= 1;
|
||||
sam.flash = 2;
|
||||
sam.recoil = 1.0;
|
||||
if sam.bursts == 0 && sam.missiles == 0 {
|
||||
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
}
|
||||
}
|
||||
let (_, rot) = sam.mount();
|
||||
let s = spec.scale;
|
||||
@@ -1876,36 +1990,40 @@ impl Sim {
|
||||
}
|
||||
// Missiles.
|
||||
if let (Some(load), Some(mt)) = (&spec.missile, sam.target) {
|
||||
let ready = sam.cooldown == 0 && sam.reloading == 0 && sam.missiles > 0;
|
||||
let aligned = match spec.family {
|
||||
Family::Trainable => {
|
||||
let t = self.drones.iter().find(|d| d.id == mt);
|
||||
// The missile turns after its boost: the launcher needn't be exact.
|
||||
t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.45)
|
||||
}
|
||||
Family::Turret => aim.id == mt && aim_error < 0.2,
|
||||
_ => true,
|
||||
};
|
||||
let ready = why::missile_blocks(sam) == 0;
|
||||
// The missile turns after its boost, but the launcher must point near the target.
|
||||
let t = drone_at.get(&mt).map(|&k| &self.drones[k]);
|
||||
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim.id, mt, aim_error);
|
||||
if ready && aligned {
|
||||
let n = load.magazine - sam.missiles;
|
||||
let (muzzle, dir) = sam.muzzle(n);
|
||||
launches.push((load.kind, muzzle, dir, mt, sam.id));
|
||||
// Its missile is on it from now, so the systems after this one this tick see
|
||||
// the target taken; this one's next launch looks for another.
|
||||
if let Some(d) = self.drones.iter_mut().find(|d| d.id == mt) {
|
||||
d.engaged += 1;
|
||||
if let Some(&k) = drone_at.get(&mt) {
|
||||
self.drones[k].engaged += 1;
|
||||
}
|
||||
sam.target = None;
|
||||
retarget(&mut missile_on, Some(mt), None);
|
||||
sam.launched = 12;
|
||||
sam.missiles -= 1;
|
||||
sam.fired += 1;
|
||||
sam.cooldown = load.interval;
|
||||
if sam.missiles == 0 && (spec.gun.is_none() || sam.bursts == 0) {
|
||||
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.note(|t| {
|
||||
t.tracks_lost += tracks_lost;
|
||||
t.ammo_waits += ammo_waits;
|
||||
});
|
||||
for (kind, muzzle, dir, target, sam) in launches {
|
||||
// A launch gives the system away.
|
||||
if let Some(s) = self.sams.iter().find(|s| s.id == sam) {
|
||||
@@ -1945,6 +2063,7 @@ impl Sim {
|
||||
|
||||
fn step_missiles(&mut self, out: &mut Out) {
|
||||
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
|
||||
for m in &mut self.missiles {
|
||||
m.age += 1;
|
||||
@@ -1983,11 +2102,18 @@ impl Sim {
|
||||
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
|
||||
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
|
||||
let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt());
|
||||
missed += u32::from(!kill);
|
||||
done.push((m.id, Some(t.id), kill, m.sam));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if m.age > spec.life || (target.is_none() && m.age > 30) {
|
||||
// Its target gone (shot down by another, or lost), or its life spent chasing one.
|
||||
if target.is_none() {
|
||||
orphaned += 1;
|
||||
} else {
|
||||
expired += 1;
|
||||
}
|
||||
done.push((m.id, None, false, m.sam));
|
||||
continue;
|
||||
}
|
||||
@@ -2021,6 +2147,11 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.note(|t| {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
t.orphaned += orphaned;
|
||||
});
|
||||
for (mid, target, kill, sam) in done {
|
||||
if let Some(i) = self.missiles.iter().position(|m| m.id == mid) {
|
||||
let m = self.missiles.swap_remove(i);
|
||||
|
||||
@@ -25,6 +25,10 @@ impl SamKind {
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// A reload truck's job: the system or depot it is going to.
|
||||
pub fn job(&self) -> Option<u32> {
|
||||
self.job
|
||||
}
|
||||
/// Packing up, driving or deploying: not fighting.
|
||||
pub fn moving(&self) -> bool {
|
||||
matches!(self.state, SamState::Packing | SamState::Moving | SamState::Deploying)
|
||||
@@ -56,6 +60,10 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
let spec = sam.kind.spec();
|
||||
// The march and the packing drive the mount themselves: no speed carries over into or out of them.
|
||||
if matches!(sam.state, SamState::Packing | SamState::Moving | SamState::Deploying) {
|
||||
sam.halt_mount();
|
||||
}
|
||||
match sam.state {
|
||||
SamState::Packing => {
|
||||
// The launcher comes down for the march.
|
||||
|
||||
+27
-12
@@ -95,23 +95,24 @@ pub(super) struct WaveStats {
|
||||
sams: usize,
|
||||
objects: f64,
|
||||
size: u32,
|
||||
tally: record::Tally,
|
||||
}
|
||||
|
||||
/// The planner's grid: cells this big, at most this many a side.
|
||||
const CELL: f64 = 16.0;
|
||||
const CELLS: i32 = 140;
|
||||
pub(super) const CELL: f64 = 16.0;
|
||||
pub(super) const CELLS: i32 = 140;
|
||||
/// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one.
|
||||
const WORK_PER_TICK: usize = 40_000;
|
||||
pub(super) const WORK_PER_TICK: usize = 40_000;
|
||||
|
||||
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
|
||||
struct Grid {
|
||||
min: DVec3,
|
||||
cell: f64,
|
||||
size: i32,
|
||||
pub(super) struct Grid {
|
||||
pub(super) min: DVec3,
|
||||
pub(super) cell: f64,
|
||||
pub(super) size: i32,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn index(&self, p: DVec3) -> (i32, i32) {
|
||||
pub(super) fn index(&self, p: DVec3) -> (i32, i32) {
|
||||
let n = self.size;
|
||||
((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1))
|
||||
}
|
||||
@@ -121,7 +122,7 @@ impl Grid {
|
||||
}
|
||||
|
||||
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
|
||||
fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
let mut opened = 0;
|
||||
let n = grid.size;
|
||||
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
|
||||
@@ -316,6 +317,7 @@ impl Sim {
|
||||
sams: self.sams.iter().filter(|s| s.alive()).count(),
|
||||
objects: self.target_health.iter().sum(),
|
||||
size: plan.total,
|
||||
tally: self.tally,
|
||||
});
|
||||
let what = if plan.smart { "умный противник" } else { plan.approach.name() };
|
||||
out.messages.push(format!("ВОЗДУШНАЯ ТРЕВОГА: налёт, {} целей ({what})", plan.total));
|
||||
@@ -401,7 +403,7 @@ impl Sim {
|
||||
|
||||
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
|
||||
/// system sees it there (its radar's reach for it, the land) and can shoot it.
|
||||
fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
let mut cost = 0;
|
||||
let spec = kind.spec();
|
||||
let height = spec.clearance.max(6.0);
|
||||
@@ -426,8 +428,16 @@ impl Sim {
|
||||
let sp = s.kind.spec();
|
||||
let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max));
|
||||
let gun = sp.gun.as_ref().map_or(0.0, |g| g.range);
|
||||
let radar = sight::reach(s, kind, height);
|
||||
let far = hi.max(gun).min(radar);
|
||||
// A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range.
|
||||
let own = sight::reach(s, kind, height);
|
||||
let net = self
|
||||
.sams
|
||||
.iter()
|
||||
.filter(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK)
|
||||
.map(|r| sight::reach(r, kind, height))
|
||||
.fold(0.0, f64::max);
|
||||
let (hi, gun) = (hi.min(own.max(net)), gun.min(own));
|
||||
let far = hi.max(gun);
|
||||
let at = sight::antenna(s);
|
||||
let horizon = self.horizons.get(&s.id);
|
||||
// Only the cells round it.
|
||||
@@ -491,6 +501,11 @@ impl Sim {
|
||||
"Налёт окончен: сбито {kills} из {launched} (заявлено {}), прорвалось {through}; ракет ПВО {fired}; потеряно комплексов {lost}{objects}",
|
||||
w.size
|
||||
));
|
||||
// Only what was counted; nothing is guessed for the rest.
|
||||
let why = self.tally.since(w.tally).text();
|
||||
if !why.is_empty() {
|
||||
out.messages.push(format!("Неудачи ПВО: {why}"));
|
||||
}
|
||||
self.wave = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,48 @@ pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Why interceptors and systems failed, as far as it was counted: only these are known.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
pub struct Tally {
|
||||
/// Interceptors that reached their fuse and did not kill (the hit chance failed).
|
||||
pub missed: u32,
|
||||
/// Interceptors whose life ran out with the target still flying.
|
||||
pub expired: u32,
|
||||
/// Interceptors whose target was gone (shot down by another, or lost) before they arrived.
|
||||
pub orphaned: u32,
|
||||
/// Times a system lost the track of the target it held, which was still in the sky.
|
||||
pub tracks_lost: u32,
|
||||
/// Times a system ran dry and began waiting for a reload truck.
|
||||
pub ammo_waits: u32,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
pub fn since(self, start: Tally) -> Tally {
|
||||
Tally {
|
||||
missed: self.missed.saturating_sub(start.missed),
|
||||
expired: self.expired.saturating_sub(start.expired),
|
||||
orphaned: self.orphaned.saturating_sub(start.orphaned),
|
||||
tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost),
|
||||
ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits),
|
||||
}
|
||||
}
|
||||
/// The non-zero counts as a line, or nothing.
|
||||
pub fn text(&self) -> String {
|
||||
let parts: Vec<String> = [
|
||||
(self.missed, "промахов по взрывателю"),
|
||||
(self.expired, "ракет выработали ресурс"),
|
||||
(self.orphaned, "ракет остались без цели"),
|
||||
(self.tracks_lost, "потерь трека"),
|
||||
(self.ammo_waits, "ожиданий БК"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|p| p.0 > 0)
|
||||
.map(|p| format!("{} {}", p.0, p.1))
|
||||
.collect();
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Recording {
|
||||
/// Which raid of the session (1, 2, …).
|
||||
pub number: u32,
|
||||
@@ -57,6 +99,8 @@ pub struct Recording {
|
||||
pub tracks: Vec<Track>,
|
||||
index: HashMap<u32, usize>,
|
||||
pub events: Vec<Event>,
|
||||
/// The failures counted during the raid.
|
||||
pub tally: Tally,
|
||||
/// The systems and objects as they stood: (id, kind, where).
|
||||
pub sams: Vec<(u32, SamKind, DVec3)>,
|
||||
pub objects: Vec<DVec3>,
|
||||
@@ -116,6 +160,7 @@ impl Recording {
|
||||
tracks: Vec::new(),
|
||||
index: HashMap::new(),
|
||||
events: Vec::new(),
|
||||
tally: Tally::default(),
|
||||
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
|
||||
objects: sim.targets.clone(),
|
||||
quiet: 0,
|
||||
@@ -150,6 +195,14 @@ impl Sim {
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Counts something in the session's tally and in the recording going on.
|
||||
pub(super) fn note(&mut self, f: impl Fn(&mut Tally)) {
|
||||
f(&mut self.tally);
|
||||
if let Some(r) = &mut self.rec {
|
||||
f(&mut r.tally);
|
||||
}
|
||||
}
|
||||
|
||||
/// Something worth a replay happened.
|
||||
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
|
||||
if let Some(r) = &mut self.rec {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Regression tests for the issues filed on Gitea (each names its issue).
|
||||
use super::*;
|
||||
use super::raid::Grid;
|
||||
|
||||
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
|
||||
#[test]
|
||||
fn terrain_queries_must_eventually_cover_all_radar_bearings() {
|
||||
let mut sim=Sim::new(1);
|
||||
for i in 0..10 {
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(i as f64*5000.,70.,0.),0.));
|
||||
}
|
||||
let mut ground=Ground::default();
|
||||
for now in 1..=2500 {
|
||||
sim.tick=now;sim.update_sight(&mut ground);
|
||||
let answers=ground.take_wanted().into_iter().map(|(x,z)|(x,z,200)).collect();
|
||||
ground.answer(answers,now);
|
||||
}
|
||||
let visible:Vec<usize>=sim.horizons.values().map(|h|h.rays.iter().filter(|r|r.last().is_some_and(|v|v.is_finite())).count()).collect();
|
||||
eprintln!("radar terrain coverage after 125 seconds: {:?} / 48 bearings each",visible);
|
||||
assert!(visible.iter().all(|n|*n==48),"refreshes of early rays starve unknown terrain in later rays");
|
||||
}
|
||||
|
||||
// #2: [bug] Уничтоженная ТЗМ навсегда резервирует заявку на подвоз боеприпасов
|
||||
#[test]
|
||||
fn dead_truck_must_not_reserve_a_waiting_system() {
|
||||
let mut sim=Sim::new(1);
|
||||
let depot=sim.id(); sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
|
||||
let target=sim.id(); let mut s=Sam::new(target,SamKind::Tor,DVec3::new(0.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
|
||||
let dead=sim.id();let mut s=Sam::new(dead,SamKind::Tzm,DVec3::new(100.,70.,0.),0.);s.job=Some(target);s.health=0.;sim.sams.push(s);
|
||||
let live=sim.id();sim.sams.push(Sam::new(live,SamKind::Tzm,DVec3::new(30.,70.,0.),0.));
|
||||
sim.tick=3;sim.step_supply(&mut Out::default());
|
||||
assert_eq!(sim.sams[3].job,Some(target),"a live loaded truck must take the waiting system");
|
||||
}
|
||||
|
||||
// #3: [bug] ТЗМ застревает, если обслуживаемая установка сменила позицию
|
||||
#[test]
|
||||
fn truck_must_follow_a_target_that_moved_away() {
|
||||
let mut sim=Sim::new(1);
|
||||
let depot=sim.id();sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
|
||||
let target=sim.id();let mut s=Sam::new(target,SamKind::Tor,DVec3::new(100.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
|
||||
let truck=sim.id();let mut s=Sam::new(truck,SamKind::Tzm,DVec3::new(0.,70.,0.),0.);s.job=Some(target);sim.sams.push(s);
|
||||
sim.tick=2;sim.step_supply(&mut Out::default());
|
||||
assert!(sim.sams[2].moving(),"truck at obsolete rendezvous must get a new move order");
|
||||
}
|
||||
|
||||
// #4: [bug] Планировщик умного налёта не учитывает пусковую с внешним целеуказанием
|
||||
#[test]
|
||||
fn network_launcher_must_contribute_to_route_risk() {
|
||||
let mut sim=Sim::new(1);
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.));
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.));
|
||||
let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20};
|
||||
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
|
||||
assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk");
|
||||
}
|
||||
|
||||
// #5: [bug] После ремонта комплекс снова уничтожается от собственной отложенной детонации
|
||||
#[test]
|
||||
fn repair_must_cancel_pending_magazine_explosions() {
|
||||
let mut sim=Sim::new(1);
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Patriot,DVec3::new(0.,70.,0.),0.));
|
||||
sim.blast_damage(DVec3::new(0.,70.,0.),4,&mut Out::default());
|
||||
assert!(!sim.later.is_empty());sim.repair();
|
||||
sim.tick=3; sim.step_later(&mut Ground::default(),None,&mut Out::default());
|
||||
assert!(sim.sams[0].alive(),"a fully repaired launcher was destroyed again by its own pre-repair cook-off");
|
||||
}
|
||||
|
||||
// #34: drones and ballistic missiles burst where their target's platform used to be.
|
||||
#[test]
|
||||
fn munitions_must_go_down_to_the_land_left_under_a_destroyed_target() {
|
||||
for (kind, from, vel) in [
|
||||
(DroneKind::Shahed, DVec3::new(0.0, 121.0, 0.0), DVec3::ZERO),
|
||||
(DroneKind::OreshnikSub, DVec3::new(0.0, 124.0, 0.0), DVec3::ZERO),
|
||||
(DroneKind::Iskander, DVec3::new(100.0, 121.0, 0.0), DVec3::new(0.0, -4.0, 0.0)),
|
||||
] {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
// The land under the old platform (at y 120) is at 64 now.
|
||||
for x in -120..=120 {
|
||||
for z in -8..=8 {
|
||||
ground.answer(vec![(x, z, 64)], 0);
|
||||
}
|
||||
}
|
||||
sim.add_drone(kind, from, DVec3::NEG_Y, DVec3::new(0.0, 120.0, 0.0));
|
||||
if vel != DVec3::ZERO {
|
||||
sim.drones[0].vel = vel;
|
||||
sim.drones[0].flight = Flight::Ballistic;
|
||||
}
|
||||
let mut out = Out::default();
|
||||
for _ in 0..400 {
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let blast = sim.blasts.first().map(|b| b.at);
|
||||
assert!(blast.is_some_and(|b| b.y < 80.0), "{kind:?} burst at {blast:?}, in the air over the land");
|
||||
}
|
||||
}
|
||||
|
||||
// #15: lights of blasts in one tick at the same whole X must stay apart
|
||||
#[test]
|
||||
fn blasts_in_one_tick_have_their_own_ids() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.tick = 100;
|
||||
sim.fireball(DVec3::new(0.0, 70.0, 100.0), 4.0, 10);
|
||||
sim.fireball(DVec3::new(0.0, 70.0, 200.0), 4.0, 10);
|
||||
assert_ne!(sim.blasts[0].id, sim.blasts[1].id);
|
||||
// A blast going away does not hand its id to the next one.
|
||||
let gone = sim.blasts.remove(0).id;
|
||||
sim.fireball(DVec3::new(0.0, 70.0, 300.0), 4.0, 10);
|
||||
assert!(sim.blasts.iter().all(|b| b.id != gone));
|
||||
}
|
||||
|
||||
// #21: a smart drone sent at a new target gets a route again, not a straight line
|
||||
#[test]
|
||||
fn retargeted_smart_drone_gets_a_route_round_the_defences() {
|
||||
let mut sim = Sim::new(1);
|
||||
// A system in the way, between the drone and the unit it is sent at.
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let goal = sim.id();
|
||||
sim.sams.push(Sam::new(goal, SamKind::Ew, DVec3::new(0.0, 70.0, 1000.0), 0.0));
|
||||
sim.intel.revealed.push((goal, DVec3::new(0.0, 70.0, 1000.0), 0));
|
||||
// Its first target, a system that is destroyed by now.
|
||||
let id = sim.id();
|
||||
let mut wreck = Sam::new(id, SamKind::Patriot, DVec3::new(3000.0, 70.0, -1000.0), 0.0);
|
||||
wreck.health = 0.0;
|
||||
sim.sams.push(wreck);
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, -1000.0), DVec3::Z, DVec3::new(3000.0, 70.0, -1000.0));
|
||||
sim.drones[0].pos = DVec3::new(0.0, 100.0, -1000.0);
|
||||
sim.drones[0].smart = true;
|
||||
sim.tick = 40;
|
||||
sim.adapt();
|
||||
let d = &sim.drones[0];
|
||||
assert!(d.target.distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
|
||||
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
|
||||
}
|
||||
|
||||
// #31: a big cache of fresh columns is not scanned on every answer
|
||||
#[test]
|
||||
fn big_ground_cache_is_swept_rarely() {
|
||||
let mut ground = Ground::default();
|
||||
ground.known.extend((0..300_001).map(|x| ((x, 0), (64, 2000))));
|
||||
ground.known.insert((-1, 0), (64, 0));
|
||||
// The first answer past the threshold sweeps what is stale and keeps what is fresh.
|
||||
ground.answer(vec![(-2, 0, 64)], 2100);
|
||||
assert!(!ground.known.contains_key(&(-1, 0)), "a stale column was never swept");
|
||||
assert!(ground.known.len() > 300_000);
|
||||
// The next ones wait their pause: a stale column added now survives them.
|
||||
ground.known.insert((-1, 0), (64, 0));
|
||||
let swept = ground.swept;
|
||||
ground.answer(vec![(-3, 0, 64)], 2101);
|
||||
assert_eq!(ground.swept, swept, "swept again at once");
|
||||
assert!(ground.known.contains_key(&(-1, 0)));
|
||||
ground.answer(vec![(-4, 0, 64)], 2100 + SWEEP_EVERY);
|
||||
assert!(!ground.known.contains_key(&(-1, 0)), "never swept again");
|
||||
}
|
||||
|
||||
// #32: the pieces of an Oreshnik teach the enemy about the sector it came from
|
||||
#[test]
|
||||
fn oreshnik_pieces_keep_the_sector() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Oreshnik, DVec3::new(100.0, 300.0, 0.0), DVec3::NEG_Y, DVec3::new(0.0, 70.0, 0.0));
|
||||
sim.drones[0].vel = DVec3::new(-1.0, -1.0, 0.0);
|
||||
sim.drones[0].sector = 7;
|
||||
let mut out = Out::default();
|
||||
sim.step_drones(&mut Ground::default(), None, &mut out);
|
||||
let pieces: Vec<_> = sim.drones.iter().filter(|d| d.kind == DroneKind::OreshnikBlock).collect();
|
||||
assert_eq!(pieces.len(), 6);
|
||||
assert!(pieces.iter().all(|d| d.sector == 7));
|
||||
}
|
||||
|
||||
// #33: a turret does not fire before it has turned to the target
|
||||
#[test]
|
||||
fn turret_waits_for_its_aim() {
|
||||
let mut sim = Sim::new(1);
|
||||
let id = sim.id();
|
||||
let mut s = Sam::new(id, SamKind::Gepard, DVec3::new(0.0, 70.0, 0.0), 0.0);
|
||||
s.cooldown = 0;
|
||||
sim.sams.push(s);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
// Straight behind the turret's heading, a slow target well inside the gun's reach.
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
let mut turned = 0;
|
||||
for _ in 0..200 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let g = &sim.sams[0];
|
||||
let want = heading_of(sim.drones[0].pos - g.mount().0);
|
||||
if angle_diff(g.yaw, want).abs() > 0.1 {
|
||||
assert_eq!(g.bursts, 160, "fired with the turret still turning");
|
||||
turned += 1;
|
||||
}
|
||||
if g.bursts < 160 || sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(turned > 20, "the turret turned too fast to tell ({turned} ticks)");
|
||||
assert!(sim.sams[0].bursts < 160, "never fired");
|
||||
}
|
||||
|
||||
// #33: the drive speeds up, brakes in time and does not overshoot
|
||||
#[test]
|
||||
fn drive_arrives_without_overshoot() {
|
||||
for err in [std::f64::consts::PI, 1.57, 0.3, 0.02] {
|
||||
let (mut vel, mut left) = (0.0, err);
|
||||
let (speed, accel) = (0.088, 0.011);
|
||||
let mut ticks = 0;
|
||||
while left > 0.0 {
|
||||
let step = drive(&mut vel, left, speed, accel);
|
||||
assert!(step.abs() <= speed + 1e-9 && step >= 0.0, "step {step} at {left} left");
|
||||
left -= step;
|
||||
ticks += 1;
|
||||
assert!(ticks < 400, "never arrived for {err}");
|
||||
}
|
||||
assert!(left.abs() < 1e-9 && vel == 0.0);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const NEAR: f64 = 24.0;
|
||||
|
||||
pub(super) struct Horizon {
|
||||
/// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation).
|
||||
rays: Vec<Vec<f32>>,
|
||||
pub(super) rays: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Horizon {
|
||||
@@ -90,7 +90,8 @@ impl Sim {
|
||||
self.horizons.insert(sam.id, h);
|
||||
}
|
||||
}
|
||||
self.horizons.retain(|id, _| self.sams.iter().any(|s| s.id == *id && s.alive()));
|
||||
let alive: std::collections::HashSet<u32> = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect();
|
||||
self.horizons.retain(|id, _| alive.contains(id));
|
||||
// Each drone's height over the land under it (or over the systems' ground, unknown).
|
||||
let heights: Vec<Option<f64>> =
|
||||
self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect();
|
||||
@@ -120,18 +121,20 @@ impl Sim {
|
||||
// The network: every unit gets the tracks of the radars within data-link range.
|
||||
let tracks: Vec<(DVec3, Vec<u32>)> =
|
||||
self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect();
|
||||
// Merged in the radars' order, each track once: a set marks what is in already.
|
||||
let mut have: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for sam in &mut self.sams {
|
||||
sam.seen.clone_from(&sam.own);
|
||||
if !sam.alive() || sam.moving() || !sam.kind.armed() {
|
||||
sam.seen = sam.own.clone();
|
||||
continue;
|
||||
}
|
||||
let mut seen = sam.own.clone();
|
||||
have.clear();
|
||||
have.extend(sam.own.iter().copied());
|
||||
for (at, ids) in &tracks {
|
||||
if at.distance(sam.pos) <= LINK {
|
||||
seen.extend(ids.iter().copied().filter(|id| !seen.contains(id)).collect::<Vec<_>>());
|
||||
sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id)));
|
||||
}
|
||||
}
|
||||
sam.seen = seen;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,19 @@ pub(super) struct GunSpec {
|
||||
pub slew: (f64, f64),
|
||||
}
|
||||
|
||||
/// How fast a weapon's mount turns: per axis the top speed (radians a tick) and the acceleration, which
|
||||
/// is also the braking (radians a tick²). Game settings, not the real machines' figures.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Drive {
|
||||
pub yaw: (f64, f64),
|
||||
pub pitch: (f64, f64),
|
||||
}
|
||||
|
||||
/// The drives are this share of the speeds they had before they had a speed of their own.
|
||||
const DRIVE_SCALE: f64 = 0.4;
|
||||
/// Ticks a drive takes to reach its top speed (and to stop from it).
|
||||
const DRIVE_RAMP: f64 = 8.0;
|
||||
|
||||
pub struct SamSpec {
|
||||
pub name: &'static str,
|
||||
pub key: &'static str,
|
||||
@@ -122,6 +135,20 @@ pub struct SamSpec {
|
||||
pub mast: f64,
|
||||
}
|
||||
|
||||
impl SamSpec {
|
||||
/// The mount's drive: the guns' own slew rates, the old rates of the launchers, scaled down.
|
||||
pub(super) fn drive(&self) -> Drive {
|
||||
let (yaw, pitch) = match (&self.gun, self.family) {
|
||||
(Some(g), _) => g.slew,
|
||||
(None, Family::Trainable) => (0.12, 0.0),
|
||||
(None, Family::Vertical) => (0.15, 0.0),
|
||||
(None, _) => (0.05, 0.05),
|
||||
};
|
||||
let axis = |speed: f64| (speed * DRIVE_SCALE, speed * DRIVE_SCALE / DRIVE_RAMP);
|
||||
Drive { yaw: axis(yaw), pitch: axis(pitch) }
|
||||
}
|
||||
}
|
||||
|
||||
const NO_PARTS: Parts = Parts { body: "", launcher: None, pack: None, dish: None, fixed: &[], spinner: None };
|
||||
/// The battery radar trailer behind a vertical launcher.
|
||||
const BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)];
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! The conditions that decide whether a system fires, as predicates: `step_sams` decides with
|
||||
//! them, and `Sim::why` lists them for a chosen system (`/airdef why`, the monitor), so the
|
||||
//! explanation can't drift from the behaviour. Each predicate returns the set of reasons it
|
||||
//! fails (bits; 0 is a pass), so the diagnostic names the reasons and the simulation only asks
|
||||
//! whether there are none.
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- choosing a target
|
||||
|
||||
/// Why a drone is not a candidate for a system at all.
|
||||
pub(super) const DEAD: u8 = 1;
|
||||
pub(super) const ROLE: u8 = 2;
|
||||
pub(super) const UNSEEN: u8 = 4;
|
||||
|
||||
pub(super) fn target_blocks(roles: u8, seen: &[u32], d: &Drone) -> u8 {
|
||||
(if d.alive() { 0 } else { DEAD })
|
||||
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
|
||||
| (if seen.contains(&d.id) { 0 } else { UNSEEN })
|
||||
}
|
||||
|
||||
/// Alive, of a class the system engages, and on its picture (own radar or the network's).
|
||||
pub(super) fn allowed(roles: u8, seen: &[u32], d: &Drone) -> bool {
|
||||
target_blocks(roles, seen, d) == 0
|
||||
}
|
||||
|
||||
pub(super) const TOO_CLOSE: u8 = 1;
|
||||
pub(super) const TOO_FAR: u8 = 2;
|
||||
|
||||
/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max).
|
||||
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> u8 {
|
||||
let dist = d.pos.distance(pos);
|
||||
(if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR })
|
||||
}
|
||||
|
||||
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
reach_blocks(min, max, k, pos, d) == 0
|
||||
}
|
||||
|
||||
/// Interceptors a target has or will have: in flight, and other systems' launchers on it.
|
||||
pub(super) fn claimed(d: &Drone, missile_on: &HashMap<u32, u32>, had_target: Option<u32>) -> u32 {
|
||||
let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id));
|
||||
d.engaged + on
|
||||
}
|
||||
|
||||
/// Ballistic missiles get a pair; anything else one.
|
||||
pub(super) fn needed(d: &Drone) -> u32 {
|
||||
if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }
|
||||
}
|
||||
|
||||
/// A gun's candidates: held by the system's own radar and within the gun's tracking range.
|
||||
pub(super) fn gun_candidate(roles: u8, seen: &[u32], own: &[u32], track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, seen, d) && own.contains(&d.id) && d.pos.distance(pos) < track
|
||||
}
|
||||
|
||||
/// How many systems have each drone as their missile target and as their gun target.
|
||||
pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap<u32, u32>, HashMap<u32, u32>) {
|
||||
let mut missile_on = HashMap::new();
|
||||
let mut guns_on = HashMap::new();
|
||||
for s in sams {
|
||||
retarget(&mut missile_on, None, s.target);
|
||||
retarget(&mut guns_on, None, s.gun_target);
|
||||
}
|
||||
(missile_on, guns_on)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- aiming
|
||||
|
||||
/// Where the lead point is and the yaw and pitch that point the mount at it.
|
||||
pub(super) fn aim_wants(mount: DVec3, aim: &Drone, lead_speed: f64) -> (DVec3, f64, f64) {
|
||||
let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed);
|
||||
let to = lead - mount;
|
||||
let want_yaw = heading_of(to);
|
||||
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
|
||||
(lead, want_yaw, want_pitch)
|
||||
}
|
||||
|
||||
/// How far the mount still is from the aim, over the axes this family aims.
|
||||
pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, want_pitch: f64) -> f64 {
|
||||
let yaw_error = angle_diff(yaw, want_yaw).abs();
|
||||
match family {
|
||||
Family::Trainable | Family::Vertical => yaw_error,
|
||||
_ => yaw_error + (want_pitch - pitch).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- firing
|
||||
|
||||
pub(super) const COOLDOWN: u8 = 1;
|
||||
pub(super) const RELOADING: u8 = 2;
|
||||
pub(super) const NO_ROUNDS: u8 = 4;
|
||||
|
||||
/// What keeps a loaded launcher from firing now (aim aside).
|
||||
pub(super) fn missile_blocks(s: &Sam) -> u8 {
|
||||
(if s.cooldown == 0 { 0 } else { COOLDOWN })
|
||||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||||
| (if s.missiles > 0 { 0 } else { NO_ROUNDS })
|
||||
}
|
||||
|
||||
/// The launcher points near enough: a trainable one only by its training, a turret by its
|
||||
/// whole aim at this target, the vertical ones need nothing.
|
||||
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<&Drone>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
|
||||
match family {
|
||||
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t.pos - mount)).abs() < 0.15),
|
||||
Family::Turret => aim_id == mt && aim_error < 0.15,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const NOT_AIMED: u8 = 8;
|
||||
pub(super) const AIM_ERROR: u8 = 16;
|
||||
pub(super) const OUT_OF_RANGE: u8 = 32;
|
||||
|
||||
/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range,
|
||||
/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits).
|
||||
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64) -> u8 {
|
||||
(if aim_id == gt { 0 } else { NOT_AIMED })
|
||||
| (if aim_error < 0.1 { 0 } else { AIM_ERROR })
|
||||
| (if dist < range { 0 } else { OUT_OF_RANGE })
|
||||
| (if s.gun_cooldown == 0 { 0 } else { COOLDOWN })
|
||||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||||
| (if s.bursts > 0 { 0 } else { NO_ROUNDS })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the diagnostic
|
||||
|
||||
/// One condition and whether it holds.
|
||||
pub struct Check {
|
||||
pub ok: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn check(ok: bool, text: impl Into<String>) -> Check {
|
||||
Check { ok, text: text.into() }
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Why system `index` fires or doesn't, at its current target or at the most threatening
|
||||
/// drone of a class it engages; computed on demand from the very predicates `step_sams`
|
||||
/// uses. The last line is the verdict.
|
||||
pub fn why(&self, index: usize) -> Vec<Check> {
|
||||
let Some(sam) = self.sams.get(index) else { return vec![check(false, "Нет такой техники")] };
|
||||
let spec = sam.kind.spec();
|
||||
let mut v = vec![check(true, format!("№{} {}: {}", index + 1, spec.name, sam.state.name()))];
|
||||
if !sam.alive() {
|
||||
v.push(check(false, "уничтожен"));
|
||||
return v;
|
||||
}
|
||||
if self.hold {
|
||||
v.push(check(false, "огонь запрещён (/airdef hold off)"));
|
||||
return v;
|
||||
}
|
||||
if sam.moving() {
|
||||
v.push(check(false, format!("{}: не стреляет", sam.state.name().to_lowercase())));
|
||||
return v;
|
||||
}
|
||||
if !sam.kind.armed() {
|
||||
v.push(check(false, "нет оружия (РЛС, склад, РЭБ, прожектор)"));
|
||||
return v;
|
||||
}
|
||||
let logistics = self.logistics();
|
||||
let (missile_on, guns_on) = engagement_counts(&self.sams);
|
||||
let alive = self.drones.iter().filter(|d| d.alive()).count();
|
||||
let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count();
|
||||
let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count();
|
||||
v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)")));
|
||||
let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id);
|
||||
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| d.alive() && sam.roles & d.kind.spec().class.bit() != 0)
|
||||
.max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
|
||||
});
|
||||
let Some(t) = subject else {
|
||||
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
|
||||
return v;
|
||||
};
|
||||
let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id);
|
||||
let dist = t.pos.distance(sam.pos);
|
||||
v.push(check(
|
||||
true,
|
||||
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)),
|
||||
));
|
||||
let blocks = target_blocks(sam.roles, &sam.seen, t);
|
||||
v.push(check(
|
||||
blocks & ROLE == 0,
|
||||
format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)),
|
||||
));
|
||||
let detect = match (sam.own.contains(&t.id), sam.seen.contains(&t.id)) {
|
||||
(true, _) => "обнаружена своей РЛС",
|
||||
(false, true) => "видна только по сети (стволы не стреляют)",
|
||||
_ => "не обнаружена ни своей РЛС, ни сетью",
|
||||
};
|
||||
v.push(check(blocks & UNSEEN == 0, detect));
|
||||
let fit = sam.fitness();
|
||||
v.push(check(fit >= 0.9, format!("состояние {:.0}%: поворот и точность {:.0}%", sam.health * 100.0, fit * 100.0)));
|
||||
// Ammunition and its supply.
|
||||
let mut rounds = String::new();
|
||||
if let Some(load) = &spec.missile {
|
||||
rounds += &format!("ракет {}/{} ", sam.missiles, load.magazine);
|
||||
}
|
||||
if let Some(gun) = &spec.gun {
|
||||
rounds += &format!("очередей {}/{} ", sam.bursts, gun.bursts);
|
||||
}
|
||||
let supply = if sam.reloading > 0 {
|
||||
format!("перезарядка, ещё {:.1} с", sam.reloading as f64 / 20.0)
|
||||
} else if sam.empty {
|
||||
if logistics { "пуст, ждёт машину-заряжающую (ТЗМ) со склада".into() } else { "пуст".into() }
|
||||
} else {
|
||||
"боезапас есть".into()
|
||||
};
|
||||
v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}")));
|
||||
let mount = sam.mount().0;
|
||||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let (_, want_yaw, want_pitch) = aim_wants(mount, t, lead_speed);
|
||||
let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
|
||||
let vertical = spec.family == Family::Vertical;
|
||||
let mut fires = false;
|
||||
if let Some(load) = &spec.missile {
|
||||
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, t);
|
||||
v.push(check(
|
||||
reach == 0,
|
||||
format!(
|
||||
"ракеты: дальность {}–{}, цель на {}{}",
|
||||
crate::panel::distance(load.min),
|
||||
crate::panel::distance(load.max),
|
||||
crate::panel::distance(dist),
|
||||
if reach & TOO_CLOSE != 0 { " — слишком близко" } else if reach & TOO_FAR != 0 { " — слишком далеко" } else { "" }
|
||||
),
|
||||
));
|
||||
let free = claimed(t, &missile_on, sam.target);
|
||||
let need = needed(t);
|
||||
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" })));
|
||||
let mb = missile_blocks(sam);
|
||||
let mut why = Vec::new();
|
||||
if mb & COOLDOWN != 0 {
|
||||
why.push(format!("интервал пусков ({:.1} с)", sam.cooldown as f64 / 20.0));
|
||||
}
|
||||
if mb & RELOADING != 0 {
|
||||
why.push("перезарядка".to_string());
|
||||
}
|
||||
if mb & NO_ROUNDS != 0 {
|
||||
why.push("нет ракет".to_string());
|
||||
}
|
||||
v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) }));
|
||||
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(t), t.id, t.id, err);
|
||||
let turn = if vertical { "вертикальный старт" } else { "доворот" };
|
||||
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
|
||||
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0;
|
||||
}
|
||||
if let Some(gun) = &spec.gun {
|
||||
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
|
||||
let cand = gun_candidate(sam.roles, &sam.seen, &sam.own, gun.track, sam.pos, t);
|
||||
v.push(check(
|
||||
cand,
|
||||
format!(
|
||||
"орудие: сопровождение до {}, цель на {}, других орудий на ней {on_it}{}",
|
||||
crate::panel::distance(gun.track),
|
||||
crate::panel::distance(dist),
|
||||
if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" }
|
||||
),
|
||||
));
|
||||
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(t.pos), gun.range);
|
||||
let mut why = Vec::new();
|
||||
for (bit, name) in [
|
||||
(AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())),
|
||||
(OUT_OF_RANGE, format!("дальше {}", crate::panel::distance(gun.range))),
|
||||
(COOLDOWN, "пауза между очередями".to_string()),
|
||||
(RELOADING, "перезарядка".to_string()),
|
||||
(NO_ROUNDS, "нет снарядов".to_string()),
|
||||
] {
|
||||
if gb & bit != 0 {
|
||||
why.push(name);
|
||||
}
|
||||
}
|
||||
v.push(check(gb == 0, if gb == 0 { "орудие готово".to_string() } else { format!("орудие не стреляет: {}", why.join(", ")) }));
|
||||
fires |= gb == 0 && cand;
|
||||
}
|
||||
v.push(check(fires, if fires { "итог: готова стрелять" } else { "итог: стрелять сейчас не может" }));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn why_names_the_reason_a_system_does_not_fire() {
|
||||
let mut sim = Sim::new(3);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
sim.sams[0].roles = 0;
|
||||
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 1, 200.0, &mut out);
|
||||
for _ in 0..60 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let w = sim.why(0);
|
||||
assert!(w.last().is_some_and(|c| !c.ok), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
|
||||
assert!(w.iter().any(|c| c.text.contains("нет целей классов")));
|
||||
sim.sams[0].roles = 0xFF;
|
||||
assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль")));
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,35 @@ impl WaveConfig {
|
||||
self.approach = p.approach;
|
||||
self.aim = p.aim;
|
||||
}
|
||||
/// The words of a scenario's `wave` line: counts, approach, distance, aim, preset.
|
||||
pub fn text(&self) -> String {
|
||||
let counts: Vec<String> = self.counts.iter().map(u32::to_string).collect();
|
||||
let approach = Approach::ALL.iter().position(|a| *a == self.approach).unwrap_or(0);
|
||||
let aim = Aim::ALL.iter().position(|a| *a == self.aim).unwrap_or(0);
|
||||
format!("{} {approach} {} {aim} {}", counts.join(","), self.dist, self.preset)
|
||||
}
|
||||
/// Reads `text`'s words; what does not parse keeps its value.
|
||||
pub fn parse(&mut self, w: &[&str]) {
|
||||
if let Some(c) = w.first() {
|
||||
for (i, v) in c.split(',').take(KINDS.len()).enumerate() {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.counts[i] = n.min(80);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = w.get(1).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Approach::ALL.get(i)) {
|
||||
self.approach = *a;
|
||||
}
|
||||
if let Some(d) = w.get(2).and_then(|v| v.parse::<f64>().ok()) {
|
||||
self.dist = d.clamp(60.0, 800.0);
|
||||
}
|
||||
if let Some(a) = w.get(3).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Aim::ALL.get(i)) {
|
||||
self.aim = *a;
|
||||
}
|
||||
if let Some(p) = w.get(4).and_then(|v| v.parse::<usize>().ok()) {
|
||||
self.preset = p % PRESETS.len();
|
||||
}
|
||||
}
|
||||
/// The next number for kind `i`, back to 0 after the last.
|
||||
pub fn step(&mut self, i: usize) {
|
||||
let n = self.counts[i];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Fixed headless scenarios on the native simulation: no NaN, the raid ends, no stuck jobs.
|
||||
//! Run: `cargo test --test scenarios` (also `cargo run --release --example sim_run -- --help`).
|
||||
#[path = "../examples/sim_run/harness.rs"]
|
||||
mod harness;
|
||||
|
||||
fn check(sc: harness::Scenario) -> harness::Report {
|
||||
let r = harness::run(&sc);
|
||||
eprintln!("{}", r.text());
|
||||
let problems = r.problems();
|
||||
assert!(problems.is_empty(), "scenario {} failed: {problems:?}\n{}", r.name, r.text());
|
||||
assert!(r.launched > 0, "nothing was launched");
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_smart_raid_on_61_systems() {
|
||||
let r = check(harness::big_smart());
|
||||
assert_eq!(r.units, 61);
|
||||
assert!(r.fired > 0, "air defence never fired");
|
||||
assert!(r.ground_queries > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logistics_trucks_depot_and_raid() {
|
||||
let r = check(harness::logistics());
|
||||
assert!(r.truck_busy_ticks > 0, "the trucks never had a job");
|
||||
assert!(r.fired > 0, "air defence never fired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ew_and_adaptive_enemy_over_three_raids() {
|
||||
let r = check(harness::ew_adaptive());
|
||||
assert_eq!(r.raids_ended, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_result() {
|
||||
let a = harness::run(&harness::logistics());
|
||||
let b = harness::run(&harness::logistics());
|
||||
assert_eq!((a.ticks_run, a.launched, a.kills, a.hits, a.fired, a.ground_queries), (b.ticks_run, b.launched, b.kills, b.hits, b.fired, b.ground_queries));
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
# HD-рендер AirDef: устройство, статус, план
|
||||
|
||||
Документ для передачи работы (человеку или агенту). Ведётся по ходу разработки: каждый этап, попавший в
|
||||
`main`, отмечается здесь вместе с коммитом. Если что-то в коде расходится с этим текстом — прав код, а текст
|
||||
надо поправить.
|
||||
|
||||
## Что это
|
||||
|
||||
HD — третий пресет графики мода (`/airdef shader off|on|hd`): отложенное освещение в стиле шейдерпаков
|
||||
(BSL/Complementary-класс), но написанное с нуля внутри нашего форка Horizon (Minecraft 26.3 на Rust/wgpu).
|
||||
Код шейдерпаков (BSL, Complementary, Photon, Iris/OptiFine) **не копируем**: их лицензии запрещают перенос, а в
|
||||
Horizon действует правило clean-room. Пишем по знанию техник (статьи, общеизвестные алгоритмы).
|
||||
|
||||
Пресеты должны оставаться: `off` (ваниль), `on` (пост-эффекты мода), `hd` (этот рендер), позже `rt` (аппаратная
|
||||
трассировка, отдельно).
|
||||
|
||||
## Где код
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
|
||||
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
|
||||
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
|
||||
| G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` |
|
||||
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
|
||||
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
|
||||
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
|
||||
|
||||
Форк ведётся в `horizon/` (ветка `airdef`), в репозиторий мода попадает **патчами**:
|
||||
|
||||
```bash
|
||||
cd horizon && git add -A crates && git commit -m "..." # коммит в форке
|
||||
cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat HORIZON_COMMIT)..airdef -o ../horizon-patches
|
||||
```
|
||||
|
||||
`tools/airdef.py fork` сверяет отпечаток `HORIZON_COMMIT` + патчей (`horizon/.git/airdef-stamp`) и пересобирает
|
||||
ветку, если они изменились. После ручной перегенерации патчей у себя обнови отпечаток (см. `fork_stamp` в
|
||||
`tools/airdef.py`), иначе следующий `airdef.py fork` пересоберёт ветку (безопасно: прежняя остаётся в `airdef-prev`).
|
||||
|
||||
## Конвейер кадра (HD включён)
|
||||
|
||||
Все цели в «масштабированном» разрешении мира (`world_post`), HDR — `Rgba16Float`. Глубина камеры — reversed-Z
|
||||
(`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64`
|
||||
на CPU.
|
||||
|
||||
1. **Каскады теней** (2 шт., `CASCADES = [40, 176]` блоков полуразмера, `SHADOW_SIZE = 2048`, массив глубины
|
||||
из 2 слоёв). Источник — солнце днём, луна ночью (`light_of`). Матрицы `cascades()` привязаны к текселям
|
||||
(тени не «ползут»). Во время прохода буфер глобалов мира подменяется на глобалы каскада (copy-трюк) и потом
|
||||
возвращается. Рисуются секции террейна в коробке дальнего каскада (`hd_shadow_visible`) и сущности-тенеотбрасыватели
|
||||
(`shadow_casters`: без оверлеев 4/9/10/40 — 40 это светящиеся модели мода).
|
||||
2. **G-буфер** (свой depth): albedo `Rgba8Unorm` (a = эмиссия), нормаль `Rgba16Float` (мировая, из `dpdx/dpdy`),
|
||||
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240). Террейн + дальние LOD-тайлы +
|
||||
сущности. Вода/полупрозрачное в G-буфер **не** попадает (идёт ванильным путём) — см. план.
|
||||
3. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
|
||||
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
|
||||
4. **Освещение** `fs_light` → `lit` (HDR): `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
|
||||
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
|
||||
ореолы вокруг источников. Точечные источники — по тайлам 16×16 (`tile_lists`, буфер `tiles`: на тайл 4 слова —
|
||||
начало/длина списка для поверхностей и для ореолов, потом индексы; переполнение → «все источники»).
|
||||
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
|
||||
«подсветка огнём» выключена — свет даёт HD).
|
||||
6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории —
|
||||
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к
|
||||
`view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат.
|
||||
7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид),
|
||||
выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая).
|
||||
|
||||
Bind groups кэшируются и пересоздаются только при resize/смене источника (`hd_bind_groups_last_frame()` → 0 в
|
||||
установившемся режиме).
|
||||
|
||||
### Привязки `fs_light` (group 0)
|
||||
|
||||
| # | Что |
|
||||
|---|---|
|
||||
| 0 | `HdFrame` (uniform) |
|
||||
| 1, 2 | цвет и глубина мира (ванильный проход) |
|
||||
| 3, 4, 5 | albedo, нормаль, глубина G-буфера |
|
||||
| 6 | источники света (storage, `HdLight`: pos+radius, color+intensity) |
|
||||
| 7 | уровни света (небо, блоки) |
|
||||
| 8, 9 | массив карт теней, comparison-сэмплер |
|
||||
| 10, 11 | лайтмап, линейный сэмплер |
|
||||
| 12 | тайлы источников (storage) |
|
||||
| 13 | AO |
|
||||
|
||||
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
|
||||
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
|
||||
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке).
|
||||
|
||||
## Как проверять
|
||||
|
||||
```bash
|
||||
cd horizon && cargo build --profile fast -p horizon-client --features mods # без предупреждений
|
||||
cargo fmt -p horizon-client
|
||||
cargo test -p horizon-client --lib hd # naga-разбор всех HD-шейдеров + тест тайлов
|
||||
cd ../airdef-mod && cargo horizon-mod build && cargo test # мод и его шейдер
|
||||
```
|
||||
|
||||
WGSL-ошибки в `hd.rs` всплывают только в рантайме — поэтому каждый новый шейдер добавляй в naga-тест
|
||||
`lighting_shader_parses`.
|
||||
|
||||
Игра офскрин (только одна копия за раз; данные в `run-home/`, мир `Polygon`):
|
||||
|
||||
```bash
|
||||
cd run-home && HORIZON_HOME=$PWD HORIZON_NO_AUDIO=1 ../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock \
|
||||
launch --world Polygon --offscreen --wait --game ../horizon/target/fast/horizon-client \
|
||||
--resources "$(ls -d "$HOME/Library/Application Support/horizon-launcher/versions"/*/.local/horizon-client/26.3 | sort -V | tail -1)"
|
||||
H="../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock"
|
||||
$H command "time set 4500"; $H command "tp @s 40 76 40 135 25" # полигон с техникой; z ≥ 60 — море
|
||||
# команды мода — через чат: $H key t tap; $H type "/airdef shader hd"; $H key return tap
|
||||
$H key f1 tap; $H screenshot out.png; $H bench 4; $H quit
|
||||
```
|
||||
|
||||
Тайминги проходов на GPU: `HORIZON_SHADER_PACK_TIMING=1` (лог в `run-home/ctl.log`) или список F3.
|
||||
Ориентиры (M5, 2560×1440): тени 0.6 + 2.85 мс (дальний каскад через кадр, ≈1.4 в среднем), G-буфер 0.8,
|
||||
AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈ 15 мс GPU (замеры шумные).
|
||||
Следующие цели по скорости: освещение (лучи 14 шагов на полном разрешении → полразрешения + апскейл) и AO.
|
||||
|
||||
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
|
||||
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
|
||||
плоскости YZ.
|
||||
|
||||
## Статус
|
||||
|
||||
| Этап | Состояние | Коммит |
|
||||
|---|---|---|
|
||||
| 1. G-буфер, точечный свет мода в HDR, bloom, тон-кривая; API `set-lights`/`set-lighting` | готово | 0a56f56 (форк) |
|
||||
| 2. Тени солнца/луны, переосвещение мира, лучи, ореолы, туман, грейд | готово | патч 12 |
|
||||
| Кэш bind groups, тени только от тенеотбрасывателей (#12, #13) | готово | патч 13 |
|
||||
| Тайловые списки источников (#10) | готово | патч 14 |
|
||||
| Тайминги HD-проходов (#9), GTAO | готово | патч 15, ed2db85 |
|
||||
| Отсечение взрывов в `effects.wgsl` (#11), порядок дыма (#16), метрика перекрытия свечения (#14) | готово | 67a2a52, ed2db85 |
|
||||
| Тени: только грани к солнцу, без «закопанных» секций, дальний каскад через кадр (`HORIZON_HD_SHADOW_FAST=0` — по-старому) | готово | патч 16 |
|
||||
| TAA: джиттер Halton(2,3) ×16, перепроекция по глубине и сдвигу камеры, variance clip в YCoCg, история Catmull-Rom 0.9, лёгкий шарп в tonemap (`HORIZON_HD_TAA=0` — выкл) | готово | патч 16 |
|
||||
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком.
|
||||
|
||||
1. **TAAU — временной апскейлер** (поверх TAA). Мир и G-буфер рендерятся в `render_scale` (например 0.67),
|
||||
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
|
||||
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
|
||||
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
|
||||
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
|
||||
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
|
||||
2. **Вода**: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
|
||||
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
|
||||
3. **Небо и атмосфера**: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
|
||||
(`weather`), закат, Незер/Энд (там — ванильное небо).
|
||||
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
|
||||
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
7. **Ветер** листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
|
||||
9. Опционально: motion blur, DoF (для кинокамеры повторов).
|
||||
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
|
||||
отражения, затем GI. Отдельный пресет, HD остаётся.
|
||||
|
||||
## Правила
|
||||
|
||||
- Ничего не ломать в `off`/`on`; HD выключен — код HD не работает вовсе (`hd = None`).
|
||||
- Без потери качества ради скорости без договорённости (шаги, разрешение, число эффектов).
|
||||
- Каждый новый WGSL — в naga-тест. Каждый новый проход — в тайминги (`PassTiming`).
|
||||
- Комментарии — короткие предложения о том «зачем»; в `gfx.rs` префикс `AirDef fork:`.
|
||||
- Коммиты: в форке, затем патчи; подпись `Co-Authored-By: Claude <модель> <noreply@anthropic.com>`.
|
||||
- Пуш в Gitea с машины без DNS: `ssh://git@100.92.6.40:2222/chexit/airdef.git` (ключ `~/Documents/claude-keys/deploy`).
|
||||
- Обнови этот документ (статус + что изменилось в конвейере) в том же коммите, что и код.
|
||||
+4
-2
@@ -55,7 +55,7 @@ cd ~/Downloads/airdef-macos-arm64
|
||||
|
||||
```powershell
|
||||
rustup target add wasm32-unknown-unknown
|
||||
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
|
||||
git clone https://gitea.maoniao.team/chexit/airdef.git
|
||||
cd airdef
|
||||
py tools\airdef.py all # форк Horizon + сборка игры + мод + установка мода
|
||||
py tools\airdef.py play # запуск; мир: py tools\airdef.py play --world "Имя мира"
|
||||
@@ -69,12 +69,14 @@ py tools\airdef.py play # запуск; мир: py tools\airdef.py play
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
|
||||
git clone https://gitea.maoniao.team/chexit/airdef.git
|
||||
cd airdef
|
||||
python3 tools/airdef.py all
|
||||
python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "Имя мира"
|
||||
```
|
||||
|
||||
Клон по HTTPS не требует ключа. Если у тебя есть SSH-ключ в Gitea, можно и так: `git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git`.
|
||||
|
||||
Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру),
|
||||
`mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/12] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/17] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/12] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/17] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/12] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/17] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/12] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/17] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/12] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/17] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/12] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/17] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/12] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/17] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/12] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/17] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/12] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/17] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/12] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/17] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/12] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/17] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/12] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/17] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/17] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 39 ++++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 247 +++++++++++++++++-----------
|
||||
2 files changed, 188 insertions(+), 98 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index e351763fd..f6bbe038c 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -459,6 +459,8 @@ pub struct Renderer {
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
entity_batch: EntityBatch,
|
||||
+ /// AirDef fork: the runs of entity quads (first, end) that cast shadows this frame.
|
||||
+ shadow_casters: Vec<(u32, u32)>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
lightmap: wgpu::Texture,
|
||||
@@ -2744,6 +2746,7 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
+ shadow_casters: Vec::new(),
|
||||
group,
|
||||
globals,
|
||||
lightmap,
|
||||
@@ -4980,6 +4983,30 @@ impl Renderer {
|
||||
}
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
if shadow_strength > 0.0 {
|
||||
+ // The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
|
||||
+ // glowing mod geometry), as runs, so those are not vertex-processed twice.
|
||||
+ self.shadow_casters.clear();
|
||||
+ let mut start = None;
|
||||
+ let quads = self.entity_batch.combined.as_chunks::<4>().0;
|
||||
+ for (i, quad) in quads.iter().take(entity_quads as usize).enumerate() {
|
||||
+ let caster = !matches!(quad[0].layer, 4 | 9 | 10 | 40);
|
||||
+ match (caster, start) {
|
||||
+ (true, None) => start = Some(i as u32),
|
||||
+ (false, Some(first)) => {
|
||||
+ self.shadow_casters.push((first, i as u32));
|
||||
+ start = None;
|
||||
+ }
|
||||
+ _ => {}
|
||||
+ }
|
||||
+ }
|
||||
+ if let Some(first) = start {
|
||||
+ self.shadow_casters.push((first, entity_quads));
|
||||
+ }
|
||||
+ // Many tiny runs cost more in draw calls than they save: draw everything.
|
||||
+ if self.shadow_casters.len() > 64 {
|
||||
+ self.shadow_casters.clear();
|
||||
+ self.shadow_casters.push((0, entity_quads));
|
||||
+ }
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
@@ -5021,11 +5048,13 @@ impl Renderer {
|
||||
let instance = hd_shadow_first + k as u32;
|
||||
pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
|
||||
}
|
||||
- if entity_quads > 0 {
|
||||
+ if !self.shadow_casters.is_empty() {
|
||||
pass.set_pipeline(&self.shadow_entity);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
- pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
+ for &(first, end) in &self.shadow_casters {
|
||||
+ pass.draw_indexed(first * 6..end * 6, 0, 0..1);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size);
|
||||
@@ -5586,7 +5615,7 @@ impl Renderer {
|
||||
);
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& hd_on
|
||||
- && let Some(hd) = &self.hd
|
||||
+ && let Some(hd) = &mut self.hd
|
||||
{
|
||||
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
|
||||
// that, then bloom and the tone curve onto the screen.
|
||||
@@ -5607,9 +5636,9 @@ impl Renderer {
|
||||
self.mod_params.1,
|
||||
);
|
||||
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
- hd.finish(&self.device, &mut encoder, &hd.fx, &view_target);
|
||||
+ hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
|
||||
}
|
||||
- _ => hd.finish(&self.device, &mut encoder, &hd.lit, &view_target),
|
||||
+ _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
|
||||
}
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& mod_post
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index c36c1795d..fe2634f2c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -359,6 +359,21 @@ fn target(
|
||||
Target { view }
|
||||
}
|
||||
|
||||
+/// The outside views and sampler the lighting group reads.
|
||||
+type LightKey = (
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::Sampler,
|
||||
+);
|
||||
+
|
||||
+/// What bloom and the tone curve read: the lit world, or the post pass's output.
|
||||
+#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
+pub enum HdSource {
|
||||
+ Lit,
|
||||
+ Fx,
|
||||
+}
|
||||
+
|
||||
pub struct Hd {
|
||||
size: [u32; 2],
|
||||
pub albedo: wgpu::TextureView,
|
||||
@@ -377,6 +392,15 @@ pub struct Hd {
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
bloom: Vec<Target>,
|
||||
+ /// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
+ /// lighting and the source-reading ones are keyed by the views they read.
|
||||
+ light_group: Option<(LightKey, wgpu::BindGroup)>,
|
||||
+ prefilter_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
|
||||
+ tone_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
|
||||
+ /// Reading bloom level k - 1 (for the down pass into level k, index k - 1) and level k (for
|
||||
+ /// the up pass onto level k - 1, index k - 1).
|
||||
+ down_groups: Vec<wgpu::BindGroup>,
|
||||
+ up_groups: Vec<wgpu::BindGroup>,
|
||||
frame: wgpu::Buffer,
|
||||
lights: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -656,6 +680,11 @@ impl Hd {
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
bloom: Vec::new(),
|
||||
+ light_group: None,
|
||||
+ prefilter_group: None,
|
||||
+ tone_group: None,
|
||||
+ down_groups: Vec::new(),
|
||||
+ up_groups: Vec::new(),
|
||||
frame,
|
||||
lights,
|
||||
sampler,
|
||||
@@ -690,6 +719,16 @@ impl Hd {
|
||||
target(device, "HD bloom", s, HDR)
|
||||
})
|
||||
.collect();
|
||||
+ // Every group names a view that was just replaced.
|
||||
+ self.light_group = None;
|
||||
+ self.prefilter_group = None;
|
||||
+ self.tone_group = None;
|
||||
+ self.down_groups = (1..self.bloom.len())
|
||||
+ .map(|k| self.bloom_group(device, &self.bloom[k - 1].view))
|
||||
+ .collect();
|
||||
+ self.up_groups = (1..self.bloom.len())
|
||||
+ .map(|k| self.bloom_group(device, &self.bloom[k].view))
|
||||
+ .collect();
|
||||
}
|
||||
|
||||
/// This frame's camera, sun, fog, lights and settings.
|
||||
@@ -829,73 +868,85 @@ impl Hd {
|
||||
|
||||
/// Lights the world (its colour and depth) into `lit`.
|
||||
pub fn light(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
|
||||
) {
|
||||
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("HD lighting"),
|
||||
- layout: &self.light_layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: self.frame.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::TextureView(world.0),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 2,
|
||||
- resource: wgpu::BindingResource::TextureView(world.1),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 3,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.albedo),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 4,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.normal),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 5,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.depth),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 6,
|
||||
- resource: self.lights.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 7,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.levels),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 8,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 9,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 10,
|
||||
- resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 11,
|
||||
- resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
+ let key = (
|
||||
+ world.0.clone(),
|
||||
+ world.1.clone(),
|
||||
+ lightmap.0.clone(),
|
||||
+ lightmap.1.clone(),
|
||||
+ );
|
||||
+ if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ layout: &self.light_layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: self.frame.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.1),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.albedo),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 4,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.normal),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 5,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 6,
|
||||
+ resource: self.lights.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 7,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.levels),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 8,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 9,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 10,
|
||||
+ resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 11,
|
||||
+ resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ self.light_group = Some((key, group));
|
||||
+ }
|
||||
+ let Some((_, group)) = &self.light_group else {
|
||||
+ return;
|
||||
+ };
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD lighting",
|
||||
&self.lit,
|
||||
false,
|
||||
&self.light_pipeline,
|
||||
- &group,
|
||||
+ group,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -922,12 +973,53 @@ impl Hd {
|
||||
|
||||
/// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`.
|
||||
pub fn finish(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
- source: &wgpu::TextureView,
|
||||
+ source: HdSource,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
+ let source = match source {
|
||||
+ HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Fx => self.fx.clone(),
|
||||
+ };
|
||||
+ if self
|
||||
+ .prefilter_group
|
||||
+ .as_ref()
|
||||
+ .is_none_or(|(v, _)| *v != source)
|
||||
+ {
|
||||
+ let group = self.bloom_group(device, &source);
|
||||
+ self.prefilter_group = Some((source.clone(), group));
|
||||
+ }
|
||||
+ if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD tone mapping"),
|
||||
+ layout: &self.tone_layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: self.frame.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(&source),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ self.tone_group = Some((source, group));
|
||||
+ }
|
||||
+ let (Some((_, prefilter)), Some((_, tone))) = (&self.prefilter_group, &self.tone_group)
|
||||
+ else {
|
||||
+ return;
|
||||
+ };
|
||||
// Down: the prefilter into the first level, then each level from the one above.
|
||||
Self::draw(
|
||||
encoder,
|
||||
@@ -935,61 +1027,30 @@ impl Hd {
|
||||
&self.bloom[0].view,
|
||||
false,
|
||||
&self.prefilter,
|
||||
- &self.bloom_group(device, source),
|
||||
+ prefilter,
|
||||
);
|
||||
for k in 1..self.bloom.len() {
|
||||
- let g = self.bloom_group(device, &self.bloom[k - 1].view);
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD bloom down",
|
||||
&self.bloom[k].view,
|
||||
false,
|
||||
&self.down,
|
||||
- &g,
|
||||
+ &self.down_groups[k - 1],
|
||||
);
|
||||
}
|
||||
// Up: each level added onto the bigger one.
|
||||
for k in (1..self.bloom.len()).rev() {
|
||||
- let g = self.bloom_group(device, &self.bloom[k].view);
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD bloom up",
|
||||
&self.bloom[k - 1].view,
|
||||
true,
|
||||
&self.up,
|
||||
- &g,
|
||||
+ &self.up_groups[k - 1],
|
||||
);
|
||||
}
|
||||
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("HD tone mapping"),
|
||||
- layout: &self.tone_layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: self.frame.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::TextureView(source),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 2,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 3,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
- Self::draw(
|
||||
- encoder,
|
||||
- "HD tone mapping",
|
||||
- screen,
|
||||
- false,
|
||||
- &self.tone,
|
||||
- &group,
|
||||
- );
|
||||
+ Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
|
||||
}
|
||||
}
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/17] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 242 +++++++++++++++++++++++++++-
|
||||
1 file changed, 236 insertions(+), 6 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index fe2634f2c..2776219fc 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -16,6 +16,12 @@ pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The most lights drawn a frame.
|
||||
pub const MAX_LIGHTS: usize = 512;
|
||||
+/// The side (pixels) of a tile of the screen with its own short list of lights.
|
||||
+const TILE: u32 = 16;
|
||||
+/// The indices all the tile lists may hold together; a tile past it loops over every light.
|
||||
+const TILE_INDICES: usize = 1 << 19;
|
||||
+/// A list length that means "every light": the list overflowed.
|
||||
+const EVERY_LIGHT: u32 = u32::MAX;
|
||||
/// Bloom's mip levels.
|
||||
const BLOOM_LEVELS: usize = 6;
|
||||
|
||||
@@ -55,7 +61,7 @@ struct HdFrame {
|
||||
fog: [f32; 4],
|
||||
/// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
|
||||
ambient: [f32; 4],
|
||||
- /// x: shadows on; y, z: the cascades' half sizes.
|
||||
+ /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -123,6 +129,10 @@ struct Light {
|
||||
@group(0) @binding(9) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(10) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(11) var linear: sampler;
|
||||
+// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
|
||||
+// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||||
+// every light.
|
||||
+@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
|
||||
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
let count = u32(frame.params.x);
|
||||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
+ let surface_start = tiles[tile];
|
||||
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
+ let halo_start = tiles[tile + 2u];
|
||||
+ let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
+ let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
var c = scene;
|
||||
let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
|
||||
if surface {
|
||||
@@ -199,8 +216,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
|
||||
// The mods' lights.
|
||||
- for (var i = 0u; i < count; i++) {
|
||||
- let l = lights[i];
|
||||
+ for (var k = 0u; k < surface_count; k++) {
|
||||
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
let to = l.pos.xyz - p;
|
||||
let dist = length(to);
|
||||
if dist >= l.pos.w { continue; }
|
||||
@@ -233,8 +250,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
|
||||
c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
}
|
||||
- for (var i = 0u; i < count; i++) {
|
||||
- let l = lights[i];
|
||||
+ for (var k = 0u; k < halo_count; k++) {
|
||||
+ let l = lights[select(tiles[halo_start + k], k, halo_all)];
|
||||
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
|
||||
let t0 = dot(l.pos.xyz, view);
|
||||
let hh = length(l.pos.xyz - view * t0) + 0.5;
|
||||
@@ -403,6 +420,11 @@ pub struct Hd {
|
||||
up_groups: Vec<wgpu::BindGroup>,
|
||||
frame: wgpu::Buffer,
|
||||
lights: wgpu::Buffer,
|
||||
+ /// The tiles' light lists (see `tile_lists`), sized for the screen, and the tiles per row.
|
||||
+ tiles: wgpu::Buffer,
|
||||
+ tiles_x: u32,
|
||||
+ tile_scratch: Vec<Vec<u16>>,
|
||||
+ tile_words: Vec<u32>,
|
||||
sampler: wgpu::Sampler,
|
||||
light_layout: wgpu::BindGroupLayout,
|
||||
light_pipeline: wgpu::RenderPipeline,
|
||||
@@ -414,6 +436,137 @@ pub struct Hd {
|
||||
tone: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
+/// The tiles' header (4 words each) and index lists for a screen of `size`.
|
||||
+fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffer {
|
||||
+ let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
|
||||
+ device.create_buffer(&wgpu::BufferDescriptor {
|
||||
+ label: Some("HD light tiles"),
|
||||
+ size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
|
||||
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
+ mapped_at_creation: false,
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// Where a camera-relative sphere can show on the screen.
|
||||
+enum Cover {
|
||||
+ /// Nowhere: it is wholly behind the camera.
|
||||
+ Nothing,
|
||||
+ /// Anywhere: it crosses the camera's plane, or holds the camera.
|
||||
+ Everywhere,
|
||||
+ /// Inside this pixel rectangle (min x, min y, max x, max y).
|
||||
+ Rect([f32; 4]),
|
||||
+}
|
||||
+
|
||||
+/// The screen cover of the sphere at `c` of radius `r`: the projection of the 8 corners of the
|
||||
+/// box round it. A box is convex and so is its projection while every corner is in front of the
|
||||
+/// camera, so the rectangle holds the whole sphere. With some corners behind, the projection
|
||||
+/// is not bounded and the cover is the whole screen; with all of them behind, no pixel ray
|
||||
+/// (they all point forward) can meet the sphere.
|
||||
+fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
|
||||
+ if !c.is_finite() || !r.is_finite() {
|
||||
+ return Cover::Everywhere;
|
||||
+ }
|
||||
+ // A little more than the shader's radius, against rounding.
|
||||
+ let r = r * 1.01 + 0.01;
|
||||
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
|
||||
+ let mut front = 0;
|
||||
+ for corner in 0..8 {
|
||||
+ let o = glam::Vec3::new(
|
||||
+ if corner & 1 == 0 { -r } else { r },
|
||||
+ if corner & 2 == 0 { -r } else { r },
|
||||
+ if corner & 4 == 0 { -r } else { r },
|
||||
+ );
|
||||
+ let h = *view_proj * (c + o).extend(1.0);
|
||||
+ if h.w > 0.0 {
|
||||
+ front += 1;
|
||||
+ let ndc = glam::Vec2::new(h.x, h.y) / h.w;
|
||||
+ let px = glam::Vec2::new(
|
||||
+ (ndc.x * 0.5 + 0.5) * size[0] as f32,
|
||||
+ (0.5 - ndc.y * 0.5) * size[1] as f32,
|
||||
+ );
|
||||
+ lo = lo.min(px);
|
||||
+ hi = hi.max(px);
|
||||
+ }
|
||||
+ }
|
||||
+ match front {
|
||||
+ 0 => Cover::Nothing,
|
||||
+ 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
|
||||
+ _ => Cover::Everywhere,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
|
||||
+/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
|
||||
+/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
|
||||
+/// surface list holds the lights whose sphere can touch what the tile sees. The halo list
|
||||
+/// holds the lights whose halo can reach a ray of the tile: the shader adds a halo for the
|
||||
+/// view *line* passing within half the radius of the light, so a ray pointing away from a light
|
||||
+/// behind it is lit too, and the mirrored sphere is covered as well. Lights stay in order, so
|
||||
+/// the sums are the ones of the full loop.
|
||||
+fn tile_lists(
|
||||
+ view_proj: &Mat4,
|
||||
+ lights: &[HdLight],
|
||||
+ size: [u32; 2],
|
||||
+ scratch: &mut Vec<Vec<u16>>,
|
||||
+ words: &mut Vec<u32>,
|
||||
+) {
|
||||
+ let tx = size[0].div_ceil(TILE).max(1) as usize;
|
||||
+ let ty = size[1].div_ceil(TILE).max(1) as usize;
|
||||
+ let tiles = tx * ty;
|
||||
+ scratch.resize_with(tiles * 2, Vec::new);
|
||||
+ scratch.truncate(tiles * 2);
|
||||
+ scratch.iter_mut().for_each(Vec::clear);
|
||||
+ let mut add = |list: usize, area: Cover, light: u16| match area {
|
||||
+ Cover::Nothing => {}
|
||||
+ Cover::Everywhere => {
|
||||
+ for t in 0..tiles {
|
||||
+ scratch[t * 2 + list].push(light);
|
||||
+ }
|
||||
+ }
|
||||
+ Cover::Rect([x0, y0, x1, y1]) => {
|
||||
+ let tile = TILE as f32;
|
||||
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
|
||||
+ for y in from(y0, ty)..=from(y1, ty) {
|
||||
+ for x in from(x0, tx)..=from(x1, tx) {
|
||||
+ scratch[(y * tx + x) * 2 + list].push(light);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
+ for (i, l) in lights.iter().enumerate() {
|
||||
+ let c = glam::Vec3::new(l.pos[0], l.pos[1], l.pos[2]);
|
||||
+ add(0, cover(view_proj, c, l.pos[3], size), i as u16);
|
||||
+ // The shader's halo reach: the line within `radius / 2 - 0.5` of the light.
|
||||
+ let reach = l.pos[3] * 0.5 - 0.5;
|
||||
+ if reach > 0.0 || !reach.is_finite() {
|
||||
+ add(1, cover(view_proj, c, reach, size), i as u16);
|
||||
+ add(1, cover(view_proj, -c, reach, size), i as u16);
|
||||
+ }
|
||||
+ }
|
||||
+ words.clear();
|
||||
+ words.resize(tiles * 4, 0);
|
||||
+ let mut room = TILE_INDICES;
|
||||
+ for t in 0..tiles {
|
||||
+ for list in 0..2 {
|
||||
+ let ids = &mut scratch[t * 2 + list];
|
||||
+ // Both the sphere and its mirror can name a light: once is enough.
|
||||
+ ids.dedup();
|
||||
+ let at = t * 4 + list * 2;
|
||||
+ if ids.len() > room {
|
||||
+ words[at + 1] = EVERY_LIGHT;
|
||||
+ } else {
|
||||
+ room -= ids.len();
|
||||
+ words[at] = words.len() as u32;
|
||||
+ words[at + 1] = ids.len() as u32;
|
||||
+ words.extend(ids.iter().map(|&i| u32::from(i)));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
@@ -527,6 +680,11 @@ impl Hd {
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
+ wgpu::BindingType::Buffer {
|
||||
+ ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
+ has_dynamic_offset: false,
|
||||
+ min_binding_size: None,
|
||||
+ },
|
||||
]),
|
||||
});
|
||||
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -687,6 +845,10 @@ impl Hd {
|
||||
up_groups: Vec::new(),
|
||||
frame,
|
||||
lights,
|
||||
+ tiles: tiles_buffer(device, [1, 1]),
|
||||
+ tiles_x: 1,
|
||||
+ tile_scratch: Vec::new(),
|
||||
+ tile_words: Vec::new(),
|
||||
sampler,
|
||||
light_layout,
|
||||
light_pipeline,
|
||||
@@ -713,6 +875,8 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.tiles = tiles_buffer(device, size);
|
||||
+ self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
.map(|k| {
|
||||
let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
|
||||
@@ -743,6 +907,15 @@ impl Hd {
|
||||
if n > 0 {
|
||||
queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n]));
|
||||
}
|
||||
+ // Written every frame, empty too: the shader reads the counts, not `params.x`.
|
||||
+ tile_lists(
|
||||
+ &scene.view_proj,
|
||||
+ &lights[..n],
|
||||
+ self.size,
|
||||
+ &mut self.tile_scratch,
|
||||
+ &mut self.tile_words,
|
||||
+ );
|
||||
+ queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
@@ -773,7 +946,7 @@ impl Hd {
|
||||
if strength > 0.0 { 1.0 } else { 0.0 },
|
||||
CASCADES[0],
|
||||
CASCADES[1],
|
||||
- 0.0,
|
||||
+ self.tiles_x as f32,
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
|
||||
@@ -933,6 +1106,10 @@ impl Hd {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 12,
|
||||
+ resource: self.tiles.as_entire_binding(),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1102,3 +1279,56 @@ pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
|
||||
(projection * view).as_mat4()
|
||||
})
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn lighting_shader_parses() {
|
||||
+ for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
|
||||
+ let source = format!("{COMMON}{body}");
|
||||
+ let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
+ panic!("{name}: {}", e.emit_to_string(&source));
|
||||
+ });
|
||||
+ naga::valid::Validator::new(
|
||||
+ naga::valid::ValidationFlags::all(),
|
||||
+ naga::valid::Capabilities::all(),
|
||||
+ )
|
||||
+ .validate(&module)
|
||||
+ .unwrap_or_else(|e| panic!("{name}: {e:?}"));
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
+ let size = [320, 160];
|
||||
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
|
||||
+ let light = |x: f32, y: f32, z: f32, r: f32| HdLight {
|
||||
+ pos: [x, y, z, r],
|
||||
+ color: [1.0; 4],
|
||||
+ };
|
||||
+ let lights = [
|
||||
+ // Dead ahead and small; far to the side; behind the camera; holding the camera.
|
||||
+ light(0.0, 0.0, -20.0, 4.0),
|
||||
+ light(30.0, 0.0, -5.0, 4.0),
|
||||
+ light(0.0, 0.0, 10.0, 8.0),
|
||||
+ light(1.0, 0.0, 0.0, 6.0),
|
||||
+ ];
|
||||
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
|
||||
+ tile_lists(&proj, &lights, size, &mut scratch, &mut words);
|
||||
+ let list = |tile: usize, which: usize| -> Vec<u32> {
|
||||
+ let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
|
||||
+ words[start as usize..(start + len) as usize].to_vec()
|
||||
+ };
|
||||
+ // The centre tile sees the first light and the one at the camera, in both lists.
|
||||
+ let centre = 5 * 20 + 10;
|
||||
+ assert_eq!(list(centre, 0), vec![0, 3]);
|
||||
+ assert!(list(centre, 1).contains(&0) && list(centre, 1).contains(&3));
|
||||
+ // A far corner sees only the one at the camera.
|
||||
+ assert_eq!(list(0, 0), vec![3]);
|
||||
+ // The light behind the camera shows in the halo list where the view points away.
|
||||
+ assert!(list(centre, 1).contains(&2));
|
||||
+ assert!(!list(0, 0).contains(&2));
|
||||
+ }
|
||||
+}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/17] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 23 +-
|
||||
crates/horizon-client/src/gfx/hd.rs | 430 +++++++++++++++++++++++++++-
|
||||
2 files changed, 443 insertions(+), 10 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index f6bbe038c..e14429b03 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3725,9 +3725,20 @@ impl Renderer {
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
- timer
|
||||
+ let mut times = timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
- .unwrap_or_default()
|
||||
+ .unwrap_or_default();
|
||||
+ // AirDef fork: the HD lighting's passes (shadows, G-buffer, occlusion, lighting, bloom,
|
||||
+ // tone curve) while it is on.
|
||||
+ if let Some(hd) = &self.hd {
|
||||
+ times.extend(hd.pass_times());
|
||||
+ }
|
||||
+ times
|
||||
+ }
|
||||
+
|
||||
+ /// AirDef fork: bind groups the HD lighting made in the last frame (0 in a steady state).
|
||||
+ pub fn hd_bind_groups_last_frame(&self) -> Option<u64> {
|
||||
+ self.hd.as_ref().map(|hd| hd.groups_last_frame)
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
@@ -4981,6 +4992,8 @@ impl Renderer {
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
+ hd.set_timing(device, &self.queue, self.pass_timing);
|
||||
+ hd.timing_begin(device, &self.queue);
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
if shadow_strength > 0.0 {
|
||||
// The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
|
||||
@@ -5090,6 +5103,9 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
+ drop(pass);
|
||||
+ // Contact shadows from the G-buffer's depth and normals, for the lighting.
|
||||
+ hd.ao(&mut encoder);
|
||||
} else if !hd_on {
|
||||
self.hd = None;
|
||||
}
|
||||
@@ -5831,6 +5847,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
+ if hd_on && let Some(hd) = &mut self.hd {
|
||||
+ hd.timing_submitted(&self.queue);
|
||||
+ }
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 2776219fc..d3944e97b 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -5,7 +5,9 @@
|
||||
//! into a high-dynamic-range target; the mod's post pass runs on that; then
|
||||
//! bright parts bloom and a tone curve brings it to the screen — vanilla's
|
||||
//! range (up to about 0.8) left as it is, only what is brighter rolled off.
|
||||
+use super::pack_timing::PassTiming;
|
||||
use glam::Mat4;
|
||||
+use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// The G-buffer's formats.
|
||||
pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
@@ -24,6 +26,25 @@ const TILE_INDICES: usize = 1 << 19;
|
||||
const EVERY_LIGHT: u32 = u32::MAX;
|
||||
/// Bloom's mip levels.
|
||||
const BLOOM_LEVELS: usize = 6;
|
||||
+/// The ambient occlusion's targets (half resolution): occlusion and the distance it was taken at.
|
||||
+pub const AO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;
|
||||
+/// The occlusion's default strength (`Hd::ao_strength`; 0 turns it off).
|
||||
+pub const AO_STRENGTH: f32 = 1.0;
|
||||
+/// The timed passes of bloom, by level (each is timed on its own; the report sums them).
|
||||
+const BLOOM_NAMES: [&str; 2 * BLOOM_LEVELS] = [
|
||||
+ "hd bloom#0",
|
||||
+ "hd bloom#1",
|
||||
+ "hd bloom#2",
|
||||
+ "hd bloom#3",
|
||||
+ "hd bloom#4",
|
||||
+ "hd bloom#5",
|
||||
+ "hd bloom#6",
|
||||
+ "hd bloom#7",
|
||||
+ "hd bloom#8",
|
||||
+ "hd bloom#9",
|
||||
+ "hd bloom#10",
|
||||
+ "hd bloom#11",
|
||||
+];
|
||||
|
||||
/// A light for this frame, relative to the camera.
|
||||
#[repr(C)]
|
||||
@@ -59,7 +80,8 @@ struct HdFrame {
|
||||
fog_color: [f32; 4],
|
||||
/// Environmental start, end; render-distance start, end.
|
||||
fog: [f32; 4],
|
||||
- /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
|
||||
+ /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos);
|
||||
+ /// w: the ambient occlusion's strength (0 off).
|
||||
ambient: [f32; 4],
|
||||
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
@@ -133,6 +155,34 @@ struct Light {
|
||||
// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||||
// every light.
|
||||
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
+// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
|
||||
+@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||||
+
|
||||
+// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
|
||||
+// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
|
||||
+fn ao_at(px: vec2<i32>, dist: f32) -> f32 {
|
||||
+ let size = vec2<i32>(textureDimensions(ao_tex));
|
||||
+ let f = vec2<f32>(px) * 0.5;
|
||||
+ let base = vec2<i32>(floor(f));
|
||||
+ let t = f - floor(f);
|
||||
+ var sum = 0.0;
|
||||
+ var total = 0.0;
|
||||
+ for (var j = 0; j < 2; j++) {
|
||||
+ for (var i = 0; i < 2; i++) {
|
||||
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
|
||||
+ let s = textureLoad(ao_tex, q, 0).xy;
|
||||
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
|
||||
+ let dz = (s.y - dist) / (0.03 * dist + 0.1);
|
||||
+ let w = select(0.0, bw / (1.0 + dz * dz * dz * dz), s.y > 0.0);
|
||||
+ sum += s.x * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ if total < 0.0001 {
|
||||
+ return textureLoad(ao_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0).x;
|
||||
+ }
|
||||
+ return sum / total;
|
||||
+}
|
||||
|
||||
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
@@ -214,7 +264,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
|
||||
// direct light by the face's angle.
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
- var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
|
||||
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
|
||||
+ // the mods' lights by half, not on the sun or on emission.
|
||||
+ var occlusion = 1.0;
|
||||
+ if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); }
|
||||
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
+ var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
// The mods' lights.
|
||||
for (var k = 0u; k < surface_count; k++) {
|
||||
let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
@@ -224,7 +280,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
|
||||
let falloff = l.color.w / (dist * dist + 1.0) * window;
|
||||
let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
|
||||
- light += l.color.rgb * falloff * k;
|
||||
+ light += l.color.rgb * falloff * k * ao_lights;
|
||||
}
|
||||
c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
// Vanilla's fog over it again.
|
||||
@@ -264,6 +320,131 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
+const AO: &str = r#"
|
||||
+const AO_RADIUS: f32 = 1.8;
|
||||
+const AO_SLICES: i32 = 3;
|
||||
+const AO_STEPS: i32 = 5;
|
||||
+const PI: f32 = 3.14159265;
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||||
+@group(0) @binding(2) var normal_tex: texture_2d<f32>;
|
||||
+
|
||||
+// Interleaved gradient noise, shifted per frame (params.w) and per use.
|
||||
+fn noise(px: vec2<f32>, shift: vec2<f32>) -> f32 {
|
||||
+ let p = px + shift + vec2<f32>(5.588238 * frame.params.w);
|
||||
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
|
||||
+}
|
||||
+
|
||||
+// The camera-relative position of a (continuous) full-resolution pixel at depth z.
|
||||
+fn position_at(px: vec2<f32>, z: f32) -> vec3<f32> {
|
||||
+ let uv = px * frame.resolution.zw;
|
||||
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
|
||||
+ return h.xyz / h.w;
|
||||
+}
|
||||
+
|
||||
+// Ground-truth ambient occlusion (horizon based): per slice through the view ray, the highest
|
||||
+// horizon on either side, integrated against the normal's cosine lobe. Half resolution: one
|
||||
+// pixel of each 2x2.
|
||||
+@fragment
|
||||
+fn fs_ao(in: FullOut) -> @location(0) vec2<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(depth_tex));
|
||||
+ let ic = min(vec2<i32>(in.position.xy) * 2, size - 1);
|
||||
+ let z = textureLoad(depth_tex, ic, 0);
|
||||
+ if z <= 0.0 { return vec2<f32>(1.0, 0.0); }
|
||||
+ let nrm = textureLoad(normal_tex, ic, 0).xyz;
|
||||
+ let c = vec2<f32>(ic) + 0.5;
|
||||
+ let p = position_at(c, z);
|
||||
+ let dist = length(p);
|
||||
+ if dot(nrm, nrm) < 0.01 { return vec2<f32>(1.0, dist); }
|
||||
+ let n = normalize(nrm);
|
||||
+ let v = -p / dist;
|
||||
+ // Blocks per pixel here (the plane of equal depth is square to the view), so how far in
|
||||
+ // pixels the radius reaches.
|
||||
+ let per_px = max(length(position_at(c + vec2<f32>(4.0, 0.0), z) - p) / 4.0, 0.00001);
|
||||
+ let reach = min(AO_RADIUS / per_px, 56.0);
|
||||
+ if reach < 2.0 { return vec2<f32>(1.0, dist); }
|
||||
+ let radius = reach * per_px;
|
||||
+ let rot = noise(in.position.xy, vec2<f32>(0.0));
|
||||
+ let jitter = noise(in.position.xy, vec2<f32>(37.0, 17.0));
|
||||
+ var visibility = 0.0;
|
||||
+ for (var s = 0; s < AO_SLICES; s++) {
|
||||
+ let phi = (f32(s) + rot) / f32(AO_SLICES) * PI;
|
||||
+ let d2 = vec2<f32>(cos(phi), sin(phi));
|
||||
+ // The slice's direction in space, square to the view ray, and its plane's axis.
|
||||
+ let q = position_at(c + d2 * 4.0, z) - p;
|
||||
+ let dir = normalize(q - v * dot(q, v));
|
||||
+ let axis = cross(dir, v);
|
||||
+ let projected = n - axis * dot(n, axis);
|
||||
+ let plen = length(projected);
|
||||
+ if plen < 0.0001 { visibility += 1.0; continue; }
|
||||
+ let cos_n = clamp(dot(projected, v) / plen, -1.0, 1.0);
|
||||
+ let nang = select(-1.0, 1.0, dot(projected, dir) >= 0.0) * acos(cos_n);
|
||||
+ var c_neg = -1.0;
|
||||
+ var c_pos = -1.0;
|
||||
+ for (var i = 0; i < AO_STEPS; i++) {
|
||||
+ let t = (f32(i) + jitter) / f32(AO_STEPS);
|
||||
+ let r = 1.5 + (reach - 1.5) * t * t;
|
||||
+ for (var side = 0; side < 2; side++) {
|
||||
+ let sg = select(-1.0, 1.0, side == 1);
|
||||
+ let ip = vec2<i32>(floor(c + d2 * (r * sg)));
|
||||
+ if any(ip < vec2<i32>(0)) || any(ip >= size) { continue; }
|
||||
+ let sz = textureLoad(depth_tex, ip, 0);
|
||||
+ if sz <= 0.0 { continue; }
|
||||
+ let to = position_at(vec2<f32>(ip) + 0.5, sz) - p;
|
||||
+ let len = length(to);
|
||||
+ // Points on (or under) the surface's own plane don't occlude it.
|
||||
+ if dot(to, n) < 0.1 * len { continue; }
|
||||
+ // Far points count less, to nothing at the radius.
|
||||
+ let fall = clamp((len - radius * 0.6) / (radius * 0.4), 0.0, 1.0);
|
||||
+ let ch = mix(dot(to / len, v), -1.0, fall);
|
||||
+ if side == 1 { c_pos = max(c_pos, ch); } else { c_neg = max(c_neg, ch); }
|
||||
+ }
|
||||
+ }
|
||||
+ var h_neg = -acos(clamp(c_neg, -1.0, 1.0));
|
||||
+ var h_pos = acos(clamp(c_pos, -1.0, 1.0));
|
||||
+ h_neg = nang + max(h_neg - nang, -0.5 * PI);
|
||||
+ h_pos = nang + min(h_pos - nang, 0.5 * PI);
|
||||
+ let sn = sin(nang);
|
||||
+ let arc_neg = cos_n + 2.0 * h_neg * sn - cos(2.0 * h_neg - nang);
|
||||
+ let arc_pos = cos_n + 2.0 * h_pos * sn - cos(2.0 * h_pos - nang);
|
||||
+ visibility += plen * 0.25 * (arc_neg + arc_pos);
|
||||
+ }
|
||||
+ let ao = clamp(visibility / f32(AO_SLICES), 0.0, 1.0);
|
||||
+ // Far away it is too coarse to show: fade out.
|
||||
+ return vec2<f32>(mix(1.0, ao, 1.0 - smoothstep(40.0, 96.0, dist)), dist);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+const AO_BLUR: &str = r#"
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var raw_ao: texture_2d<f32>;
|
||||
+
|
||||
+// A 5x5 blur that only takes texels at about the same distance (so not across an edge).
|
||||
+@fragment
|
||||
+fn fs_ao_blur(in: FullOut) -> @location(0) vec2<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(raw_ao));
|
||||
+ let ip = vec2<i32>(in.position.xy);
|
||||
+ let centre = textureLoad(raw_ao, ip, 0).xy;
|
||||
+ if centre.y <= 0.0 { return centre; }
|
||||
+ let tolerance = 0.03 * centre.y + 0.1;
|
||||
+ var sum = 0.0;
|
||||
+ var total = 0.0;
|
||||
+ for (var y = -2; y <= 2; y++) {
|
||||
+ for (var x = -2; x <= 2; x++) {
|
||||
+ let q = ip + vec2<i32>(x, y);
|
||||
+ if any(q < vec2<i32>(0)) || any(q >= size) { continue; }
|
||||
+ let s = textureLoad(raw_ao, q, 0).xy;
|
||||
+ if s.y <= 0.0 { continue; }
|
||||
+ let dz = (s.y - centre.y) / tolerance;
|
||||
+ let w = 1.0 / (1.0 + dz * dz * dz * dz);
|
||||
+ sum += s.x * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ return vec2<f32>(sum / max(total, 0.0001), centre.y);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
const BLOOM: &str = r#"
|
||||
@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
@@ -434,6 +615,23 @@ pub struct Hd {
|
||||
up: wgpu::RenderPipeline,
|
||||
tone_layout: wgpu::BindGroupLayout,
|
||||
tone: wgpu::RenderPipeline,
|
||||
+ /// The ambient occlusion: its strength in the lighting, the half-resolution targets (raw, then
|
||||
+ /// blurred), their passes and bind groups (made at `resize`).
|
||||
+ pub ao_strength: f32,
|
||||
+ ao_raw: wgpu::TextureView,
|
||||
+ ao_blurred: wgpu::TextureView,
|
||||
+ ao_layout: wgpu::BindGroupLayout,
|
||||
+ ao_blur_layout: wgpu::BindGroupLayout,
|
||||
+ ao_pipeline: wgpu::RenderPipeline,
|
||||
+ ao_blur_pipeline: wgpu::RenderPipeline,
|
||||
+ ao_group: Option<wgpu::BindGroup>,
|
||||
+ ao_blur_group: Option<wgpu::BindGroup>,
|
||||
+ /// GPU time of the HD passes while diagnostics ask for it (`set_timing`).
|
||||
+ timing: Option<PassTiming>,
|
||||
+ /// Bind groups made so far, and in the last frame (a steady state makes none).
|
||||
+ groups_made: AtomicU64,
|
||||
+ groups_seen: u64,
|
||||
+ pub groups_last_frame: u64,
|
||||
}
|
||||
|
||||
/// The tiles' header (4 words each) and index lists for a screen of `size`.
|
||||
@@ -567,6 +765,22 @@ fn tile_lists(
|
||||
}
|
||||
}
|
||||
|
||||
+/// The timestamps of the pass `name` while HD passes are timed.
|
||||
+fn stamp<'a>(
|
||||
+ timing: &'a mut Option<PassTiming>,
|
||||
+ name: &str,
|
||||
+) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
|
||||
+ timing.as_mut()?.writes(name)
|
||||
+}
|
||||
+
|
||||
+/// `HORIZON_HD_AO=0` turns the ambient occlusion off, another number sets its strength.
|
||||
+fn ao_strength_default() -> f32 {
|
||||
+ std::env::var("HORIZON_HD_AO")
|
||||
+ .ok()
|
||||
+ .and_then(|v| v.parse().ok())
|
||||
+ .unwrap_or(AO_STRENGTH)
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
@@ -685,8 +899,17 @@ impl Hd {
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
+ UNFILTERED,
|
||||
]),
|
||||
});
|
||||
+ let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD ambient occlusion"),
|
||||
+ entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
+ });
|
||||
+ let ao_blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD ambient occlusion blur"),
|
||||
+ entries: &entries(&[UNIFORM, UNFILTERED]),
|
||||
+ });
|
||||
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD bloom"),
|
||||
entries: &entries(&[UNIFORM, FLOAT, SAMPLER]),
|
||||
@@ -698,6 +921,26 @@ impl Hd {
|
||||
let light = module(device, "HD lighting", LIGHT);
|
||||
let bloom = module(device, "HD bloom", BLOOM);
|
||||
let tone = module(device, "HD tone mapping", TONEMAP);
|
||||
+ let ao = module(device, "HD ambient occlusion", AO);
|
||||
+ let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
|
||||
+ let ao_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD ambient occlusion",
|
||||
+ &ao_layout,
|
||||
+ &ao,
|
||||
+ "fs_ao",
|
||||
+ AO_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
+ let ao_blur_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &ao_blur_layout,
|
||||
+ &ao_blur,
|
||||
+ "fs_ao_blur",
|
||||
+ AO_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
let add = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
@@ -858,6 +1101,19 @@ impl Hd {
|
||||
up,
|
||||
tone_layout,
|
||||
tone,
|
||||
+ ao_strength: ao_strength_default(),
|
||||
+ ao_raw: target(device, "HD AO", [1, 1], AO_FORMAT).view,
|
||||
+ ao_blurred: target(device, "HD AO blurred", [1, 1], AO_FORMAT).view,
|
||||
+ ao_layout,
|
||||
+ ao_blur_layout,
|
||||
+ ao_pipeline,
|
||||
+ ao_blur_pipeline,
|
||||
+ ao_group: None,
|
||||
+ ao_blur_group: None,
|
||||
+ timing: None,
|
||||
+ groups_made: AtomicU64::new(0),
|
||||
+ groups_seen: 0,
|
||||
+ groups_last_frame: 0,
|
||||
};
|
||||
hd.resize(device, size);
|
||||
hd
|
||||
@@ -875,6 +1131,28 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
|
||||
+ self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
|
||||
+ self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
|
||||
+ self.ao_group = Some(self.make_group(
|
||||
+ device,
|
||||
+ "HD ambient occlusion",
|
||||
+ &self.ao_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.normal),
|
||||
+ ],
|
||||
+ ));
|
||||
+ self.ao_blur_group = Some(self.make_group(
|
||||
+ device,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &self.ao_blur_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.ao_raw),
|
||||
+ ],
|
||||
+ ));
|
||||
self.tiles = tiles_buffer(device, size);
|
||||
self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
@@ -917,6 +1195,15 @@ impl Hd {
|
||||
);
|
||||
queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
+ let made = self.groups_made.load(Ordering::Relaxed);
|
||||
+ self.groups_last_frame = made - self.groups_seen;
|
||||
+ self.groups_seen = made;
|
||||
+ if PassTiming::logging() && self.frames.is_multiple_of(240) {
|
||||
+ log::info!(
|
||||
+ "HD bind groups made in the last frame: {} ({made} in all)",
|
||||
+ self.groups_last_frame
|
||||
+ );
|
||||
+ }
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
let cascades = cascades(scene.camera, dir);
|
||||
@@ -941,7 +1228,12 @@ impl Hd {
|
||||
fog_color: scene.fog_color,
|
||||
fog: scene.fog,
|
||||
// In the shade by day a surface keeps half its sky light; at night almost all.
|
||||
- ambient: [0.5 + 0.45 * night, 1.0, 0.0018 + scene.rain * 0.006, 0.0],
|
||||
+ ambient: [
|
||||
+ 0.5 + 0.45 * night,
|
||||
+ 1.0,
|
||||
+ 0.0018 + scene.rain * 0.006,
|
||||
+ self.ao_strength.max(0.0),
|
||||
+ ],
|
||||
shadow: [
|
||||
if strength > 0.0 { 1.0 } else { 0.0 },
|
||||
CASCADES[0],
|
||||
@@ -954,13 +1246,14 @@ impl Hd {
|
||||
|
||||
/// The shadow pass of cascade `c`: its depth layer cleared, nothing else.
|
||||
pub fn shadow_pass<'e>(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
c: usize,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD shadow cascade"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, ["hd shadow 0", "hd shadow 1"][c.min(1)]),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.shadow_layers[c],
|
||||
@@ -976,7 +1269,10 @@ impl Hd {
|
||||
}
|
||||
|
||||
/// The G-buffer pass's attachments: albedo and normal cleared, its own depth.
|
||||
- pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> {
|
||||
+ pub fn gbuffer_pass<'e>(
|
||||
+ &mut self,
|
||||
+ encoder: &'e mut wgpu::CommandEncoder,
|
||||
+ ) -> wgpu::RenderPass<'e> {
|
||||
let clear = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -991,6 +1287,7 @@ impl Hd {
|
||||
encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD G-buffer"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd g-buffer"),
|
||||
color_attachments: &[
|
||||
clear(&self.albedo),
|
||||
clear(&self.normal),
|
||||
@@ -1016,9 +1313,11 @@ impl Hd {
|
||||
load: bool,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
+ timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
||||
) {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
+ timestamp_writes,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: to,
|
||||
resolve_target: None,
|
||||
@@ -1054,6 +1353,7 @@ impl Hd {
|
||||
lightmap.1.clone(),
|
||||
);
|
||||
if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD lighting"),
|
||||
layout: &self.light_layout,
|
||||
@@ -1110,6 +1410,10 @@ impl Hd {
|
||||
binding: 12,
|
||||
resource: self.tiles.as_entire_binding(),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 13,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1124,10 +1428,36 @@ impl Hd {
|
||||
false,
|
||||
&self.light_pipeline,
|
||||
group,
|
||||
+ stamp(&mut self.timing, "hd lighting"),
|
||||
);
|
||||
}
|
||||
|
||||
+ /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
|
||||
+ fn make_group(
|
||||
+ &self,
|
||||
+ device: &wgpu::Device,
|
||||
+ label: &str,
|
||||
+ layout: &wgpu::BindGroupLayout,
|
||||
+ resources: &[wgpu::BindingResource<'_>],
|
||||
+ ) -> wgpu::BindGroup {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
+ let entries: Vec<_> = resources
|
||||
+ .iter()
|
||||
+ .enumerate()
|
||||
+ .map(|(i, r)| wgpu::BindGroupEntry {
|
||||
+ binding: i as u32,
|
||||
+ resource: r.clone(),
|
||||
+ })
|
||||
+ .collect();
|
||||
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some(label),
|
||||
+ layout,
|
||||
+ entries: &entries,
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD bloom"),
|
||||
layout: &self.bloom_layout,
|
||||
@@ -1169,6 +1499,7 @@ impl Hd {
|
||||
self.prefilter_group = Some((source.clone(), group));
|
||||
}
|
||||
if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD tone mapping"),
|
||||
layout: &self.tone_layout,
|
||||
@@ -1205,6 +1536,7 @@ impl Hd {
|
||||
false,
|
||||
&self.prefilter,
|
||||
prefilter,
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[0]),
|
||||
);
|
||||
for k in 1..self.bloom.len() {
|
||||
Self::draw(
|
||||
@@ -1214,6 +1546,7 @@ impl Hd {
|
||||
false,
|
||||
&self.down,
|
||||
&self.down_groups[k - 1],
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[k]),
|
||||
);
|
||||
}
|
||||
// Up: each level added onto the bigger one.
|
||||
@@ -1225,9 +1558,84 @@ impl Hd {
|
||||
true,
|
||||
&self.up,
|
||||
&self.up_groups[k - 1],
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[BLOOM_LEVELS + k - 1]),
|
||||
);
|
||||
}
|
||||
- Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD tone mapping",
|
||||
+ screen,
|
||||
+ false,
|
||||
+ &self.tone,
|
||||
+ tone,
|
||||
+ stamp(&mut self.timing, "hd tonemap"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
|
||||
+ /// its blur. Run after the G-buffer pass; nothing when the strength is 0.
|
||||
+ pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
+ if self.ao_strength <= 0.0 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let (Some(raw), Some(blur)) = (&self.ao_group, &self.ao_blur_group) else {
|
||||
+ return;
|
||||
+ };
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD ambient occlusion",
|
||||
+ &self.ao_raw,
|
||||
+ false,
|
||||
+ &self.ao_pipeline,
|
||||
+ raw,
|
||||
+ stamp(&mut self.timing, "hd ao"),
|
||||
+ );
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &self.ao_blurred,
|
||||
+ false,
|
||||
+ &self.ao_blur_pipeline,
|
||||
+ blur,
|
||||
+ stamp(&mut self.timing, "hd ao blur"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// Times the HD passes on the GPU while `on` (and the device can), else not.
|
||||
+ pub fn set_timing(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, on: bool) {
|
||||
+ if on != self.timing.is_some() {
|
||||
+ self.timing = on.then(|| PassTiming::new(device, queue)).flatten();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// At the start of the frame's passes, before the first of them.
|
||||
+ pub fn timing_begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
|
||||
+ if let Some(t) = &mut self.timing {
|
||||
+ t.begin(device, queue);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// After the frame is submitted.
|
||||
+ pub fn timing_submitted(&mut self, queue: &wgpu::Queue) {
|
||||
+ if let Some(t) = &mut self.timing {
|
||||
+ t.submitted(queue);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Each HD pass's median GPU time in milliseconds (bloom's passes summed into one).
|
||||
+ pub fn pass_times(&self) -> Vec<(String, f32)> {
|
||||
+ let Some(t) = &self.timing else {
|
||||
+ return Vec::new();
|
||||
+ };
|
||||
+ let mut out: Vec<(String, f32)> = Vec::new();
|
||||
+ for (name, ms) in t.medians() {
|
||||
+ let name = name.split('#').next().unwrap_or(&name).to_owned();
|
||||
+ match out.iter_mut().find(|o| o.0 == name) {
|
||||
+ Some(o) => o.1 += ms,
|
||||
+ None => out.push((name, ms)),
|
||||
+ }
|
||||
+ }
|
||||
+ out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,7 +1694,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lighting_shader_parses() {
|
||||
- for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
|
||||
+ for (name, body) in [
|
||||
+ ("light", LIGHT),
|
||||
+ ("bloom", BLOOM),
|
||||
+ ("tonemap", TONEMAP),
|
||||
+ ("ao", AO),
|
||||
+ ("ao blur", AO_BLUR),
|
||||
+ ] {
|
||||
let source = format!("{COMMON}{body}");
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
panic!("{name}: {}", e.emit_to_string(&source));
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/17] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 126 +++++++++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 347 +++++++++++++++++++++++++++-
|
||||
2 files changed, 457 insertions(+), 16 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index e14429b03..eb152667c 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -569,6 +569,11 @@ pub struct Renderer {
|
||||
shadow_terrain: wgpu::RenderPipeline,
|
||||
shadow_entity: wgpu::RenderPipeline,
|
||||
hd_shadow_visible: Vec<resident::Visible>,
|
||||
+ /// The far cascade as last drawn (matrix, camera, light) and the frames since.
|
||||
+ hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
|
||||
+ /// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
|
||||
+ taa_frame: u32,
|
||||
+ taa_prev: Option<(Mat4, glam::DVec3)>,
|
||||
hd: Option<hd::Hd>,
|
||||
mod_params: (Vec<f32>, f32),
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
@@ -2815,6 +2820,9 @@ impl Renderer {
|
||||
shadow_terrain,
|
||||
shadow_entity,
|
||||
hd_shadow_visible: Vec::new(),
|
||||
+ hd_far: None,
|
||||
+ taa_frame: 0,
|
||||
+ taa_prev: None,
|
||||
hd: None,
|
||||
mod_params: (Vec::new(), 0.0),
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -3954,7 +3962,29 @@ impl Renderer {
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
- let view_proj = self.view_projection(frame);
|
||||
+ let hd_wanted = !frame.title
|
||||
+ && frame.hd.is_some()
|
||||
+ && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
|
||||
+ && !self.native.as_ref().is_some_and(|n| n.post.is_some());
|
||||
+ // AirDef fork: HD's temporal anti-aliasing shifts the whole projection by a sub-pixel
|
||||
+ // offset each frame; `unjittered` is what the next frame reprojects with.
|
||||
+ let unjittered = self.view_projection(frame);
|
||||
+ let taa = hd_wanted && hd::taa_enabled();
|
||||
+ let view_proj = if taa {
|
||||
+ let size = self
|
||||
+ .world_post
|
||||
+ .size()
|
||||
+ .unwrap_or([self.config.width, self.config.height]);
|
||||
+ let j = hd::jitter(self.taa_frame, size);
|
||||
+ self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
+ Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
|
||||
+ } else {
|
||||
+ unjittered
|
||||
+ };
|
||||
+ let taa_prev = std::mem::replace(
|
||||
+ &mut self.taa_prev,
|
||||
+ taa.then_some((unjittered, frame.camera)),
|
||||
+ );
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
&self.queue,
|
||||
@@ -4158,13 +4188,12 @@ impl Renderer {
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
// AirDef fork: the HD sun shadows draw every section in the farther cascade's box.
|
||||
- let hd_wanted = !frame.title
|
||||
- && frame.hd.is_some()
|
||||
- && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
|
||||
- && !self.native.as_ref().is_some_and(|n| n.post.is_some());
|
||||
- let hd_scene = hd::HdScene {
|
||||
+ let mut hd_scene = hd::HdScene {
|
||||
+ cascades: [Mat4::IDENTITY; 2],
|
||||
view_proj,
|
||||
- camera,
|
||||
+ taa: taa && taa_prev.is_some(),
|
||||
+ prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
|
||||
+ camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
|
||||
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
|
||||
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
|
||||
dark: 1.0 - frame.sky.sky_light_factor,
|
||||
@@ -4174,10 +4203,34 @@ impl Renderer {
|
||||
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
|
||||
};
|
||||
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
|
||||
- let hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
+ let mut hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
+ // The far cascade is redrawn every second frame (it is snapped to texels and the sun is
|
||||
+ // slow); between, the lighting pass gets the matrix its map was drawn with.
|
||||
+ let hd_far_fresh = if hd_wanted && shadow_strength > 0.0 {
|
||||
+ let fresh = match self.hd_far {
|
||||
+ Some((_, at, dir, age)) if hd_shadow_fast() => {
|
||||
+ age >= 1
|
||||
+ || (camera - at).length_squared() > 48.0 * 48.0
|
||||
+ || dir.dot(shadow_dir) < 0.99995
|
||||
+ || self.hd.is_none()
|
||||
+ }
|
||||
+ _ => true,
|
||||
+ };
|
||||
+ if fresh {
|
||||
+ self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
|
||||
+ } else if let Some((held, _, _, age)) = &mut self.hd_far {
|
||||
+ hd_cascades[1] = *held;
|
||||
+ *age += 1;
|
||||
+ }
|
||||
+ fresh
|
||||
+ } else {
|
||||
+ self.hd_far = None;
|
||||
+ true
|
||||
+ };
|
||||
+ hd_scene.cascades = hd_cascades;
|
||||
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
|
||||
hd_shadow_visible.clear();
|
||||
- if hd_wanted && shadow_strength > 0.0 {
|
||||
+ if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
|
||||
let radius =
|
||||
((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
|
||||
let light = Frustum {
|
||||
@@ -4191,6 +4244,11 @@ impl Renderer {
|
||||
|_, _| false,
|
||||
&mut hd_shadow_visible,
|
||||
);
|
||||
+ if hd_shadow_fast() {
|
||||
+ drop_buried(&mut hd_shadow_visible, |slot| {
|
||||
+ self.sections.get(slot).solid > 0
|
||||
+ });
|
||||
+ }
|
||||
}
|
||||
// Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's.
|
||||
let globals_size = std::mem::size_of::<Globals>() as u64;
|
||||
@@ -5022,7 +5080,12 @@ impl Renderer {
|
||||
}
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
+ // Facing the light only: the nearest surface to it is always one that does.
|
||||
+ let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
+ if c == 1 && !hd_far_fresh {
|
||||
+ continue;
|
||||
+ }
|
||||
let light = Globals {
|
||||
view_proj: cascade.to_cols_array_2d(),
|
||||
..globals
|
||||
@@ -5059,7 +5122,13 @@ impl Renderer {
|
||||
page = Some(s.page);
|
||||
}
|
||||
let instance = hd_shadow_first + k as u32;
|
||||
- pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
|
||||
+ facing_runs(&s.sides, toward, v.origin, |first, quads| {
|
||||
+ pass.draw_indexed(
|
||||
+ 0..quads * 6,
|
||||
+ s.start as i32 + first as i32 * 4,
|
||||
+ instance..instance + 1,
|
||||
+ );
|
||||
+ });
|
||||
}
|
||||
if !self.shadow_casters.is_empty() {
|
||||
pass.set_pipeline(&self.shadow_entity);
|
||||
@@ -5652,9 +5721,13 @@ impl Renderer {
|
||||
self.mod_params.1,
|
||||
);
|
||||
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
- hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
|
||||
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
|
||||
+ hd.finish(&self.device, &mut encoder, source, &view_target);
|
||||
+ }
|
||||
+ _ => {
|
||||
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Lit, depth);
|
||||
+ hd.finish(&self.device, &mut encoder, source, &view_target);
|
||||
}
|
||||
- _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
|
||||
}
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& mod_post
|
||||
@@ -6055,6 +6128,35 @@ fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: imp
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
+/// `HORIZON_HD_SHADOW_FAST=0` draws the HD shadow cascades the slow way (every section, both
|
||||
+/// faces, the far one every frame), for comparison.
|
||||
+fn hd_shadow_fast() -> bool {
|
||||
+ static FAST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *FAST.get_or_init(|| !std::env::var("HORIZON_HD_SHADOW_FAST").is_ok_and(|v| v == "0"))
|
||||
+}
|
||||
+/// Drops the sections that sit well under the ground of their own and all eight neighbouring
|
||||
+/// columns: shut in by rock, what they hold neither lights nor shades anything the sun reaches.
|
||||
+fn drop_buried(list: &mut Vec<resident::Visible>, has_solid: impl Fn(u32) -> bool) {
|
||||
+ let mut top: std::collections::HashMap<(i32, i32), i32> = std::collections::HashMap::new();
|
||||
+ for v in list.iter() {
|
||||
+ if has_solid(v.slot) {
|
||||
+ let e = top.entry((v.key.0, v.key.2)).or_insert(i32::MIN);
|
||||
+ *e = (*e).max(v.key.1);
|
||||
+ }
|
||||
+ }
|
||||
+ list.retain(|v| {
|
||||
+ let mut lowest = i32::MAX;
|
||||
+ for dx in -1..=1 {
|
||||
+ for dz in -1..=1 {
|
||||
+ match top.get(&(v.key.0 + dx, v.key.2 + dz)) {
|
||||
+ Some(&t) => lowest = lowest.min(t),
|
||||
+ None => return true,
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ v.key.1 > lowest - 3
|
||||
+ });
|
||||
+}
|
||||
fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
let r = |i| m.row(i);
|
||||
let planes = [
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index d3944e97b..16610cb02 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -85,14 +85,26 @@ struct HdFrame {
|
||||
ambient: [f32; 4],
|
||||
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
+ /// The previous frame's camera-relative view-projection without jitter (for TAA).
|
||||
+ prev_view_proj: [[f32; 4]; 4],
|
||||
+ /// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
|
||||
+ taa: [f32; 4],
|
||||
+ /// x: the history's weight; y: the sharpening (0 without TAA).
|
||||
+ taa_params: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the world around looks like this frame (from the renderer's globals).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct HdScene {
|
||||
+ /// The shadow cascades' matrices (`cascades`; the far one may be a frame or two old).
|
||||
+ pub cascades: [Mat4; 2],
|
||||
+ /// The (jittered) projection the world was drawn with.
|
||||
pub view_proj: Mat4,
|
||||
- /// The camera in the world, for steady shadows.
|
||||
- pub camera: glam::DVec3,
|
||||
+ /// Last frame's view-projection without jitter, and the camera's move since (TAA).
|
||||
+ pub prev_view_proj: Mat4,
|
||||
+ pub camera_delta: glam::Vec3,
|
||||
+ /// This frame is anti-aliased over time (the projection is jittered).
|
||||
+ pub taa: bool,
|
||||
/// Towards the sun, and the sky's darkening (0 day, 1 night).
|
||||
pub sun: glam::Vec3,
|
||||
pub dark: f32,
|
||||
@@ -119,6 +131,9 @@ struct HdFrame {
|
||||
fog: vec4<f32>,
|
||||
ambient: vec4<f32>,
|
||||
shadow: vec4<f32>,
|
||||
+ prev_view_proj: mat4x4<f32>,
|
||||
+ taa: vec4<f32>,
|
||||
+ taa_params: vec4<f32>,
|
||||
}
|
||||
struct FullOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -507,7 +522,21 @@ const TONEMAP: &str = r#"
|
||||
|
||||
@fragment
|
||||
fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
|
||||
- var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb * frame.params.y;
|
||||
+ var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb;
|
||||
+ // A slight sharpen after TAA (its history resampling softens): the centre against its four
|
||||
+ // neighbours, kept within their range so it can't ring.
|
||||
+ let k = frame.taa_params.y;
|
||||
+ if k > 0.0 {
|
||||
+ let t = 1.0 / vec2<f32>(textureDimensions(hdr));
|
||||
+ let n0 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(t.x, 0.0), 0.0).rgb;
|
||||
+ let n1 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(t.x, 0.0), 0.0).rgb;
|
||||
+ let n2 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(0.0, t.y), 0.0).rgb;
|
||||
+ let n3 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(0.0, t.y), 0.0).rgb;
|
||||
+ let lo = min(min(n0, n1), min(min(n2, n3), c));
|
||||
+ let hi = max(max(n0, n1), max(max(n2, n3), c));
|
||||
+ c = clamp(c * (1.0 + 4.0 * k) - (n0 + n1 + n2 + n3) * k, lo, hi);
|
||||
+ }
|
||||
+ c = c * frame.params.y;
|
||||
c += textureSampleLevel(bloom, linear, in.uv, 0.0).rgb * frame.params.z;
|
||||
// Up to 0.8 as it is (vanilla's look); above it, rolled off towards white by the
|
||||
// brightest channel, so hues hold instead of clipping.
|
||||
@@ -528,6 +557,130 @@ fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
+const TAA: &str = r#"
|
||||
+struct TaaOut {
|
||||
+ @location(0) color: vec4<f32>,
|
||||
+ @location(1) history: vec4<f32>,
|
||||
+}
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
+@group(0) @binding(2) var depth_tex: texture_depth_2d;
|
||||
+@group(0) @binding(3) var history: texture_2d<f32>;
|
||||
+@group(0) @binding(4) var linear: sampler;
|
||||
+
|
||||
+fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
|
||||
+ return vec3<f32>(c.r * 0.25 + c.g * 0.5 + c.b * 0.25, c.r * 0.5 - c.b * 0.5, c.g * 0.5 - c.r * 0.25 - c.b * 0.25);
|
||||
+}
|
||||
+fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
|
||||
+ let t = c.x - c.z;
|
||||
+ return vec3<f32>(t + c.y, c.x + c.z, t - c.y);
|
||||
+}
|
||||
+// Compress the range so a very bright pixel does not dominate the blend or the clamp.
|
||||
+fn squash(c: vec3<f32>) -> vec3<f32> {
|
||||
+ return c / (1.0 + max(c.r, max(c.g, c.b)));
|
||||
+}
|
||||
+fn unsquash(c: vec3<f32>) -> vec3<f32> {
|
||||
+ return c / max(1.0 - max(c.r, max(c.g, c.b)), 0.001);
|
||||
+}
|
||||
+
|
||||
+// The history at a uv, filtered with a Catmull-Rom spline (five bilinear taps) so that
|
||||
+// resampling it every frame doesn't blur.
|
||||
+fn history_color(uv: vec2<f32>) -> vec3<f32> {
|
||||
+ let size = vec2<f32>(textureDimensions(history));
|
||||
+ let pos = uv * size;
|
||||
+ let c1 = floor(pos - 0.5) + 0.5;
|
||||
+ let f = pos - c1;
|
||||
+ let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
+ let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
+ let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
+ let w3 = f * f * (-0.5 + 0.5 * f);
|
||||
+ let w12 = w1 + w2;
|
||||
+ let t0 = (c1 - 1.0) / size;
|
||||
+ let t3 = (c1 + 2.0) / size;
|
||||
+ let t12 = (c1 + w2 / w12) / size;
|
||||
+ var sum = textureSampleLevel(history, linear, vec2<f32>(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y);
|
||||
+ var total = w12.x * w0.y;
|
||||
+ sum += textureSampleLevel(history, linear, vec2<f32>(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y);
|
||||
+ total += w0.x * w12.y;
|
||||
+ sum += textureSampleLevel(history, linear, t12, 0.0).rgb * (w12.x * w12.y);
|
||||
+ total += w12.x * w12.y;
|
||||
+ sum += textureSampleLevel(history, linear, vec2<f32>(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y);
|
||||
+ total += w3.x * w12.y;
|
||||
+ sum += textureSampleLevel(history, linear, vec2<f32>(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
|
||||
+ total += w12.x * w3.y;
|
||||
+ return max(sum / total, vec3<f32>(0.0));
|
||||
+}
|
||||
+
|
||||
+// Pulls `p` towards the box's centre until it is inside.
|
||||
+fn clip_to_box(lo: vec3<f32>, hi: vec3<f32>, p: vec3<f32>) -> vec3<f32> {
|
||||
+ let centre = (lo + hi) * 0.5;
|
||||
+ let ext = max((hi - lo) * 0.5, vec3<f32>(0.00001));
|
||||
+ let off = (p - centre) / ext;
|
||||
+ let m = max(abs(off.x), max(abs(off.y), abs(off.z)));
|
||||
+ return select(p, centre + (p - centre) / m, m > 1.0);
|
||||
+}
|
||||
+
|
||||
+// Temporal anti-aliasing: the history, reprojected by depth and the camera's move, clamped to
|
||||
+// the colours round this pixel (so what moved or was uncovered can't ghost), blended in.
|
||||
+@fragment
|
||||
+fn fs_taa(in: FullOut) -> TaaOut {
|
||||
+ let size = vec2<i32>(textureDimensions(source));
|
||||
+ let px = vec2<i32>(in.position.xy);
|
||||
+ let d = textureLoad(depth_tex, px, 0);
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ var sum2 = vec3<f32>(0.0);
|
||||
+ var lo = vec3<f32>(1.0e9);
|
||||
+ var hi = vec3<f32>(-1.0e9);
|
||||
+ var centre = vec3<f32>(0.0);
|
||||
+ for (var y = -1; y <= 1; y++) {
|
||||
+ for (var x = -1; x <= 1; x++) {
|
||||
+ let q = clamp(px + vec2<i32>(x, y), vec2<i32>(0), size - 1);
|
||||
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
|
||||
+ sum += s;
|
||||
+ sum2 += s * s;
|
||||
+ lo = min(lo, s);
|
||||
+ hi = max(hi, s);
|
||||
+ if x == 0 && y == 0 { centre = s; }
|
||||
+ }
|
||||
+ }
|
||||
+ let sky = d <= 0.0;
|
||||
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
|
||||
+ let h = frame.inverse_view_proj * ndc;
|
||||
+ let p = h.xyz / h.w;
|
||||
+ let dist = select(length(p), 0.0, sky);
|
||||
+ var resolved = centre;
|
||||
+ if frame.taa.w > 0.5 {
|
||||
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
|
||||
+ if prev.w > 0.0 {
|
||||
+ let pn = prev.xy / prev.w;
|
||||
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
|
||||
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
|
||||
+ let hp = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - 1);
|
||||
+ let seen = textureLoad(history, hp, 0).a;
|
||||
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
|
||||
+ // The same surface as last frame (not uncovered), by its distance.
|
||||
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
|
||||
+ let mean = sum / 9.0;
|
||||
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
|
||||
+ let box_lo = min(max(mean - sigma * 1.25, lo), centre);
|
||||
+ let box_hi = max(min(mean + sigma * 1.25, hi), centre);
|
||||
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
|
||||
+ // Fast motion trusts the history less.
|
||||
+ let moved = length((uv - in.uv) * vec2<f32>(size));
|
||||
+ let keep = mix(frame.taa_params.x, frame.taa_params.x * 0.75, clamp(moved / 4.0, 0.0, 1.0));
|
||||
+ resolved = mix(centre, past, keep);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ let color = unsquash(from_ycocg(resolved));
|
||||
+ var out: TaaOut;
|
||||
+ out.color = vec4<f32>(color, 1.0);
|
||||
+ out.history = vec4<f32>(color, dist);
|
||||
+ return out;
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
struct Target {
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
@@ -570,6 +723,35 @@ type LightKey = (
|
||||
pub enum HdSource {
|
||||
Lit,
|
||||
Fx,
|
||||
+ /// The anti-aliased result of `taa`.
|
||||
+ Taa,
|
||||
+}
|
||||
+
|
||||
+/// TAA is on unless `HORIZON_HD_TAA=0`.
|
||||
+pub fn taa_enabled() -> bool {
|
||||
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
|
||||
+}
|
||||
+
|
||||
+/// The Halton sequence's `index`-th number in `base`, in (0, 1).
|
||||
+fn halton(mut index: u32, base: u32) -> f32 {
|
||||
+ let (mut f, mut r) = (1.0, 0.0);
|
||||
+ while index > 0 {
|
||||
+ f /= base as f32;
|
||||
+ r += f * (index % base) as f32;
|
||||
+ index /= base;
|
||||
+ }
|
||||
+ r
|
||||
+}
|
||||
+
|
||||
+/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
|
||||
+/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
|
||||
+pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
|
||||
+ let i = frame % 16 + 1;
|
||||
+ [
|
||||
+ (halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
|
||||
+ (halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
|
||||
+ ]
|
||||
}
|
||||
|
||||
pub struct Hd {
|
||||
@@ -589,6 +771,15 @@ pub struct Hd {
|
||||
/// The lit world, and the post pass's output.
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
+ /// TAA: the resolved frame, and the two histories (one read, one written, alternating).
|
||||
+ taa: wgpu::TextureView,
|
||||
+ history: [wgpu::TextureView; 2],
|
||||
+ history_ready: bool,
|
||||
+ history_flip: usize,
|
||||
+ taa_active: bool,
|
||||
+ taa_layout: wgpu::BindGroupLayout,
|
||||
+ taa_pipeline: wgpu::RenderPipeline,
|
||||
+ taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
|
||||
bloom: Vec<Target>,
|
||||
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
/// lighting and the source-reading ones are keyed by the views they read.
|
||||
@@ -902,6 +1093,44 @@ impl Hd {
|
||||
UNFILTERED,
|
||||
]),
|
||||
});
|
||||
+ let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
|
||||
+ });
|
||||
+ let taa_module = module(device, "HD TAA", TAA);
|
||||
+ let taa_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ bind_group_layouts: &[Some(&taa_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ let target = Some(wgpu::ColorTargetState {
|
||||
+ format: HDR,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &taa_module,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &taa_module,
|
||||
+ entry_point: Some("fs_taa"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[target.clone(), target],
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD ambient occlusion"),
|
||||
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
@@ -1080,6 +1309,17 @@ impl Hd {
|
||||
.view,
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
+ taa: target(device, "HD TAA", [1, 1], HDR).view,
|
||||
+ history: [
|
||||
+ target(device, "HD TAA history", [1, 1], HDR).view,
|
||||
+ target(device, "HD TAA history", [1, 1], HDR).view,
|
||||
+ ],
|
||||
+ history_ready: false,
|
||||
+ history_flip: 0,
|
||||
+ taa_active: false,
|
||||
+ taa_layout,
|
||||
+ taa_pipeline,
|
||||
+ taa_groups: None,
|
||||
bloom: Vec::new(),
|
||||
light_group: None,
|
||||
prefilter_group: None,
|
||||
@@ -1131,6 +1371,13 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.taa = target(device, "HD TAA", size, HDR).view;
|
||||
+ self.history = [
|
||||
+ target(device, "HD TAA history", size, HDR).view,
|
||||
+ target(device, "HD TAA history", size, HDR).view,
|
||||
+ ];
|
||||
+ self.history_ready = false;
|
||||
+ self.taa_groups = None;
|
||||
let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
|
||||
self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
|
||||
self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
|
||||
@@ -1206,9 +1453,22 @@ impl Hd {
|
||||
}
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
- let cascades = cascades(scene.camera, dir);
|
||||
+ let cascades = scene.cascades;
|
||||
let night = scene.dark.clamp(0.0, 1.0);
|
||||
+ self.taa_active = scene.taa && taa_enabled();
|
||||
+ // A long jump of the camera (a teleport) starts the history over.
|
||||
+ if !self.taa_active || scene.camera_delta.length() > 64.0 {
|
||||
+ self.history_ready = false;
|
||||
+ }
|
||||
let frame = HdFrame {
|
||||
+ prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
|
||||
+ taa: [
|
||||
+ scene.camera_delta.x,
|
||||
+ scene.camera_delta.y,
|
||||
+ scene.camera_delta.z,
|
||||
+ if self.history_ready { 1.0 } else { 0.0 },
|
||||
+ ],
|
||||
+ taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
|
||||
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
|
||||
resolution: [
|
||||
self.size[0] as f32,
|
||||
@@ -1489,6 +1749,7 @@ impl Hd {
|
||||
let source = match source {
|
||||
HdSource::Lit => self.lit.clone(),
|
||||
HdSource::Fx => self.fx.clone(),
|
||||
+ HdSource::Taa => self.taa.clone(),
|
||||
};
|
||||
if self
|
||||
.prefilter_group
|
||||
@@ -1572,6 +1833,70 @@ impl Hd {
|
||||
);
|
||||
}
|
||||
|
||||
+ /// Temporal anti-aliasing of `source` (with the world's `depth`): the source it left, to hand
|
||||
+ /// to `finish` — the same one when TAA is off this frame.
|
||||
+ pub fn taa(
|
||||
+ &mut self,
|
||||
+ device: &wgpu::Device,
|
||||
+ encoder: &mut wgpu::CommandEncoder,
|
||||
+ source: HdSource,
|
||||
+ depth: &wgpu::TextureView,
|
||||
+ ) -> HdSource {
|
||||
+ if !self.taa_active {
|
||||
+ return source;
|
||||
+ }
|
||||
+ let input = match source {
|
||||
+ HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Fx => self.fx.clone(),
|
||||
+ HdSource::Taa => return source,
|
||||
+ };
|
||||
+ let key = (input, depth.clone());
|
||||
+ if self.taa_groups.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let groups = [0, 1].map(|read| {
|
||||
+ self.make_group(
|
||||
+ device,
|
||||
+ "HD TAA",
|
||||
+ &self.taa_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&key.0),
|
||||
+ wgpu::BindingResource::TextureView(depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.history[read]),
|
||||
+ wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ ],
|
||||
+ )
|
||||
+ });
|
||||
+ self.taa_groups = Some((key, groups));
|
||||
+ }
|
||||
+ let Some((_, groups)) = &self.taa_groups else {
|
||||
+ return source;
|
||||
+ };
|
||||
+ let (read, write) = (self.history_flip, 1 - self.history_flip);
|
||||
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd taa"),
|
||||
+ color_attachments: &[&self.taa, &self.history[write]].map(|view| {
|
||||
+ Some(wgpu::RenderPassColorAttachment {
|
||||
+ view,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })
|
||||
+ }),
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ pass.set_pipeline(&self.taa_pipeline);
|
||||
+ pass.set_bind_group(0, &groups[read], &[]);
|
||||
+ pass.draw(0..3, 0..1);
|
||||
+ drop(pass);
|
||||
+ self.history_flip = write;
|
||||
+ self.history_ready = true;
|
||||
+ HdSource::Taa
|
||||
+ }
|
||||
+
|
||||
/// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
|
||||
/// its blur. Run after the G-buffer pass; nothing when the strength is 0.
|
||||
pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
@@ -1698,6 +2023,7 @@ mod tests {
|
||||
("light", LIGHT),
|
||||
("bloom", BLOOM),
|
||||
("tonemap", TONEMAP),
|
||||
+ ("taa", TAA),
|
||||
("ao", AO),
|
||||
("ao blur", AO_BLUR),
|
||||
] {
|
||||
@@ -1714,6 +2040,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn jitter_stays_within_a_pixel() {
|
||||
+ let size = [1920u32, 1080];
|
||||
+ let mut seen = std::collections::HashSet::new();
|
||||
+ for f in 0..32 {
|
||||
+ let j = jitter(f, size);
|
||||
+ assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
|
||||
+ assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
|
||||
+ seen.insert((j[0].to_bits(), j[1].to_bits()));
|
||||
+ }
|
||||
+ assert_eq!(seen.len(), 16);
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
let size = [320, 160];
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/17] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game/mod_models.rs | 21 +-
|
||||
crates/horizon-client/src/game/mods.rs | 10 +
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 31 +++
|
||||
crates/horizon-mods/src/api_level.rs | 7 +-
|
||||
crates/horizon-mods/src/runtime.rs | 212 +++++++++++++++----
|
||||
docs/api/mods-reference.md | 113 ++++++++++
|
||||
6 files changed, 351 insertions(+), 43 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
||||
index 2d1b4780a..f2329a123 100644
|
||||
--- a/crates/horizon-client/src/game/mod_models.rs
|
||||
+++ b/crates/horizon-client/src/game/mod_models.rs
|
||||
@@ -11,7 +11,7 @@ use {
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
},
|
||||
horizon_core::random::RandomSource,
|
||||
- horizon_mods::{CameraView, ParticleSpawn, PointLight},
|
||||
+ horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight},
|
||||
horizon_world::registry::BlockStateId,
|
||||
std::collections::HashMap,
|
||||
};
|
||||
@@ -84,10 +84,21 @@ impl Game<'_> {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let models = &mut self.mods.models;
|
||||
for (owner, m) in self.mods.mods.models() {
|
||||
- let state = *models
|
||||
- .states
|
||||
- .entry(m.model.clone())
|
||||
- .or_insert_with(|| super::render_control::parse_state(registry, &m.model).ok());
|
||||
+ let state = match &m.model {
|
||||
+ ModelRef::Handle(h) => {
|
||||
+ let id = BlockStateId(*h);
|
||||
+ registry.state(id).map(|_| id)
|
||||
+ }
|
||||
+ ModelRef::Name(name) => {
|
||||
+ if let Some(s) = models.states.get(name.as_str()) {
|
||||
+ *s
|
||||
+ } else {
|
||||
+ let s = super::render_control::parse_state(registry, name).ok();
|
||||
+ models.states.insert(name.clone(), s);
|
||||
+ s
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
let Some(state) = state else {
|
||||
continue;
|
||||
};
|
||||
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
|
||||
index a760c7763..db468c7cb 100644
|
||||
--- a/crates/horizon-client/src/game/mods.rs
|
||||
+++ b/crates/horizon-client/src/game/mods.rs
|
||||
@@ -615,6 +615,16 @@ impl Game<'_> {
|
||||
}
|
||||
let snapshot = self.mod_snapshot();
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
+ let registry = self.pack.assets.registry;
|
||||
+ self.mods
|
||||
+ .mods
|
||||
+ .set_model_resolver(registry as *const _ as usize, || {
|
||||
+ Box::new(move |name| {
|
||||
+ super::render_control::parse_state(registry, name)
|
||||
+ .ok()
|
||||
+ .map(|s| s.0)
|
||||
+ })
|
||||
+ });
|
||||
self.mods.mods.tick(self.ticks);
|
||||
self.update_mod_models();
|
||||
self.show_mod_output();
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index 6696fa134..8019aab95 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -467,6 +467,34 @@ interface render {
|
||||
/// each id between its last two positions and rotations every frame.
|
||||
/// An empty list removes them.
|
||||
set-models: func(models: list<model-instance>);
|
||||
+ /// A handle for a block state name, for `set-models-by-id` (AirDef
|
||||
+ /// fork, API level 11): the name crosses once, not every tick. None for
|
||||
+ /// a name that is no block state of the game's registry (nothing is
|
||||
+ /// remembered for it). Handles are valid until `model-generation`
|
||||
+ /// changes (the registry changed: a resource reload, another world's
|
||||
+ /// data); ask again for every name then.
|
||||
+ model-id: func(model: string) -> option<u32>;
|
||||
+ /// Moves on whenever the handles `model-id` gave stop being valid. A
|
||||
+ /// mod reads it before `set-models-by-id` and re-asks when it changed.
|
||||
+ model-generation: func() -> u32;
|
||||
+ /// `model-instance` with the model as a handle.
|
||||
+ record model-instance-id {
|
||||
+ id: u32,
|
||||
+ /// From `model-id`.
|
||||
+ model: u32,
|
||||
+ x: f64,
|
||||
+ y: f64,
|
||||
+ z: f64,
|
||||
+ rotation: tuple<f32, f32, f32, f32>,
|
||||
+ scale: f32,
|
||||
+ glow: bool,
|
||||
+ }
|
||||
+ /// `set-models` with handles instead of names: the same models, limits
|
||||
+ /// and interpolation, fewer bytes across the boundary. `generation` is
|
||||
+ /// the `model-generation` the handles were asked under; if it is not
|
||||
+ /// the current one the call is ignored and returns false (re-ask the
|
||||
+ /// handles and call again). Handles the game doesn't know draw nothing.
|
||||
+ set-models-by-id: func(generation: u32, models: list<model-instance-id>) -> bool;
|
||||
/// Particles to spawn on this client (AirDef fork).
|
||||
record particle-spawn {
|
||||
/// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`.
|
||||
@@ -508,6 +536,7 @@ interface render {
|
||||
/// Compile errors come back in the chat and the log; the last good pass
|
||||
/// keeps running.
|
||||
set-post: func(wgsl: string);
|
||||
+ /// Removes this mod's post pass: the world is shown as the game draws it.
|
||||
clear-post: func();
|
||||
/// The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until
|
||||
/// replaced; call it once a tick (the pass gets the partial tick in
|
||||
@@ -561,6 +590,8 @@ interface render {
|
||||
/// How much bright things glow (0 none, 1 strong).
|
||||
bloom: f32,
|
||||
}
|
||||
+ /// Switches the HD lighting on or off for this mod's world, with its
|
||||
+ /// exposure and bloom; off, the world is drawn as the game draws it.
|
||||
set-lighting: func(lighting: lighting);
|
||||
}
|
||||
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index 80ac74b6f..d71396f59 100644
|
||||
--- a/crates/horizon-mods/src/api_level.rs
|
||||
+++ b/crates/horizon-mods/src/api_level.rs
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
-pub const LEVEL: u32 = 10;
|
||||
+pub const LEVEL: u32 = 11;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
-pub const FUNCTIONS: [(&str, u32); 58] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 61] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -73,6 +73,9 @@ pub const FUNCTIONS: [(&str, u32); 58] = [
|
||||
("game.world-id", 9),
|
||||
("render.set-lights", 10),
|
||||
("render.set-lighting", 10),
|
||||
+ ("render.model-id", 11),
|
||||
+ ("render.model-generation", 11),
|
||||
+ ("render.set-models-by-id", 11),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||||
index 57713e228..951ff2e24 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -79,9 +79,10 @@ use render_bindings::horizon::mods::input;
|
||||
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
|
||||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||||
pub use render_bindings::horizon::mods::render::{
|
||||
- Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
|
||||
- Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
|
||||
- CameraView, Lighting as ModLighting, PointLight, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
|
||||
+ CameraView, Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate,
|
||||
+ ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId,
|
||||
+ ParticleSpawn, Point as RenderPoint, PointLight, QuadShape, Rect as RenderRect, Shape,
|
||||
+ SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||||
@@ -278,6 +279,42 @@ pub trait WorldView: std::any::Any + Send {
|
||||
/// The view lent for the current calls, shared by every mod's store.
|
||||
type WorldSlot = Arc<Mutex<Option<Box<dyn WorldView>>>>;
|
||||
|
||||
+/// How the game turns a block state name into a handle (`render.model-id`):
|
||||
+/// None for a name that is no state of its registry.
|
||||
+pub type ModelResolver = Box<dyn Fn(&str) -> Option<u32> + Send>;
|
||||
+
|
||||
+/// The handles of `render.model-id`: the game's registry decides which names
|
||||
+/// have one, so nothing grows with what mods ask; `generation` moves on
|
||||
+/// when the registry changes and the handles with it.
|
||||
+#[derive(Default)]
|
||||
+pub struct ModelTable {
|
||||
+ key: usize,
|
||||
+ generation: u32,
|
||||
+ resolve: Option<ModelResolver>,
|
||||
+}
|
||||
+
|
||||
+type ModelSlot = Arc<Mutex<ModelTable>>;
|
||||
+
|
||||
+/// A model as a mod set it: by name, or by a handle of the [`ModelTable`].
|
||||
+#[derive(Clone, Debug)]
|
||||
+pub enum ModelRef {
|
||||
+ Name(String),
|
||||
+ Handle(u32),
|
||||
+}
|
||||
+
|
||||
+/// One world model (`render.set-models` or `set-models-by-id`).
|
||||
+#[derive(Clone, Debug)]
|
||||
+pub struct HostModel {
|
||||
+ pub id: u32,
|
||||
+ pub model: ModelRef,
|
||||
+ pub x: f64,
|
||||
+ pub y: f64,
|
||||
+ pub z: f64,
|
||||
+ pub rotation: (f32, f32, f32, f32),
|
||||
+ pub scale: f32,
|
||||
+ pub glow: bool,
|
||||
+}
|
||||
+
|
||||
/// A mod's text panel, drawn every frame until it changes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Panel {
|
||||
@@ -429,7 +466,9 @@ struct State {
|
||||
/// Capability `render`'s far terrain, when the mod draws one.
|
||||
far_terrain: Option<ModFarTerrain>,
|
||||
/// Capability `render`'s world models (AirDef fork), drawn until replaced.
|
||||
- models: Vec<ModelInstance>,
|
||||
+ models: Vec<HostModel>,
|
||||
+ /// The game's block state names -> handles, shared by every mod's store.
|
||||
+ model_table: ModelSlot,
|
||||
/// `render.query-ground`'s columns, answered before the next tick.
|
||||
ground_queries: Vec<(i32, i32)>,
|
||||
/// The game's answers to them.
|
||||
@@ -464,6 +503,7 @@ pub struct Mods {
|
||||
snapshot: Arc<Snapshot>,
|
||||
out: Vec<Output>,
|
||||
world: WorldSlot,
|
||||
+ model_table: ModelSlot,
|
||||
/// Moves on whenever a mod starts or stops: the game resends the server
|
||||
/// mods' commands to players when it changes.
|
||||
generation: u64,
|
||||
@@ -492,6 +532,26 @@ impl Mods {
|
||||
*slot = Some(view);
|
||||
}
|
||||
}
|
||||
+ /// The game's registry for `render.model-id`. `key` names the registry
|
||||
+ /// (its address): the same key keeps the handles, another one makes
|
||||
+ /// `model-generation` move on and drops every mod's models. `make` runs
|
||||
+ /// only then.
|
||||
+ pub fn set_model_resolver(&mut self, key: usize, make: impl FnOnce() -> ModelResolver) {
|
||||
+ let Ok(mut t) = self.model_table.lock() else {
|
||||
+ return;
|
||||
+ };
|
||||
+ if t.key != key || t.resolve.is_none() {
|
||||
+ t.key = key;
|
||||
+ t.generation = t.generation.wrapping_add(1);
|
||||
+ t.resolve = Some(make());
|
||||
+ drop(t);
|
||||
+ for e in &mut self.entries {
|
||||
+ if let Some(r) = e.run.as_mut() {
|
||||
+ r.store.data_mut().models.clear();
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
/// The view [`Mods::lend_world`] lent, back to the game.
|
||||
pub fn take_world(&mut self) -> Option<Box<dyn WorldView>> {
|
||||
self.world.lock().ok()?.take()
|
||||
@@ -613,6 +673,7 @@ impl Mods {
|
||||
.collect();
|
||||
let snapshot = self.snapshot.clone();
|
||||
let world = self.world.clone();
|
||||
+ let model_table = self.model_table.clone();
|
||||
let (engine, linker) = self.engine()?;
|
||||
let component =
|
||||
Component::new(engine, wasm).map_err(|e| format!("not a component: {e}"))?;
|
||||
@@ -646,6 +707,7 @@ impl Mods {
|
||||
minimap: None,
|
||||
far_terrain: None,
|
||||
models: Vec::new(),
|
||||
+ model_table: model_table.clone(),
|
||||
ground_queries: Vec::new(),
|
||||
ground: Vec::new(),
|
||||
post: None,
|
||||
@@ -1209,7 +1271,7 @@ impl Mods {
|
||||
}
|
||||
/// Every running mod's world models (AirDef fork), with the mod's index
|
||||
/// so ids of different mods don't meet.
|
||||
- pub fn models(&self) -> impl Iterator<Item = (usize, &ModelInstance)> {
|
||||
+ pub fn models(&self) -> impl Iterator<Item = (usize, &HostModel)> {
|
||||
self.entries.iter().enumerate().flat_map(|(i, e)| {
|
||||
e.run
|
||||
.iter()
|
||||
@@ -1234,7 +1296,9 @@ impl Mods {
|
||||
}
|
||||
/// The first running mod's lighting, if it set one.
|
||||
pub fn lighting(&self) -> Option<ModLighting> {
|
||||
- self.entries.iter().find_map(|e| e.run.as_ref()?.store.data().lighting)
|
||||
+ self.entries
|
||||
+ .iter()
|
||||
+ .find_map(|e| e.run.as_ref()?.store.data().lighting)
|
||||
}
|
||||
/// The columns each running mod asked the ground height of, taken.
|
||||
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
|
||||
@@ -1435,6 +1499,29 @@ fn reason(e: &wasmtime::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
+/// The models a mod set, limited: finite, normalised, at most [`MODELS`].
|
||||
+fn clean_models(models: impl Iterator<Item = HostModel>) -> Vec<HostModel> {
|
||||
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
+ models
|
||||
+ .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
|
||||
+ .take(MODELS)
|
||||
+ .map(|mut m| {
|
||||
+ let (x, y, z, w) = m.rotation;
|
||||
+ let len = (x * x + y * y + z * z + w * w).sqrt();
|
||||
+ m.rotation = if len.is_finite() && len > 1e-6 {
|
||||
+ (x / len, y / len, z / len, w / len)
|
||||
+ } else {
|
||||
+ (0.0, 0.0, 0.0, 1.0)
|
||||
+ };
|
||||
+ m.scale = if m.scale.is_finite() {
|
||||
+ m.scale.clamp(0.0, MODEL_SCALE)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ };
|
||||
+ m
|
||||
+ })
|
||||
+ .collect()
|
||||
+}
|
||||
fn clip(text: String) -> String {
|
||||
clip_to(text, LINE_CHARS)
|
||||
}
|
||||
@@ -1949,28 +2036,45 @@ impl render::Host for State {
|
||||
}
|
||||
}
|
||||
fn set_models(&mut self, models: Vec<ModelInstance>) {
|
||||
- let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
- self.models = models
|
||||
- .into_iter()
|
||||
- .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
|
||||
- .take(MODELS)
|
||||
- .map(|mut m| {
|
||||
- let (x, y, z, w) = m.rotation;
|
||||
- let len = (x * x + y * y + z * z + w * w).sqrt();
|
||||
- m.rotation = if len.is_finite() && len > 1e-6 {
|
||||
- (x / len, y / len, z / len, w / len)
|
||||
- } else {
|
||||
- (0.0, 0.0, 0.0, 1.0)
|
||||
- };
|
||||
- m.scale = if m.scale.is_finite() {
|
||||
- m.scale.clamp(0.0, MODEL_SCALE)
|
||||
- } else {
|
||||
- 1.0
|
||||
- };
|
||||
- m.model = clip(m.model);
|
||||
- m
|
||||
- })
|
||||
- .collect();
|
||||
+ let models = models.into_iter().map(|m| HostModel {
|
||||
+ id: m.id,
|
||||
+ model: ModelRef::Name(clip(m.model)),
|
||||
+ x: m.x,
|
||||
+ y: m.y,
|
||||
+ z: m.z,
|
||||
+ rotation: m.rotation,
|
||||
+ scale: m.scale,
|
||||
+ glow: m.glow,
|
||||
+ });
|
||||
+ self.models = clean_models(models);
|
||||
+ }
|
||||
+ fn model_id(&mut self, model: String) -> Option<u32> {
|
||||
+ let t = self.model_table.lock().ok()?;
|
||||
+ (t.resolve.as_ref()?)(&model)
|
||||
+ }
|
||||
+ fn model_generation(&mut self) -> u32 {
|
||||
+ self.model_table.lock().map_or(0, |t| t.generation)
|
||||
+ }
|
||||
+ fn set_models_by_id(&mut self, generation: u32, models: Vec<ModelInstanceId>) -> bool {
|
||||
+ if self
|
||||
+ .model_table
|
||||
+ .lock()
|
||||
+ .map_or(true, |t| t.generation != generation)
|
||||
+ {
|
||||
+ return false;
|
||||
+ }
|
||||
+ let models = models.into_iter().map(|m| HostModel {
|
||||
+ id: m.id,
|
||||
+ model: ModelRef::Handle(m.model),
|
||||
+ x: m.x,
|
||||
+ y: m.y,
|
||||
+ z: m.z,
|
||||
+ rotation: m.rotation,
|
||||
+ scale: m.scale,
|
||||
+ glow: m.glow,
|
||||
+ });
|
||||
+ self.models = clean_models(models);
|
||||
+ true
|
||||
}
|
||||
fn spawn_particles(&mut self, particles: Vec<ParticleSpawn>) {
|
||||
let particles: Vec<ParticleSpawn> = particles
|
||||
@@ -2012,7 +2116,11 @@ impl render::Host for State {
|
||||
.map(|mut v| {
|
||||
v.yaw = angle(v.yaw);
|
||||
v.pitch = angle(v.pitch).clamp(-90.0, 90.0);
|
||||
- v.fov = if v.fov.is_finite() { v.fov.clamp(1.0, 110.0) } else { 70.0 };
|
||||
+ v.fov = if v.fov.is_finite() {
|
||||
+ v.fov.clamp(1.0, 110.0)
|
||||
+ } else {
|
||||
+ 70.0
|
||||
+ };
|
||||
v.width = v.width.clamp(16, 1920);
|
||||
v.height = v.height.clamp(16, 1080);
|
||||
v
|
||||
@@ -2021,22 +2129,44 @@ impl render::Host for State {
|
||||
}
|
||||
fn set_lights(&mut self, lights: Vec<PointLight>) {
|
||||
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
- let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
+ let unit = |v: f32| {
|
||||
+ if v.is_finite() {
|
||||
+ v.clamp(0.0, 1.0)
|
||||
+ } else {
|
||||
+ 0.0
|
||||
+ }
|
||||
+ };
|
||||
self.lights = lights
|
||||
.into_iter()
|
||||
.filter(|l| finite(l.x) && finite(l.y) && finite(l.z))
|
||||
.take(512)
|
||||
.map(|mut l| {
|
||||
l.color = (unit(l.color.0), unit(l.color.1), unit(l.color.2));
|
||||
- l.intensity = if l.intensity.is_finite() { l.intensity.clamp(0.0, 100_000.0) } else { 0.0 };
|
||||
- l.radius = if l.radius.is_finite() { l.radius.clamp(1.0, 256.0) } else { 1.0 };
|
||||
+ l.intensity = if l.intensity.is_finite() {
|
||||
+ l.intensity.clamp(0.0, 100_000.0)
|
||||
+ } else {
|
||||
+ 0.0
|
||||
+ };
|
||||
+ l.radius = if l.radius.is_finite() {
|
||||
+ l.radius.clamp(1.0, 256.0)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ };
|
||||
l
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
fn set_lighting(&mut self, mut lighting: ModLighting) {
|
||||
- lighting.exposure = if lighting.exposure.is_finite() { lighting.exposure.clamp(0.05, 8.0) } else { 1.0 };
|
||||
- lighting.bloom = if lighting.bloom.is_finite() { lighting.bloom.clamp(0.0, 1.0) } else { 0.0 };
|
||||
+ lighting.exposure = if lighting.exposure.is_finite() {
|
||||
+ lighting.exposure.clamp(0.05, 8.0)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ };
|
||||
+ lighting.bloom = if lighting.bloom.is_finite() {
|
||||
+ lighting.bloom.clamp(0.0, 1.0)
|
||||
+ } else {
|
||||
+ 0.0
|
||||
+ };
|
||||
self.lighting = Some(lighting);
|
||||
}
|
||||
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
|
||||
@@ -2050,8 +2180,18 @@ impl render::Host for State {
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
|
||||
return;
|
||||
}
|
||||
- let ok = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max) } else { 1.0 };
|
||||
- let sounds = self.out.iter().filter(|o| matches!(o, Output::Sound { .. })).count();
|
||||
+ let ok = |v: f32, max: f32| {
|
||||
+ if v.is_finite() {
|
||||
+ v.clamp(0.0, max)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ }
|
||||
+ };
|
||||
+ let sounds = self
|
||||
+ .out
|
||||
+ .iter()
|
||||
+ .filter(|o| matches!(o, Output::Sound { .. }))
|
||||
+ .count();
|
||||
if sounds < 64 {
|
||||
self.out.push(Output::Sound {
|
||||
event: clip(event),
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index b25ef5b9e..150453ed5 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -125,6 +125,7 @@ A block along the player's view.
|
||||
| --- | --- | --- |
|
||||
| `get-player` | `func() -> option<player>` | None on the title screen. |
|
||||
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
|
||||
+| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
|
||||
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
|
||||
|
||||
## `chat`
|
||||
@@ -399,6 +400,16 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `centered` | `bool` | Centred on `at.x` instead of starting there. |
|
||||
| `shadow` | `bool` | |
|
||||
|
||||
+### record `view-shape`
|
||||
+
|
||||
+A picture of one of the mod's `set-views` cameras (AirDef fork).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `view` | `u32` | The camera's id; an unknown one draws nothing. |
|
||||
+| `rect` | `rect` | |
|
||||
+| `mode` | `u32` | 0 as seen, 1 thermal (white hot), 2 night vision (green). |
|
||||
+
|
||||
### variant `shape`
|
||||
|
||||
| Case | Payload | |
|
||||
@@ -408,6 +419,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `sprite` | `sprite-shape` | |
|
||||
| `item` | `item-shape` | |
|
||||
| `text` | `text-shape` | |
|
||||
+| `view` | `view-shape` | |
|
||||
|
||||
### record `frame-rate`
|
||||
|
||||
@@ -442,6 +454,93 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
|
||||
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
|
||||
|
||||
+### record `model-instance`
|
||||
+
|
||||
+A block model drawn in the world (AirDef fork).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | Stays the same while the object lives: the game moves an id smoothly from where the last `set-models` put it. |
|
||||
+| `model` | `string` | A block state as commands write it (`airdef:shahed`, `minecraft:stone`, `minecraft:furnace[facing=east]`): its block model is drawn. An unknown one draws nothing. |
|
||||
+| `x` | `f64` | Where the model's centre is, in blocks. |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `rotation` | `tuple<f32, f32, f32, f32>` | Rotation about the model's centre: a quaternion (x, y, z, w). |
|
||||
+| `scale` | `f32` | Uniform scale, 1.0 a block (0 to 64). |
|
||||
+| `glow` | `bool` | Drawn at full brightness, whatever the light where it is. |
|
||||
+
|
||||
+### record `model-instance-id`
|
||||
+
|
||||
+`model-instance` with the model as a handle.
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | |
|
||||
+| `model` | `u32` | From `model-id`. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `rotation` | `tuple<f32, f32, f32, f32>` | |
|
||||
+| `scale` | `f32` | |
|
||||
+| `glow` | `bool` | |
|
||||
+
|
||||
+### record `particle-spawn`
|
||||
+
|
||||
+Particles to spawn on this client (AirDef fork).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `particle` | `string` | As `/particle` names the type: `minecraft:flame`, `minecraft:dust`. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `spread` | `tuple<f32, f32, f32>` | Gaussian spread round the point, per axis, in blocks. |
|
||||
+| `velocity` | `tuple<f32, f32, f32>` | Every particle's starting velocity, blocks a tick. |
|
||||
+| `speed` | `f32` | Gaussian speed added to it per axis. |
|
||||
+| `count` | `u32` | 1 to 256. |
|
||||
+| `color` | `u32` | 0xRRGGBB for dust and coloured types (0: white). |
|
||||
+| `scale` | `f32` | Dust size (0.01 to 4). |
|
||||
+
|
||||
+### record `camera-view`
|
||||
+
|
||||
+A camera the game renders the world from besides the player's eye (AirDef fork): the land, the sky and the entities near the player.
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | Stays the same while the camera lives: the game turns it smoothly between the last two `set-views`. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `yaw` | `f32` | Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down. |
|
||||
+| `pitch` | `f32` | |
|
||||
+| `fov` | `f32` | Vertical field of view, degrees (1 to 110). |
|
||||
+| `width` | `u32` | The picture's size in pixels (16 to 1920 × 16 to 1080). |
|
||||
+| `height` | `u32` | |
|
||||
+
|
||||
+### record `point-light`
|
||||
+
|
||||
+A light the mod's things give off (AirDef fork): rocket flames, flares, blasts. Drawn by the HD lighting (`set-lighting`).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | Stays the same while the light lives: the game moves it smoothly. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `color` | `tuple<f32, f32, f32>` | Linear RGB, 0 to 1. |
|
||||
+| `intensity` | `f32` | Brightness: about what it lights one block away (0 to 100000). |
|
||||
+| `radius` | `f32` | Nothing past this many blocks (1 to 256). |
|
||||
+
|
||||
+### record `lighting`
|
||||
+
|
||||
+The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in high dynamic range, with bloom and tone mapping.
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `hd` | `bool` | |
|
||||
+| `exposure` | `f32` | 1 as is; more brightens. |
|
||||
+| `bloom` | `f32` | How much bright things glow (0 none, 1 strong). |
|
||||
+
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
|
||||
@@ -454,6 +553,20 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
|
||||
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
|
||||
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
|
||||
+| `set-models` | `func(models: list<model-instance>)` | Replaces the mod's models: up to 4096, drawn with the world's entities until replaced. Call it once a tick: the game interpolates each id between its last two positions and rotations every frame. An empty list removes them. |
|
||||
+| `model-id` | `func(model: string) -> option<u32>` | A handle for a block state name, for `set-models-by-id` (AirDef fork, API level 11): the name crosses once, not every tick. None for a name that is no block state of the game's registry (nothing is remembered for it). Handles are valid until `model-generation` changes (the registry changed: a resource reload, another world's data); ask again for every name then. |
|
||||
+| `model-generation` | `func() -> u32` | Moves on whenever the handles `model-id` gave stop being valid. A mod reads it before `set-models-by-id` and re-asks when it changed. |
|
||||
+| `set-models-by-id` | `func(generation: u32, models: list<model-instance-id>) -> bool` | `set-models` with handles instead of names: the same models, limits and interpolation, fewer bytes across the boundary. `generation` is the `model-generation` the handles were asked under; if it is not the current one the call is ignored and returns false (re-ask the handles and call again). Handles the game doesn't know draw nothing. |
|
||||
+| `spawn-particles` | `func(particles: list<particle-spawn>)` | Spawns particles on this client only: no command, no server. At most 512 spawns a call. |
|
||||
+| `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `<mod>:<name>`) at a point on this client. |
|
||||
+| `query-ground` | `func(columns: list<tuple<s32, s32>>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. |
|
||||
+| `ground` | `func() -> list<tuple<s32, s32, s32>>` | The answers to the last tick's `query-ground`: (x, z, y), y one above the highest block that blocks motion or holds a fluid, as the motion blocking heightmap; -64 where the chunk isn't loaded. |
|
||||
+| `set-post` | `func(wgsl: string)` | A full-screen post pass (AirDef fork): WGSL defining `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, after a prelude with `frame` (camera matrices, time, `params`), the world's colour and depth and helpers (`scene`, `depth-at`, `view-dir`, `scene-distance`, `project`). It runs after the world, before the hand and the HUD. Compile errors come back in the chat and the log; the last good pass keeps running. |
|
||||
+| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
|
||||
+| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
|
||||
+| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
|
||||
+| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
|
||||
+| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
|
||||
|
||||
## `events`
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -227,6 +227,7 @@ TEXTURES = {
|
||||
"lamp": lambda: gradient((255, 255, 240), (255, 230, 170)),
|
||||
"beam": lambda: gradient((255, 250, 225), (255, 240, 200)),
|
||||
"antenna": lambda: noise((70, 72, 70), 3, 65),
|
||||
"nose_dark": lambda: noise((58, 60, 62), 3, 66),
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +342,30 @@ def pac3():
|
||||
return p
|
||||
|
||||
|
||||
def irist_missile():
|
||||
"""IRIS-T SLM: тонкая белая ракета, две пары малых канардов у носа и четыре хвостовых киля."""
|
||||
p = taper("white", -16, -10, 1, 3, 1, 3, 3)
|
||||
p += [el((6.5, 6.5, -10), (9.5, 9.5, 24), "white")]
|
||||
p += both(el((3, 7.6, -9), (6.5, 8.4, -5), "missile_grey")) # канарды у носа
|
||||
p += [el((7.6, 3, -9), (8.4, 6.5, -5), "missile_grey"), el((7.6, 9.5, -9), (8.4, 13, -5), "missile_grey")]
|
||||
p += both(el((2, 7.6, 18), (6.5, 8.4, 26), "white")) # хвостовые киля
|
||||
p += [el((7.6, 2, 18), (8.4, 6.5, 26), "white"), el((7.6, 9.5, 18), (8.4, 14, 26), "white")]
|
||||
p += [el((7, 7, 24), (9, 9, 28), "nozzle")]
|
||||
return p
|
||||
|
||||
|
||||
def amraam_missile():
|
||||
"""AMRAAM: белое тело с тёмным носом, четыре крыла на середине корпуса и четыре хвостовых киля."""
|
||||
p = taper("nose_dark", -16, -9, 1, 3, 1, 3, 3)
|
||||
p += [el((6.5, 6.5, -9), (9.5, 9.5, 26), "white")]
|
||||
p += both(el((1, 7.6, 2), (6.5, 8.4, 9), "white")) # среднекорпусные крылья
|
||||
p += [el((7.6, 1, 2), (8.4, 6.5, 9), "white"), el((7.6, 9.5, 2), (8.4, 15, 9), "white")]
|
||||
p += both(el((4, 7.6, 19), (6.5, 8.4, 25), "white")) # хвостовые киля
|
||||
p += [el((7.6, 4, 19), (8.4, 6.5, 25), "white"), el((7.6, 9.5, 19), (8.4, 12, 25), "white")]
|
||||
p += [el((7, 7, 26), (9, 9, 28), "nozzle")]
|
||||
return p
|
||||
|
||||
|
||||
def flame():
|
||||
"""Факел двигателя: светящаяся «капля», хвост — на юг (+z)."""
|
||||
return taper("flame", -8, 26, 5, 1, 5, 1, 8)
|
||||
@@ -750,6 +775,8 @@ MODELS = {
|
||||
"geran3": (geran3, "Герань-3"),
|
||||
"geran3_flame": (geran3_flame, "Факел"),
|
||||
"pac3": (pac3, "PAC-3 MSE"),
|
||||
"irist_missile": (irist_missile, "IRIS-T SLM"),
|
||||
"amraam_missile": (amraam_missile, "AMRAAM"),
|
||||
"flame": (flame, "Факел"),
|
||||
"patriot_launcher": (patriot_launcher, "ПУ M903"),
|
||||
"patriot_truck": (patriot_truck, "Тягач"),
|
||||
|
||||
Reference in New Issue
Block a user