Compare commits

...

1 Commits

38 changed files with 3912 additions and 87 deletions
+7
View File
@@ -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: Релиз
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/amraam_missile"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/irist_missile"
}
}
}
+2
View File
@@ -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": "Тягач",
+2
View File
@@ -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

+413
View File
@@ -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"];
+156
View File
@@ -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);
}
}
+65 -17
View File
@@ -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,16 +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 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);
}
@@ -212,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) < length(a.xyz - frame.camera.xyz) - 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);
}
+57 -14
View File
@@ -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};
@@ -505,12 +506,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(),
@@ -519,24 +528,31 @@ 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);
}
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(()) => {
@@ -556,7 +572,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());
@@ -601,6 +622,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");
@@ -910,7 +938,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> {
@@ -1286,6 +1314,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
+7
View File
@@ -230,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
}
+5
View File
@@ -692,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;
+122
View File
@@ -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()),
}
}
}
+80 -42
View File
@@ -18,6 +18,7 @@ mod lights;
mod logistics;
mod orders;
mod units;
mod why;
#[cfg(test)]
mod regress;
pub use ew::EW_RADIUS;
@@ -112,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;
@@ -400,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 {
@@ -818,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,
@@ -832,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,
@@ -1179,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).
@@ -1220,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(),
@@ -1304,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);
}
@@ -1763,12 +1782,9 @@ impl Sim {
// 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: HashMap<u32, u32> = HashMap::new();
let mut guns_on: HashMap<u32, u32> = HashMap::new();
for s in &self.sams {
retarget(&mut missile_on, None, s.target);
retarget(&mut guns_on, None, s.gun_target);
}
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();
@@ -1816,16 +1832,12 @@ impl Sim {
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);
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| {
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.
let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
// 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| (load.min..load.max * k).contains(&d.pos.distance(pos));
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))
});
@@ -1844,7 +1856,7 @@ impl Sim {
};
self.drones
.iter()
.filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track)
.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)
@@ -1855,6 +1867,13 @@ impl Sim {
(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;
}
@@ -1871,7 +1890,12 @@ impl Sim {
// 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 } else { sam.reloading = spec.reload }
if logistics {
sam.empty = true;
ammo_waits += 1;
} else {
sam.reloading = spec.reload
}
}
sam.state = if sam.reloading > 0 {
SamState::Reload
@@ -1886,10 +1910,7 @@ impl Sim {
};
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 (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).
@@ -1900,11 +1921,7 @@ impl Sim {
_ => sam.aim_towards(want_yaw, Some(want_pitch)),
}
// How far the mount still is from the aim, over the axes this family aims.
let yaw_error = angle_diff(sam.yaw, want_yaw).abs();
let aim_error = match spec.family {
Family::Trainable | Family::Vertical => yaw_error,
_ => yaw_error + (want_pitch - sam.pitch).abs(),
};
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
@@ -1925,13 +1942,18 @@ impl Sim {
if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) {
if let Some(t) = drone_at.get(&gt).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;
@@ -1968,16 +1990,10 @@ 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 = drone_at.get(&mt).map(|&k| &self.drones[k]);
// The missile turns after its boost, but the launcher must point near the target.
t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.15)
}
Family::Turret => aim.id == mt && aim_error < 0.15,
_ => 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);
@@ -1994,11 +2010,20 @@ impl Sim {
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) {
@@ -2038,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;
@@ -2076,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;
}
@@ -2114,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);
+4
View File
@@ -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)
+7
View File
@@ -95,6 +95,7 @@ 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.
@@ -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));
@@ -499,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;
}
}
+53
View File
@@ -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 {
+305
View File
@@ -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("роль")));
}
}
+29
View File
@@ -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];
+41
View File
@@ -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));
}
@@ -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/14] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/15] 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/14] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/15] 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/14] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/15] 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/14] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/15] 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/14] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/15] 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/14] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/15] 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/14] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/15] 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/14] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/15] 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/14] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/15] 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/14] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/15] 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/14] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/15] 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/14] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/15] 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
@@ -1,7 +1,7 @@
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/14] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/15] 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>
@@ -1,7 +1,7 @@
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/14] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/15] 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>
@@ -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/15] 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)
+27
View File
@@ -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, "Тягач"),