Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a12225ba1 | |||
| 4bab572293 | |||
| f43fa5ee66 | |||
| 336ae9f7cc | |||
| 5f8bff31b9 | |||
| 3ced60e84f | |||
| 6f3fc644bc | |||
| 9075717872 | |||
| e3148bf458 | |||
| d0b9498aaf | |||
| 8065f2225c | |||
| a8551b5c28 | |||
| f443f0a94a | |||
| e33daac636 | |||
| 661c852bbf | |||
| 428713d4bc | |||
| 5839da129a | |||
| ab4a7899af | |||
| 65dd84646a | |||
| ed2db8523b | |||
| 67a2a523b4 | |||
| a806635681 | |||
| 0092ec64a9 | |||
| e08fc65ac8 | |||
| 90379f6e17 | |||
| bb35975642 | |||
| f2838a8f08 | |||
| c42d97af08 |
@@ -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: Релиз
|
||||
|
||||
@@ -22,7 +22,39 @@ jobs:
|
||||
if: inputs.os == 'macos' || inputs.os == 'both'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Проверка релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
if ! [[ "$TAG" =~ ^build-[0-9]+$ ]]; then echo "::error::тег '$TAG' не похож на build-N"; exit 1; fi
|
||||
REL_JSON=$(mktemp)
|
||||
CODE=$(curl -s -o "$REL_JSON" -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
|
||||
if [ "$CODE" != "200" ]; then echo "::error::релиза с тегом $TAG нет (HTTP $CODE): сначала дождись сборки build.yml на main"; exit 1; fi
|
||||
python3 - "$TAG" "$REL_JSON" <<'PY'
|
||||
import json, os, sys
|
||||
tag, path = sys.argv[1], sys.argv[2]
|
||||
r = json.load(open(path))
|
||||
assets = [a.get('name') for a in (r.get('assets') or [])]
|
||||
if r.get('tag_name') != tag or r.get('name') != tag or 'airdef.hmod' not in assets:
|
||||
print('::error::релиз %s не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем' % tag)
|
||||
sys.exit(1)
|
||||
with open(os.environ['GITHUB_ENV'], 'a') as f:
|
||||
f.write('RELEASE_ID=%s\nRELEASE_SHA=%s\n' % (r['id'], r.get('target_commitish', '')))
|
||||
PY
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
- name: Проверка коммита
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$RELEASE_SHA" ]; then echo "::error::тег $TAG указывает на $HEAD_SHA, а релиз создан из $RELEASE_SHA"; exit 1; fi
|
||||
echo "собираю $TAG = $HEAD_SHA"
|
||||
- name: Rust
|
||||
run: |
|
||||
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
@@ -47,11 +79,10 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
|
||||
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$RELEASE_ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
|
||||
|
||||
game-windows:
|
||||
if: inputs.os == 'windows' || inputs.os == 'both'
|
||||
@@ -60,7 +91,34 @@ jobs:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- name: Проверка релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:TAG -notmatch '^build-\d+$') { Write-Output "::error::тег '$env:TAG' не похож на build-N"; exit 1 }
|
||||
$h = @{ Authorization = "token $env:TOKEN" }
|
||||
try { $r = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" }
|
||||
catch { Write-Output "::error::релиза с тегом $env:TAG нет: сначала дождись сборки build.yml на main"; exit 1 }
|
||||
$assets = @($r.assets | ForEach-Object { $_.name })
|
||||
if ($r.tag_name -ne $env:TAG -or $r.name -ne $env:TAG -or ($assets -notcontains 'airdef.hmod')) {
|
||||
Write-Output "::error::релиз $env:TAG не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем"; exit 1
|
||||
}
|
||||
"RELEASE_ID=$($r.id)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
|
||||
"RELEASE_SHA=$($r.target_commitish)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
- name: Проверка коммита
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$head = git rev-parse HEAD
|
||||
if ($env:RELEASE_SHA -match '^[0-9a-f]{40}$' -and $head -ne $env:RELEASE_SHA) { Write-Output "::error::тег $env:TAG указывает на $head, а релиз создан из $env:RELEASE_SHA"; exit 1 }
|
||||
Write-Output "собираю $env:TAG = $head"
|
||||
- name: Rust
|
||||
run: |
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||
@@ -88,9 +146,7 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$h = @{ Authorization = "token $env:TOKEN" }
|
||||
$id = (Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG").id
|
||||
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$id/assets?name=airdef-windows-x86_64.zip"
|
||||
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
|
||||
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$env:RELEASE_ID/assets?name=airdef-windows-x86_64.zip"
|
||||
|
||||
|
||||
@@ -25,7 +25,39 @@ jobs:
|
||||
FREE=$(df --output=avail -BG / | tail -1 | tr -dc 0-9)
|
||||
echo "свободно: ${FREE} ГБ"
|
||||
if [ "$FREE" -lt 15 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 15: сборка игры забьёт диск и уронит Gitea"; exit 1; fi
|
||||
- name: Проверка релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
if ! [[ "$TAG" =~ ^build-[0-9]+$ ]]; then echo "::error::тег '$TAG' не похож на build-N"; exit 1; fi
|
||||
REL_JSON=$(mktemp)
|
||||
CODE=$(curl -s -o "$REL_JSON" -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
|
||||
if [ "$CODE" != "200" ]; then echo "::error::релиза с тегом $TAG нет (HTTP $CODE): сначала дождись сборки build.yml на main"; exit 1; fi
|
||||
python3 - "$TAG" "$REL_JSON" <<'PY'
|
||||
import json, os, sys
|
||||
tag, path = sys.argv[1], sys.argv[2]
|
||||
r = json.load(open(path))
|
||||
assets = [a.get('name') for a in (r.get('assets') or [])]
|
||||
if r.get('tag_name') != tag or r.get('name') != tag or 'airdef.hmod' not in assets:
|
||||
print('::error::релиз %s не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем' % tag)
|
||||
sys.exit(1)
|
||||
with open(os.environ['GITHUB_ENV'], 'a') as f:
|
||||
f.write('RELEASE_ID=%s\nRELEASE_SHA=%s\n' % (r['id'], r.get('target_commitish', '')))
|
||||
PY
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
- name: Проверка коммита
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$RELEASE_SHA" ]; then echo "::error::тег $TAG указывает на $HEAD_SHA, а релиз создан из $RELEASE_SHA"; exit 1; fi
|
||||
echo "собираю $TAG = $HEAD_SHA"
|
||||
- name: Системные библиотеки
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
@@ -45,12 +77,11 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
D=airdef-linux-x86_64
|
||||
mkdir -p "$D" out
|
||||
cp horizon/target/fast/horizon-client horizon/target/fast/horizon-ctl airdef-mod/target/horizon-mod/airdef.hmod dist/play.sh docs/INSTALL.md "$D/"
|
||||
tar czf "out/$D.tar.gz" "$D"
|
||||
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$D.tar.gz" "$API/releases/$ID/assets?name=$D.tar.gz" > /dev/null
|
||||
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$D.tar.gz" "$API/releases/$RELEASE_ID/assets?name=$D.tar.gz" > /dev/null
|
||||
|
||||
@@ -5,3 +5,5 @@ airdef-mod/target/
|
||||
__pycache__/
|
||||
/out/
|
||||
.DS_Store
|
||||
run-home-bench/
|
||||
run-home-smoke/
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
## Запуск
|
||||
|
||||
Пошаговый гайд для Windows и macOS (готовая сборка из релиза или сборка из исходников): [docs/INSTALL.md](docs/INSTALL.md).
|
||||
Устройство HD-рендера, его статус и план доработки: [docs/HD-RENDERER.md](docs/HD-RENDERER.md).
|
||||
|
||||
Коротко, из исходников:
|
||||
|
||||
@@ -23,9 +24,12 @@ python3 tools/airdef.py play # запустить (аргументы игр
|
||||
(`tools/gen_sounds.py`, нужны numpy и ffmpeg), собирает и ставит мод. Сгенерированные файлы лежат в репозитории,
|
||||
так что для обычной сборки нужен только Rust.
|
||||
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает мод и игру для Linux, а пуш в `main` публикует релиз
|
||||
`build-N`; `.gitea/workflows/desktop.yml` (запуск вручную с тегом релиза) собирает игру для macOS и Windows на
|
||||
раннерах с метками `macos-latest` / `windows-latest` и дописывает архивы в релиз.
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его
|
||||
релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml`
|
||||
(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом
|
||||
готового релиза `build-N`. Перед сборкой workflow проверяет, что такой релиз создан `build.yml` (имя релиза = тег,
|
||||
есть `airdef.hmod`), иначе падает с ошибкой; затем выкачивает ровно коммит этого тега (а не ветку, с которой запущен),
|
||||
сверяет его с коммитом релиза и дописывает архивы только в этот релиз.
|
||||
|
||||
## Как играть
|
||||
|
||||
@@ -97,6 +101,14 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
|
||||
25 с на цель в темноте), игрок — **осветительным пистолетом** или `/airdef flare`. Ракета висит ~30 с и
|
||||
освещает всё вокруг на ~60–90 м.
|
||||
|
||||
**Графика: три пресета** (меню N → «Огонь, ремонт, эффекты…» → «Эффекты», или `/airdef shader off|on|hd`):
|
||||
выкл — ванильная картинка; обычные — шейдерные огненные шары, ударная волна и подсветка; **HD** — свой рендер
|
||||
освещения в форке: мир дополнительно рисуется в G-буфер (цвет без света и нормали), и все источники света мода
|
||||
освещают его попиксельно в HDR — факелы ракет и двигатели, раскалённые боевые блоки, осветительные ракеты, вспышки
|
||||
выстрелов, трассеры, взрывы (вспышка и догорающий шар), горящие остовы, пятна прожекторов, — затем bloom и мягкий
|
||||
тонмаппинг (ванильный диапазон не трогается, сжимается только то, что ярче). Источники передаются движку новым
|
||||
API мода `render.set-lights` (до 512, плавно интерполируются), режим — `render.set-lighting`.
|
||||
|
||||
**Сетка J** после сбития ещё 3,5 с держит камеру на сбитой цели, и комплекс в это время не пропадает из сетки.
|
||||
|
||||
**Повреждения.** Взрыв рядом с комплексом повреждает его: он хуже поворачивается, хуже видит и попадает,
|
||||
@@ -122,7 +134,7 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
|
||||
перехватчиков (жёлтые), сбития (x), прилёты, шкала времени с событиями; время идёт само, колесо мыши перематывает;
|
||||
- **повтор момента** — клик по событию в списке: «призраки» дронов и ракет снова летят в мире, камера идёт за
|
||||
целью, потом за перехватчиком (или смотрит из пушки, со стороны на прилёт), в момент сбития время замедляется;
|
||||
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — скорость (×0.5–×16);
|
||||
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — перемотка, стрелки — скорость;
|
||||
- **лучшие моменты** — нарезка до 8 самых интересных событий (перехват баллистики и гиперзвука, поражения
|
||||
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Status of terrain-aware flight (not committed):
|
||||
- Done: sim/terrain.rs (look-ahead terrain following, dive only down a clear line, interceptor steer/loft/self-destruct, launch predicate), planner relief pricing + spawn above land (raid.rs), SAM launch check + why line (mod.rs, why.rs), Tally.scrubbed, world.probe, harness terrain fixes, 6 new tests in regress.rs. Build has no warnings; cargo test passes (43 + 4).
|
||||
- Numbers (6 seeds, sim_run big-smart): hills:70:100:140 crashes 13 -> 3, hills:70:150:120 29 -> 2, default hills 2 -> 2 (all terminal-dive near-target by Kh101/Kalibr).
|
||||
- Half-done / known: terminal dive of slow-turning cruise missiles still sometimes hits ground 7-20 blocks short of target; hills:70:150:120 tick max 38 ms / p95 2 ms (planner terrain phase + ground queries) not profiled; not tested in game.
|
||||
- Next: tune dive geometry for Kh101/Kalibr, profile the planner's 1024-columns-per-tick height requests, optional in-game check.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "airdef:block/amraam_missile"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "airdef:block/irist_missile"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
"block.airdef.geran3": "Герань-3",
|
||||
"block.airdef.geran3_flame": "Факел",
|
||||
"block.airdef.pac3": "PAC-3 MSE",
|
||||
"block.airdef.irist_missile": "IRIS-T SLM",
|
||||
"block.airdef.amraam_missile": "AMRAAM",
|
||||
"block.airdef.flame": "Факел",
|
||||
"block.airdef.patriot_launcher": "ПУ M903",
|
||||
"block.airdef.patriot_truck": "Тягач",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"block.airdef.geran3": "Герань-3",
|
||||
"block.airdef.geran3_flame": "Факел",
|
||||
"block.airdef.pac3": "PAC-3 MSE",
|
||||
"block.airdef.irist_missile": "IRIS-T SLM",
|
||||
"block.airdef.amraam_missile": "AMRAAM",
|
||||
"block.airdef.flame": "Факел",
|
||||
"block.airdef.patriot_launcher": "ПУ M903",
|
||||
"block.airdef.patriot_truck": "Тягач",
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"nose_dark": "airdef:block/nose_dark",
|
||||
"white": "airdef:block/white",
|
||||
"nozzle": "airdef:block/nozzle",
|
||||
"particle": "airdef:block/nose_dark"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
7.333333333333333,
|
||||
7.333333333333333,
|
||||
-16.0
|
||||
],
|
||||
"to": [
|
||||
8.666666666666666,
|
||||
8.666666666666666,
|
||||
-13.666666666666666
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
-13.666666666666666
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
-11.333333333333332
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.666666666666666,
|
||||
6.666666666666666,
|
||||
-11.333333333333332
|
||||
],
|
||||
"to": [
|
||||
9.333333333333334,
|
||||
9.333333333333334,
|
||||
-9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333332,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333332,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.333333333333332
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.333333333333332
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.5,
|
||||
6.5,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
9.5,
|
||||
9.5,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
1.0,
|
||||
7.6,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
15.0,
|
||||
8.4,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
1.0,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
15.0,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
4.0,
|
||||
7.6,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
12.0,
|
||||
8.4,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
4.0,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
12.0,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
26.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
28.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"white": "airdef:block/white",
|
||||
"missile_grey": "airdef:block/missile_grey",
|
||||
"nozzle": "airdef:block/nozzle",
|
||||
"particle": "airdef:block/white"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
7.333333333333333,
|
||||
7.333333333333333,
|
||||
-16.0
|
||||
],
|
||||
"to": [
|
||||
8.666666666666666,
|
||||
8.666666666666666,
|
||||
-14.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
-14.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
-12.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.666666666666666,
|
||||
6.666666666666666,
|
||||
-12.0
|
||||
],
|
||||
"to": [
|
||||
9.333333333333334,
|
||||
9.333333333333334,
|
||||
-10.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.5,
|
||||
6.5,
|
||||
-10.0
|
||||
],
|
||||
"to": [
|
||||
9.5,
|
||||
9.5,
|
||||
24.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
3.0,
|
||||
7.6,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
13.0,
|
||||
8.4,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
3.0,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
13.0,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
2.0,
|
||||
7.6,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
14.0,
|
||||
8.4,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
2.0,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
14.0,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
24.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
28.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
4.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
4.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,59 @@
|
||||
//! Rough cost of handing 2000 model instances to the game each tick: allocations and bytes the mod
|
||||
//! builds (counting allocator) and the bytes the canonical ABI then copies across (wasm32 layout:
|
||||
//! `model-instance` 64 bytes + its name, `model-instance-id` 56 bytes).
|
||||
//! Run: `cargo run --release --example emit_cost`.
|
||||
use horizon_mod_sdk::render::{ModelInstance, ModelInstanceId};
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
|
||||
|
||||
static COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static BYTES: AtomicUsize = AtomicUsize::new(0);
|
||||
struct Counting;
|
||||
unsafe impl GlobalAlloc for Counting {
|
||||
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
||||
COUNT.fetch_add(1, Relaxed);
|
||||
BYTES.fetch_add(l.size(), Relaxed);
|
||||
unsafe { System.alloc(l) }
|
||||
}
|
||||
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
||||
unsafe { System.dealloc(p, l) }
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static A: Counting = Counting;
|
||||
|
||||
const NAMES: [&str; 4] = ["airdef:shahed", "airdef:geran_2", "airdef:iris_t", "airdef:explosion_core"];
|
||||
|
||||
fn measure(f: impl FnOnce()) -> (usize, usize) {
|
||||
let (c, b) = (COUNT.load(Relaxed), BYTES.load(Relaxed));
|
||||
f();
|
||||
(COUNT.load(Relaxed) - c, BYTES.load(Relaxed) - b)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
const N: usize = 2000;
|
||||
let (mut old, mut by_id): (Vec<ModelInstance>, Vec<ModelInstanceId>) = (Vec::new(), Vec::new());
|
||||
by_id.reserve(N);
|
||||
let inst = |i: usize| (i as u32, NAMES[i % 4], i as f64);
|
||||
let wire_old: usize = (0..N).map(|i| 64 + NAMES[i % 4].len()).sum();
|
||||
let (c, b) = measure(|| {
|
||||
old = (0..N)
|
||||
.map(|i| {
|
||||
let (id, n, x) = inst(i);
|
||||
ModelInstance { id, model: n.to_string(), x, y: x, z: x, rotation: (0., 0., 0., 1.), scale: 1., glow: false }
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
println!("old : {c} allocations, {b} bytes built per tick; {wire_old} bytes across, {N} strings lowered");
|
||||
drop(old);
|
||||
for tick in 0..2 {
|
||||
let (c, b) = measure(|| {
|
||||
by_id.clear();
|
||||
for i in 0..N {
|
||||
let (id, _, x) = inst(i);
|
||||
by_id.push(ModelInstanceId { id, model: (i % 4) as u32, x, y: x, z: x, rotation: (0., 0., 0., 1.), scale: 1., glow: false });
|
||||
}
|
||||
});
|
||||
println!("new : tick {tick}: {c} allocations, {b} bytes built; {} bytes across, no strings", N * 56);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! 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 },
|
||||
}
|
||||
|
||||
/// The land as blocks: solid at and below the column's height (what the game would answer `render.sections` with).
|
||||
impl airdef::sim::world::WorldSource for Terrain {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
Some(y < self.height(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
/// The block cache's counters (issue #43) and the drones that struck the land (issue #42).
|
||||
pub world: String,
|
||||
pub crashes: u32,
|
||||
/// What went wrong for the air defence, as the raid report words it.
|
||||
pub failures: String,
|
||||
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();
|
||||
// Systems stand on the land, whatever the layout says.
|
||||
let pos = DVec3::new(u.pos.x, f64::from(sc.terrain.height(u.pos.x.floor() as i32, u.pos.z.floor() as i32)), u.pos.z);
|
||||
let mut s = Sam::new(id, u.kind, pos, u.heading);
|
||||
if u.empty {
|
||||
s.empty = true;
|
||||
s.missiles = 0;
|
||||
}
|
||||
sim.sams.push(s);
|
||||
}
|
||||
sim.targets = sc.objects.iter().map(|o| DVec3::new(o.x, f64::from(sc.terrain.height(o.x.floor() as i32, o.z.floor() as i32)), o.z)).collect();
|
||||
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;
|
||||
let objects = sim.targets.clone();
|
||||
sim.order_raid(&order, &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 sections = ground.world.take_wanted(now);
|
||||
ground.world.serve(&sc.terrain, sections, now);
|
||||
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.world = ground.world.text();
|
||||
r.crashes = sim.crashes;
|
||||
r.failures = sim.tally.text();
|
||||
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\
|
||||
crashes into the land: {crashes}; {world}\n\
|
||||
air-defence failures: {failures}\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,
|
||||
crashes = self.crashes,
|
||||
world = self.world,
|
||||
failures = if self.failures.is_empty() { "none" } else { &self.failures },
|
||||
)
|
||||
}
|
||||
pub fn json(&self) -> String {
|
||||
format!(
|
||||
"{{\"scenario\":{:?},\"seed\":{},\"units\":{},\"ticks\":{},\"raids_ordered\":{},\"raids_ended\":{},\
|
||||
\"launched\":{},\"kills\":{},\"hits\":{},\"fired\":{},\
|
||||
\"tick_ms\":{{\"p50\":{:.4},\"p95\":{:.4},\"max\":{:.4},\"total\":{:.2}}},\
|
||||
\"planning_ms\":{:.2},\"planning_ticks\":{},\"ground_queries\":{},\"ground_batches\":{},\
|
||||
\"nan_positions\":{},\"stuck_trucks\":{:?},\"truck_busy_ticks\":{}}}",
|
||||
self.name,
|
||||
self.seed,
|
||||
self.units,
|
||||
self.ticks_run,
|
||||
self.raids_ordered,
|
||||
self.raids_ended,
|
||||
self.launched,
|
||||
self.kills,
|
||||
self.hits,
|
||||
self.fired,
|
||||
ms(self.tick_p50),
|
||||
ms(self.tick_p95),
|
||||
ms(self.tick_max),
|
||||
ms(self.tick_total),
|
||||
ms(self.planning),
|
||||
self.planning_ticks,
|
||||
self.ground_queries,
|
||||
self.ground_batches,
|
||||
self.nan_positions,
|
||||
self.stuck_trucks,
|
||||
self.truck_busy_ticks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ready-made scenarios
|
||||
|
||||
fn ring(sc: &mut Scenario, kinds: &[SamKind], count: usize, r0: f64, r1: f64) {
|
||||
for i in 0..count {
|
||||
let a = i as f64 * 2.399_963; // golden angle: an even spread without randomness
|
||||
let r = r0 + (r1 - r0) * (i as f64 / count.max(1) as f64);
|
||||
let kind = kinds[i % kinds.len()];
|
||||
sc.unit(kind, a.sin() * r, a.cos() * r).heading = a;
|
||||
}
|
||||
}
|
||||
|
||||
/// A big smart raid on 61 systems.
|
||||
pub fn big_smart() -> Scenario {
|
||||
let mut sc = Scenario::new("big-smart");
|
||||
sc.terrain = Terrain::Hills { base: 70, amp: 14.0, period: 90.0 };
|
||||
for (x, z) in [(0.0, 0.0), (120.0, 80.0), (-150.0, -60.0)] {
|
||||
sc.objects.push(DVec3::new(x, 70.0, z));
|
||||
}
|
||||
ring(&mut sc, &[SamKind::Radar], 6, 150.0, 500.0);
|
||||
ring(&mut sc, &[SamKind::Ew], 2, 100.0, 300.0);
|
||||
ring(&mut sc, &SamKind::ARMED, 53, 60.0, 450.0);
|
||||
assert_eq!(sc.units.len(), 61);
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![
|
||||
(DroneKind::Gerbera, 8),
|
||||
(DroneKind::Shahed, 12),
|
||||
(DroneKind::Geran3, 4),
|
||||
(DroneKind::Lancet, 4),
|
||||
(DroneKind::Kh101, 4),
|
||||
(DroneKind::Kalibr, 4),
|
||||
(DroneKind::Iskander, 2),
|
||||
],
|
||||
approach: Approach::AllSides,
|
||||
aim: Aim::Smart,
|
||||
dist: 500.0,
|
||||
at: 1,
|
||||
});
|
||||
sc.ticks = 20 * 60 * 8;
|
||||
sc
|
||||
}
|
||||
|
||||
/// Trucks and a depot supplying systems that start with empty magazines, under a raid.
|
||||
pub fn logistics() -> Scenario {
|
||||
let mut sc = Scenario::new("logistics");
|
||||
sc.unit(SamKind::Radar, 0.0, -40.0);
|
||||
sc.unit(SamKind::Depot, 300.0, 300.0);
|
||||
for (x, z) in [(310.0, 320.0), (290.0, 330.0)] {
|
||||
sc.unit(SamKind::Tzm, x, z);
|
||||
}
|
||||
sc.unit(SamKind::Tzm, 280.0, 280.0);
|
||||
for (i, kind) in [SamKind::Patriot, SamKind::Tor, SamKind::Buk, SamKind::Nasams, SamKind::Pantsir, SamKind::S400].into_iter().enumerate() {
|
||||
let a = i as f64 * 1.05;
|
||||
let u = sc.unit(kind, a.sin() * 120.0, a.cos() * 120.0);
|
||||
// Every other system starts out of missiles: the trucks have work from the first second.
|
||||
u.empty = i % 2 == 0;
|
||||
}
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![(DroneKind::Shahed, 16), (DroneKind::Gerbera, 8), (DroneKind::Kh101, 3)],
|
||||
approach: Approach::TwoSectors,
|
||||
aim: Aim::Objects,
|
||||
dist: 400.0,
|
||||
at: 1,
|
||||
});
|
||||
sc.ticks = 20 * 60 * 6;
|
||||
sc
|
||||
}
|
||||
|
||||
/// EW stations on and a smart enemy that adapts over three raids.
|
||||
pub fn ew_adaptive() -> Scenario {
|
||||
let mut sc = Scenario::new("ew-adaptive");
|
||||
sc.ew = true;
|
||||
sc.unit(SamKind::Radar, 0.0, 0.0);
|
||||
sc.unit(SamKind::Radar, 200.0, -200.0);
|
||||
sc.unit(SamKind::Ew, 80.0, 0.0);
|
||||
sc.unit(SamKind::Ew, -80.0, 60.0);
|
||||
for (i, kind) in [SamKind::Pantsir, SamKind::Gepard, SamKind::Tor, SamKind::Patriot, SamKind::Buk, SamKind::Gepard].into_iter().enumerate() {
|
||||
let a = i as f64 * 1.05;
|
||||
sc.unit(kind, a.sin() * 150.0, a.cos() * 150.0);
|
||||
}
|
||||
for at in [1, 1, 1] {
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![(DroneKind::Gerbera, 6), (DroneKind::Shahed, 10), (DroneKind::Kh101, 4), (DroneKind::Kalibr, 3)],
|
||||
approach: Approach::AllSides,
|
||||
aim: Aim::Smart,
|
||||
dist: 450.0,
|
||||
at,
|
||||
});
|
||||
}
|
||||
sc.ticks = 20 * 60 * 12;
|
||||
sc
|
||||
}
|
||||
|
||||
pub fn preset(name: &str) -> Option<Scenario> {
|
||||
match name {
|
||||
"big-smart" => Some(big_smart()),
|
||||
"logistics" => Some(logistics()),
|
||||
"ew-adaptive" => Some(ew_adaptive()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub const PRESETS: [&str; 3] = ["big-smart", "logistics", "ew-adaptive"];
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Native headless runner for the simulation.
|
||||
//!
|
||||
//! cargo run --release --example sim_run -- [options]
|
||||
//!
|
||||
//! Options (all optional; a preset gives the defaults, the others override it):
|
||||
//! --scenario NAME big-smart | logistics | ew-adaptive (default big-smart)
|
||||
//! --seed N random seed
|
||||
//! --layout FILE systems from a text file, replacing the preset's; one per line:
|
||||
//! `KIND X Z [HEADING_RAD] [empty]` (`#` starts a comment)
|
||||
//! KIND is a system key (patriot, tor, radar, depot, tzm, ew, ...)
|
||||
//! --raid SPEC replaces the preset's raids; repeatable. SPEC is
|
||||
//! `kind=count,kind=count[;dist=500][;aim=smart|objects|sams][;approach=sector|two|all|waves][;at=TICK]`
|
||||
//! --terrain T flat[:HEIGHT] | hills[:BASE:AMP:PERIOD]
|
||||
//! --ticks N tick limit (20 a second)
|
||||
//! --run-on do not stop when the raids have ended
|
||||
//! --ew / --no-ew EW stations on or off
|
||||
//! --json one JSON object instead of text
|
||||
//! --list list scenarios, system and drone kinds
|
||||
//! Exit code 1 when the run has problems (NaN positions, stuck trucks, a raid that never ended).
|
||||
//! Native timings are for comparing runs on one machine, not an estimate of WASM/GPU cost.
|
||||
mod harness;
|
||||
use airdef::sim::{Aim, Approach, DroneKind, SamKind};
|
||||
use glam::DVec3;
|
||||
use harness::*;
|
||||
|
||||
fn die(msg: &str) -> ! {
|
||||
eprintln!("sim_run: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn parse_raid(spec: &str) -> RaidSpec {
|
||||
let mut r = RaidSpec { kinds: Vec::new(), approach: Approach::Sector, aim: Aim::Smart, dist: 400.0, at: 1 };
|
||||
for (i, part) in spec.split(';').enumerate() {
|
||||
if i == 0 {
|
||||
for kc in part.split(',').filter(|s| !s.is_empty()) {
|
||||
let (k, c) = kc.split_once('=').unwrap_or_else(|| die(&format!("raid kind `{kc}` needs =count")));
|
||||
let kind = DroneKind::from_key(k).unwrap_or_else(|| die(&format!("unknown drone kind `{k}` (see --list)")));
|
||||
r.kinds.push((kind, c.parse().unwrap_or_else(|_| die(&format!("bad count `{c}`")))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (k, v) = part.split_once('=').unwrap_or_else(|| die(&format!("bad raid option `{part}`")));
|
||||
match k {
|
||||
"dist" => r.dist = v.parse().unwrap_or_else(|_| die("bad dist")),
|
||||
"at" => r.at = v.parse().unwrap_or_else(|_| die("bad at")),
|
||||
"aim" => {
|
||||
r.aim = match v {
|
||||
"smart" => Aim::Smart,
|
||||
"objects" => Aim::Objects,
|
||||
"sams" => Aim::ObjectsAndSams,
|
||||
_ => die("aim: smart | objects | sams"),
|
||||
}
|
||||
}
|
||||
"approach" => {
|
||||
r.approach = match v {
|
||||
"sector" => Approach::Sector,
|
||||
"two" => Approach::TwoSectors,
|
||||
"all" => Approach::AllSides,
|
||||
"waves" => Approach::Waves,
|
||||
_ => die("approach: sector | two | all | waves"),
|
||||
}
|
||||
}
|
||||
_ => die(&format!("unknown raid option `{k}`")),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn parse_terrain(t: &str) -> Terrain {
|
||||
let n: Vec<&str> = t.split(':').collect();
|
||||
let num = |i: usize, d: f64| n.get(i).map_or(d, |s| s.parse().unwrap_or_else(|_| die("bad terrain number")));
|
||||
match n[0] {
|
||||
"flat" => Terrain::Flat(num(1, 70.0) as i32),
|
||||
"hills" => Terrain::Hills { base: num(1, 70.0) as i32, amp: num(2, 14.0), period: num(3, 90.0) },
|
||||
_ => die("terrain: flat[:H] | hills[:BASE:AMP:PERIOD]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_layout(path: &str) -> Vec<Unit> {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_else(|e| die(&format!("{path}: {e}")));
|
||||
let mut units = Vec::new();
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let bad = || die(&format!("{path}:{}: expected `KIND X Z [HEADING] [empty]`", n + 1));
|
||||
if w.len() < 3 {
|
||||
bad();
|
||||
}
|
||||
let kind = SamKind::from_key(w[0]).unwrap_or_else(|| die(&format!("{path}:{}: unknown system `{}`", n + 1, w[0])));
|
||||
let f = |s: &str| s.parse::<f64>().unwrap_or_else(|_| bad());
|
||||
let heading = w.get(3).filter(|s| **s != "empty").map_or(0.0, |s| f(s));
|
||||
units.push(Unit { kind, pos: DVec3::new(f(w[1]), 70.0, f(w[2])), heading, empty: w.contains(&"empty") });
|
||||
}
|
||||
units
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut name = "big-smart".to_string();
|
||||
let mut i = 0;
|
||||
// The scenario first, so the other options override it whatever their order.
|
||||
while i < args.len() {
|
||||
if args[i] == "--scenario" {
|
||||
name = args.get(i + 1).cloned().unwrap_or_else(|| die("--scenario needs a name"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut sc = preset(&name).unwrap_or_else(|| die(&format!("unknown scenario `{name}`; one of {PRESETS:?}")));
|
||||
let (mut json, mut raids) = (false, Vec::new());
|
||||
let mut it = args.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
let mut value = || it.next().cloned().unwrap_or_else(|| die(&format!("{arg} needs a value")));
|
||||
match arg.as_str() {
|
||||
"--scenario" => {
|
||||
value();
|
||||
}
|
||||
"--seed" => sc.seed = value().parse().unwrap_or_else(|_| die("bad seed")),
|
||||
"--ticks" => sc.ticks = value().parse().unwrap_or_else(|_| die("bad ticks")),
|
||||
"--layout" => sc.units = parse_layout(&value()),
|
||||
"--raid" => raids.push(parse_raid(&value())),
|
||||
"--terrain" => sc.terrain = parse_terrain(&value()),
|
||||
"--run-on" => sc.stop_at_end = false,
|
||||
"--ew" => sc.ew = true,
|
||||
"--no-ew" => sc.ew = false,
|
||||
"--json" => json = true,
|
||||
"--list" => {
|
||||
println!("scenarios: {PRESETS:?}");
|
||||
println!("systems: {:?}", SamKind::ALL.iter().map(|k| k.key()).collect::<Vec<_>>());
|
||||
println!("drones: {:?}", DroneKind::ALL.iter().map(|k| k.spec().key).collect::<Vec<_>>());
|
||||
return;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("{}", include_str!("main.rs").lines().take_while(|l| l.starts_with("//")).map(|l| l.trim_start_matches("//!").trim_start_matches("//").to_string() + "\n").collect::<String>());
|
||||
return;
|
||||
}
|
||||
other => die(&format!("unknown option `{other}` (try --help)")),
|
||||
}
|
||||
}
|
||||
if !raids.is_empty() {
|
||||
sc.raids = raids;
|
||||
}
|
||||
let r = run(&sc);
|
||||
if json {
|
||||
println!("{}", r.json());
|
||||
} else {
|
||||
print!("{}", r.text());
|
||||
}
|
||||
let problems = r.problems();
|
||||
if !problems.is_empty() {
|
||||
eprintln!("PROBLEMS: {}", problems.join("; "));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
+106
-26
@@ -8,6 +8,7 @@
|
||||
// [0] now (mod ticks), blasts, lights, -
|
||||
// [1 + 2i], [2 + 2i] blast i: x, y, z, size | start tick, life ticks, kind, -
|
||||
// [97 + 2j], [98 + 2j] light j: x, y, z, radius | r, g, b, intensity
|
||||
// [255].x hold: 0 live; a replay's share of the frame's partial tick it does not advance with
|
||||
|
||||
const BLAST_BASE: i32 = 1;
|
||||
const LIGHT_BASE: i32 = 97;
|
||||
@@ -59,8 +60,28 @@ 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;
|
||||
return frame.params[0].x + frame.time.y * (1.0 - frame.params[255].x);
|
||||
}
|
||||
|
||||
// Fire's colour from its heat (0 cold soot .. 1 white-hot).
|
||||
@@ -86,22 +107,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;
|
||||
@@ -115,7 +141,8 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
// the pixel sees, much stronger at night.
|
||||
let night = clamp(0.35 - frame.time.z * 1.8, 0.0, 1.0);
|
||||
let strength = mix(0.35, 2.2, night);
|
||||
if wall < 1.0e5 {
|
||||
// Under the HD lighting (params[0].w) the engine lights the world from the same lights.
|
||||
if wall < 1.0e5 && frame.params[0].w < 0.5 {
|
||||
let p = dir * wall;
|
||||
var light = vec3<f32>(0.0);
|
||||
for (var i = 0; i < blasts; i++) {
|
||||
@@ -148,8 +175,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);
|
||||
@@ -160,7 +202,9 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let t1 = min(span.y, wall);
|
||||
if t1 <= t0 { continue; }
|
||||
let heat = 1.0 - smoothstep(0.05, 0.55, k);
|
||||
let fade = 1.0 - smoothstep(0.65, 1.0, k);
|
||||
// With the HD renderer's volumetric smoke (params[0].w = 2) only the fireball is drawn here.
|
||||
var fade = 1.0 - smoothstep(0.65, 1.0, k);
|
||||
if frame.params[0].w > 1.5 { fade = 1.0 - smoothstep(0.08, 0.28, k); }
|
||||
// Fewer steps for a fireball that is small on screen.
|
||||
let on_screen = radius / max(length(centre), 1.0);
|
||||
let steps = i32(clamp(on_screen * 40.0, 6.0, 14.0));
|
||||
@@ -188,31 +232,67 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
}
|
||||
color = color * trans + lit;
|
||||
|
||||
// 3. Flashes: a bright bloom at the blast and the whole view lit up for a moment.
|
||||
// 3. Flashes (#51): a huge bright bloom at the blast, a wide halo that lifts the sky and the
|
||||
// clouds, a white shock ring racing out, and the view lit up for a moment. The sum is
|
||||
// squashed, so a crowded sky never whites the screen out for long.
|
||||
var flare = vec3<f32>(0.0);
|
||||
for (var i = 0; i < blasts; i++) {
|
||||
let a = frame.params[BLAST_BASE + 2 * i];
|
||||
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 || age > 10.0 { continue; }
|
||||
let p = project(a.xyz);
|
||||
if p.z <= 0.5 { continue; }
|
||||
let visible = select(0.25, 1.0, scene_distance(p.xy) > p.z - a.w);
|
||||
let 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 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);
|
||||
color += vec3<f32>(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01);
|
||||
if age < 0.0 || age > 24.0 { 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 mass = min(a.w / 4.0, 2.5);
|
||||
// How close it is, in its own sizes: a burst overhead is felt, a far one only seen.
|
||||
let near = clamp(1.0 - dist / (a.w * 40.0), 0.0, 1.0);
|
||||
let centre = p.xy * frame.resolution.xy;
|
||||
if age <= 10.0 {
|
||||
let f = exp(-age * 0.7) * mass * visible;
|
||||
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
|
||||
let dd = length(px - centre) / r;
|
||||
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
|
||||
if dd < 9.0 {
|
||||
flare += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 3.2 + exp(-dd * 0.8) * 0.3);
|
||||
}
|
||||
// The halo: a third of the screen wide for a close blast, lifting the sky and clouds most.
|
||||
let wide = (0.12 + 0.3 * near) * frame.resolution.y;
|
||||
let dw = length(px - centre) / wide;
|
||||
if dw < 4.0 {
|
||||
let sky = select(1.0, 1.7, wall > 1.0e5);
|
||||
flare += vec3<f32>(1.0, 0.82, 0.58) * f * near * sky * exp(-dw * dw) * 0.55;
|
||||
}
|
||||
flare += vec3<f32>(1.0, 0.8, 0.55) * f * (0.12 + 0.2 * near) / (1.0 + p.z * 0.01);
|
||||
}
|
||||
// The shock ring: the same radius as the wave that bends the picture.
|
||||
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
|
||||
let width = 3.0 + ring * 0.025;
|
||||
let x = (length(px - centre) - ring) / width;
|
||||
if abs(x) < 4.0 {
|
||||
let fade = 1.0 - age / 24.0;
|
||||
flare += vec3<f32>(1.0, 0.9, 0.72) * exp(-x * x) * fade * fade * mass * (0.25 + 0.5 * near) * visible;
|
||||
}
|
||||
}
|
||||
color += flare / (1.0 + flare * 0.2);
|
||||
|
||||
// 4. Glows: engine flames and muzzle flashes.
|
||||
for (var j = 0; j < lights; j++) {
|
||||
let a = frame.params[LIGHT_BASE + 2 * j];
|
||||
let b = frame.params[LIGHT_BASE + 2 * j + 1];
|
||||
let p = project(a.xyz);
|
||||
if p.z <= 0.3 { continue; }
|
||||
if scene_distance(p.xy) < p.z - a.w * 1.5 { continue; }
|
||||
let r = screen_radius(a.xyz, a.w) + 3.0;
|
||||
let dd = length(px - p.xy * frame.resolution.xy) / r;
|
||||
let c0 = clip_of(a.xyz);
|
||||
if c0.w <= 0.3 { continue; }
|
||||
let p = clip_uv(c0);
|
||||
let r = screen_radius_clip(c0, a.w) + 3.0;
|
||||
let dd = length(px - p * frame.resolution.xy) / r;
|
||||
// Far from the glow (intensity at most 2): its tail is below 5e-4, under 8 bits.
|
||||
// This is checked before the depth lookup, which most pixels then never make.
|
||||
if dd > 6.0 { continue; }
|
||||
if scene_distance(p) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; }
|
||||
let flicker = 0.85 + 0.15 * noise3(vec3<f32>(a.xyz * 0.7 + vec3<f32>(t_now * 1.3)));
|
||||
color += b.rgb * b.w * flicker * (exp(-dd * dd * 4.0) * 1.2 + exp(-dd * 1.2) * 0.18);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The cinematic look of a blast (#51): a layered sound (a close crack, a low thump, a rolling
|
||||
//! tail; from afar the muffled report and a late boom), and the camera's reaction (a brief
|
||||
//! exposure and bloom punch). Everything is a function of the blast, its distance and its age, so
|
||||
//! a replay hears and sees the same as the live raid did, from the replay's camera.
|
||||
|
||||
/// The power (0 air burst .. 4 ballistic) of a blast sound event, if it is one.
|
||||
pub fn blast_power(event: &str) -> Option<usize> {
|
||||
Some(match event {
|
||||
"airdef:blast.air" => 0,
|
||||
"airdef:blast.medium" => 1,
|
||||
"airdef:blast.big" => 2,
|
||||
"airdef:blast.heavy" => 3,
|
||||
"airdef:blast.huge" => 4,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One voice of a blast: the sound event (with `_far` where the listener is far), its loudness
|
||||
/// and pitch, and the ticks it follows the first after the sound has travelled.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Layer {
|
||||
pub name: String,
|
||||
pub volume: f32,
|
||||
pub pitch: f32,
|
||||
pub delay: u64,
|
||||
}
|
||||
|
||||
/// Blasts of one tick that get all their layers, the nearest first: the rest of a mass raid
|
||||
/// plays its one voice, so the key events are not buried and the mix does not clip.
|
||||
pub const FULL: usize = 3;
|
||||
|
||||
/// The voices of a blast of `power`, `dist` blocks from the listener (`far`: where the muffled
|
||||
/// variants take over), `gain` its loudness (1 normal; bigger warheads more), the `rank`-th
|
||||
/// nearest of its tick.
|
||||
pub fn layers(power: usize, dist: f64, far: f64, gain: f32, pitch: f32, rank: usize) -> Vec<Layer> {
|
||||
let g = gain.clamp(0.3, 2.0);
|
||||
let own = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power.min(4)];
|
||||
let layer = |name: &str, volume: f32, pitch: f32, delay: u64| Layer { name: name.to_string(), volume, pitch, delay };
|
||||
let far_away = dist > far;
|
||||
let suffix = if far_away { "_far" } else { "" };
|
||||
if rank >= FULL {
|
||||
return vec![layer(&format!("{own}{suffix}"), g, pitch, 0)];
|
||||
}
|
||||
if far_away {
|
||||
// The muffled report, then a deep boom that arrives late.
|
||||
return vec![
|
||||
layer(&format!("{own}_far"), g, pitch, 0),
|
||||
layer("airdef:blast.big_far", 0.7 * g, pitch * 0.6, 10 + 2 * power as u64),
|
||||
];
|
||||
}
|
||||
let tail = (0.45 + 0.08 * power as f32) * g;
|
||||
if power == 0 {
|
||||
// An air burst has no body of its own: the crack, the thump, the roll.
|
||||
vec![
|
||||
layer("airdef:blast.air", g, pitch * 1.12, 0),
|
||||
layer("airdef:blast.big", 0.85 * g, pitch * 0.66, 2),
|
||||
layer("airdef:blast.huge", tail, pitch * 0.5, 7),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
layer(own, g, pitch, 0),
|
||||
layer("airdef:blast.air", 0.7 * g, pitch * 1.15, 0),
|
||||
layer("airdef:blast.huge", tail, pitch * 0.5, 7),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// How hard the picture is punched now (0 to 1) by a blast of fireball radius `size` that went
|
||||
/// off `age` ticks ago `dist` blocks from the camera: bright and brief, near and big blasts most.
|
||||
pub fn punch(size: f32, age: f32, dist: f64) -> f32 {
|
||||
if age < 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let reach = (size as f64 * 45.0).max(30.0);
|
||||
let near = (1.0 - dist / reach).clamp(0.0, 1.0) as f32;
|
||||
let big = (size / 8.0).clamp(0.35, 1.0);
|
||||
// A rise over a tick, then gone in about half a second.
|
||||
(near * big * (-age / 3.0).exp() * (age / 0.8).min(1.0)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_near_intercept_is_layered_and_a_mass_raid_is_bounded() {
|
||||
let l = layers(0, 40.0, 140.0, 1.2, 1.0, 0);
|
||||
assert_eq!(l.len(), 3);
|
||||
assert!(l.iter().all(|v| !v.name.ends_with("_far")));
|
||||
assert!(l[0].delay == 0 && l[2].delay > l[1].delay, "crack, then thump, then the roll");
|
||||
// A far one is the muffled report and a late boom.
|
||||
let f = layers(0, 400.0, 140.0, 1.0, 1.0, 0);
|
||||
assert!(f.iter().all(|v| v.name.ends_with("_far")) && f[1].delay > 0);
|
||||
// The rest of a tick's blasts play one voice, and the whole mix stays bounded.
|
||||
let total: f32 = (0..12).flat_map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r)).map(|v| v.volume).sum();
|
||||
let voices: usize = (0..12).map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r).len()).sum();
|
||||
assert_eq!(voices, FULL * 3 + (12 - FULL));
|
||||
assert!(total < 18.0, "{total}");
|
||||
assert_eq!(blast_power("airdef:blast.huge"), Some(4));
|
||||
assert_eq!(blast_power("airdef:boom.sonic"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_punch_is_brief_near_and_bounded() {
|
||||
assert_eq!(punch(5.0, -1.0, 10.0), 0.0);
|
||||
assert!(punch(5.0, 1.5, 20.0) > punch(5.0, 1.5, 150.0));
|
||||
assert!(punch(5.0, 1.5, 20.0) > punch(2.0, 1.5, 20.0));
|
||||
assert!(punch(13.0, 1.0, 0.0) <= 1.0);
|
||||
assert!(punch(5.0, 30.0, 20.0) < 0.01, "gone in a second and a half");
|
||||
}
|
||||
}
|
||||
+480
-64
@@ -7,16 +7,19 @@ use horizon_mod_sdk::{
|
||||
Mod, Registration, chat, command, game,
|
||||
hud::{self, Corner},
|
||||
input::{self, Event},
|
||||
render::{self, ModelInstance, ParticleSpawn},
|
||||
render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget},
|
||||
};
|
||||
|
||||
mod cinema;
|
||||
mod monitor;
|
||||
mod panel;
|
||||
mod radar;
|
||||
mod replay;
|
||||
mod sim;
|
||||
mod smoke;
|
||||
pub mod sim;
|
||||
mod scenario;
|
||||
mod wave;
|
||||
use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim};
|
||||
|
||||
@@ -53,9 +56,13 @@ const ITEMS: [&str; 29] = [
|
||||
];
|
||||
|
||||
struct AirDef {
|
||||
smoke: smoke::Smoke,
|
||||
sim: Sim,
|
||||
ground: Ground,
|
||||
dirty: bool,
|
||||
/// Wall-clock-free pacing of autosaves: the tick before which none is written (at most one a
|
||||
/// second while guns fire, and a pause after a failed write).
|
||||
next_save: u64,
|
||||
hud_shown: bool,
|
||||
/// The system the monitor shows (K cycles), none: hidden.
|
||||
monitor: Option<usize>,
|
||||
@@ -72,6 +79,8 @@ struct AirDef {
|
||||
far: bool,
|
||||
/// The unit the move remote picked (its id), waiting for where to go.
|
||||
moving: Option<u32>,
|
||||
/// The HD lighting preset (the engine's lights, bloom, tone mapping).
|
||||
hd: bool,
|
||||
/// The world whose state is loaded (`game.world-id`), once one is.
|
||||
world: Option<Option<String>>,
|
||||
/// The systems' and objects' health as last saved, to save again when it changes.
|
||||
@@ -80,8 +89,25 @@ struct AirDef {
|
||||
menu: Option<Page>,
|
||||
/// Where the player is, for the sounds' delay and far variants.
|
||||
listener: DVec3,
|
||||
/// Sounds on their way to the player at the speed of sound: (tick, event, at, pitch).
|
||||
pending: Vec<(u64, String, DVec3, f32)>,
|
||||
/// The picture punch (#51) the HD exposure and bloom were last set for.
|
||||
punch: f32,
|
||||
/// Sounds on their way to the player at the speed of sound.
|
||||
pending: Vec<PendingSound>,
|
||||
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
|
||||
/// a name the game doesn't know.
|
||||
model_ids: std::collections::HashMap<&'static str, Option<u32>>,
|
||||
model_generation: u32,
|
||||
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
|
||||
by_id: Vec<ModelInstanceId>,
|
||||
}
|
||||
|
||||
/// A sound heard later: when, what, where, and how loud and high it was.
|
||||
struct PendingSound {
|
||||
due: u64,
|
||||
name: String,
|
||||
at: DVec3,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
}
|
||||
|
||||
/// Blocks a tick the sound travels (343 m/s).
|
||||
@@ -140,6 +166,16 @@ fn say(text: &str) {
|
||||
chat::show(text);
|
||||
}
|
||||
|
||||
/// What the effects draw this tick (`Mod::shown`).
|
||||
struct Shown {
|
||||
blasts: Vec<sim::Blast>,
|
||||
/// The clock the blasts' `start` is on: the simulation's ticks, or the recording's.
|
||||
now: f32,
|
||||
/// 1 minus the share of the frame's partial tick the effects advance with.
|
||||
hold: f32,
|
||||
replay: bool,
|
||||
}
|
||||
|
||||
fn player() -> Option<game::Player> {
|
||||
game::get_player()
|
||||
}
|
||||
@@ -276,12 +312,118 @@ impl AirDef {
|
||||
self.emit(out);
|
||||
}
|
||||
|
||||
/// The HD lighting's lights: flames, glowing warheads, flares, muzzle flashes, tracers,
|
||||
/// blasts (a flash, then the fireball's glow dying down), burning wrecks and the
|
||||
/// searchlights' spots.
|
||||
fn lights(&self, out: &Out) -> Vec<PointLight> {
|
||||
let shown = self.shown();
|
||||
let mut lights: Vec<PointLight> = Vec::new();
|
||||
let mut add = |id: u32, at: DVec3, rgb: (f32, f32, f32), intensity: f32, radius: f32| {
|
||||
if lights.len() < 500 && intensity > 0.05 {
|
||||
lights.push(PointLight { id, x: at.x, y: at.y, z: at.z, color: rgb, intensity, radius: radius.clamp(1.0, 256.0) });
|
||||
}
|
||||
};
|
||||
let tick = shown.now as u64;
|
||||
let flicker = |id: u32| 0.85 + 0.15 * (((tick as u32).wrapping_mul(2654435761) ^ id.wrapping_mul(97)) % 100) as f32 / 100.0;
|
||||
// Blasts first, the nearest first: a crowded sky never costs the key intercept its light.
|
||||
let viewer = self.viewer(&shown);
|
||||
let mut near: Vec<&sim::Blast> = shown.blasts.iter().collect();
|
||||
if let Some(e) = viewer {
|
||||
near.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
|
||||
}
|
||||
for (rank, b) in near.into_iter().enumerate() {
|
||||
let age = (shown.now - b.start as f32).max(0.0);
|
||||
let size = b.size;
|
||||
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
|
||||
let flash = (-age * 0.35).exp() * 4.0;
|
||||
// Its own id range: the models' and the systems' ids are small numbers.
|
||||
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
|
||||
let centre = b.at + DVec3::Y * size as f64 * 0.5;
|
||||
add(id, centre, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
|
||||
// The punch (#51): a white burst that lights the land, the smoke and the wrecks far round
|
||||
// for a moment; for the nearest few, scaled by the blast.
|
||||
if rank < 6 {
|
||||
let burst = (-age / 2.0).exp();
|
||||
add(0x6300_0000 | (b.id & 0x00ff_ffff), centre, (1.0, 0.9, 0.74), size * size * 95.0 * burst, (size * 30.0).min(256.0));
|
||||
}
|
||||
}
|
||||
for m in out.models.iter().filter(|m| m.glow) {
|
||||
let s = m.scale.max(0.1);
|
||||
match m.name {
|
||||
"airdef:flame" => add(m.id, m.at, (1.0, 0.55, 0.2), 45.0 * s * flicker(m.id), 32.0 * s.sqrt()),
|
||||
"airdef:geran3_flame" => add(m.id, m.at, (1.0, 0.6, 0.25), 25.0 * s * flicker(m.id), 22.0),
|
||||
"airdef:reentry" => add(m.id, m.at, (1.0, 0.6, 0.32), 60.0 * s, 40.0),
|
||||
"airdef:flare" => add(m.id, m.at, (1.0, 0.93, 0.78), 420.0 * s, 110.0),
|
||||
"airdef:flash" => add(m.id, m.at, (1.0, 0.8, 0.45), 30.0, 14.0),
|
||||
"airdef:tracer" => add(m.id, m.at, (1.0, 0.25, 0.1), 2.5, 6.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A replay shows its own flames (the models of the ghosts) and none of the live world's.
|
||||
if shown.replay {
|
||||
return lights;
|
||||
}
|
||||
// Fires burn on for minutes: a flickering, wide glow that dies down with the flames.
|
||||
for f in self.sim.fires.list.iter().filter(|f| f.shown) {
|
||||
let (fl, sm) = f.level(tick);
|
||||
if f.quiet {
|
||||
continue;
|
||||
}
|
||||
let glow = fl.max(0.12 * sm * (1.0 - (tick.saturating_sub(f.start)) as f32 / f.life as f32));
|
||||
let id = 0x6200_0000 | (f.id & 0x00ff_ffff);
|
||||
add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size);
|
||||
}
|
||||
for s in &self.sim.sams {
|
||||
if !s.alive() && s.burning() {
|
||||
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
|
||||
}
|
||||
// A searchlight's spot where its beam meets the target.
|
||||
if s.kind == SamKind::Light
|
||||
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
|
||||
&& self.sim.lit.contains(&t.id)
|
||||
{
|
||||
add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0);
|
||||
}
|
||||
}
|
||||
lights
|
||||
}
|
||||
|
||||
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
|
||||
fn set_effects(&mut self, shader: bool, hd: bool) {
|
||||
self.sim.shader = shader;
|
||||
if shader {
|
||||
render::set_post(include_str!("../shaders/effects.wgsl"));
|
||||
} else {
|
||||
render::clear_post();
|
||||
}
|
||||
self.hd = shader && hd;
|
||||
self.apply_hd();
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Switches the HD lighting.
|
||||
fn apply_hd(&self) {
|
||||
render::set_lighting(Lighting { hd: self.hd, exposure: 1.0, bloom: 0.55 });
|
||||
// (The punch of the next blast sets its own.)
|
||||
if !self.hd {
|
||||
render::set_lights(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the simulation's output to the game.
|
||||
/// The post pass's parameters: blasts and glowing parts (shaders/effects.wgsl).
|
||||
fn shader_params(&self, out: &Out) -> Vec<f32> {
|
||||
let shown = self.shown();
|
||||
let mut p = vec![0.0f32; 4];
|
||||
p[0] = self.sim.tick as f32;
|
||||
let blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
|
||||
p[0] = shown.now;
|
||||
p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 };
|
||||
let mut blasts: Vec<&sim::Blast> = shown.blasts.iter().rev().take(48).collect();
|
||||
// The shader composites the volumes front to back with one shared transmittance, so
|
||||
// the nearest to the player goes first. This is exact for volumes that do not
|
||||
// overlap; where two intersect, no order by centre is right along every ray.
|
||||
if let Some(e) = self.viewer(&shown) {
|
||||
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
|
||||
}
|
||||
p[1] = blasts.len() as f32;
|
||||
for b in &blasts {
|
||||
p.extend([b.at.x as f32, b.at.y as f32, b.at.z as f32, b.size]);
|
||||
@@ -300,7 +442,6 @@ impl AirDef {
|
||||
"airdef:tracer" => (1.0, 0.12, 0.05, 0.9),
|
||||
"airdef:reentry" => (1.0, 0.6, 0.3, 1.4),
|
||||
"airdef:flare" => (1.0, 0.94, 0.78, 1.2),
|
||||
"airdef:beam" => (1.0, 0.97, 0.88, 0.15),
|
||||
_ => continue,
|
||||
};
|
||||
// The shader lights about six times the radius round a light and draws a glow of
|
||||
@@ -311,37 +452,114 @@ impl AirDef {
|
||||
lights += 1;
|
||||
}
|
||||
p[2] = lights as f32;
|
||||
// The last vec4: how much of the frame's partial tick the effects run on (0 live; a
|
||||
// slowed or paused replay runs on its own time, not the frame's).
|
||||
p.resize(1024, 0.0);
|
||||
p[1020] = shown.hold;
|
||||
p
|
||||
}
|
||||
|
||||
/// The smoke volumes the renderer draws and the clock they are aged by: the live ones, or (in a
|
||||
/// replay) the blasts' billows at the replay's moment, never the live raid's trails and fires.
|
||||
/// The billows are the live ones' own function (`Smoke::segments`), run on the recording's
|
||||
/// blasts and time with the live state set aside for the call.
|
||||
fn shown_smoke(&mut self) -> (f64, Vec<render::SmokeSegment>) {
|
||||
let shown = self.shown();
|
||||
if !shown.replay {
|
||||
return (self.sim.tick as f64, self.smoke.segments(&self.sim));
|
||||
}
|
||||
let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts);
|
||||
let fires = std::mem::take(&mut self.sim.fires.list);
|
||||
let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64);
|
||||
let segments = smoke::Smoke::default().segments(&self.sim);
|
||||
self.sim.blasts = blasts;
|
||||
self.sim.fires.list = fires;
|
||||
self.sim.tick = tick;
|
||||
(f64::from(shown.now), segments)
|
||||
}
|
||||
|
||||
/// Where the picture is seen from: the replay's camera, or the player's eyes.
|
||||
fn viewer(&self, shown: &Shown) -> Option<DVec3> {
|
||||
match &self.replay {
|
||||
Some(r) if shown.replay => Some(r.eye()),
|
||||
_ => player().map(|pl| eye(&pl)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The blasts the effects show and the clock they run on: the live ones, or (in a replay) the
|
||||
/// recording's at the replay's moment, never the live world's.
|
||||
fn shown(&self) -> Shown {
|
||||
if let Some(r) = &self.replay
|
||||
&& r.filming()
|
||||
&& let Some(rec) = self.sim.replay(r.rec)
|
||||
{
|
||||
return Shown { blasts: sim::record::replay_blasts(rec, r.time()), now: r.time() as f32, hold: 1.0 - r.rate().clamp(0.0, 1.0), replay: true };
|
||||
}
|
||||
Shown { blasts: self.sim.blasts.clone(), now: self.sim.tick as f32, hold: 0.0, replay: false }
|
||||
}
|
||||
|
||||
/// The models to the game by handle: each name crosses once, the buffer stays allocated.
|
||||
fn send_models(&mut self, models: &[sim::Model]) {
|
||||
let generation = render::model_generation();
|
||||
if generation != self.model_generation {
|
||||
// The game's registry changed (or this is the first tick): ask again.
|
||||
self.model_generation = generation;
|
||||
self.model_ids.clear();
|
||||
}
|
||||
self.by_id.clear();
|
||||
for m in models {
|
||||
let ids = &mut self.model_ids;
|
||||
let handle = *ids.entry(m.name).or_insert_with(|| render::model_id(m.name));
|
||||
// A name the game's registry doesn't have draws nothing, by name too.
|
||||
let Some(model) = handle else { continue };
|
||||
let r = m.rotation.as_quat();
|
||||
self.by_id.push(ModelInstanceId {
|
||||
id: m.id,
|
||||
model,
|
||||
x: m.at.x,
|
||||
y: m.at.y,
|
||||
z: m.at.z,
|
||||
rotation: (r.x, r.y, r.z, r.w),
|
||||
scale: m.scale,
|
||||
glow: m.glow,
|
||||
});
|
||||
}
|
||||
if !render::set_models_by_id(generation, &self.by_id) {
|
||||
// The registry changed between the two calls: ask again next tick.
|
||||
self.model_generation = u32::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, out: Out) {
|
||||
if self.sim.shader {
|
||||
render::set_params(&self.shader_params(&out));
|
||||
}
|
||||
let models: Vec<ModelInstance> = out
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let r = m.rotation.as_quat();
|
||||
ModelInstance {
|
||||
id: m.id,
|
||||
model: m.name.to_string(),
|
||||
x: m.at.x,
|
||||
y: m.at.y,
|
||||
z: m.at.z,
|
||||
rotation: (r.x, r.y, r.z, r.w),
|
||||
scale: m.scale,
|
||||
glow: m.glow,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !models.is_empty() || self.sim.tick % 20 == 0 {
|
||||
render::set_models(&models);
|
||||
if self.hd {
|
||||
// The camera's reaction to a near blast: a brief exposure and bloom punch.
|
||||
let shown = self.shown();
|
||||
let p = self.viewer(&shown).map_or(0.0, |e| {
|
||||
shown.blasts.iter().map(|b| cinema::punch(b.size, shown.now - b.start as f32, b.at.distance(e))).fold(0.0, f32::max)
|
||||
});
|
||||
if (p - self.punch).abs() > 0.01 || (p == 0.0 && self.punch != 0.0) {
|
||||
self.punch = p;
|
||||
render::set_lighting(Lighting { hd: true, exposure: 1.0 + 0.4 * p, bloom: 0.55 + 0.9 * p });
|
||||
}
|
||||
render::set_lights(&self.lights(&out));
|
||||
let (clock, segments) = self.shown_smoke();
|
||||
self.smoke.hd = render::set_smoke(clock, &segments);
|
||||
} else {
|
||||
self.smoke.hd = false;
|
||||
}
|
||||
if !out.models.is_empty() || self.sim.tick % 20 == 0 {
|
||||
self.send_models(&out.models);
|
||||
}
|
||||
if !out.particles.is_empty() {
|
||||
let hd_smoke = self.smoke.hd && !self.replay.as_ref().is_some_and(|r| r.filming());
|
||||
let spawns: Vec<ParticleSpawn> = out
|
||||
.particles
|
||||
.into_iter()
|
||||
// The long smoke is the renderer's volume then.
|
||||
.filter(|p| !(hd_smoke && matches!(p.kind, "minecraft:campfire_cosy_smoke" | "minecraft:campfire_signal_smoke" | "minecraft:large_smoke")))
|
||||
.take(512)
|
||||
.map(|p| ParticleSpawn {
|
||||
particle: p.kind.to_string(),
|
||||
@@ -359,26 +577,42 @@ impl AirDef {
|
||||
render::spawn_particles(&spawns);
|
||||
}
|
||||
let now = self.sim.tick;
|
||||
// Blasts play layered (cinema.rs), the nearest ones in full.
|
||||
let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new();
|
||||
for (event, at, volume, pitch) in out.sounds {
|
||||
let dist = at.distance(self.listener);
|
||||
if travels(event) {
|
||||
if let Some(power) = cinema::blast_power(event) {
|
||||
booms.push((dist, power, at, volume, pitch));
|
||||
} else if travels(event) {
|
||||
// Seen first, heard later; from afar muffled.
|
||||
let name = if dist > FAR { format!("{event}_far") } else { event.to_string() };
|
||||
let delay = (dist / SOUND_SPEED) as u64;
|
||||
if delay == 0 {
|
||||
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
|
||||
} else if self.pending.len() < 256 {
|
||||
self.pending.push((now + delay, name, at, pitch));
|
||||
self.pending.push(PendingSound { due: now + delay, name, at, volume, pitch });
|
||||
}
|
||||
} else {
|
||||
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
|
||||
}
|
||||
}
|
||||
booms.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() {
|
||||
for l in cinema::layers(power, dist, FAR, volume, pitch, rank) {
|
||||
// Seen first, heard later: each voice after the sound has travelled.
|
||||
let delay = (dist / SOUND_SPEED) as u64 + l.delay;
|
||||
if delay == 0 {
|
||||
render::play_sound(&l.name, at.x, at.y, at.z, l.volume, l.pitch);
|
||||
} else if self.pending.len() < 256 {
|
||||
self.pending.push(PendingSound { due: now + delay, name: l.name, at, volume: l.volume, pitch: l.pitch });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.pending.len() {
|
||||
if self.pending[i].0 <= now {
|
||||
let (_, name, at, pitch) = self.pending.swap_remove(i);
|
||||
render::play_sound(&name, at.x, at.y, at.z, 1.0, pitch);
|
||||
if self.pending[i].due <= now {
|
||||
let s = self.pending.swap_remove(i);
|
||||
render::play_sound(&s.name, s.at.x, s.at.y, s.at.z, s.volume, s.pitch);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -412,24 +646,67 @@ 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 {
|
||||
text += &format!("sam {} {} {} {} {} {} {} {}\n", s.kind.key(), s.pos.x, s.pos.y, s.pos.z, s.heading, s.roles, s.health, s.cargo);
|
||||
// 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.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)
|
||||
};
|
||||
// The parts' states (sensor, mount, launcher, gun, tracks, comms) close the line; older saves lack them.
|
||||
let comps = if fresh { new.comps } else { s.comps };
|
||||
let parts = comps.0.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>().join(" ");
|
||||
text += &format!(
|
||||
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts}\n",
|
||||
s.kind.key(),
|
||||
s.pos.x,
|
||||
s.pos.y,
|
||||
s.pos.z,
|
||||
s.heading,
|
||||
s.roles,
|
||||
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);
|
||||
if view {
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
}
|
||||
for (kind, roles) in &self.sim.roles {
|
||||
text += &format!("roles {} {}\n", kind.key(), roles);
|
||||
}
|
||||
if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) {
|
||||
horizon_mod_sdk::warn(&format!("could not save: {e}"));
|
||||
text
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let text = self.state_text(false, true);
|
||||
// Clean only once written: a failed write is tried again after a pause.
|
||||
match storage::set(&self.state_key(), text.as_bytes()) {
|
||||
Ok(()) => {
|
||||
self.dirty = false;
|
||||
self.next_save = self.sim.tick + 20;
|
||||
}
|
||||
Err(e) => {
|
||||
horizon_mod_sdk::warn(&format!("could not save: {e}"));
|
||||
self.next_save = self.sim.tick + 200;
|
||||
}
|
||||
}
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
fn load(&mut self) {
|
||||
@@ -438,7 +715,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());
|
||||
@@ -458,7 +740,29 @@ impl AirDef {
|
||||
if let Some(c) = num(8) {
|
||||
sam.cargo = c as u32;
|
||||
}
|
||||
// Older saves have no ammunition: full, as before.
|
||||
if let (Some(m), Some(b)) = (num(9), num(10)) {
|
||||
sam.missiles = (m as u32).min(sam.missiles);
|
||||
sam.bursts = (b as u32).min(sam.bursts);
|
||||
sam.empty = num(11) == Some(1.0);
|
||||
sam.reloading = num(12).map_or(0, |r| r as u32);
|
||||
}
|
||||
let dest = match (num(13), num(14), num(15)) {
|
||||
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
|
||||
_ => None,
|
||||
};
|
||||
// The parts: absent in older saves, which load with every part whole.
|
||||
for (k, v) in sam.comps.0.iter_mut().enumerate() {
|
||||
if let Some(c) = num(16 + k) {
|
||||
*v = c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
sam.fit_cells();
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
self.sim.order_move(i, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(&"target") => {
|
||||
@@ -468,7 +772,19 @@ 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");
|
||||
let hd = w.get(2) == Some(&"true");
|
||||
self.set_effects(shader, hd);
|
||||
}
|
||||
Some(&"roles") => {
|
||||
if let (Some(kind), Some(r)) = (w.get(1).and_then(|k| SamKind::from_key(k)), w.get(2).and_then(|v| v.parse().ok())) {
|
||||
self.sim.roles.insert(kind, r);
|
||||
@@ -524,11 +840,18 @@ impl AirDef {
|
||||
let shader = self.sim.shader;
|
||||
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
|
||||
self.sim = Sim::new(seed);
|
||||
self.next_save = 0;
|
||||
self.sim.shader = shader;
|
||||
self.ground = Ground::default();
|
||||
self.monitor = None;
|
||||
self.grid = false;
|
||||
self.grid_ids.clear();
|
||||
// The last world's transient state: its sounds were due on its own
|
||||
// clock, its picked unit may have another meaning in this one, and
|
||||
// its models would stay until the next full model list.
|
||||
self.pending.clear();
|
||||
self.moving = None;
|
||||
render::set_models(&[]);
|
||||
self.far = true;
|
||||
self.world = Some(world);
|
||||
self.load();
|
||||
@@ -563,7 +886,7 @@ impl AirDef {
|
||||
}
|
||||
_ => replay::Mode::Map,
|
||||
};
|
||||
self.replay = Some(replay::Player::new(n - 1, mode, &self.sim));
|
||||
self.replay = Some(replay::Player::new(rec.number, mode, &self.sim));
|
||||
self.grid = false;
|
||||
input::claim_wheel(true);
|
||||
Ok(())
|
||||
@@ -571,6 +894,8 @@ impl AirDef {
|
||||
|
||||
fn stop_replay(&mut self) {
|
||||
self.replay = None;
|
||||
render::set_time_override(None);
|
||||
render::set_main_view(None);
|
||||
input::claim_wheel(false);
|
||||
render::set_hud(&[]);
|
||||
render::set_views(&[]);
|
||||
@@ -699,7 +1024,8 @@ impl AirDef {
|
||||
}));
|
||||
let broken = self.sim.sams.iter().filter(|s| s.health < 1.0).count();
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPAIR, label: format!("Ремонт всех (повреждено: {broken})") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {}", onoff(self.sim.shader)) }));
|
||||
let fx = if !self.sim.shader { "выкл" } else if self.hd { "HD (свет, bloom)" } else { "обычные" };
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
|
||||
@@ -764,7 +1090,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 | 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 | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
|
||||
|
||||
impl Mod for AirDef {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
@@ -773,6 +1099,7 @@ impl Mod for AirDef {
|
||||
sim: Sim::new(seed),
|
||||
ground: Ground::default(),
|
||||
dirty: false,
|
||||
next_save: 0,
|
||||
hud_shown: false,
|
||||
monitor: None,
|
||||
monitor_shown: false,
|
||||
@@ -780,13 +1107,19 @@ impl Mod for AirDef {
|
||||
grid_ids: Vec::new(),
|
||||
wave: wave::WaveConfig::default(),
|
||||
replay: None,
|
||||
smoke: Default::default(),
|
||||
far: true,
|
||||
moving: None,
|
||||
hd: false,
|
||||
world: None,
|
||||
health_mark: 0,
|
||||
menu: None,
|
||||
listener: DVec3::ZERO,
|
||||
punch: 0.0,
|
||||
pending: Vec::new(),
|
||||
model_ids: std::collections::HashMap::new(),
|
||||
model_generation: u32::MAX,
|
||||
by_id: Vec::new(),
|
||||
};
|
||||
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
|
||||
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
|
||||
@@ -834,7 +1167,7 @@ impl Mod for AirDef {
|
||||
_ => replay::Control::Pause,
|
||||
};
|
||||
if let Some(r) = &mut self.replay
|
||||
&& let Some(rec) = self.sim.replays.get(r.rec)
|
||||
&& let Some(rec) = self.sim.replay(r.rec)
|
||||
{
|
||||
r.control(c, rec);
|
||||
}
|
||||
@@ -851,7 +1184,7 @@ impl Mod for AirDef {
|
||||
}
|
||||
Event::Wheel(w) => {
|
||||
if let Some(r) = &mut self.replay
|
||||
&& let Some(rec) = self.sim.replays.get(r.rec)
|
||||
&& let Some(rec) = self.sim.replay(r.rec)
|
||||
{
|
||||
r.wheel(w, rec);
|
||||
}
|
||||
@@ -861,15 +1194,70 @@ impl Mod for AirDef {
|
||||
}
|
||||
let now = self.sim.tick;
|
||||
self.ground.answer(render::ground(), now);
|
||||
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
|
||||
let changes = render::world_changes();
|
||||
self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections);
|
||||
let sections = render::sections()
|
||||
.into_iter()
|
||||
.map(|s| sim::world::SectionAnswer {
|
||||
sec: (s.x, s.y, s.z),
|
||||
fill: match s.fill {
|
||||
render::SectionFill::Unknown => sim::world::Fill::Unknown,
|
||||
render::SectionFill::Empty => sim::world::Fill::Empty,
|
||||
render::SectionFill::Full => sim::world::Fill::Full,
|
||||
render::SectionFill::Mixed => match <[u8; 512]>::try_from(s.bits) {
|
||||
Ok(bits) => sim::world::Fill::Mixed(Box::new(bits)),
|
||||
Err(_) => sim::world::Fill::Unknown,
|
||||
},
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
self.ground.world.answer(sections, now);
|
||||
// Exact collision shapes of blocks a flight met (#63): the answers to last tick's rays.
|
||||
let replies: Vec<sim::world::RayReply> = render::rays()
|
||||
.into_iter()
|
||||
.map(|r| sim::world::RayReply {
|
||||
id: r.id,
|
||||
clear: matches!(r.verdict, render::Verdict::Clear),
|
||||
hit: matches!(r.verdict, render::Verdict::Hit),
|
||||
fraction: r.fraction,
|
||||
normal: [r.normal.0, r.normal.1, r.normal.2],
|
||||
block: [r.block.0, r.block.1, r.block.2],
|
||||
})
|
||||
.collect();
|
||||
self.ground.world.answer_rays(&replies);
|
||||
let mut out = Out::default();
|
||||
self.listener = DVec3::new(p.x, p.y + 1.6, p.z);
|
||||
self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
|
||||
self.sim.day_time = game::get_clock().map(|c| c.day_time);
|
||||
self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out);
|
||||
self.smoke.update(&self.sim);
|
||||
let wanted = self.ground.take_wanted();
|
||||
if !wanted.is_empty() {
|
||||
render::query_ground(&wanted);
|
||||
}
|
||||
let rays: Vec<render::Ray> = self
|
||||
.ground
|
||||
.world
|
||||
.take_rays(now)
|
||||
.into_iter()
|
||||
.map(|(id, a, b)| render::Ray { id, start: (a.x, a.y, a.z), end: (b.x, b.y, b.z), policy: render::Policy::Collision })
|
||||
.collect();
|
||||
if !rays.is_empty() {
|
||||
render::query_rays(&rays);
|
||||
}
|
||||
let sections = self.ground.world.take_wanted(now);
|
||||
if !sections.is_empty() {
|
||||
render::query_sections(render::Policy::Collision, §ions);
|
||||
}
|
||||
if let Some(mut r) = self.replay.take() {
|
||||
// A replay shows the recording alone: not the live raid's models, particles, sounds or jolts.
|
||||
if r.filming() {
|
||||
out.models.clear();
|
||||
out.particles.clear();
|
||||
out.sounds.clear();
|
||||
out.shake = None;
|
||||
}
|
||||
r.tick(&self.sim, self.listener, &mut out);
|
||||
if r.done {
|
||||
self.replay = Some(r);
|
||||
@@ -885,6 +1273,10 @@ impl Mod for AirDef {
|
||||
let size = render::gui_size();
|
||||
render::set_hud(&r.hud(&self.sim, size));
|
||||
render::set_views(&r.views(size));
|
||||
// The world through the director's camera, with the full pipeline (HD, the post pass).
|
||||
render::set_main_view(r.filming().then(|| r.view_id()));
|
||||
// The sky of the raid, not of now (None for a raid whose clock was unknown).
|
||||
render::set_time_override(r.day_time(&self.sim));
|
||||
self.monitor_shown = true;
|
||||
} else if self.grid {
|
||||
let stale = self.grid_ids.iter().any(|id| !self.sim.sams.iter().any(|s| s.id == *id));
|
||||
@@ -913,19 +1305,26 @@ impl Mod for AirDef {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Damage is saved too: a destroyed system stays destroyed.
|
||||
// Damage is saved too: a destroyed system stays destroyed. So are where the units stand
|
||||
// (or drive to: not every step of the way), their ammunition and the depots' stock.
|
||||
let mark = self
|
||||
.sim
|
||||
.sams
|
||||
.iter()
|
||||
.map(|s| (s.health * 1000.0) as u64)
|
||||
.map(|s| {
|
||||
let at = s.dest.unwrap_or(s.pos);
|
||||
let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42);
|
||||
let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49;
|
||||
let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64));
|
||||
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13)
|
||||
})
|
||||
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
|
||||
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
|
||||
if mark != self.health_mark {
|
||||
self.health_mark = mark;
|
||||
self.dirty = true;
|
||||
}
|
||||
if self.dirty {
|
||||
if self.dirty && self.sim.tick >= self.next_save {
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
@@ -1032,12 +1431,13 @@ impl Mod for AirDef {
|
||||
return;
|
||||
}
|
||||
(Page::More, BTN_SHADER) => {
|
||||
self.sim.shader = !self.sim.shader;
|
||||
if self.sim.shader {
|
||||
render::set_post(include_str!("../shaders/effects.wgsl"));
|
||||
} else {
|
||||
render::clear_post();
|
||||
}
|
||||
// Off → effects → HD → off.
|
||||
let (shader, hd) = match (self.sim.shader, self.hd) {
|
||||
(false, _) => (true, false),
|
||||
(true, false) => (true, true),
|
||||
(true, true) => (false, false),
|
||||
};
|
||||
self.set_effects(shader, hd);
|
||||
}
|
||||
(Page::More, BTN_FAR) => {
|
||||
self.far = !self.far;
|
||||
@@ -1131,6 +1531,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
|
||||
@@ -1164,7 +1579,7 @@ impl Mod for AirDef {
|
||||
["repair"] => {
|
||||
let n = self.sim.repair();
|
||||
self.dirty = true;
|
||||
Some(format!("Отремонтировано комплексов: {n}; объекты восстановлены"))
|
||||
Some(format!("Отремонтировано комплексов: {n} (корпус и все узлы: РЛС, привод, пусковая, орудие, ходовая, связь); объекты восстановлены"))
|
||||
}
|
||||
["clear"] => {
|
||||
self.sim.clear();
|
||||
@@ -1183,6 +1598,10 @@ impl Mod for AirDef {
|
||||
self.dirty = true;
|
||||
Some("Цели убраны".to_string())
|
||||
}
|
||||
["truth", v @ ("on" | "off")] => {
|
||||
self.sim.truth = *v == "on";
|
||||
Some(format!("Истинные данные на радаре: {} (боевую логику не меняют)", if self.sim.truth { "вкл" } else { "выкл" }))
|
||||
}
|
||||
["damage", v @ ("on" | "off")] => {
|
||||
self.sim.damage = *v == "on";
|
||||
self.dirty = true;
|
||||
@@ -1224,22 +1643,19 @@ impl Mod for AirDef {
|
||||
self.sim.hold = *v == "on";
|
||||
Some(if self.sim.hold { "ПВО: огонь запрещён".into() } else { "ПВО: огонь разрешён".into() })
|
||||
}
|
||||
["shader", v @ ("on" | "off")] => {
|
||||
self.sim.shader = *v == "on";
|
||||
if self.sim.shader {
|
||||
render::set_post(include_str!("../shaders/effects.wgsl"));
|
||||
} else {
|
||||
render::clear_post();
|
||||
}
|
||||
Some(format!("Шейдерные эффекты: {}", if self.sim.shader { "вкл" } else { "выкл" }))
|
||||
["shader", v @ ("on" | "off" | "hd")] => {
|
||||
self.set_effects(*v != "off", *v == "hd");
|
||||
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
|
||||
}
|
||||
["stats"] => Some(format!(
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}",
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
|
||||
self.sim.drones.iter().filter(|d| d.alive()).count(),
|
||||
self.sim.missiles.len(),
|
||||
self.sim.sams.len(),
|
||||
self.sim.kills,
|
||||
self.sim.hits
|
||||
self.sim.hits,
|
||||
self.sim.crashes,
|
||||
self.ground.world.text()
|
||||
)),
|
||||
_ => return Err(HELP.to_string()),
|
||||
};
|
||||
|
||||
+76
-15
@@ -25,11 +25,22 @@ fn fit(t: String, w: f32) -> String {
|
||||
if render::text_width(&t) <= w {
|
||||
return t;
|
||||
}
|
||||
let mut cut: String = t;
|
||||
while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w {
|
||||
cut.pop();
|
||||
// The width grows with the prefix: bisect over its char starts, so a long
|
||||
// label costs a few host calls rather than one per dropped char.
|
||||
let starts: Vec<usize> = t.char_indices().map(|(i, _)| i).collect();
|
||||
let (mut lo, mut hi) = (0, starts.len());
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if render::text_width(&format!("{}…", &t[..starts[mid]])) <= w {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
format!("{cut}…")
|
||||
if lo == 0 {
|
||||
return "…".into();
|
||||
}
|
||||
format!("{}…", &t[..starts[lo - 1]])
|
||||
}
|
||||
|
||||
fn argb(rgb: u32, a: u32) -> u32 {
|
||||
@@ -101,9 +112,17 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
s.push(quad(p(a0, r - 0.4), p(a0, r + 0.4), p(a1, r + 0.4), p(a1, r - 0.4), 0x9040FF60));
|
||||
}
|
||||
}
|
||||
let reach = sam.kind.range();
|
||||
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
|
||||
for k in 0..6 {
|
||||
// A system that only fires (no radar) has no reach: a radar's sensor
|
||||
// range stands in, and nothing is divided by zero.
|
||||
let reach = match sam.kind.range() {
|
||||
r if r > 0.0 => r,
|
||||
_ => spec.sensor,
|
||||
};
|
||||
let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 };
|
||||
// The beam is the radar's real one: targets are measured as it passes them.
|
||||
let sweep = sam.beam() as f32;
|
||||
let radar_up = sam.radar_up();
|
||||
for k in 0..if radar_up { 6 } else { 0 } {
|
||||
let a1 = sweep - k as f32 * 0.09;
|
||||
let a0 = a1 - 0.09;
|
||||
let p = |a: f32| (rx + a.sin() * rr, ry - a.cos() * rr);
|
||||
@@ -112,14 +131,20 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
let target = sim.watched(sam);
|
||||
let mut seen = 0;
|
||||
// Only what its own radar holds.
|
||||
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
|
||||
let rel = d.pos - sam.pos;
|
||||
for (d, tr) in sim.drones.iter().filter(|d| d.alive()).filter_map(|d| sam.track(d.id).map(|t| (d, t))) {
|
||||
// Where the system believes it is: the last measurement carried on; a forecast is drawn hollow.
|
||||
let est = tr.est(tick);
|
||||
if sim.truth {
|
||||
let r = d.pos - sam.pos;
|
||||
s.push(fill(rx + r.x as f32 * scale as f32 - 0.5, ry + r.z as f32 * scale as f32 - 0.5, 1.0, 1.0, 0xFFFFFFFF));
|
||||
}
|
||||
let rel = est - sam.pos;
|
||||
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
|
||||
if flat > reach * 1.25 {
|
||||
if reach <= 0.0 || flat > reach * 1.25 {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
let k = (rr as f64 / (reach * 1.25)) as f32;
|
||||
let k = scale as f32;
|
||||
let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
|
||||
let allowed = sam.roles & d.kind.spec().class.bit() != 0;
|
||||
let color = if Some(d.id) == target.map(|t| t.id) {
|
||||
@@ -129,10 +154,24 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
} else {
|
||||
0xFF808080
|
||||
};
|
||||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
|
||||
if tr.forecast(tick) {
|
||||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 1.0, color));
|
||||
s.push(fill(bx - 1.5, by + 0.5, 3.0, 1.0, color));
|
||||
s.push(fill(bx - 1.5, by - 0.5, 1.0, 1.0, color));
|
||||
s.push(fill(bx + 0.5, by - 0.5, 1.0, 1.0, color));
|
||||
} else {
|
||||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
|
||||
}
|
||||
}
|
||||
s.push(fill(rx - 1.5, ry - 1.5, 3.0, 3.0, 0xFF40A0FF));
|
||||
s.push(text(fit(format!("РЛС {} · {}", distance(reach), seen), rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, 0xFF80FF90));
|
||||
let radar_text = if spec.sensor <= 0.0 {
|
||||
format!("сеть · {seen}")
|
||||
} else if radar_up {
|
||||
format!("РЛС {} · {}", distance(reach), seen)
|
||||
} else {
|
||||
"РЛС ОТКАЗ".to_string()
|
||||
};
|
||||
s.push(text(fit(radar_text, rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, if spec.sensor > 0.0 && !radar_up { 0xFFFF5040 } else { 0xFF80FF90 }));
|
||||
|
||||
// Its health under the radar: a bar, red when it is low.
|
||||
if sam.alive() && sam.health < 1.0 {
|
||||
@@ -167,6 +206,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
(false, false) => 0,
|
||||
};
|
||||
s.push(Shape::View(ViewShape { view: sam.id, rect: Rect { x: cx, y: cy, w: cw, h: ch }, mode }));
|
||||
// Worn or failed parts, over the picture: orange, red when one has failed.
|
||||
if !sam.comps.whole() {
|
||||
let failed = crate::sim::Part::ALL.iter().any(|p| sam.part_state(*p) == crate::sim::PartState::Failed);
|
||||
s.push(text(fit(sam.parts_text(), cw * 0.55), cx + 3.0, cy + 3.0, if failed { 0xFFFF4040 } else { 0xFFFFB030 }));
|
||||
}
|
||||
// Crosshair.
|
||||
s.push(fill(cx + cw / 2.0 - 0.5, cy + 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
|
||||
s.push(fill(cx + cw / 2.0 - 0.5, cy + ch * 0.82 - 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
|
||||
@@ -183,12 +227,22 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
if !t.alive() || sim.showing_kill(sam) {
|
||||
s.push(text("ПОРАЖЕНА", cx + cw / 2.0 - 24.0, cy + ch / 2.0 + bh / 2.0 + 2.0, 0xFF60FF60));
|
||||
}
|
||||
let rel = t.pos - sam.pos;
|
||||
// The system knows the target by its track: the forecast of the last measurement.
|
||||
let track = sam.track(t.id);
|
||||
let at = track.map_or(t.pos, |tr| tr.est(tick));
|
||||
let rel = at - sam.pos;
|
||||
let bearing = (heading_of(rel).to_degrees() + 180.0).rem_euclid(360.0);
|
||||
if ch > 50.0 {
|
||||
s.push(text(fit(t.kind.spec().name.into(), cw - 6.0), cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF));
|
||||
}
|
||||
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), t.pos.y - sam.pos.y, t.vel.length() * 72.0);
|
||||
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), at.y - sam.pos.y, t.vel.length() * 72.0);
|
||||
if let Some(tr) = track {
|
||||
if tr.forecast(tick) {
|
||||
info += &format!(" прогноз {:.1}с", tr.age(tick) as f64 / 20.0);
|
||||
} else if cw > 200.0 {
|
||||
info += &format!(" {}", tr.ident().name());
|
||||
}
|
||||
}
|
||||
if cw > 220.0 {
|
||||
info += &format!(" П{bearing:03.0}");
|
||||
}
|
||||
@@ -213,6 +267,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
|
||||
let mut s = Vec::new();
|
||||
cell(sim, index, 4.0, 4.0, 300.0, 150.0, &mut s);
|
||||
// Why it fires or doesn't: the conditions the simulation decides by, worked out now for this one.
|
||||
let checks = sim.why(index);
|
||||
s.push(fill(4.0, 156.0, 300.0, 4.0 + 10.0 * checks.len() as f32, 0xA0080C0A));
|
||||
for (k, c) in checks.iter().enumerate() {
|
||||
let color = if c.ok { 0xFF80FF90 } else { 0xFFFF9060 };
|
||||
s.push(text(fit(format!("{} {}", if c.ok { "+" } else { "-" }, c.text), 294.0), 7.0, 158.0 + 10.0 * k as f32, color));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -10,6 +10,8 @@ const CYAN: u32 = 0x9FE0FF;
|
||||
const GREY: u32 = 0xB0B0B0;
|
||||
const YELLOW: u32 = 0xFFE070;
|
||||
const GREEN: u32 = 0x80FF90;
|
||||
/// The most lines the summary holds (the HUD's room).
|
||||
const MAX_LINES: usize = 16;
|
||||
|
||||
/// Metres (a block is a metre) as a short distance.
|
||||
pub fn distance(m: f64) -> String {
|
||||
@@ -77,7 +79,15 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
if !sim.missiles.is_empty() {
|
||||
lines.push(line(format!("Перехватчиков в полёте: {}", sim.missiles.len()), YELLOW));
|
||||
}
|
||||
for sam in sim.sams.iter().take(8) {
|
||||
// The score is the last line and always shown: the unit list gets what
|
||||
// room is left, and cuts its own tail.
|
||||
let room = (MAX_LINES - 1).saturating_sub(lines.len());
|
||||
let total = sim.sams.len();
|
||||
let mut shown = total.min(8);
|
||||
if shown + usize::from(total > 8) > room {
|
||||
shown = room.saturating_sub(1);
|
||||
}
|
||||
for sam in sim.sams.iter().take(shown) {
|
||||
let mut text = if sam.kind.armed() {
|
||||
format!("{} [{}]", sam.status(), crate::sim::roles_text(sam.roles))
|
||||
} else {
|
||||
@@ -91,15 +101,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
}
|
||||
lines.push(line(text, CYAN));
|
||||
}
|
||||
if sim.sams.len() > 8 {
|
||||
lines.push(line(format!("…и ещё {} комплексов", sim.sams.len() - 8), CYAN));
|
||||
if total > shown && room > 0 {
|
||||
lines.push(line(format!("…и ещё {} комплексов", total - shown), CYAN));
|
||||
}
|
||||
// Room for the score whatever came before.
|
||||
lines.truncate(MAX_LINES - 1);
|
||||
let real = sim.launched.saturating_sub(0);
|
||||
let rate = if real > 0 { sim.kills as f64 * 100.0 / real as f64 } else { 0.0 };
|
||||
lines.push(line(
|
||||
format!("Сбито {} из {} ({rate:.0}%) · прорвалось {}", sim.kills, sim.launched, sim.hits),
|
||||
GREY,
|
||||
));
|
||||
lines.truncate(16);
|
||||
lines
|
||||
}
|
||||
|
||||
+24
-8
@@ -108,24 +108,40 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
|
||||
}
|
||||
}
|
||||
let mut far = 0;
|
||||
// The air picture is what the systems' radars hold; with none deployed, everything.
|
||||
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
|
||||
// is a forecast, dimmer); with no system deployed, everything as it is.
|
||||
let any = !sim.sams.is_empty();
|
||||
for d in sim.drones.iter().filter(|d| !any || !d.alive() || sim.tracked(d.id)) {
|
||||
match scope(d.pos) {
|
||||
let picture = sim.picture();
|
||||
let now = sim.tick;
|
||||
for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) {
|
||||
let track = picture.get(&d.id).filter(|_| any && d.alive());
|
||||
let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel));
|
||||
match scope(pos) {
|
||||
Some((x, y, bearing)) => {
|
||||
let a = fade(bearing);
|
||||
if d.alive() {
|
||||
s.push(square(x, y, 1.8, alpha(0xFF4040, a)));
|
||||
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
|
||||
let (a, color) = match track {
|
||||
Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040),
|
||||
Some(_) => (1.0, 0xFF4040),
|
||||
None => (fade(bearing), 0xFF4040),
|
||||
};
|
||||
s.push(square(x, y, 1.8, alpha(color, a)));
|
||||
// Its heading, a short tail.
|
||||
let v = d.vel.normalize_or_zero();
|
||||
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(0xFF4040, a * 0.6)));
|
||||
let v = vel.normalize_or_zero();
|
||||
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
|
||||
} else {
|
||||
s.push(square(x, y, 1.2, alpha(0x909090, a)));
|
||||
s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing))));
|
||||
}
|
||||
}
|
||||
None if d.alive() => far += 1,
|
||||
None => {}
|
||||
}
|
||||
// The true place, when asked for: a white dot beside the track, to see how far the picture is off.
|
||||
if sim.truth && d.alive() && any {
|
||||
if let Some((x, y, _)) = scope(d.pos) {
|
||||
s.push(square(x, y, 0.8, 0xFFFFFFFF));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The player: a white arrow the way they look.
|
||||
// Minecraft's yaw 0 looks south (+z, down the scope).
|
||||
|
||||
+115
-47
@@ -5,10 +5,11 @@
|
||||
//! behind the drone, then behind the interceptor or from the side — slowing
|
||||
//! time round the moment itself.
|
||||
use crate::panel::distance;
|
||||
use crate::sim::record::{EventKind, Recording, What};
|
||||
use crate::sim::record::{EventKind, Recording, STEP, What};
|
||||
use std::cell::Cell;
|
||||
use crate::sim::{Class, DroneKind, Out, Sim};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
|
||||
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape};
|
||||
|
||||
/// Ghost models' ids, clear of the live ones.
|
||||
const GHOSTS: u32 = 0x5000_0000;
|
||||
@@ -31,11 +32,13 @@ pub struct Clip {
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
/// Which of the sim's recordings.
|
||||
pub rec: usize,
|
||||
/// Which raid's recording (its number: the list drops the oldest as new ones come).
|
||||
pub rec: u32,
|
||||
pub mode: Mode,
|
||||
t: f64,
|
||||
speed: f64,
|
||||
/// How fast the replay's time ran this tick (0 paused or held on a card; slowed round a moment).
|
||||
rate: f64,
|
||||
/// The director's current shot (event, shot) and the view id it films with.
|
||||
shot: (usize, u8),
|
||||
view: u32,
|
||||
@@ -55,6 +58,8 @@ pub struct Player {
|
||||
/// Ticks the controls stay on screen.
|
||||
help: u32,
|
||||
pub done: bool,
|
||||
/// The map's area, worked out on the first map frame.
|
||||
area: Cell<Option<[f64; 4]>>,
|
||||
}
|
||||
|
||||
/// What the replay's keys do.
|
||||
@@ -104,6 +109,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
|
||||
EventKind::Hit { kind, .. } => format!("Прилёт: «{}»", kind.spec().name),
|
||||
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
|
||||
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
|
||||
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +125,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
|
||||
EventKind::Hit { kind, .. } => format!("{}: прилёт", kind.spec().name),
|
||||
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
|
||||
EventKind::Split { .. } => "Орешник: разделение".into(),
|
||||
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
|
||||
};
|
||||
format!("{} {what}", clock(e.at as f64))
|
||||
}
|
||||
@@ -149,6 +156,7 @@ fn interest(rec: &Recording, i: usize) -> f64 {
|
||||
power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 }
|
||||
}
|
||||
EventKind::SamLost { .. } => 12.0,
|
||||
EventKind::Crash { power, .. } => power as f64,
|
||||
EventKind::Split { drone } => {
|
||||
// The bus's split, not each block's.
|
||||
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
|
||||
@@ -165,6 +173,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
|
||||
EventKind::Hit { .. } => (160.0, 60.0),
|
||||
EventKind::SamLost { .. } => (80.0, 90.0),
|
||||
EventKind::Split { .. } => (40.0, 170.0),
|
||||
EventKind::Crash { .. } => (120.0, 60.0),
|
||||
};
|
||||
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
|
||||
}
|
||||
@@ -188,7 +197,7 @@ pub fn reel(rec: &Recording) -> Vec<Clip> {
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new(rec: usize, mode: Mode, sim: &Sim) -> Self {
|
||||
pub fn new(rec: u32, mode: Mode, sim: &Sim) -> Self {
|
||||
let start = match &mode {
|
||||
Mode::Clips { list, .. } => list.first().map_or(0.0, |c| c.from),
|
||||
_ => 0.0,
|
||||
@@ -201,10 +210,12 @@ impl Player {
|
||||
};
|
||||
let _ = sim;
|
||||
Player {
|
||||
area: Cell::new(None),
|
||||
rec,
|
||||
mode,
|
||||
t: start,
|
||||
speed,
|
||||
rate: 0.0,
|
||||
shot: (usize::MAX, 0),
|
||||
view: VIEW,
|
||||
fade: 0,
|
||||
@@ -289,9 +300,34 @@ impl Player {
|
||||
self.fade = 6;
|
||||
}
|
||||
|
||||
/// Where the camera is this tick.
|
||||
pub fn eye(&self) -> DVec3 {
|
||||
self.eye
|
||||
}
|
||||
/// The replay's moment, in the recording's ticks.
|
||||
pub fn time(&self) -> f64 {
|
||||
self.t
|
||||
}
|
||||
/// How fast the replay's time runs now, as a share of real time (1 normal, 0 held).
|
||||
pub fn rate(&self) -> f32 {
|
||||
self.rate as f32
|
||||
}
|
||||
/// Whether the world is filmed (the map is drawn on the HUD instead).
|
||||
pub fn filming(&self) -> bool {
|
||||
!matches!(self.mode, Mode::Map)
|
||||
}
|
||||
/// The camera the world is rendered from (`set-main-view`).
|
||||
pub fn view_id(&self) -> u32 {
|
||||
self.view
|
||||
}
|
||||
/// The time of day the raid was flown in, as of the replay's moment now.
|
||||
pub fn day_time(&self, sim: &Sim) -> Option<u64> {
|
||||
sim.replay(self.rec)?.day_time(self.t)
|
||||
}
|
||||
|
||||
/// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`.
|
||||
pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) {
|
||||
let Some(rec) = sim.replays.get(self.rec) else {
|
||||
let Some(rec) = sim.replay(self.rec) else {
|
||||
self.done = true;
|
||||
return;
|
||||
};
|
||||
@@ -312,6 +348,7 @@ impl Player {
|
||||
speed = 0.0;
|
||||
}
|
||||
}
|
||||
self.rate = if self.paused { 0.0 } else { speed };
|
||||
if !self.paused {
|
||||
self.t += speed;
|
||||
}
|
||||
@@ -348,11 +385,12 @@ impl Player {
|
||||
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
|
||||
let event = match e.kind {
|
||||
EventKind::Kill { .. } => "airdef:blast.air",
|
||||
EventKind::Hit { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
|
||||
EventKind::SamLost { .. } => "airdef:blast.big",
|
||||
EventKind::Split { .. } => "airdef:boom.sonic",
|
||||
};
|
||||
out.sound(event, heard(e.pos), 1.0, 1.0);
|
||||
let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 };
|
||||
out.sound(event, heard(e.pos), gain, 1.0);
|
||||
}
|
||||
for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) {
|
||||
if let What::Missile(kind, _) = tr.what
|
||||
@@ -408,7 +446,7 @@ impl Player {
|
||||
}
|
||||
}
|
||||
}
|
||||
EventKind::Hit { drone, .. } => {
|
||||
EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => {
|
||||
if t < te - 50.0 {
|
||||
if let Some((eye, look)) = chase(drone, 12.0, 4.0) {
|
||||
return (eye, look, 55.0, 0);
|
||||
@@ -491,12 +529,12 @@ impl Player {
|
||||
|
||||
/// The replay's screen: the film with its titles, or the map.
|
||||
pub fn hud(&self, sim: &Sim, (gw, gh): (f32, f32)) -> Vec<Shape> {
|
||||
let Some(rec) = sim.replays.get(self.rec) else { return Vec::new() };
|
||||
let Some(rec) = sim.replay(self.rec) else { return Vec::new() };
|
||||
if matches!(self.mode, Mode::Map) {
|
||||
return self.map(rec, gw, gh);
|
||||
}
|
||||
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xFF000000)];
|
||||
s.push(Shape::View(ViewShape { view: self.view, rect: Rect { x: 0.0, y: 0.0, w: gw, h: gh }, mode: 0 }));
|
||||
// The world itself is the camera's (`set-main-view`): the HUD only frames it.
|
||||
let mut s: Vec<Shape> = Vec::new();
|
||||
// Letterbox, the recording's marks.
|
||||
let bar = (gh * 0.09).round();
|
||||
s.push(fill(0.0, 0.0, gw, bar, 0xFF000000));
|
||||
@@ -581,34 +619,16 @@ impl Player {
|
||||
fn map(&self, rec: &Recording, gw: f32, gh: f32) -> Vec<Shape> {
|
||||
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xF0060A08)];
|
||||
let o = rec.origin;
|
||||
// The area: everything that flew, but not far beyond the systems.
|
||||
let mut lo = (f64::MAX, f64::MAX);
|
||||
let mut hi = (f64::MIN, f64::MIN);
|
||||
let mut grow = |p: DVec3| {
|
||||
lo = (lo.0.min(p.x), lo.1.min(p.z));
|
||||
hi = (hi.0.max(p.x), hi.1.max(p.z));
|
||||
// The area is the same every tick: worked out once for the recording.
|
||||
let [l0, l1, h0, h1] = match self.area.get() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let a = area(rec, o);
|
||||
self.area.set(Some(a));
|
||||
a
|
||||
}
|
||||
};
|
||||
for tr in &rec.tracks {
|
||||
if let What::Drone(k) = tr.what
|
||||
&& k.spec().ballistic.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in tr.path(o, 20, rec.len as f64) {
|
||||
grow(p);
|
||||
}
|
||||
}
|
||||
for sam in &rec.sams {
|
||||
grow(sam.2 - DVec3::splat(60.0));
|
||||
grow(sam.2 + DVec3::splat(60.0));
|
||||
}
|
||||
for p in &rec.objects {
|
||||
grow(*p);
|
||||
}
|
||||
if lo.0 > hi.0 {
|
||||
lo = (o.x - 200.0, o.z - 200.0);
|
||||
hi = (o.x + 200.0, o.z + 200.0);
|
||||
}
|
||||
let (lo, hi) = ((l0, l1), (h0, h1));
|
||||
let (top, bottom) = (20.0f32, 34.0f32);
|
||||
let (aw, ah) = (gw - 16.0, gh - top - bottom);
|
||||
let k = (aw as f64 / (hi.0 - lo.0).max(1.0)).min(ah as f64 / (hi.1 - lo.1).max(1.0));
|
||||
@@ -638,7 +658,9 @@ impl Player {
|
||||
let (x, y) = at(*p);
|
||||
s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFFFFF));
|
||||
}
|
||||
// Tracks up to now: a budget of segments shared out.
|
||||
// Tracks up to now: a budget of segments shared out. The stride is
|
||||
// worked out from the points there are, so only the segments drawn
|
||||
// are ever copied.
|
||||
let n = rec.tracks.len().max(1);
|
||||
let per = (640 / n).clamp(2, 14);
|
||||
for tr in &rec.tracks {
|
||||
@@ -647,16 +669,21 @@ impl Player {
|
||||
What::Drone(_) => (0x90FF4040, 0xFFFF6060),
|
||||
What::Missile(..) => (0x90FFD040, 0xFFFFE070),
|
||||
};
|
||||
let pts = tr.path(o, 1, self.t);
|
||||
let upto = ((self.t - tr.first as f64) / STEP as f64).floor();
|
||||
if upto < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let last = ((tr.last() - tr.first) / STEP) as f64;
|
||||
let count = upto.min(last) as usize + 1;
|
||||
if count < 2 {
|
||||
continue;
|
||||
}
|
||||
let pts = tr.path(o, count.div_ceil(per).max(1), self.t);
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let every = pts.len().div_ceil(per).max(1);
|
||||
let mut prev = at(pts[0]);
|
||||
for (i, p) in pts.iter().enumerate().skip(1) {
|
||||
if i % every != 0 && i != pts.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
for p in pts.iter().skip(1) {
|
||||
let q = at(*p);
|
||||
if inside(prev) && inside(q) {
|
||||
s.push(line(prev, q, 1.0, color));
|
||||
@@ -679,7 +706,7 @@ impl Player {
|
||||
s.push(line((x - 2.5, y - 2.5), (x + 2.5, y + 2.5), 1.2, 0xFFFFE040));
|
||||
s.push(line((x + 2.5, y - 2.5), (x - 2.5, y + 2.5), 1.2, 0xFFFFE040));
|
||||
}
|
||||
EventKind::Hit { power, .. } => {
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
|
||||
let r = 2.0 + power as f32;
|
||||
s.push(fill(x - r, y - r, r * 2.0, r * 2.0, 0xB0FF3020));
|
||||
}
|
||||
@@ -699,6 +726,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;
|
||||
@@ -707,6 +739,7 @@ impl Player {
|
||||
let c = match e.kind {
|
||||
EventKind::Kill { .. } => 0xFFFFE040,
|
||||
EventKind::Hit { .. } => 0xFFFF3020,
|
||||
EventKind::Crash { .. } => 0xFF903020,
|
||||
EventKind::SamLost { .. } => 0xFFFF00FF,
|
||||
EventKind::Split { .. } => 0xFFFF9020,
|
||||
};
|
||||
@@ -718,6 +751,41 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ground the map shows, as [lo x, lo z, hi x, hi z]: everything that
|
||||
/// flew (not the ballistic falls), with the systems and objects, or round the
|
||||
/// origin when nothing did.
|
||||
fn area(rec: &Recording, o: DVec3) -> [f64; 4] {
|
||||
let mut lo = (f64::MAX, f64::MAX);
|
||||
let mut hi = (f64::MIN, f64::MIN);
|
||||
let mut grow = |p: DVec3| {
|
||||
lo = (lo.0.min(p.x), lo.1.min(p.z));
|
||||
hi = (hi.0.max(p.x), hi.1.max(p.z));
|
||||
};
|
||||
for tr in &rec.tracks {
|
||||
if let What::Drone(k) = tr.what
|
||||
&& k.spec().ballistic.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in tr.path(o, 20, rec.len as f64) {
|
||||
grow(p);
|
||||
}
|
||||
}
|
||||
// The systems, with some room round them.
|
||||
for sam in &rec.sams {
|
||||
grow(sam.2 - DVec3::splat(60.0));
|
||||
grow(sam.2 + DVec3::splat(60.0));
|
||||
}
|
||||
for p in &rec.objects {
|
||||
grow(*p);
|
||||
}
|
||||
if lo.0 > hi.0 {
|
||||
lo = (o.x - 200.0, o.z - 200.0);
|
||||
hi = (o.x + 200.0, o.z + 200.0);
|
||||
}
|
||||
[lo.0, lo.1, hi.0, hi.1]
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn event_clip(rec: &Recording, i: usize) -> Mode {
|
||||
Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false }
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Named scenarios: a snapshot of the layout (systems, objects), the wave menu's raid and the
|
||||
//! random generator, kept under a name and loaded back later. The body is the autosave's text
|
||||
//! (`state_text`); a header names the format's version and the world it was made in.
|
||||
use super::*;
|
||||
|
||||
/// The format this build writes and reads.
|
||||
const VERSION: u32 = 1;
|
||||
/// Names of the saved scenarios, one a line.
|
||||
const INDEX: &str = "scenarios";
|
||||
|
||||
fn key(name: &str) -> String {
|
||||
format!("scn-{name}")
|
||||
}
|
||||
|
||||
fn valid(name: &str) -> Result<String, String> {
|
||||
let n = name.to_lowercase();
|
||||
if n.is_empty() || n.len() > 32 || !n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err("Имя сценария: до 32 символов, латиница, цифры, - и _".into());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn names() -> Vec<String> {
|
||||
storage::get(INDEX).map(|b| String::from_utf8_lossy(&b).lines().map(str::to_string).filter(|l| !l.is_empty()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl AirDef {
|
||||
/// The scenario as text.
|
||||
fn scenario_text(&self, name: &str, fresh: bool) -> String {
|
||||
let mut text = format!("scenario {VERSION}\nname {name}\nworld {}\n", self.state_key());
|
||||
text += &self.state_text(fresh, false);
|
||||
text += &format!("seed {}\nwave {}\n", self.sim.rng.state(), self.wave.text());
|
||||
text
|
||||
}
|
||||
|
||||
/// Replaces the world's layout with the scenario `text`; nothing changes if it is refused.
|
||||
fn apply_scenario(&mut self, text: &str, force: bool) -> Result<(), String> {
|
||||
let mut lines = text.lines();
|
||||
let version = lines
|
||||
.next()
|
||||
.and_then(|l| l.strip_prefix("scenario "))
|
||||
.ok_or("Это не сценарий (нет строки версии)")?
|
||||
.trim();
|
||||
match version.parse::<u32>() {
|
||||
Ok(VERSION) => {}
|
||||
_ => return Err(format!("Сценарий формата {version}: эта версия мода понимает только формат {VERSION}")),
|
||||
}
|
||||
let world = text.lines().find_map(|l| l.strip_prefix("world ")).unwrap_or("").trim();
|
||||
if !force && world != self.state_key() {
|
||||
return Err("Сценарий сделан в другом мире: координаты могут не подойти (загрузить всё равно: добавьте force)".into());
|
||||
}
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
}
|
||||
// Everything of the last layout goes: its systems with their jobs and targets, the sky,
|
||||
// the cook-offs to come; ids are made anew with the new systems.
|
||||
self.sim.clear();
|
||||
self.sim.sams.clear();
|
||||
self.sim.targets.clear();
|
||||
self.sim.target_health.clear();
|
||||
self.sim.roles.clear();
|
||||
self.sim.kills = 0;
|
||||
self.sim.hits = 0;
|
||||
self.sim.launched = 0;
|
||||
self.monitor = None;
|
||||
self.grid_ids.clear();
|
||||
self.moving = None;
|
||||
self.pending.clear();
|
||||
render::set_models(&[]);
|
||||
self.apply_state(text);
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/airdef scenario save|load|list|delete`.
|
||||
pub(crate) fn scenario_command(&mut self, args: &[&str]) -> Result<String, String> {
|
||||
match args {
|
||||
["save", name, flags @ ..] => {
|
||||
let name = valid(name)?;
|
||||
let fresh = flags.contains(&"fresh");
|
||||
let text = self.scenario_text(&name, fresh);
|
||||
storage::set(&key(&name), text.as_bytes()).map_err(|e| format!("Не удалось сохранить: {e}"))?;
|
||||
let mut all = names();
|
||||
if !all.contains(&name) {
|
||||
all.push(name.clone());
|
||||
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
|
||||
}
|
||||
Ok(format!(
|
||||
"Сценарий «{name}» сохранён: комплексов {}, объектов {}{}",
|
||||
self.sim.sams.len(),
|
||||
self.sim.targets.len(),
|
||||
if fresh { " (целые, с полным боезапасом)" } else { "" }
|
||||
))
|
||||
}
|
||||
["load", name, flags @ ..] => {
|
||||
let name = valid(name)?;
|
||||
let bytes = storage::get(&key(&name)).ok_or(format!("Нет сценария «{name}» (/airdef scenario list)"))?;
|
||||
self.apply_scenario(&String::from_utf8_lossy(&bytes), flags.contains(&"force"))?;
|
||||
Ok(format!("Сценарий «{name}» загружен: комплексов {}, объектов {}", self.sim.sams.len(), self.sim.targets.len()))
|
||||
}
|
||||
["list"] => {
|
||||
let all = names();
|
||||
if all.is_empty() {
|
||||
return Ok("Сценариев нет: /airdef scenario save <имя>".into());
|
||||
}
|
||||
Ok(format!("Сценарии: {}", all.join(", ")))
|
||||
}
|
||||
["delete", name] => {
|
||||
let name = valid(name)?;
|
||||
let mut all = names();
|
||||
if !all.contains(&name) {
|
||||
return Err(format!("Нет сценария «{name}»"));
|
||||
}
|
||||
all.retain(|n| *n != name);
|
||||
storage::delete(&key(&name));
|
||||
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
|
||||
Ok(format!("Сценарий «{name}» удалён"))
|
||||
}
|
||||
_ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,124 @@ use super::*;
|
||||
/// By blast power (as `explode` grades it): how far it reaches a vehicle, and how hard it hits one at the centre.
|
||||
const REACH: [f64; 5] = [4.0, 7.0, 12.0, 18.0, 26.0];
|
||||
const HIT: [f64; 5] = [0.2, 0.45, 0.85, 1.25, 1.7];
|
||||
/// A unit's working parts, each 1 (whole) to 0 (failed); the hull is `Sam::health` (0 destroys it).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Part {
|
||||
/// The radar (or optics): its reach and its scan.
|
||||
Sensor,
|
||||
/// The aiming drive of the launcher or turret.
|
||||
Mount,
|
||||
/// The launcher: cells, reload.
|
||||
Launcher,
|
||||
/// The gun: it jams.
|
||||
Gun,
|
||||
/// The running gear: it can't march when it fails.
|
||||
Tracks,
|
||||
/// The data link: no network when it fails.
|
||||
Comms,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PartState {
|
||||
Ok,
|
||||
Damaged,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Part {
|
||||
pub const ALL: [Part; 6] = [Part::Sensor, Part::Mount, Part::Launcher, Part::Gun, Part::Tracks, Part::Comms];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Part::Sensor => "РЛС",
|
||||
Part::Mount => "привод наведения",
|
||||
Part::Launcher => "пусковая",
|
||||
Part::Gun => "орудие",
|
||||
Part::Tracks => "ходовая",
|
||||
Part::Comms => "связь",
|
||||
}
|
||||
}
|
||||
pub fn short(self) -> &'static str {
|
||||
match self {
|
||||
Part::Sensor => "РЛС",
|
||||
Part::Mount => "Прив",
|
||||
Part::Launcher => "ПУ",
|
||||
Part::Gun => "Ор",
|
||||
Part::Tracks => "Ход",
|
||||
Part::Comms => "Св",
|
||||
}
|
||||
}
|
||||
fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
/// Whether this kind of unit has the part at all.
|
||||
pub fn on(self, kind: SamKind) -> bool {
|
||||
let spec = kind.spec();
|
||||
match self {
|
||||
Part::Sensor => spec.sensor > 0.0,
|
||||
Part::Mount => kind.armed() && kind != SamKind::Light && (spec.missile.is_some() || spec.gun.is_some()),
|
||||
Part::Launcher => spec.missile.is_some(),
|
||||
Part::Gun => spec.gun.is_some(),
|
||||
Part::Tracks => kind.mobile(),
|
||||
Part::Comms => true,
|
||||
}
|
||||
}
|
||||
/// Where the part is on the vehicle: right, up, back, blocks at scale 1 (the nose points to -back).
|
||||
fn place(self, spec: &super::units::SamSpec) -> (f64, f64, f64) {
|
||||
match self {
|
||||
Part::Sensor => (0.0, spec.mast / spec.scale, 1.0),
|
||||
Part::Mount => (0.0, spec.mount.0, -spec.mount.1),
|
||||
Part::Launcher => (0.0, spec.mount.0, -spec.mount.1 + 0.4),
|
||||
Part::Gun => (0.0, spec.mount.0, -spec.mount.1 - 1.0),
|
||||
Part::Tracks => (0.0, 0.3, 0.0),
|
||||
Part::Comms => (0.0, 1.6, 1.8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Below this a part is damaged (works worse), at 0 it has failed.
|
||||
pub const WORN: f64 = 0.6;
|
||||
|
||||
/// The state of every part: `Part::ALL`'s order.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub struct Comps(pub [f64; 6]);
|
||||
|
||||
impl Default for Comps {
|
||||
fn default() -> Self {
|
||||
Self([1.0; 6])
|
||||
}
|
||||
}
|
||||
|
||||
impl Comps {
|
||||
pub fn get(&self, p: Part) -> f64 {
|
||||
self.0[p.index()]
|
||||
}
|
||||
pub fn state(&self, p: Part) -> PartState {
|
||||
let v = self.get(p);
|
||||
if v <= 0.0 {
|
||||
PartState::Failed
|
||||
} else if v < WORN {
|
||||
PartState::Damaged
|
||||
} else {
|
||||
PartState::Ok
|
||||
}
|
||||
}
|
||||
pub fn whole(&self) -> bool {
|
||||
self.0.iter().all(|&v| v >= 1.0)
|
||||
}
|
||||
/// Parts of this kind that are not whole, as "РЛС 40%, ходовая отказ".
|
||||
pub fn describe(&self, kind: SamKind) -> String {
|
||||
Part::ALL
|
||||
.iter()
|
||||
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
|
||||
.map(|p| match self.state(*p) {
|
||||
PartState::Failed => format!("{} отказ", p.short()),
|
||||
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Health points an object loses at the centre of a blast.
|
||||
const OBJECT_HIT: [f64; 5] = [2.0, 6.0, 14.0, 26.0, 40.0];
|
||||
|
||||
@@ -28,7 +146,72 @@ impl MissileKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target).
|
||||
pub(super) const MISS_RADIUS: f64 = 3.0;
|
||||
/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale).
|
||||
const HULL_RADIUS: f64 = 2.2;
|
||||
const HULL_TOP: f64 = 3.0;
|
||||
|
||||
impl DroneKind {
|
||||
/// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh)
|
||||
/// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`.
|
||||
pub(super) fn accuracy(self) -> (f64, f64) {
|
||||
match self {
|
||||
DroneKind::Shahed => (1.2, 0.03),
|
||||
DroneKind::Geran3 => (1.0, 0.03),
|
||||
DroneKind::Lancet => (0.8, 0.02),
|
||||
DroneKind::Gerbera => (1.5, 0.04),
|
||||
DroneKind::Kh101 => (1.3, 0.05),
|
||||
DroneKind::Kalibr => (1.3, 0.05),
|
||||
DroneKind::Kh22 => (1.6, 0.06),
|
||||
DroneKind::Zircon => (1.5, 0.06),
|
||||
DroneKind::Iskander => (1.4, 0.06),
|
||||
DroneKind::Kinzhal => (1.5, 0.06),
|
||||
// The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies.
|
||||
DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight.
|
||||
pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 {
|
||||
let (cep, gross) = kind.accuracy();
|
||||
let angle = self.rng.f() * std::f64::consts::TAU;
|
||||
let r = if self.rng.chance(gross) {
|
||||
MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f())
|
||||
} else {
|
||||
// Rayleigh with its median at the CEP.
|
||||
let u = 1.0 - self.rng.f();
|
||||
cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt()
|
||||
};
|
||||
DVec3::new(angle.cos() * r, 0.0, angle.sin() * r)
|
||||
}
|
||||
|
||||
/// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is
|
||||
/// destroyed at once, whatever the blast would have done to it. A near miss is only splash.
|
||||
pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) {
|
||||
if !self.damage || tnt == 0 {
|
||||
return;
|
||||
}
|
||||
let from = pos - vel;
|
||||
for i in 0..self.sams.len() {
|
||||
let sam = &self.sams[i];
|
||||
if !sam.alive() {
|
||||
continue;
|
||||
}
|
||||
let s = sam.kind.spec().scale;
|
||||
let hit = (0..=8).any(|k| {
|
||||
let p = from.lerp(pos, k as f64 / 8.0);
|
||||
let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length();
|
||||
across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s
|
||||
});
|
||||
if hit {
|
||||
self.destroy(i, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage.
|
||||
pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, out: &mut Out) {
|
||||
let (reach, hit) = (REACH[power], HIT[power]);
|
||||
@@ -47,9 +230,39 @@ impl Sim {
|
||||
sam.health -= hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
|
||||
if sam.health <= 0.0 {
|
||||
destroyed.push(i);
|
||||
} else if before >= 0.5 && sam.health < 0.5 {
|
||||
continue;
|
||||
}
|
||||
if before >= 0.5 && sam.health < 0.5 {
|
||||
out.messages.push(format!("{} №{} повреждён", sam.kind.name(), i + 1));
|
||||
}
|
||||
// The parts take the blast by their own distance from it: the side it came from decides which.
|
||||
let spec = sam.kind.spec();
|
||||
let rot = DQuat::from_rotation_y(sam.heading);
|
||||
for part in Part::ALL {
|
||||
if !part.on(sam.kind) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, back) = part.place(spec);
|
||||
let at = sam.pos + rot * DVec3::new(x, y, back) * spec.scale;
|
||||
let size = if part == Part::Tracks { 2.5 } else { 1.0 };
|
||||
let dp = (at.distance(pos) - size).max(0.0);
|
||||
if dp >= reach {
|
||||
continue;
|
||||
}
|
||||
let kp = 1.0 - dp / reach;
|
||||
let roll = dice(self.seed, self.tick, sam.id as u64, part as u64);
|
||||
// The parts are softer than the hull: a blast the hull survives can still put one out.
|
||||
let dmg = hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 };
|
||||
let was = sam.comps.state(part);
|
||||
let v = &mut sam.comps.0[part as usize];
|
||||
*v = (*v - dmg).max(0.0);
|
||||
let now = sam.comps.state(part);
|
||||
if now != was && now != PartState::Ok {
|
||||
let word = if now == PartState::Failed { "отказал(а)" } else { "повреждён(а)" };
|
||||
out.messages.push(format!("{} №{}: {} {word}", sam.kind.name(), i + 1, part.name()));
|
||||
}
|
||||
}
|
||||
sam.fit_cells();
|
||||
}
|
||||
for i in destroyed {
|
||||
self.destroy(i, out);
|
||||
@@ -79,6 +292,9 @@ impl Sim {
|
||||
sam.target = None;
|
||||
sam.gun_target = None;
|
||||
sam.reloading = 0;
|
||||
sam.tracks.clear();
|
||||
sam.seen.clear();
|
||||
sam.own.clear();
|
||||
let spec = sam.kind.spec();
|
||||
let missiles = sam.missiles;
|
||||
let bursts = sam.bursts;
|
||||
@@ -159,11 +375,76 @@ impl Sim {
|
||||
|
||||
}
|
||||
|
||||
/// A roll from 0 to 1 fixed by the seed and three numbers: the same events roll the same in every run,
|
||||
/// and the simulation's own random sequence is left alone.
|
||||
pub(super) fn dice(seed: u64, a: u64, b: u64, c: u64) -> f64 {
|
||||
let mut x = seed ^ a.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ c.wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^= x >> 31;
|
||||
(x >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// Its wreck still burns.
|
||||
pub fn burning(&self) -> bool {
|
||||
self.burn > 0
|
||||
}
|
||||
/// A part's state, `Ok` for one this unit does not have.
|
||||
pub fn part(&self, p: Part) -> f64 {
|
||||
if p.on(self.kind) { self.comps.get(p) } else { 1.0 }
|
||||
}
|
||||
pub fn part_state(&self, p: Part) -> PartState {
|
||||
if p.on(self.kind) { self.comps.state(p) } else { PartState::Ok }
|
||||
}
|
||||
pub fn parts_text(&self) -> String {
|
||||
self.comps.describe(self.kind)
|
||||
}
|
||||
/// The aiming drive's share of its speed: a failed one doesn't turn at all.
|
||||
pub(super) fn drive_fit(&self) -> f64 {
|
||||
let h = self.part(Part::Mount);
|
||||
if h <= 0.0 { 0.0 } else { 0.3 + 0.7 * h }
|
||||
}
|
||||
/// The radar's share of its reach; the scan is slower by the same.
|
||||
pub(super) fn radar_fit(&self) -> f64 {
|
||||
let h = self.part(Part::Sensor);
|
||||
if h <= 0.0 { 0.0 } else { 0.4 + 0.6 * h }
|
||||
}
|
||||
/// The gun's share of its hit chance.
|
||||
pub(super) fn gun_fit(&self) -> f64 {
|
||||
0.5 + 0.5 * self.part(Part::Gun)
|
||||
}
|
||||
/// Its launcher's cells still good: a damaged one holds fewer missiles.
|
||||
pub(super) fn cells(&self) -> u32 {
|
||||
let Some(load) = &self.kind.spec().missile else { return 0 };
|
||||
let h = self.part(Part::Launcher);
|
||||
if h <= 0.0 { 0 } else { ((load.magazine as f64 * (0.4 + 0.6 * h)).ceil() as u32).clamp(1, load.magazine) }
|
||||
}
|
||||
/// Missiles above what the cells hold are lost with them.
|
||||
pub fn fit_cells(&mut self) {
|
||||
if self.kind.spec().missile.is_some() {
|
||||
self.missiles = self.missiles.min(self.cells());
|
||||
}
|
||||
}
|
||||
/// Ticks a reload takes: longer with a damaged launcher.
|
||||
pub(super) fn reload_time(&self) -> u32 {
|
||||
let r = self.kind.spec().reload.max(1);
|
||||
(r as f64 * (1.0 + 1.5 * (1.0 - self.part(Part::Launcher)))).round() as u32
|
||||
}
|
||||
/// The gun fires (not jammed for good, not jammed now).
|
||||
pub(super) fn gun_up(&self) -> bool {
|
||||
self.part(Part::Gun) > 0.0 && self.jam == 0
|
||||
}
|
||||
/// How far it can march: the running gear's share of the speed (0 can't).
|
||||
pub(super) fn drive_speed(&self) -> f64 {
|
||||
let h = self.part(Part::Tracks);
|
||||
if h <= 0.0 { 0.0 } else { 0.35 + 0.65 * h }
|
||||
}
|
||||
/// The data link's reach, 0 when the link has failed.
|
||||
pub(super) fn link_range(&self) -> f64 {
|
||||
let h = self.part(Part::Comms);
|
||||
if h <= 0.0 { 0.0 } else { super::sight::LINK * (0.3 + 0.7 * h) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
@@ -171,20 +452,29 @@ impl Sim {
|
||||
pub fn repair(&mut self) -> u32 {
|
||||
let mut n = 0;
|
||||
for sam in &mut self.sams {
|
||||
if sam.health < 1.0 {
|
||||
if sam.health < 1.0 || !sam.comps.whole() {
|
||||
n += 1;
|
||||
}
|
||||
sam.comps = Comps::default();
|
||||
sam.jam = 0;
|
||||
let spec = sam.kind.spec();
|
||||
sam.health = 1.0;
|
||||
sam.burn = 0;
|
||||
sam.state = SamState::Search;
|
||||
sam.reloading = 0;
|
||||
sam.empty = false;
|
||||
// Rebuilt where it stands: a march under way is called off.
|
||||
sam.dest = None;
|
||||
sam.march = 0;
|
||||
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine);
|
||||
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
|
||||
}
|
||||
for h in &mut self.target_health {
|
||||
*h = 100.0;
|
||||
}
|
||||
// The wrecks' ammunition still to cook off goes with them.
|
||||
self.later.clear();
|
||||
self.fires.extinguish();
|
||||
n
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ impl Sim {
|
||||
out
|
||||
}
|
||||
|
||||
/// During a raid: drones not yet launched go for what just gave itself away (a jamming station
|
||||
/// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station
|
||||
/// first, then systems that fired), and drones whose system target is gone pick another.
|
||||
pub(super) fn adapt(&mut self) {
|
||||
if self.tick % 40 != 0 {
|
||||
@@ -159,32 +159,96 @@ impl Sim {
|
||||
return;
|
||||
}
|
||||
let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1;
|
||||
// The drones sent at a new target: (waiting in the queue, index), to get a new route.
|
||||
let mut retargeted: Vec<(bool, usize)> = Vec::new();
|
||||
let mut n = 0usize;
|
||||
for (_, d) in self.queue.iter_mut() {
|
||||
for (qi, (_, d)) in self.queue.iter_mut().enumerate().filter(|(_, (_, d))| d.smart) {
|
||||
let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon);
|
||||
if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) {
|
||||
n += 1;
|
||||
continue;
|
||||
}
|
||||
// Only those not yet sent at a unit.
|
||||
if self.sams.iter().any(|s| s.pos.distance(d.target) < 6.0) {
|
||||
if self.sams.iter().any(|s| s.pos.distance(d.target - d.err) < 6.0) {
|
||||
continue;
|
||||
}
|
||||
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
|
||||
d.target = to;
|
||||
d.target = to + d.err;
|
||||
d.route.clear();
|
||||
retargeted.push((true, qi));
|
||||
n += 1;
|
||||
}
|
||||
// In flight: a target system destroyed — the next known one, or keep going.
|
||||
for d in self.drones.iter_mut().filter(|d| d.alive()) {
|
||||
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0);
|
||||
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
|
||||
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target - d.err) < 6.0);
|
||||
if aimed_at_wreck {
|
||||
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
|
||||
if let Some((_, at)) = next {
|
||||
d.target = *at;
|
||||
d.target = *at + d.err;
|
||||
d.route.clear();
|
||||
retargeted.push((false, di));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.replan_routes(&retargeted);
|
||||
}
|
||||
|
||||
/// Routes round the known defences for drones that were just sent at a new target, from where they are
|
||||
/// (or wait). One grid and one set of prices cover them all, and the work is the planner's share for a
|
||||
/// tick: those left over fly straight, as they did before there was a replan. A Lancet's strike on a
|
||||
/// unit and the ballistic missiles go straight on purpose, as in the raid's own plan.
|
||||
fn replan_routes(&mut self, jobs: &[(bool, usize)]) {
|
||||
let drone = |s: &Sim, (queued, i): (bool, usize)| if queued { s.queue[i].1.clone() } else { s.drones[i].clone() };
|
||||
let jobs: Vec<(bool, usize)> = jobs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&j| {
|
||||
let d = drone(self, j);
|
||||
let spec = d.kind.spec();
|
||||
d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet
|
||||
})
|
||||
.collect();
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
// One grid over all their starts and goals, with room round them to go by.
|
||||
let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
|
||||
for &j in &jobs {
|
||||
let d = drone(self, j);
|
||||
for p in [d.pos, d.target] {
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
}
|
||||
}
|
||||
let span = (hi.x - lo.x).max(hi.z - lo.z) / 2.0 + 450.0;
|
||||
let cell = raid::CELL.max(2.0 * span / raid::CELLS as f64);
|
||||
let centre = (lo + hi) / 2.0;
|
||||
let grid = raid::Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 };
|
||||
let mut prices: HashMap<DroneKind, Vec<f32>> = HashMap::new();
|
||||
let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec<DVec3>> = HashMap::new();
|
||||
let mut work = 0usize;
|
||||
for j in jobs {
|
||||
let d = drone(self, j);
|
||||
if work >= raid::WORK_PER_TICK {
|
||||
break;
|
||||
}
|
||||
let key = (d.kind, grid.index(d.pos), grid.index(d.target));
|
||||
let route = match routes.get(&key) {
|
||||
Some(r) => r.clone(),
|
||||
None => {
|
||||
if !prices.contains_key(&d.kind) {
|
||||
let (p, cost) = self.price_grid(&grid, d.kind);
|
||||
work += cost;
|
||||
prices.insert(d.kind, p);
|
||||
}
|
||||
let (r, cost) = raid::plan_route(&grid, &prices[&d.kind], d.pos, d.target);
|
||||
work += cost;
|
||||
routes.insert(key, r.clone());
|
||||
r
|
||||
}
|
||||
};
|
||||
let d = if j.0 { &mut self.queue[j.1].1 } else { &mut self.drones[j.1] };
|
||||
d.route = route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Long fires (#52): where a drone or a missile came down, where a warhead struck and on a
|
||||
//! destroyed system a fire burns for minutes: it flares up, burns steadily, smokes thick and
|
||||
//! heavy, dies down and smoulders. Visual first (flames, smoke columns, a flickering light, a
|
||||
//! crackle); with damage on, a few real fire blocks on solid ground (never on water or in the
|
||||
//! air), put back while it burns and taken away when it is out. At most `MAX` fires burn, the
|
||||
//! oldest dying down first. A replay draws the same fires from its events (`replay_fire`).
|
||||
use super::*;
|
||||
|
||||
/// Fires drawn at full strength at once (flames, smoke columns, lights, fire blocks): the strongest `MAX`.
|
||||
pub const MAX: usize = 12;
|
||||
/// Fires kept at all, burning places the player may come back to: past it the weakest are put out (#69).
|
||||
/// The rest of them, beyond `MAX`, only smoulder (a thin smoke, now and then), with no light.
|
||||
pub const LIST_MAX: usize = 32;
|
||||
/// A fire put out by the water it fell in lasts this long (steam only).
|
||||
const STEAM: u32 = 160;
|
||||
/// Ticks the oldest fire has left when a newer one pushes it out.
|
||||
const FADE: u32 = 160;
|
||||
/// Ticks between the fire blocks being set again.
|
||||
const REFRESH: u64 = 80;
|
||||
|
||||
pub struct Fire {
|
||||
pub id: u32,
|
||||
pub at: DVec3,
|
||||
pub start: u64,
|
||||
pub life: u32,
|
||||
/// 0.6 (a crashed drone) to 3 (a cruise or ballistic warhead, a depot).
|
||||
pub size: f32,
|
||||
/// The system it burns on, if any: its own wreck fire does the flames for as long as it lasts.
|
||||
host: Option<u32>,
|
||||
/// The land under it found: `Some(true)` land, `Some(false)` water (steam only).
|
||||
land: Option<bool>,
|
||||
/// Asked about the ground since this tick.
|
||||
asked: u64,
|
||||
/// Fire blocks set (whole coordinates).
|
||||
blocks: Vec<(i32, i32, i32)>,
|
||||
/// The host's wreck is flaming: this fire leaves the flames to it.
|
||||
pub quiet: bool,
|
||||
/// Among the `MAX` strongest this tick: the only ones drawn in full and lit (#69).
|
||||
pub shown: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Fires {
|
||||
pub list: Vec<Fire>,
|
||||
/// Commands to take fire blocks away, sent next tick.
|
||||
cleanup: Vec<String>,
|
||||
}
|
||||
|
||||
/// How long a fire of this power burns, in ticks: 2 to 5 minutes.
|
||||
pub fn life_for(power: u32) -> u32 {
|
||||
2400 + 900 * power.min(4)
|
||||
}
|
||||
|
||||
/// The size of a fire of this power.
|
||||
pub fn size_for(power: u32) -> f32 {
|
||||
[0.6, 1.0, 1.6, 2.4, 3.0][power.min(4) as usize]
|
||||
}
|
||||
|
||||
/// How strong the flames (`.0`) and the smoke (`.1`) are, 0 to about 1, `age` ticks into a fire
|
||||
/// that burns `life`: flaring up, steady flame, thick smoke while it dies down, then embers.
|
||||
pub fn level(age: f64, life: f64) -> (f32, f32) {
|
||||
let f = (age / life.max(1.0)).clamp(0.0, 1.0);
|
||||
let flare = if age < 40.0 { 0.35 + 0.65 * age / 40.0 } else { 1.0 };
|
||||
let flame = if f < 0.45 { flare } else { flare * (1.0 - (f - 0.45) / 0.4).clamp(0.0, 1.0) };
|
||||
let smoke = if age < 100.0 { age / 100.0 } else if f < 0.8 { 1.0 } else { 1.0 - 0.85 * (f - 0.8) / 0.2 };
|
||||
(flame as f32, smoke.clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
impl Fires {
|
||||
/// Fires burning now.
|
||||
pub fn len(&self) -> usize {
|
||||
self.list.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.list.is_empty()
|
||||
}
|
||||
/// Puts every fire out; the fire blocks are taken away on the next step.
|
||||
pub fn extinguish(&mut self) {
|
||||
let list = std::mem::take(&mut self.list);
|
||||
for f in list {
|
||||
self.cleanup.extend(f.take_blocks());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fire {
|
||||
fn take_blocks(self) -> Vec<String> {
|
||||
self.blocks
|
||||
.into_iter()
|
||||
.map(|(x, y, z)| format!("fill {x} {y} {z} {x} {y} {z} minecraft:air replace minecraft:fire"))
|
||||
.collect()
|
||||
}
|
||||
/// How strong it burns at tick `now`: (flames, smoke); water leaves steam only.
|
||||
pub fn level(&self, now: u64) -> (f32, f32) {
|
||||
let (fl, sm) = level(now.saturating_sub(self.start) as f64, self.life as f64);
|
||||
if self.land == Some(false) { (0.0, sm * 0.5) } else { (fl, sm) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Something came down or struck at `pos` (power 0 to 4): a fire starts there, or the fire
|
||||
/// already burning close by grows. `host` is the system it burns on.
|
||||
pub(super) fn ignite(&mut self, pos: DVec3, power: u32, host: Option<u32>) {
|
||||
let now = self.tick;
|
||||
let (size, life) = (size_for(power), life_for(power));
|
||||
if let Some(f) = self.fires.list.iter_mut().find(|f| f.at.distance(pos) < 6.0) {
|
||||
// The same spot (a warhead and the wreck it made): one fire, the greater of the two.
|
||||
f.size = f.size.max(size);
|
||||
f.life = f.life.max((now - f.start) as u32 + life);
|
||||
f.host = f.host.or(host);
|
||||
return;
|
||||
}
|
||||
let id = self.id();
|
||||
self.fires.list.push(Fire {
|
||||
id,
|
||||
at: pos,
|
||||
start: now,
|
||||
life,
|
||||
size,
|
||||
host,
|
||||
land: None,
|
||||
asked: 0,
|
||||
blocks: Vec::new(),
|
||||
quiet: false,
|
||||
shown: true,
|
||||
});
|
||||
// A hard bound on the places kept (#69): the weakest, soonest out, goes now and its fire blocks with it.
|
||||
while self.fires.list.len() > LIST_MAX {
|
||||
let weakest = (0..self.fires.list.len())
|
||||
.min_by(|&a, &b| {
|
||||
let (fa, fb) = (&self.fires.list[a], &self.fires.list[b]);
|
||||
let w = |f: &Fire| f.size as f64 * (f.life as f64 - now.saturating_sub(f.start) as f64).max(0.0);
|
||||
w(fa).total_cmp(&w(fb)).then(fa.id.cmp(&fb.id))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let gone = self.fires.list.swap_remove(weakest);
|
||||
self.fires.cleanup.extend(gone.take_blocks());
|
||||
}
|
||||
if self.fires.list.len() > MAX {
|
||||
// The oldest dies down (and is gone) instead of burning on.
|
||||
let old = self.fires.list.iter_mut().filter(|f| now.saturating_sub(f.start) as u32 + FADE < f.life).min_by_key(|f| f.start);
|
||||
if let Some(f) = old {
|
||||
f.life = (now - f.start) as u32 + FADE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: finds the ground under each new fire, burns the old ones, ends the burnt out.
|
||||
pub(super) fn step_fires(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
out.commands.append(&mut self.fires.cleanup);
|
||||
let damage = self.damage;
|
||||
let mut fires = std::mem::take(&mut self.fires.list);
|
||||
// The strongest `MAX` are drawn in full and lit; the rest smoulder (#69).
|
||||
let mut rank: Vec<(f32, u32, usize)> = fires
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let (fl, sm) = f.level(now);
|
||||
(f.size * (fl + 0.3 * sm), f.id, i)
|
||||
})
|
||||
.collect();
|
||||
rank.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
for (k, &(_, _, i)) in rank.iter().enumerate() {
|
||||
fires[i].shown = k < MAX;
|
||||
}
|
||||
for f in &mut fires {
|
||||
let age = now.saturating_sub(f.start);
|
||||
// Where the land is under it: it burns on the surface, not at the height of what fell.
|
||||
if f.land.is_none() && age >= f.asked {
|
||||
f.asked = age + 10;
|
||||
if let Some(h) = ground.at(f.at, now) {
|
||||
let (x, z) = (f.at.x.floor() as i32, f.at.z.floor() as i32);
|
||||
let h = h as i32;
|
||||
if ground.world.is_known_solid(x, h - 1, z, now) {
|
||||
f.at.y = h as f64;
|
||||
f.land = Some(true);
|
||||
} else if ground.world.is_known_open(x, h - 1, z, now) {
|
||||
// Water (or another fluid): the fire goes out in a cloud of steam.
|
||||
f.at.y = h as f64;
|
||||
f.land = Some(false);
|
||||
f.life = f.life.min(age as u32 + STEAM);
|
||||
}
|
||||
}
|
||||
// Not known for a while: it burns where it is, on the land.
|
||||
if f.land.is_none() && age > 100 {
|
||||
f.land = Some(true);
|
||||
}
|
||||
}
|
||||
f.quiet = f.host.is_some_and(|id| self.sams.iter().any(|s| s.id == id && !s.alive() && s.burn > 0));
|
||||
let (fl, sm) = f.level(now);
|
||||
let s = f.size;
|
||||
if !f.quiet && !f.shown {
|
||||
// Beyond the budget: a thin smoke now and then, no flames, no light, no new fire blocks.
|
||||
if sm > 0.04 && now % 24 == (f.id % 24) as u64 {
|
||||
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.4 + 0.3 * f.size, DVec3::new(0.0, 0.1, 0.0), 0.03, 1);
|
||||
}
|
||||
} else if !f.quiet {
|
||||
let top = f.at + DVec3::Y * 0.4;
|
||||
let spread = 0.4 + 0.5 * s;
|
||||
if fl > 0.04 && now % 2 == 0 {
|
||||
out.particles("minecraft:flame", top, spread, DVec3::new(0.0, 0.05 + 0.05 * fl as f64, 0.0), 0.02, (3.0 * s * fl).ceil() as u32);
|
||||
}
|
||||
if sm > 0.04 && now % 3 == 0 {
|
||||
out.particles("minecraft:large_smoke", top + DVec3::Y * 0.6, spread, DVec3::new(0.0, 0.1, 0.0), 0.03, (2.0 * s * sm).ceil() as u32);
|
||||
}
|
||||
// The tall column: a plume that rises far.
|
||||
if sm > 0.3 && now % 6 == (f.id % 6) as u64 {
|
||||
out.particles("minecraft:campfire_signal_smoke", top, 0.5 * s, DVec3::new(0.0, 0.09, 0.0), 0.01, 1 + (s * sm) as u32);
|
||||
}
|
||||
if fl > 0.5 && now % 20 == (f.id % 20) as u64 {
|
||||
out.particles("minecraft:lava", top + DVec3::Y * 0.5, 0.4 * s, DVec3::new(0.0, 0.2, 0.0), 0.1, 2);
|
||||
}
|
||||
if now % 50 == (f.id % 50) as u64 && fl + sm * 0.3 > 0.1 {
|
||||
out.sound("minecraft:block.fire.ambient", f.at, 0.5 + 1.2 * fl, 0.8 + 0.2 * s.min(1.0));
|
||||
}
|
||||
}
|
||||
// Real fire, only where the damage is real: a few blocks on the solid ground.
|
||||
if damage && f.shown && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 {
|
||||
let (x, y, z) = (f.at.x.floor() as i32, f.at.y.floor() as i32, f.at.z.floor() as i32);
|
||||
let n = if s >= 2.5 { 5 } else if s >= 1.5 { 3 } else { 1 };
|
||||
for k in 0..n {
|
||||
let (dx, dz) = if k == 0 { (0, 0) } else { [(1, 0), (-1, 0), (0, 1), (0, -1)][k - 1] };
|
||||
let (bx, bz) = (x + dx, z + dz);
|
||||
// Only where the land is as high as at the centre.
|
||||
let level = ground.at(DVec3::new(bx as f64 + 0.5, 0.0, bz as f64 + 0.5), now).is_some_and(|h| h as i32 == y);
|
||||
if level && ground.world.is_known_solid(bx, y - 1, bz, now) && ground.world.is_known_open(bx, y, bz, now) {
|
||||
out.commands.push(format!("setblock {bx} {y} {bz} minecraft:fire keep"));
|
||||
if !f.blocks.contains(&(bx, y, bz)) {
|
||||
f.blocks.push((bx, y, bz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The burnt out: their fire blocks go too.
|
||||
let mut keep = Vec::with_capacity(fires.len());
|
||||
for f in fires {
|
||||
if now.saturating_sub(f.start) < f.life as u64 {
|
||||
keep.push(f);
|
||||
} else {
|
||||
out.commands.extend(f.take_blocks());
|
||||
}
|
||||
}
|
||||
// Fires lit this very tick were pushed to the list meanwhile (none are: ignite runs inside the step).
|
||||
keep.append(&mut self.fires.list);
|
||||
self.fires.list = keep;
|
||||
}
|
||||
}
|
||||
|
||||
/// A fire for a replay: the event's power at age `age` ticks, as a glowing flame model's scale
|
||||
/// (zero when it is out or not started) and a smoke strength.
|
||||
pub fn replay_fire(power: u32, age: f64) -> (f32, f32) {
|
||||
replay_fire_of(size_for(power), life_for(power), age)
|
||||
}
|
||||
|
||||
/// The same for a fire of a given `size` and `life` (a merged fire has grown): out (zero) before
|
||||
/// it starts and from the end of its life on, as the live fire is removed then.
|
||||
pub fn replay_fire_of(size: f32, life: u32, age: f64) -> (f32, f32) {
|
||||
if !(0.0..life as f64).contains(&age) {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let (fl, sm) = level(age, life as f64);
|
||||
(fl * size, sm)
|
||||
}
|
||||
@@ -103,18 +103,23 @@ impl Sim {
|
||||
sam.yaw = slew(sam.yaw, want_yaw, 0.12);
|
||||
sam.pitch = slew(sam.pitch, want_pitch.clamp(0.05, 1.5), 0.08);
|
||||
sam.target = target.map(|t| t.0);
|
||||
if let Some((t, _)) = target {
|
||||
self.lit.push(t);
|
||||
}
|
||||
// The beam: segments widening with distance, out to the target.
|
||||
let (head, rot) = self.sams[i].mount();
|
||||
let dir = rot * DVec3::NEG_Z;
|
||||
// The target is lit once the beam is on it, not while it still swings round.
|
||||
if let Some((t, at)) = target
|
||||
&& dir.angle_between(at - head) < 0.08
|
||||
{
|
||||
self.lit.push(t);
|
||||
}
|
||||
let mut along = 1.0;
|
||||
for k in 0..10u32 {
|
||||
// Up to 14 segments (the unit's part ids 2..15), each longer than the last: one
|
||||
// reaches about 550 blocks, past the beam's reach, about 9 blocks wide out there.
|
||||
for k in 0..14u32 {
|
||||
if along >= reach {
|
||||
break;
|
||||
}
|
||||
let size = 0.8 + along * 0.025;
|
||||
let size = 0.8 + along * 0.12;
|
||||
let len = 3.0 * size;
|
||||
out.model(id + 2 + k, "airdef:beam", head + dir * (along + len / 2.0), rot, size, true);
|
||||
along += len;
|
||||
|
||||
@@ -12,9 +12,11 @@ const HANDOVER: f64 = 10.0;
|
||||
const TRUCK_LOAD: u32 = 2;
|
||||
|
||||
impl Sim {
|
||||
/// Whether ammunition is counted: a depot stands (without one, magazines refill as before).
|
||||
/// Whether ammunition is counted: a depot was set up (without one, magazines refill as
|
||||
/// before). A destroyed depot still counts: losing the last one leaves the systems short,
|
||||
/// not supplied from nowhere again.
|
||||
pub fn logistics(&self) -> bool {
|
||||
self.sams.iter().any(|s| s.kind == SamKind::Depot && s.alive())
|
||||
self.sams.iter().any(|s| s.kind == SamKind::Depot)
|
||||
}
|
||||
|
||||
/// Reloads from depots near the systems, and the trucks' rounds.
|
||||
@@ -41,10 +43,14 @@ impl Sim {
|
||||
self.reload_now(i, out);
|
||||
}
|
||||
}
|
||||
// A destroyed truck drops its job, for another to take.
|
||||
for s in self.sams.iter_mut().filter(|s| s.kind == SamKind::Tzm && !s.alive()) {
|
||||
s.job = None;
|
||||
}
|
||||
// The trucks: a job each, when idle.
|
||||
for t in 0..self.sams.len() {
|
||||
let truck = &self.sams[t];
|
||||
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || now % 10 != t as u64 % 10 {
|
||||
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || truck.drive_speed() <= 0.0 || now % 10 != t as u64 % 10 {
|
||||
continue;
|
||||
}
|
||||
let at = truck.pos;
|
||||
@@ -64,13 +70,17 @@ impl Sim {
|
||||
}
|
||||
self.sams[t].job = None;
|
||||
} else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) {
|
||||
continue; // still on its way (a new order follows when it arrives)
|
||||
// Stopped short of it (it drove off meanwhile): on to where it stands now.
|
||||
let to = self.sams[j].pos;
|
||||
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
|
||||
self.order_move(t, to + side);
|
||||
continue;
|
||||
} else {
|
||||
self.sams[t].job = None;
|
||||
}
|
||||
}
|
||||
// A new job: the nearest waiting system no other truck is driving to, or a depot to load at.
|
||||
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm).filter_map(|s| s.job).collect();
|
||||
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()).filter_map(|s| s.job).collect();
|
||||
let next = if self.sams[t].cargo > 0 {
|
||||
self.sams
|
||||
.iter()
|
||||
@@ -106,7 +116,7 @@ impl Sim {
|
||||
fn reload_now(&mut self, i: usize, out: &mut Out) {
|
||||
let sam = &mut self.sams[i];
|
||||
sam.empty = false;
|
||||
sam.reloading = sam.kind.spec().reload.max(1);
|
||||
sam.reloading = sam.reload_time();
|
||||
out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
+783
-172
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -32,10 +36,10 @@ impl Sam {
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck).
|
||||
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear).
|
||||
pub fn order_move(&mut self, i: usize, to: DVec3) -> bool {
|
||||
let Some(sam) = self.sams.get_mut(i) else { return false };
|
||||
if !sam.alive() || !sam.kind.mobile() {
|
||||
if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
sam.dest = Some(to);
|
||||
@@ -56,6 +60,10 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
let spec = sam.kind.spec();
|
||||
// The march and the packing drive the mount themselves: no speed carries over into or out of them.
|
||||
if matches!(sam.state, SamState::Packing | SamState::Moving | SamState::Deploying) {
|
||||
sam.halt_mount();
|
||||
}
|
||||
match sam.state {
|
||||
SamState::Packing => {
|
||||
// The launcher comes down for the march.
|
||||
@@ -67,6 +75,10 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
SamState::Moving => {
|
||||
// The running gear failed on the way: it stops where it is and deploys there.
|
||||
if sam.drive_speed() <= 0.0 {
|
||||
sam.dest = None;
|
||||
}
|
||||
let Some(to) = sam.dest else {
|
||||
sam.state = SamState::Deploying;
|
||||
sam.march = sam.kind.march_ticks();
|
||||
@@ -85,7 +97,7 @@ impl Sim {
|
||||
sam.heading = slew(sam.heading, heading_of(flat), 0.06);
|
||||
let ahead = DQuat::from_rotation_y(sam.heading) * DVec3::NEG_Z;
|
||||
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
|
||||
let step = SPEED * (1.0 - turning / std::f64::consts::PI).max(0.2);
|
||||
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
|
||||
let next = sam.pos + ahead * step;
|
||||
let y = ground.at(next, now).unwrap_or(sam.pos.y);
|
||||
sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z);
|
||||
|
||||
+175
-20
@@ -84,6 +84,12 @@ pub(super) struct Plan {
|
||||
approach: Approach,
|
||||
total: u32,
|
||||
centre: DVec3,
|
||||
/// The land under the grid's cells (NaN where the game has none to tell of), the cells still unanswered, and
|
||||
/// the ticks the planner has waited for the answers. A smart raid is routed over the land, not round it.
|
||||
heights: Vec<f32>,
|
||||
unanswered: Vec<u32>,
|
||||
waited: u32,
|
||||
mapped: bool,
|
||||
}
|
||||
|
||||
/// The raid in progress: the counts at its start, for the report.
|
||||
@@ -95,23 +101,24 @@ pub(super) struct WaveStats {
|
||||
sams: usize,
|
||||
objects: f64,
|
||||
size: u32,
|
||||
tally: record::Tally,
|
||||
}
|
||||
|
||||
/// The planner's grid: cells this big, at most this many a side.
|
||||
const CELL: f64 = 16.0;
|
||||
const CELLS: i32 = 140;
|
||||
pub(super) const CELL: f64 = 16.0;
|
||||
pub(super) const CELLS: i32 = 140;
|
||||
/// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one.
|
||||
const WORK_PER_TICK: usize = 40_000;
|
||||
pub(super) const WORK_PER_TICK: usize = 40_000;
|
||||
|
||||
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
|
||||
struct Grid {
|
||||
min: DVec3,
|
||||
cell: f64,
|
||||
size: i32,
|
||||
pub(super) struct Grid {
|
||||
pub(super) min: DVec3,
|
||||
pub(super) cell: f64,
|
||||
pub(super) size: i32,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn index(&self, p: DVec3) -> (i32, i32) {
|
||||
pub(super) fn index(&self, p: DVec3) -> (i32, i32) {
|
||||
let n = self.size;
|
||||
((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1))
|
||||
}
|
||||
@@ -121,7 +128,21 @@ impl Grid {
|
||||
}
|
||||
|
||||
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
|
||||
fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
plan_route_on(grid, prices, None, from, to)
|
||||
}
|
||||
|
||||
/// The land a route crosses: its height under each cell (NaN unknown), the climb a kind holds for long
|
||||
/// (rise a block), and the height above which the land forces a drone up from its low altitude.
|
||||
pub(super) struct Relief<'a> {
|
||||
pub heights: &'a [f32],
|
||||
pub slope: f32,
|
||||
pub floor: f32,
|
||||
}
|
||||
|
||||
/// [`plan_route`] over relief: a climb steeper than the kind holds is dear (a cliff or a ridge it would have to
|
||||
/// pull up over), and high land costs more than a valley, where it can fly low and stay under the radars.
|
||||
pub(super) fn plan_route_on(grid: &Grid, prices: &[f32], relief: Option<&Relief>, from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
let mut opened = 0;
|
||||
let n = grid.size;
|
||||
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
|
||||
@@ -150,7 +171,21 @@ fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3
|
||||
}
|
||||
let len = if di != 0 && dj != 0 { std::f32::consts::SQRT_2 } else { 1.0 };
|
||||
// The target's own cell is where it must go, whatever covers it.
|
||||
let price = if nb == goal { len } else { len * prices[at(nb)] };
|
||||
let mut price = if nb == goal { len } else { len * prices[at(nb)] };
|
||||
if let Some(r) = relief
|
||||
&& nb != goal
|
||||
{
|
||||
let (h0, h1) = (r.heights[at(c)], r.heights[at(nb)]);
|
||||
if h1.is_finite() {
|
||||
price += len * ((h1 - r.floor) / 30.0).clamp(0.0, 4.0);
|
||||
if h0.is_finite() {
|
||||
let slope = (h1 - h0) / (len * grid.cell as f32);
|
||||
if slope > r.slope {
|
||||
price += (slope - r.slope) * 20.0 * len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let ng = g + price;
|
||||
if ng < best[at(nb)] {
|
||||
best[at(nb)] = ng;
|
||||
@@ -195,6 +230,11 @@ impl Sim {
|
||||
/// (each route found in full, a few a tick) and launched when its plan is ready.
|
||||
pub fn order_raid(&mut self, order: &RaidOrder, objects: &[DVec3], out: &mut Out) -> u32 {
|
||||
let Some(¢re) = objects.first() else { return 0 };
|
||||
// One raid is planned at a time: another order waits for this one's drones to be sent.
|
||||
if self.plan.is_some() {
|
||||
out.messages.push("Противник ещё готовит прошлый налёт: подожди пару секунд".into());
|
||||
return 0;
|
||||
}
|
||||
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
|
||||
if total == 0 {
|
||||
return 0;
|
||||
@@ -244,15 +284,53 @@ impl Sim {
|
||||
approach: order.approach,
|
||||
total,
|
||||
centre,
|
||||
heights: Vec::new(),
|
||||
unanswered: Vec::new(),
|
||||
waited: 0,
|
||||
mapped: !smart,
|
||||
});
|
||||
// A raid without routes is ready at once.
|
||||
self.plan_step(out);
|
||||
self.plan_step(None, out);
|
||||
total
|
||||
}
|
||||
|
||||
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
|
||||
pub(super) fn plan_step(&mut self, out: &mut Out) {
|
||||
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
|
||||
let Some(mut plan) = self.plan.take() else { return };
|
||||
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
|
||||
// up to two seconds (what the game has no land for stays unknown and costs nothing).
|
||||
if !plan.mapped {
|
||||
let Some(ground) = ground else {
|
||||
self.plan = Some(plan);
|
||||
return;
|
||||
};
|
||||
let n = plan.grid.size;
|
||||
if plan.heights.is_empty() {
|
||||
plan.heights = vec![f32::NAN; (n * n) as usize];
|
||||
plan.unanswered = (0..(n * n) as u32).collect();
|
||||
}
|
||||
let Plan { grid, heights, unanswered, .. } = &mut plan;
|
||||
unanswered.retain(|&k| {
|
||||
let (i, j) = (k as i32 / n, k as i32 % n);
|
||||
let c = grid.centre((i, j), 0.0);
|
||||
match ground.column(c.x.floor() as i32, c.z.floor() as i32, 1024) {
|
||||
None => true,
|
||||
Some(h) => {
|
||||
if let Some(h) = h {
|
||||
heights[k as usize] = h as f32;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
plan.waited += 1;
|
||||
if plan.unanswered.is_empty() || plan.waited >= 40 {
|
||||
plan.mapped = true;
|
||||
} else {
|
||||
self.plan = Some(plan);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut work = 0usize;
|
||||
while plan.next < plan.items.len() && work < WORK_PER_TICK {
|
||||
let i = plan.next;
|
||||
@@ -262,17 +340,40 @@ impl Sim {
|
||||
};
|
||||
let spec = kind.spec();
|
||||
let mut d = plan.items[i].d.clone();
|
||||
// It starts over the land, not in it: a hill under the starting point lifts it.
|
||||
let mut from = from;
|
||||
if !plan.heights.is_empty() {
|
||||
let (gi, gj) = plan.grid.index(from);
|
||||
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
|
||||
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
|
||||
if g.is_finite() && from.y < floor {
|
||||
from.y = floor;
|
||||
d.pos.y = floor;
|
||||
if spec.ballistic.is_some() {
|
||||
let aimed = aim + d.err;
|
||||
let flat = horizontal(aimed - from);
|
||||
let time = (flat.length() / spec.speed).max(30.0);
|
||||
d.vel = flat / time + DVec3::Y * ((aimed.y - from.y + 0.5 * GRAVITY * time * time) / time);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut path = from.distance(aim);
|
||||
if let Some(key) = key {
|
||||
let route = match plan.routes.get(&key) {
|
||||
Some(r) => r.clone(),
|
||||
None => {
|
||||
if !plan.prices.contains_key(&kind) {
|
||||
let (p, cost) = self.price_grid(&plan.grid, kind);
|
||||
let heights = (!plan.heights.is_empty()).then_some(plan.heights.as_slice());
|
||||
let (p, cost) = self.price_grid_on(&plan.grid, kind, heights);
|
||||
work += cost;
|
||||
plan.prices.insert(kind, p);
|
||||
}
|
||||
let (r, cost) = plan_route(&plan.grid, &plan.prices[&kind], from, aim);
|
||||
let relief = (!plan.heights.is_empty()).then(|| Relief {
|
||||
heights: &plan.heights,
|
||||
slope: (kind.climb_pitch().tan() * 0.5) as f32,
|
||||
floor: (aim.y + spec.clearance) as f32,
|
||||
});
|
||||
let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim);
|
||||
work += cost;
|
||||
plan.routes.insert(key, r.clone());
|
||||
r
|
||||
@@ -298,7 +399,8 @@ impl Sim {
|
||||
// Timing: smart raids arrive at once; others leave at once (a moment apart).
|
||||
let now = self.tick;
|
||||
let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
|
||||
for (d, time, early, gap) in plan.done {
|
||||
for (mut d, time, early, gap) in plan.done {
|
||||
d.smart = plan.smart;
|
||||
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
|
||||
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
|
||||
}
|
||||
@@ -310,6 +412,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));
|
||||
@@ -325,6 +428,10 @@ impl Sim {
|
||||
pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone {
|
||||
let id = self.id();
|
||||
let spec = kind.spec();
|
||||
// Its navigation error (CEP and a rare gross fault, #59/#65): the same for a manual launch and a raid.
|
||||
// The raid's own ±4 spread of aim points is the tactical dispersal and stays on top of it.
|
||||
let err = self.aim_error(kind);
|
||||
let target = target + err;
|
||||
let (vel, flight) = if spec.ballistic.is_some() {
|
||||
let flat = horizontal(target - from);
|
||||
let time = (flat.length() / spec.speed).max(30.0);
|
||||
@@ -348,6 +455,10 @@ impl Sim {
|
||||
low: false,
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
err,
|
||||
hold: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +505,13 @@ impl Sim {
|
||||
|
||||
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
|
||||
/// system sees it there (its radar's reach for it, the land) and can shoot it.
|
||||
fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
self.price_grid_on(grid, kind, None)
|
||||
}
|
||||
|
||||
/// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet,
|
||||
/// so what a radar sees and what a hill hides follow the relief.
|
||||
pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec<f32>, usize) {
|
||||
let mut cost = 0;
|
||||
let spec = kind.spec();
|
||||
let height = spec.clearance.max(6.0);
|
||||
@@ -419,8 +536,16 @@ impl Sim {
|
||||
let sp = s.kind.spec();
|
||||
let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max));
|
||||
let gun = sp.gun.as_ref().map_or(0.0, |g| g.range);
|
||||
let radar = sight::reach(s, kind, height);
|
||||
let far = hi.max(gun).min(radar);
|
||||
// A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range.
|
||||
let own = sight::reach(s, kind, height);
|
||||
let net = self
|
||||
.sams
|
||||
.iter()
|
||||
.filter(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK)
|
||||
.map(|r| sight::reach(r, kind, height))
|
||||
.fold(0.0, f64::max);
|
||||
let (hi, gun) = (hi.min(own.max(net)), gun.min(own));
|
||||
let far = hi.max(gun);
|
||||
let at = sight::antenna(s);
|
||||
let horizon = self.horizons.get(&s.id);
|
||||
// Only the cells round it.
|
||||
@@ -429,7 +554,8 @@ impl Sim {
|
||||
for i in (ci - r).max(0)..=(ci + r).min(n - 1) {
|
||||
for j in (cj - r).max(0)..=(cj + r).min(n - 1) {
|
||||
cost += 1;
|
||||
let p = grid.centre((i, j), s.pos.y + height);
|
||||
let ground = land.map(|l| l[(i * n + j) as usize]).filter(|g| g.is_finite());
|
||||
let p = grid.centre((i, j), ground.map_or(s.pos.y + height, |g| f64::from(g) + height));
|
||||
let d = horizontal(p - s.pos).length();
|
||||
if d > far {
|
||||
continue;
|
||||
@@ -471,7 +597,8 @@ impl Sim {
|
||||
let kills = self.kills - w.kills;
|
||||
let through = self.hits - w.hits;
|
||||
let launched = self.launched - w.launched;
|
||||
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>() - w.fired;
|
||||
// A system taken away during the raid takes its count along: never below none.
|
||||
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>().saturating_sub(w.fired);
|
||||
let lost = w.sams.saturating_sub(self.sams.iter().filter(|s| s.alive()).count());
|
||||
let objects = if self.target_health.is_empty() {
|
||||
String::new()
|
||||
@@ -483,6 +610,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;
|
||||
}
|
||||
}
|
||||
@@ -537,6 +669,29 @@ mod tests {
|
||||
eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits);
|
||||
assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended");
|
||||
}
|
||||
#[test]
|
||||
fn systems_ready_together_send_one_missile_at_a_drone() {
|
||||
let mut sim = Sim::new(3);
|
||||
// Vertical launchers of one kind side by side: the drone comes into their reach in the
|
||||
// same tick, and they fire without turning.
|
||||
for i in 0..6 {
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0));
|
||||
}
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for _ in 0..30 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
|
||||
let id = sim.drones[0].id;
|
||||
for _ in 0..1200 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let on_it = sim.missiles.iter().filter(|m| m.target == id).count();
|
||||
assert!(on_it <= 1, "{on_it} missiles on one Shahed");
|
||||
}
|
||||
assert!(sim.sams.iter().any(|s| s.fired > 0), "nothing was fired");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum EventKind {
|
||||
SamLost { sam: u32, kind: SamKind, index: usize },
|
||||
/// The Oreshnik's bus or a block splitting.
|
||||
Split { drone: u32 },
|
||||
/// A drone's warhead went off against the land or a building short of its aim.
|
||||
Crash { drone: u32, kind: DroneKind, power: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -48,6 +50,56 @@ 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,
|
||||
/// Interceptors lost against the land or a building on their way.
|
||||
pub blocked: u32,
|
||||
/// Interceptors that blew up in the air short of land they could not climb over.
|
||||
pub scrubbed: 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),
|
||||
blocked: self.blocked.saturating_sub(start.blocked),
|
||||
scrubbed: self.scrubbed.saturating_sub(start.scrubbed),
|
||||
}
|
||||
}
|
||||
/// 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, "ожиданий БК"),
|
||||
(self.blocked, "ракет разбились о препятствие"),
|
||||
(self.scrubbed, "ракет самоуничтожились у рельефа"),
|
||||
]
|
||||
.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,13 +109,23 @@ 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>,
|
||||
/// The world clock's time of day as (recording tick, day ticks), sampled on every
|
||||
/// `TIME_STEP`-th tick the clock was known: replays show the sky of the raid, not the current
|
||||
/// one. A tick the clock was unknown has no sample (a gap), so a late start or a lost clock
|
||||
/// never shifts later samples; empty when the clock was never known.
|
||||
times: Vec<(u32, u64)>,
|
||||
/// Ticks the sky has been clear.
|
||||
quiet: u32,
|
||||
}
|
||||
|
||||
/// Ticks between the recording's clock samples.
|
||||
const TIME_STEP: u32 = 20;
|
||||
|
||||
impl Track {
|
||||
/// Where it was at `t` (the recording's ticks, fractional), if it was.
|
||||
pub fn at(&self, origin: DVec3, t: f64) -> Option<DVec3> {
|
||||
@@ -106,6 +168,86 @@ impl Track {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fire as a replay shows it: what the events lit and kept alight, merged as the live fires are.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ReplayFire {
|
||||
pub at: DVec3,
|
||||
/// The recording's tick it was lit.
|
||||
pub start: u32,
|
||||
pub life: u32,
|
||||
pub size: f32,
|
||||
/// The event that lit it (the models' ids).
|
||||
pub key: usize,
|
||||
}
|
||||
|
||||
/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness).
|
||||
/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay.
|
||||
pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
|
||||
let w = kind.spec().warhead.min(8) as f32;
|
||||
(3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w)
|
||||
}
|
||||
|
||||
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
|
||||
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
|
||||
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
|
||||
const LIFE: [u32; 5] = [11, 11, 18, 22, 26];
|
||||
match kind {
|
||||
EventKind::Kill { kind, .. } => {
|
||||
let (size, life, _) = kill_look(kind);
|
||||
(size, life)
|
||||
}
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
|
||||
let p = (power as usize).min(4);
|
||||
(SIZE[p], LIFE[p] * 9)
|
||||
}
|
||||
EventKind::SamLost { .. } => (8.0, 18 * 9),
|
||||
EventKind::Split { .. } => (2.0, 7 * 9),
|
||||
}
|
||||
}
|
||||
|
||||
/// The blasts of the recording's events burning at `t` (its ticks, fractional): the same
|
||||
/// `Blast`s the live simulation keeps, with `start` in the recording's ticks and the event's
|
||||
/// own id range. A pure function of `t`, so a seek or a pause shows the same frame.
|
||||
pub fn replay_blasts(rec: &Recording, t: f64) -> Vec<Blast> {
|
||||
rec.events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, e)| {
|
||||
let (size, life) = blast_spec(e.kind);
|
||||
let age = t - e.at as f64;
|
||||
(0.0..life as f64).contains(&age).then_some(Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The fires burning at `t` in the recording: events within 6 blocks of a fire still burning
|
||||
/// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light
|
||||
/// a new one. A pure function of `t`.
|
||||
pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
|
||||
let mut fires: Vec<ReplayFire> = Vec::new();
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
if e.at as f64 > t {
|
||||
break;
|
||||
}
|
||||
let power = match e.kind {
|
||||
EventKind::Crash { power, .. } => power,
|
||||
EventKind::Hit { power, .. } => power.max(1),
|
||||
EventKind::SamLost { .. } => 2,
|
||||
_ => continue,
|
||||
};
|
||||
let (size, life) = (fires::size_for(power), fires::life_for(power));
|
||||
let alive = |f: &ReplayFire| e.at < f.start + f.life;
|
||||
if let Some(f) = fires.iter_mut().find(|f| alive(f) && f.at.distance(e.pos) < 6.0) {
|
||||
f.size = f.size.max(size);
|
||||
f.life = f.life.max(e.at - f.start + life);
|
||||
} else {
|
||||
fires.push(ReplayFire { at: e.pos, start: e.at, life, size, key: k });
|
||||
}
|
||||
}
|
||||
fires.retain(|f| t < (f.start + f.life) as f64);
|
||||
fires
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
fn new(number: u32, sim: &Sim) -> Self {
|
||||
let origin = sim.targets.first().copied().or_else(|| sim.sams.first().map(|s| s.pos)).unwrap_or(DVec3::ZERO);
|
||||
@@ -116,8 +258,10 @@ 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(),
|
||||
times: Vec::new(),
|
||||
quiet: 0,
|
||||
}
|
||||
}
|
||||
@@ -133,6 +277,23 @@ impl Recording {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The time of day (ticks, 24000 a day) the world showed at the recording's tick `t`: the
|
||||
/// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None
|
||||
/// when the clock was not known (the replay then shows the world's own time).
|
||||
pub fn day_time(&self, t: f64) -> Option<u64> {
|
||||
let t = t.max(0.0);
|
||||
// The last sample at or before `t`; none before the first one means unknown.
|
||||
let i = self.times.partition_point(|s| s.0 as f64 <= t).checked_sub(1)?;
|
||||
let (ta, a) = self.times[i];
|
||||
match self.times.get(i + 1) {
|
||||
// Neighbouring samples with no gap between: run on at the pace between them.
|
||||
Some(&(tb, b)) if tb - ta <= TIME_STEP && b >= a && b - a <= 2 * TIME_STEP as u64 => {
|
||||
let f = ((t - ta as f64) / (tb - ta) as f64).clamp(0.0, 1.0);
|
||||
Some(a + ((b - a) as f64 * f) as u64)
|
||||
}
|
||||
_ => Some(a),
|
||||
}
|
||||
}
|
||||
pub fn track(&self, id: u32) -> Option<&Track> {
|
||||
self.index.get(&id).map(|&i| &self.tracks[i])
|
||||
}
|
||||
@@ -143,8 +304,32 @@ impl Recording {
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// The recording of raid `number`, while it is kept.
|
||||
pub fn replay(&self, number: u32) -> Option<&Recording> {
|
||||
self.replays.iter().find(|r| r.number == number)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
match kind {
|
||||
EventKind::Crash { power, .. } => self.ignite(pos, power, None),
|
||||
EventKind::Hit { power, sam, .. } => {
|
||||
let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id);
|
||||
self.ignite(pos, power.max(1), host)
|
||||
}
|
||||
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(r) = &mut self.rec {
|
||||
let at = r.len;
|
||||
if let EventKind::Kill { drone, .. } = kind
|
||||
@@ -170,6 +355,11 @@ impl Sim {
|
||||
self.rec = Some(Recording::new(self.recordings, self));
|
||||
}
|
||||
let Some(r) = &mut self.rec else { return };
|
||||
if r.len % TIME_STEP == 0
|
||||
&& let Some(dt) = self.day_time
|
||||
{
|
||||
r.times.push((r.len, dt));
|
||||
}
|
||||
if r.len % STEP == 0 {
|
||||
for d in &self.drones {
|
||||
r.point(d.id, What::Drone(d.kind), d.pos);
|
||||
@@ -244,18 +434,32 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Blasts: a fireball growing and fading for 1.5 s after each.
|
||||
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
|
||||
// out when their life ends.
|
||||
for f in replay_fires(rec, t) {
|
||||
let age = t - f.start as f64;
|
||||
let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age);
|
||||
let id = base + 0x00c0_0000 + f.key as u32 * PARTS;
|
||||
if flame > 0.05 {
|
||||
let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin();
|
||||
out.model(id, "airdef:flame", f.at + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
|
||||
}
|
||||
if smoke > 0.1 && (age as u64) % 3 == 0 {
|
||||
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
|
||||
}
|
||||
}
|
||||
// Blasts: with the shader effects on, the post pass and the HD lights draw them from
|
||||
// `replay_blasts` (the same as the live ones); the models are the fallback without them.
|
||||
if self.shader {
|
||||
return;
|
||||
}
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
let age = t - e.at as f64;
|
||||
if !(0.0..30.0).contains(&age) {
|
||||
continue;
|
||||
}
|
||||
let size = match e.kind {
|
||||
EventKind::Kill { .. } => 3.5,
|
||||
EventKind::Hit { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
|
||||
EventKind::SamLost { .. } => 8.0,
|
||||
EventKind::Split { .. } => 2.0,
|
||||
};
|
||||
let (size, _) = blast_spec(e.kind);
|
||||
let size = size as f64;
|
||||
let f = age / 30.0;
|
||||
let s = size * (f * 4.0).min(1.0) * (1.0 - f * f);
|
||||
let id = base + 0x0080_0000 + k as u32 * PARTS;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
//! Radar scan cycles with ageing tracks (#46) and damage to a system's parts (#50).
|
||||
use super::sight::{swept, FORECAST_AGE};
|
||||
use super::*;
|
||||
|
||||
fn flat() -> (Ground, Out) {
|
||||
(Ground::default(), Out::default())
|
||||
}
|
||||
|
||||
fn radar_at(sim: &mut Sim, kind: SamKind, x: f64) -> usize {
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0));
|
||||
sim.sams.len() - 1
|
||||
}
|
||||
|
||||
/// A Shahed flying east along z = 40, from x = `from`, high enough to be above any horizon of the systems.
|
||||
fn drone_from(sim: &mut Sim, from: f64) -> u32 {
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(from, 95.0, 40.0), DVec3::X, DVec3::new(5000.0, 95.0, 40.0));
|
||||
sim.drones.last().unwrap().id
|
||||
}
|
||||
|
||||
// #46: the radar measures a target when the beam passes it; between passes its track stays where it was measured.
|
||||
#[test]
|
||||
fn a_radar_measures_once_a_turn_and_the_track_stays_put_between() {
|
||||
let mut sim = Sim::new(5);
|
||||
let (mut ground, mut out) = flat();
|
||||
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
|
||||
let id = drone_from(&mut sim, -150.0);
|
||||
let (mut seen_at, mut last) = (Vec::new(), None::<(u64, DVec3)>);
|
||||
let mut stale_gap = 0.0f64;
|
||||
for _ in 0..700 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let Some(t) = sim.sams[r].track(id).copied() else { continue };
|
||||
match last {
|
||||
Some((tick, pos)) if tick == t.tick => {
|
||||
assert_eq!(pos, t.pos, "the track moved without a new measurement");
|
||||
let d = sim.drones.iter().find(|d| d.id == id).unwrap();
|
||||
stale_gap = stale_gap.max(d.pos.distance(t.pos));
|
||||
if t.age(sim.tick) > FORECAST_AGE {
|
||||
assert!(t.forecast(sim.tick));
|
||||
}
|
||||
}
|
||||
_ => seen_at.push(t.tick),
|
||||
}
|
||||
last = Some((t.tick, t.pos));
|
||||
}
|
||||
assert!(seen_at.len() >= 2, "measured {} times", seen_at.len());
|
||||
// A turn of the early-warning radar is 140 ticks (less relative to a target that moves round it): never every tick.
|
||||
assert!(seen_at.windows(2).all(|w| w[1] - w[0] >= 60), "{seen_at:?}");
|
||||
assert!(stale_gap > 20.0, "the track never fell behind the target ({stale_gap})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_beam_crossing_north_between_ticks_still_passes_a_target_there() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
assert!(swept(0.01, tau - 0.05, 0.1));
|
||||
assert!(swept(tau - 0.02, tau - 0.05, 0.1));
|
||||
assert!(!swept(0.2, tau - 0.05, 0.1));
|
||||
assert!(!swept(tau - 0.1, tau - 0.05, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_radars_have_their_own_phase_and_the_network_keeps_the_age() {
|
||||
let mut sim = Sim::new(7);
|
||||
let (mut ground, mut out) = flat();
|
||||
let a = radar_at(&mut sim, SamKind::Radar, 0.0);
|
||||
let b = radar_at(&mut sim, SamKind::Radar, 500.0);
|
||||
let n = radar_at(&mut sim, SamKind::P1sun, 250.0);
|
||||
assert_ne!(sim.sams[a].beam, sim.sams[b].beam, "radars start at different phases");
|
||||
let id = drone_from(&mut sim, -50.0);
|
||||
let mut relayed = 0;
|
||||
for _ in 0..800 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let own = |s: usize| sim.sams[s].track(id).map_or(0, |t| t.own_tick);
|
||||
let Some(t) = sim.sams[n].track(id) else { continue };
|
||||
relayed += 1;
|
||||
assert_eq!(t.own_tick, 0, "a relayed measurement is not the receiver's own");
|
||||
assert!(t.tick <= own(a).max(own(b)), "the network made a track fresher than any measurement");
|
||||
if own(a).max(own(b)) > 0 {
|
||||
assert_eq!(t.tick, own(a).max(own(b)), "the network holds the freshest measurement");
|
||||
}
|
||||
}
|
||||
assert!(relayed > 100, "the network never passed a track ({relayed})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lock_measures_every_tick_and_a_dead_radar_stops_the_feed() {
|
||||
let mut sim = Sim::new(3);
|
||||
let (mut ground, mut out) = flat();
|
||||
let p = radar_at(&mut sim, SamKind::Pantsir, 0.0);
|
||||
let net = radar_at(&mut sim, SamKind::P1sun, 100.0);
|
||||
let id = drone_from(&mut sim, -190.0);
|
||||
let mut locked = 0;
|
||||
for _ in 0..600 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if sim.sams[p].target == Some(id)
|
||||
&& let Some(t) = sim.sams[p].track(id)
|
||||
&& t.age(sim.tick) <= 1
|
||||
&& t.locked
|
||||
{
|
||||
locked += 1;
|
||||
}
|
||||
}
|
||||
assert!(locked > 5, "no fire-control lock ({locked})");
|
||||
// The radar is destroyed: the network's track is no longer renewed.
|
||||
let newest = sim.sams[net].tracks.iter().map(|t| t.tick).max();
|
||||
sim.sams[p].health = 0.0;
|
||||
let drones = sim.drones.iter().filter(|d| d.alive()).count();
|
||||
for _ in 0..40 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.sams[p].tracks.is_empty() && sim.sams[p].seen.is_empty());
|
||||
if drones > 0 {
|
||||
assert!(sim.sams[net].tracks.iter().map(|t| t.tick).max() <= newest, "a dead radar still renewed a track");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_radar_gives_no_own_tracks_but_the_network_still_feeds_it() {
|
||||
let mut sim = Sim::new(9);
|
||||
let (mut ground, mut out) = flat();
|
||||
let p = radar_at(&mut sim, SamKind::Patriot, 0.0);
|
||||
let early = radar_at(&mut sim, SamKind::Radar, 120.0);
|
||||
let id = drone_from(&mut sim, -150.0);
|
||||
sim.sams[p].comps.0[Part::Sensor as usize] = 0.0;
|
||||
for _ in 0..500 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(!sim.sams[p].radar_up());
|
||||
assert!(sim.sams[p].own.is_empty(), "a failed radar measured something");
|
||||
assert!(sim.sams[p].track(id).is_some(), "the network should still feed the system");
|
||||
// Without its link it is blind.
|
||||
sim.sams[p].comps.0[Part::Comms as usize] = 0.0;
|
||||
sim.sams[p].tracks.clear();
|
||||
for _ in 0..200 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.sams[p].tracks.is_empty(), "no link, no network tracks");
|
||||
assert!(sim.sams[early].own.len() >= sim.sams[p].own.len());
|
||||
}
|
||||
|
||||
// #50: a blast hurts the parts nearest to it.
|
||||
#[test]
|
||||
fn a_blast_from_behind_hurts_the_link_a_blast_from_the_front_the_drive() {
|
||||
let at = DVec3::new(0.0, 70.0, 0.0);
|
||||
let hurt = |dz: f64| {
|
||||
let mut sim = Sim::new(4);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, at, 0.0));
|
||||
let mut out = Out::default();
|
||||
sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &mut out);
|
||||
assert!(sim.sams[0].alive());
|
||||
let c = sim.sams[0].comps;
|
||||
(1.0 - c.get(Part::Comms), 1.0 - c.get(Part::Mount), 1.0 - c.get(Part::Sensor))
|
||||
};
|
||||
let (back_comms, back_mount, _) = hurt(6.0);
|
||||
let (front_comms, front_mount, _) = hurt(-6.0);
|
||||
assert!(back_comms > back_mount, "behind: link {back_comms} drive {back_mount}");
|
||||
assert!(front_mount > front_comms, "front: link {front_comms} drive {front_mount}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_blast_can_leave_a_firing_but_immobile_system() {
|
||||
// Over many blasts at moderate range some leave the system able to fire and unable to drive.
|
||||
let mut found = false;
|
||||
for seed in 0..60 {
|
||||
let mut sim = Sim::new(seed);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Pantsir, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut out = Out::default();
|
||||
sim.tick = seed;
|
||||
sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &mut out);
|
||||
let s = &sim.sams[0];
|
||||
if s.alive() && s.part(Part::Tracks) <= 0.0 && s.part(Part::Launcher) > 0.0 && s.part(Part::Gun) > 0.0 {
|
||||
assert!(!sim.order_move(0, DVec3::new(300.0, 70.0, 0.0)), "failed running gear must refuse a march");
|
||||
assert!(why::missile_blocks(&sim.sams[0]) & why::LAUNCHER_DOWN == 0);
|
||||
assert!(sim.why(0).iter().any(|c| c.text.contains("ходовая отказала")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found, "no seed left it firing but immobile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_parts_block_what_they_serve_and_repair_restores_them() {
|
||||
let mut sim = Sim::new(2);
|
||||
let g = radar_at(&mut sim, SamKind::Gepard, 0.0);
|
||||
let p = radar_at(&mut sim, SamKind::Patriot, 400.0);
|
||||
sim.sams[g].comps.0[Part::Gun as usize] = 0.0;
|
||||
assert!(!sim.sams[g].gun_up());
|
||||
assert!(why::gun_blocks(&sim.sams[g], 1, 1, 0.0, 10.0, 90.0, 0) & why::JAMMED != 0);
|
||||
sim.sams[p].comps.0[Part::Launcher as usize] = 0.0;
|
||||
assert!(why::missile_blocks(&sim.sams[p]) & why::LAUNCHER_DOWN != 0);
|
||||
// A worn launcher: fewer cells, a longer reload.
|
||||
sim.sams[p].comps.0[Part::Launcher as usize] = 0.3;
|
||||
sim.sams[p].fit_cells();
|
||||
let full = Sam::new(0, SamKind::Patriot, DVec3::ZERO, 0.0);
|
||||
assert!(sim.sams[p].cells() < full.cells());
|
||||
assert!(sim.sams[p].missiles <= sim.sams[p].cells());
|
||||
assert!(sim.sams[p].reload_time() > full.reload_time());
|
||||
// A worn mount turns slower, a failed one not at all.
|
||||
sim.sams[p].comps.0[Part::Mount as usize] = 0.0;
|
||||
assert_eq!(sim.sams[p].drive_fit(), 0.0);
|
||||
sim.sams[p].comps.0[Part::Tracks as usize] = 0.0;
|
||||
assert_eq!(sim.repair(), 2, "both damaged systems are counted");
|
||||
assert!(sim.sams.iter().all(|s| s.comps.whole() && s.cells() == s.kind.spec().missile.as_ref().map_or(0, |m| m.magazine)));
|
||||
assert!(sim.later.is_empty(), "repair must not leave cook-offs behind");
|
||||
assert!(sim.order_move(p, DVec3::new(500.0, 70.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_seed_rolls_the_same_scan_and_damage() {
|
||||
let run = || {
|
||||
let mut sim = Sim::new(11);
|
||||
let (mut ground, mut out) = flat();
|
||||
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
|
||||
let id = drone_from(&mut sim, -150.0);
|
||||
let mut ticks = Vec::new();
|
||||
for _ in 0..500 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if let Some(t) = sim.sams[r].track(id) {
|
||||
ticks.push(t.tick);
|
||||
}
|
||||
}
|
||||
ticks
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
+250
-23
@@ -3,6 +3,17 @@
|
||||
//! (over the curve of the earth, scaled to the game's distances), and the land
|
||||
//! hides what flies behind hills and woods: each system keeps its horizon, the
|
||||
//! highest the land rises seen from its antenna, ray by ray round it.
|
||||
//!
|
||||
//! A radar does not know where everything is all the time. Its beam sweeps round
|
||||
//! (a period by the kind of radar; an electronic one also revisits what it holds
|
||||
//! more often), a target is measured when the beam passes it, with a chance that
|
||||
//! falls with range and size, and the measurement is kept as a *track*: where it
|
||||
//! was and how fast it went then, and when. Between measurements the track is
|
||||
//! carried on by that velocity (a forecast, shown as one), and after three missed
|
||||
//! scans it is dropped. A fire-control lock on a target the system is engaging is
|
||||
//! a dedicated channel: it measures every tick while the target is in reach and in
|
||||
//! sight. The network passes tracks with the age of their measurement; relaying
|
||||
//! makes none of them fresher.
|
||||
use super::*;
|
||||
|
||||
/// Rays round a radar, and blocks between the land's samples along them.
|
||||
@@ -10,6 +21,13 @@ const RAYS: usize = 48;
|
||||
const STEP: f64 = 16.0;
|
||||
/// How far units share their tracks (the network's data links).
|
||||
pub(super) const LINK: f64 = 1500.0;
|
||||
/// A launcher fires on a track no older than this (ticks); a gun needs its own radar's, newer than `GUN_AGE`.
|
||||
pub(super) const MISSILE_AGE: u64 = 120;
|
||||
pub(super) const GUN_AGE: u64 = 3;
|
||||
/// A gun takes a target its radar measured this recently (then the lock refreshes it every tick).
|
||||
pub(super) const GUN_PICK_AGE: u64 = 40;
|
||||
/// A track older than this is shown as a forecast.
|
||||
pub const FORECAST_AGE: u64 = 20;
|
||||
/// Radio horizon: `HORIZON * (√mast + √height)` blocks.
|
||||
const HORIZON: f64 = 35.0;
|
||||
/// Anything this near is seen (and heard), whatever hides it.
|
||||
@@ -17,7 +35,7 @@ const NEAR: f64 = 24.0;
|
||||
|
||||
pub(super) struct Horizon {
|
||||
/// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation).
|
||||
rays: Vec<Vec<f32>>,
|
||||
pub(super) rays: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Horizon {
|
||||
@@ -70,13 +88,144 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
|
||||
if spec.sensor <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let radar = spec.sensor * sam.fitness() * kind.rcs().powf(0.25);
|
||||
let radar = spec.sensor * sam.radar_fit() * kind.rcs().powf(0.25);
|
||||
let horizon = HORIZON * (spec.mast.sqrt() + height.max(0.0).sqrt());
|
||||
radar.min(horizon).max(NEAR)
|
||||
}
|
||||
|
||||
/// How a kind's radar looks round.
|
||||
pub(super) struct Scan {
|
||||
/// Ticks for the beam to go round.
|
||||
pub period: u32,
|
||||
/// An electronic radar: what it holds it revisits at once a third of the period, between the beam's passes.
|
||||
pub agile: bool,
|
||||
}
|
||||
|
||||
impl SamKind {
|
||||
pub(super) fn scan(self) -> Scan {
|
||||
let (period, agile) = match self {
|
||||
SamKind::Patriot => (30, true),
|
||||
SamKind::S400 => (36, true),
|
||||
SamKind::S500 => (24, true),
|
||||
SamKind::IrisT | SamKind::Nasams => (40, true),
|
||||
SamKind::Buk => (50, false),
|
||||
SamKind::Tor | SamKind::Pantsir => (40, false),
|
||||
SamKind::Gepard => (50, false),
|
||||
// The early-warning radar: a big antenna turning slowly.
|
||||
SamKind::Radar => (140, false),
|
||||
_ => (40, false),
|
||||
};
|
||||
Scan { period, agile }
|
||||
}
|
||||
}
|
||||
|
||||
/// How well a track says what it is.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Ident {
|
||||
/// One measurement: something is there.
|
||||
Unknown,
|
||||
/// Measured again: a drone or missile by how it flies.
|
||||
Probable,
|
||||
/// Held by a lock or by several measurements.
|
||||
Confirmed,
|
||||
}
|
||||
|
||||
impl Ident {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Ident::Unknown => "неопознана",
|
||||
Ident::Probable => "предположительно воздушная цель",
|
||||
Ident::Confirmed => "подтверждена",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a system knows of one target: the last measurement, not the target itself.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Track {
|
||||
pub id: u32,
|
||||
/// Where it was and how it went when last measured (blocks, blocks a tick).
|
||||
pub pos: DVec3,
|
||||
pub vel: DVec3,
|
||||
/// When that was, and when this system's own radar last measured it (0: never, the network's only).
|
||||
pub tick: u64,
|
||||
pub own_tick: u64,
|
||||
/// Ticks the track lives without a new measurement.
|
||||
pub ttl: u32,
|
||||
pub hits: u8,
|
||||
/// The last own measurement came from a fire-control lock.
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn age(&self, now: u64) -> u64 {
|
||||
now.saturating_sub(self.tick)
|
||||
}
|
||||
/// Where the target should be by now, carrying the last measurement on: the forecast, not a measurement.
|
||||
pub fn est(&self, now: u64) -> DVec3 {
|
||||
self.pos + self.vel * self.age(now) as f64
|
||||
}
|
||||
pub fn forecast(&self, now: u64) -> bool {
|
||||
self.age(now) > FORECAST_AGE
|
||||
}
|
||||
/// Own radar measured it within `age` ticks.
|
||||
pub fn own_within(&self, now: u64, age: u64) -> bool {
|
||||
self.own_tick > 0 && now.saturating_sub(self.own_tick) <= age
|
||||
}
|
||||
pub fn ident(&self) -> Ident {
|
||||
match self.hits {
|
||||
0 | 1 => Ident::Unknown,
|
||||
2 => Ident::Probable,
|
||||
_ => Ident::Confirmed,
|
||||
}
|
||||
}
|
||||
/// How far the forecast may be off, blocks: grows with the age.
|
||||
pub fn uncertainty(&self, now: u64) -> f64 {
|
||||
1.0 + self.age(now) as f64 * (0.05 + self.vel.length() * 0.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// Ticks for its beam to go round (longer when the radar is damaged).
|
||||
pub(super) fn scan_ticks(&self) -> f64 {
|
||||
self.kind.scan().period as f64 / self.radar_fit().max(0.2)
|
||||
}
|
||||
/// Its own radar works: it has one, it is not failed and the unit is deployed.
|
||||
pub fn radar_up(&self) -> bool {
|
||||
self.alive() && !self.moving() && self.kind.spec().sensor > 0.0 && self.part(Part::Sensor) > 0.0
|
||||
}
|
||||
pub fn track(&self, id: u32) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a beam that moved from `from` by `w` radians this tick passed `bearing` (all angles taken round 2π,
|
||||
/// so a beam crossing north between two ticks still passes a target there).
|
||||
pub(super) fn swept(bearing: f64, from: f64, w: f64) -> bool {
|
||||
(bearing - from).rem_euclid(TAU) < w
|
||||
}
|
||||
|
||||
/// A new measurement into a list of tracks (the index maps ids to places): a fresher one replaces an older.
|
||||
fn merge(tracks: &mut Vec<Track>, index: &mut HashMap<u32, usize>, t: Track) {
|
||||
match index.get(&t.id) {
|
||||
Some(&k) => {
|
||||
let mine = &mut tracks[k];
|
||||
if t.tick > mine.tick {
|
||||
let (own_tick, locked) = (mine.own_tick, if t.own_tick > 0 { t.locked } else { mine.locked });
|
||||
let hits = mine.hits.max(t.hits);
|
||||
*mine = Track { own_tick: if t.own_tick > 0 { t.own_tick } else { own_tick }, hits, locked, ..t };
|
||||
}
|
||||
}
|
||||
None => {
|
||||
index.insert(t.id, tracks.len());
|
||||
tracks.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Rebuilds the systems' horizons in turn, and what each radar holds now.
|
||||
/// Rebuilds the systems' horizons in turn, then lets each radar's beam pass the sky: new measurements
|
||||
/// into the tracks, old tracks out, and the network's tracks shared.
|
||||
pub(super) fn update_sight(&mut self, ground: &mut Ground) {
|
||||
let now = self.tick;
|
||||
// One horizon every few ticks: the land answers come in over time.
|
||||
@@ -90,48 +239,115 @@ impl Sim {
|
||||
self.horizons.insert(sam.id, h);
|
||||
}
|
||||
}
|
||||
self.horizons.retain(|id, _| self.sams.iter().any(|s| s.id == *id && s.alive()));
|
||||
let alive: std::collections::HashSet<u32> = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect();
|
||||
self.horizons.retain(|id, _| alive.contains(id));
|
||||
// Each drone's height over the land under it (or over the systems' ground, unknown).
|
||||
let heights: Vec<Option<f64>> =
|
||||
self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect();
|
||||
let drone_at: HashMap<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
|
||||
let seed = self.seed;
|
||||
let mut index: HashMap<u32, usize> = HashMap::new();
|
||||
for sam in &mut self.sams {
|
||||
sam.seen.clear();
|
||||
sam.own.clear();
|
||||
if !sam.alive() || sam.kind.spec().sensor <= 0.0 || sam.moving() {
|
||||
// A track of a drone that is gone is dropped (the kill is seen), so is one not renewed for its time.
|
||||
sam.tracks.retain(|t| now - t.tick <= t.ttl as u64 && drone_at.get(&t.id).is_some_and(|&k| self.drones[k].alive()));
|
||||
if !sam.alive() || sam.moving() {
|
||||
sam.tracks.clear();
|
||||
continue;
|
||||
}
|
||||
if !sam.radar_up() {
|
||||
continue;
|
||||
}
|
||||
index.clear();
|
||||
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
|
||||
let spec = sam.kind.spec();
|
||||
let scan = sam.kind.scan();
|
||||
let period = sam.scan_ticks();
|
||||
let w = TAU / period;
|
||||
let (b0, ttl) = (sam.beam, ((period * 3.0) as u32).max(60));
|
||||
sam.beam = (b0 + w) % TAU;
|
||||
let at = antenna(sam);
|
||||
let horizon = self.horizons.get(&sam.id);
|
||||
let mut fresh: Vec<Track> = Vec::new();
|
||||
for (d, h) in self.drones.iter().zip(&heights) {
|
||||
if !d.alive() {
|
||||
continue;
|
||||
}
|
||||
let dist = d.pos.distance(at);
|
||||
let rel = d.pos - at;
|
||||
let dist = rel.length();
|
||||
if dist > spec.sensor * 1.2 {
|
||||
continue;
|
||||
}
|
||||
let locked = sam.target == Some(d.id) || sam.gun_target == Some(d.id);
|
||||
let held = index.get(&d.id).map(|&k| sam.tracks[k]);
|
||||
// The beam passes its bearing this tick (the angle wraps at 2π: the difference is taken round).
|
||||
let in_beam = swept(rel.x.atan2(-rel.z), b0, w);
|
||||
let revisit = scan.agile && held.is_some_and(|t| t.own_tick > 0 && now - t.own_tick >= (period / 3.0) as u64);
|
||||
if !(in_beam || locked || revisit) {
|
||||
continue;
|
||||
}
|
||||
let height = h.unwrap_or(d.pos.y - sam.pos.y);
|
||||
if dist > reach(sam, d.kind, height) {
|
||||
let reach = reach(sam, d.kind, height);
|
||||
if dist > reach || (dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos))) {
|
||||
continue;
|
||||
}
|
||||
if dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos)) {
|
||||
let key = (sam.id as u64) << 32 | d.id as u64;
|
||||
// A target in the beam is measured with a chance that falls towards the edge of reach and
|
||||
// with the target's stealth; one the radar already follows is lost only now and then.
|
||||
let p = if locked || revisit {
|
||||
0.985
|
||||
} else {
|
||||
((0.97 - 0.5 * (dist / reach).powi(3)) / d.kind.spec().stealth.sqrt()).clamp(0.05, 0.97)
|
||||
};
|
||||
if dist > NEAR && dice(seed, now, key, 0) >= p {
|
||||
continue;
|
||||
}
|
||||
sam.own.push(d.id);
|
||||
let sigma = if locked { 0.3 } else { 0.8 + dist * 0.004 };
|
||||
let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma;
|
||||
fresh.push(Track {
|
||||
id: d.id,
|
||||
pos: d.pos + noise,
|
||||
vel: d.vel,
|
||||
tick: now,
|
||||
own_tick: now,
|
||||
ttl,
|
||||
hits: held.map_or(1, |t| t.hits.saturating_add(1)),
|
||||
locked,
|
||||
});
|
||||
}
|
||||
for t in fresh {
|
||||
merge(&mut sam.tracks, &mut index, t);
|
||||
}
|
||||
}
|
||||
// The network: every unit gets the tracks of the radars within data-link range.
|
||||
let tracks: Vec<(DVec3, Vec<u32>)> =
|
||||
self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect();
|
||||
// The network: every unit gets the tracks the radars within data-link range measured themselves,
|
||||
// with the time they were measured: a relayed track is never fresher than its measurement.
|
||||
let shared: Vec<(u32, DVec3, f64, Vec<Track>)> = self
|
||||
.sams
|
||||
.iter()
|
||||
.filter(|s| s.alive() && !s.moving() && s.link_range() > 0.0)
|
||||
.map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick == t.tick).copied().collect::<Vec<_>>()))
|
||||
.filter(|s| !s.3.is_empty())
|
||||
.collect();
|
||||
for sam in &mut self.sams {
|
||||
if !sam.alive() || sam.moving() || !sam.kind.armed() {
|
||||
sam.seen = sam.own.clone();
|
||||
continue;
|
||||
}
|
||||
let mut seen = sam.own.clone();
|
||||
for (at, ids) in &tracks {
|
||||
if at.distance(sam.pos) <= LINK {
|
||||
seen.extend(ids.iter().copied().filter(|id| !seen.contains(id)).collect::<Vec<_>>());
|
||||
if sam.alive() && !sam.moving() && sam.kind.armed() && sam.link_range() > 0.0 && !shared.is_empty() {
|
||||
index.clear();
|
||||
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
|
||||
let mine = sam.link_range();
|
||||
for (id, at, range, tracks) in &shared {
|
||||
if *id != sam.id && at.distance(sam.pos) <= range.min(mine) {
|
||||
for t in tracks {
|
||||
merge(&mut sam.tracks, &mut index, Track { own_tick: 0, ..*t });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sam.seen.clear();
|
||||
sam.own.clear();
|
||||
for t in &sam.tracks {
|
||||
sam.seen.push(t.id);
|
||||
if t.own_within(now, t.ttl as u64) {
|
||||
sam.own.push(t.id);
|
||||
}
|
||||
}
|
||||
sam.seen = seen;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,4 +355,15 @@ impl Sim {
|
||||
pub fn tracked(&self, id: u32) -> bool {
|
||||
self.sams.iter().any(|s| s.seen.contains(&id))
|
||||
}
|
||||
|
||||
/// The freshest track of each drone any system holds: the air picture.
|
||||
pub fn picture(&self) -> HashMap<u32, Track> {
|
||||
let mut best: HashMap<u32, Track> = HashMap::new();
|
||||
for t in self.sams.iter().flat_map(|s| s.tracks.iter()) {
|
||||
if best.get(&t.id).is_none_or(|b| t.tick > b.tick) {
|
||||
best.insert(t.id, *t);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight
|
||||
//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and
|
||||
//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and
|
||||
//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a
|
||||
//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill.
|
||||
//!
|
||||
//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and
|
||||
//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a
|
||||
//! probe through the block cache.
|
||||
use super::*;
|
||||
|
||||
/// Land heights are sampled at the centres of cells this wide.
|
||||
const LATTICE: i32 = 8;
|
||||
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
|
||||
const ASK_MAX: usize = 192;
|
||||
|
||||
/// What the game has said of the land at a place.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(super) enum Rise {
|
||||
/// Not yet: asked.
|
||||
Pending,
|
||||
/// There is no land to tell of (no chunk).
|
||||
Void,
|
||||
/// One above the highest solid block.
|
||||
At(f64),
|
||||
}
|
||||
|
||||
impl Ground {
|
||||
/// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't.
|
||||
/// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more.
|
||||
pub fn land(&mut self, p: DVec3) -> Option<f64> {
|
||||
match self.rise(p) {
|
||||
Rise::At(g) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same, saying whether the game has not answered yet or has no land there to tell of.
|
||||
pub(super) fn rise(&mut self, p: DVec3) -> Rise {
|
||||
let key = (
|
||||
(p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
(p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
);
|
||||
match self.known.get(&key) {
|
||||
Some(&(y, when)) => {
|
||||
// Old or reported changed: still used, asked again with the questions to spare.
|
||||
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
Rise::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)`
|
||||
/// where it has no land to tell of. Asks, up to `cap` questions waiting.
|
||||
pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option<Option<f64>> {
|
||||
match self.known.get(&(x, z)) {
|
||||
Some(&(y, when)) => {
|
||||
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
Some((y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < cap && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flight can do about a rise ahead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Climb {
|
||||
/// Radius of its tightest pull-up (blocks).
|
||||
pub radius: f64,
|
||||
/// The steepest climb it holds (radians).
|
||||
pub pitch: f64,
|
||||
}
|
||||
|
||||
impl Climb {
|
||||
/// How much height it can have gained by the time it has gone `s` blocks on, starting level.
|
||||
pub fn gain(&self, s: f64) -> f64 {
|
||||
let arc = self.radius * self.pitch.sin();
|
||||
if s <= arc {
|
||||
self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt()
|
||||
} else {
|
||||
self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DroneKind {
|
||||
/// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest.
|
||||
pub(super) fn climb_pitch(self) -> f64 {
|
||||
match self {
|
||||
DroneKind::Kh101 | DroneKind::Kalibr => 0.45,
|
||||
DroneKind::Lancet => 0.7,
|
||||
DroneKind::Geran3 | DroneKind::Kh22 => 0.6,
|
||||
_ => 0.5,
|
||||
}
|
||||
}
|
||||
pub(super) fn climb(self) -> Climb {
|
||||
let s = self.spec();
|
||||
Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() }
|
||||
}
|
||||
/// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill.
|
||||
pub(super) fn look_len(self) -> f64 {
|
||||
let s = self.spec();
|
||||
(s.speed * 160.0).clamp(160.0, 900.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together
|
||||
/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle
|
||||
/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the
|
||||
/// yaw gets what is left of the rate.
|
||||
pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 {
|
||||
let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin());
|
||||
let want_yaw = heading.x.atan2(heading.z);
|
||||
let mut yaw = angle_diff(old_yaw, want_yaw);
|
||||
let mut pitch = slope.atan() - old_pitch;
|
||||
if climb {
|
||||
pitch = pitch.clamp(-rate, rate);
|
||||
yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt());
|
||||
} else {
|
||||
let total = yaw.hypot(pitch);
|
||||
if total > rate {
|
||||
yaw *= rate / total;
|
||||
pitch *= rate / total;
|
||||
}
|
||||
}
|
||||
let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch);
|
||||
DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
|
||||
}
|
||||
|
||||
/// How far over the land a dive's line passes.
|
||||
pub(super) const DIVE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down.
|
||||
pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
|
||||
let pitch = v.y.clamp(-1.0, 1.0).asin();
|
||||
if (lo..=hi).contains(&pitch) {
|
||||
return v;
|
||||
}
|
||||
let pitch = pitch.clamp(lo, hi);
|
||||
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
|
||||
}
|
||||
|
||||
/// Distances ahead where the land is sampled.
|
||||
const REACH: [f64; 21] = [
|
||||
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
|
||||
720.0,
|
||||
];
|
||||
|
||||
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
|
||||
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
|
||||
/// less the height it can gain on the way. `None` when nothing is known.
|
||||
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> Option<f64> {
|
||||
let mut need: Option<f64> = None;
|
||||
for &(dir, len) in lines {
|
||||
for &s in REACH.iter().chain(&[900.0]) {
|
||||
if s > len {
|
||||
break;
|
||||
}
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let h = g + clear - climb.gain(s);
|
||||
need = Some(need.map_or(h, |n: f64| n.max(h)));
|
||||
}
|
||||
}
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
|
||||
/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known.
|
||||
pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option<f64> {
|
||||
let flat = horizontal(target - pos);
|
||||
let total = flat.length();
|
||||
if total < 20.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let mut need: Option<f64> = None;
|
||||
let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32;
|
||||
for k in 0..=n {
|
||||
let s = (total - 14.0) * f64::from(k) / f64::from(n);
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let f = s / total;
|
||||
let y = (g + margin - target.y * f) / (1.0 - f);
|
||||
need = Some(need.map_or(y, |m: f64| m.max(y)));
|
||||
}
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards
|
||||
/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`.
|
||||
/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known.
|
||||
pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> {
|
||||
let flat = horizontal(to - from);
|
||||
let total = flat.length();
|
||||
if total < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let end = (total - skip).min(reach);
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
let mut s = near;
|
||||
while s <= end {
|
||||
match ground.rise(from + dir * s) {
|
||||
Rise::At(g) => {
|
||||
let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0);
|
||||
if best.is_none_or(|b| slope > b.0) {
|
||||
best = Some((slope, s));
|
||||
}
|
||||
}
|
||||
Rise::Pending => *pending = true,
|
||||
Rise::Void => {}
|
||||
}
|
||||
s += if s < 64.0 { 8.0 } else { 16.0 };
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- interceptors
|
||||
|
||||
impl MissileKind {
|
||||
/// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't.
|
||||
pub(super) fn loft(self) -> f64 {
|
||||
match self {
|
||||
MissileKind::M77n6 => 1.25,
|
||||
MissileKind::M48n6 => 1.1,
|
||||
MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9,
|
||||
MissileKind::M9m317 => 0.8,
|
||||
MissileKind::P1sun => 0.5,
|
||||
MissileKind::M57e6 | MissileKind::M9m338 => 0.3,
|
||||
}
|
||||
}
|
||||
/// Whether it lofts over a ridge to a target behind it.
|
||||
pub(super) fn lofts(self) -> bool {
|
||||
self.loft() >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// How high above the land an interceptor keeps.
|
||||
pub(super) const MISSILE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// What keeps an interceptor from being launched at a target for the land in the way.
|
||||
pub(super) const MASKED: u8 = 1;
|
||||
pub(super) const PATH_BLOCKED: u8 = 2;
|
||||
/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two.
|
||||
pub(super) const WAIT: u8 = 4;
|
||||
/// Ticks a shot waits for the land to be answered before it is judged on what is known.
|
||||
pub(super) const WAIT_TICKS: u64 = 30;
|
||||
|
||||
/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be
|
||||
/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile
|
||||
/// lofts, and the path the missile would fly must be clear.
|
||||
pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 {
|
||||
let spec = kind.spec();
|
||||
// Where it meets the target: the forecast carried on for the flight time.
|
||||
let mut at = est;
|
||||
for _ in 0..2 {
|
||||
let tgo = muzzle.distance(at) / (spec.speed * 0.8);
|
||||
at = est + vel * tgo;
|
||||
}
|
||||
let flat = horizontal(at - antenna);
|
||||
let total = flat.length();
|
||||
if total < 40.0 {
|
||||
return 0;
|
||||
}
|
||||
let dir = flat / total;
|
||||
// The line of sight from the launcher: any rise of the land over the line to the intercept point masks it.
|
||||
let mut masked = false;
|
||||
let mut waiting = false;
|
||||
let mut s = 12.0;
|
||||
while s < total - 12.0 {
|
||||
match ground.rise(antenna + dir * s) {
|
||||
Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => {
|
||||
masked = true;
|
||||
break;
|
||||
}
|
||||
Rise::Pending => waiting = true,
|
||||
_ => {}
|
||||
}
|
||||
s += 16.0;
|
||||
}
|
||||
if !masked && waiting {
|
||||
return WAIT;
|
||||
}
|
||||
if !masked {
|
||||
return 0;
|
||||
}
|
||||
if !kind.lofts() {
|
||||
return MASKED;
|
||||
}
|
||||
// A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target.
|
||||
let radius = spec.speed / spec.turn.max(0.05);
|
||||
let mut pending = false;
|
||||
let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending);
|
||||
match path {
|
||||
Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED,
|
||||
_ if pending => WAIT,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// What the land said of a shot at `target` when it was last judged, if lately.
|
||||
pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option<u8> {
|
||||
self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again.
|
||||
pub(super) const VERDICT_AGE: u64 = 20;
|
||||
|
||||
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
|
||||
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
let flat_len = flat.length();
|
||||
let reach = (speed * 18.0).clamp(60.0, 320.0);
|
||||
let radius = speed / spec.turn.max(0.05);
|
||||
let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) };
|
||||
let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY };
|
||||
if slope <= wanted {
|
||||
return (want, false);
|
||||
}
|
||||
let cap = kind.loft().tan();
|
||||
let dir = flat.normalize_or(DVec3::X);
|
||||
let raised = (dir + DVec3::Y * slope.min(cap)).normalize();
|
||||
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
|
||||
let mut fail = false;
|
||||
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
|
||||
fail = (1..=4).any(|k| {
|
||||
let p = pos + raised * speed * f64::from(k);
|
||||
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
|
||||
});
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
@@ -99,6 +99,19 @@ pub(super) struct GunSpec {
|
||||
pub slew: (f64, f64),
|
||||
}
|
||||
|
||||
/// How fast a weapon's mount turns: per axis the top speed (radians a tick) and the acceleration, which
|
||||
/// is also the braking (radians a tick²). Game settings, not the real machines' figures.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Drive {
|
||||
pub yaw: (f64, f64),
|
||||
pub pitch: (f64, f64),
|
||||
}
|
||||
|
||||
/// The drives are this share of the speeds they had before they had a speed of their own.
|
||||
const DRIVE_SCALE: f64 = 0.4;
|
||||
/// Ticks a drive takes to reach its top speed (and to stop from it).
|
||||
const DRIVE_RAMP: f64 = 8.0;
|
||||
|
||||
pub struct SamSpec {
|
||||
pub name: &'static str,
|
||||
pub key: &'static str,
|
||||
@@ -122,6 +135,20 @@ pub struct SamSpec {
|
||||
pub mast: f64,
|
||||
}
|
||||
|
||||
impl SamSpec {
|
||||
/// The mount's drive: the guns' own slew rates, the old rates of the launchers, scaled down.
|
||||
pub(super) fn drive(&self) -> Drive {
|
||||
let (yaw, pitch) = match (&self.gun, self.family) {
|
||||
(Some(g), _) => g.slew,
|
||||
(None, Family::Trainable) => (0.12, 0.0),
|
||||
(None, Family::Vertical) => (0.15, 0.0),
|
||||
(None, _) => (0.05, 0.05),
|
||||
};
|
||||
let axis = |speed: f64| (speed * DRIVE_SCALE, speed * DRIVE_SCALE / DRIVE_RAMP);
|
||||
Drive { yaw: axis(yaw), pitch: axis(pitch) }
|
||||
}
|
||||
}
|
||||
|
||||
const NO_PARTS: Parts = Parts { body: "", launcher: None, pack: None, dish: None, fixed: &[], spinner: None };
|
||||
/// The battery radar trailer behind a vertical launcher.
|
||||
const BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)];
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! 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;
|
||||
/// Its track is older than a launcher fires on.
|
||||
pub(super) const STALE: u8 = 8;
|
||||
|
||||
pub(super) fn target_blocks(roles: u8, track: Option<&Track>, now: u64, d: &Drone) -> u8 {
|
||||
(if d.alive() { 0 } else { DEAD })
|
||||
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
|
||||
| (if track.is_some() { 0 } else { UNSEEN })
|
||||
| (if track.is_some_and(|t| t.age(now) > sight::MISSILE_AGE) { STALE } else { 0 })
|
||||
}
|
||||
|
||||
/// Alive, of a class the system engages, and on its picture (own radar or the network's), fresh enough.
|
||||
pub(super) fn allowed(roles: u8, track: &Track, now: u64, d: &Drone) -> bool {
|
||||
target_blocks(roles, Some(track), now, 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, at: DVec3) -> u8 {
|
||||
let dist = at.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, at: DVec3) -> bool {
|
||||
reach_blocks(min, max, k, pos, at) == 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: measured by the system's own radar a moment ago and within the gun's tracking range
|
||||
/// (by the track's forecast, not the target's true place).
|
||||
pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_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, at: DVec3, vel: DVec3, lead_speed: f64) -> (DVec3, f64, f64) {
|
||||
let lead = at + vel * (at.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;
|
||||
/// The launcher has failed.
|
||||
pub(super) const LAUNCHER_DOWN: u8 = 8;
|
||||
|
||||
/// What keeps a loaded launcher from firing now (aim aside).
|
||||
pub(super) fn missile_blocks(s: &Sam) -> u8 {
|
||||
(if s.part(Part::Launcher) > 0.0 { 0 } else { LAUNCHER_DOWN })
|
||||
| (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<DVec3>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
|
||||
match family {
|
||||
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t - 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;
|
||||
/// The gun is jammed (for a while, or failed).
|
||||
pub(super) const JAMMED: u8 = 64;
|
||||
/// Its radar has no fresh measurement of the target.
|
||||
pub(super) const NO_LOCK: u8 = 128;
|
||||
|
||||
/// 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, now: u64) -> u8 {
|
||||
(if aim_id == gt { 0 } else { NOT_AIMED })
|
||||
| (if s.gun_up() { 0 } else { JAMMED })
|
||||
| (if s.track(gt).is_some_and(|t| t.own_within(now, sight::GUN_AGE)) { 0 } else { NO_LOCK })
|
||||
| (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 now = self.tick;
|
||||
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}, остальные по сети)")));
|
||||
// The parts: what is worn or failed and what it costs.
|
||||
if !sam.comps.whole() {
|
||||
v.push(check(false, format!("узлы: {}", sam.parts_text())));
|
||||
}
|
||||
for p in Part::ALL {
|
||||
if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok {
|
||||
continue;
|
||||
}
|
||||
let failed = sam.part_state(p) == PartState::Failed;
|
||||
let effect = match (p, failed) {
|
||||
(Part::Sensor, true) => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".to_string(),
|
||||
(Part::Sensor, false) => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", sam.radar_fit() * 100.0),
|
||||
(Part::Mount, true) => format!("привод наведения заклинил: стоит на {:.0}°, не доворачивается", sam.yaw.to_degrees().rem_euclid(360.0)),
|
||||
(Part::Mount, false) => format!("привод наведения повреждён: поворот {:.0}% от нормы", sam.drive_fit() * 100.0),
|
||||
(Part::Launcher, true) => "пусковая отказала: пуски невозможны".to_string(),
|
||||
(Part::Launcher, false) => format!("пусковая повреждена: {} ячеек из {}, перезарядка {:.0} с", sam.cells(), spec.missile.as_ref().map_or(0, |m| m.magazine), sam.reload_time() as f64 / 20.0),
|
||||
(Part::Gun, true) => "орудие заклинило: не стреляет".to_string(),
|
||||
(Part::Gun, false) => format!("орудие повреждено: меткость {:.0}%, заклинивает", sam.gun_fit() * 100.0),
|
||||
(Part::Tracks, true) => "ходовая отказала: на марш не выйдет".to_string(),
|
||||
(Part::Tracks, false) => format!("ходовая повреждена: скорость марша {:.0}%", sam.drive_speed() * 100.0),
|
||||
(Part::Comms, true) => "связь отказала: сети нет, цели только свои".to_string(),
|
||||
(Part::Comms, false) => format!("связь повреждена: линия {:.0} м", sam.link_range()),
|
||||
};
|
||||
v.push(check(false, effect));
|
||||
}
|
||||
if sam.jam > 0 {
|
||||
v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0)));
|
||||
}
|
||||
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 = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos);
|
||||
v.push(check(
|
||||
true,
|
||||
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)),
|
||||
));
|
||||
let track = sam.track(t.id);
|
||||
let blocks = target_blocks(sam.roles, track, now, t);
|
||||
// Where the system believes the target is: its track's forecast (the true place only when it has no track).
|
||||
let est = track.map_or(t.pos, |tr| tr.est(now));
|
||||
let evel = track.map_or(t.vel, |tr| tr.vel);
|
||||
v.push(check(
|
||||
blocks & ROLE == 0,
|
||||
format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)),
|
||||
));
|
||||
let detect = match (track, sam.own.contains(&t.id)) {
|
||||
(Some(tr), own) => {
|
||||
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
|
||||
let age = tr.age(now) as f64 / 20.0;
|
||||
let lock = if tr.own_within(now, sight::GUN_AGE) && tr.locked { ", захват" } else { "" };
|
||||
let how = if tr.forecast(now) { format!("прогноз ±{:.0} м", tr.uncertainty(now)) } else { "измерение".to_string() };
|
||||
format!("трек {kind}: {}, возраст {age:.1} с, {how}{lock}, {}", tr.ident().name(), if blocks & STALE != 0 { "устарел для пуска" } else { "годен для пуска" })
|
||||
}
|
||||
_ => "не обнаружена ни своей РЛС, ни сетью".to_string(),
|
||||
};
|
||||
v.push(check(blocks & (UNSEEN | STALE) == 0, detect));
|
||||
if sam.kind.spec().sensor > 0.0 {
|
||||
let period = sam.scan_ticks();
|
||||
v.push(check(
|
||||
sam.radar_up(),
|
||||
if sam.radar_up() {
|
||||
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
|
||||
} else {
|
||||
"обзор: РЛС не работает".to_string()
|
||||
},
|
||||
));
|
||||
}
|
||||
// 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, est, evel, 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, est);
|
||||
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());
|
||||
}
|
||||
if mb & LAUNCHER_DOWN != 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(est), t.id, t.id, err);
|
||||
let turn = if vertical { "вертикальный старт" } else { "доворот" };
|
||||
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
|
||||
// The land: the verdict the launch decision gave this target (`terrain::launch_blocks`) a moment ago.
|
||||
let land = sam.terrain_verdict(t.id, now).unwrap_or(0);
|
||||
if land != 0 {
|
||||
v.push(check(
|
||||
false,
|
||||
if land & terrain::MASKED != 0 {
|
||||
"рельеф: цель за рельефом — ракета не стартует, ждём, пока цель выйдет из-за него"
|
||||
} else {
|
||||
"рельеф: траектория перекрыта рельефом — подняться над ним ракета не может"
|
||||
},
|
||||
));
|
||||
}
|
||||
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 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 = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, 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(est), gun.range, now);
|
||||
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()),
|
||||
(JAMMED, "орудие заклинило".to_string()),
|
||||
(NO_LOCK, "нет свежего захвата своей РЛС".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("роль")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//! What the game's blocks are to the simulation (issues #42, #43): a cache of
|
||||
//! 16-block sections answered by `render.sections` (a bit per block that
|
||||
//! stops a projectile), a swept segment through it, and the bookkeeping that
|
||||
//! keeps the questions few: the sections along a flight asked for ahead of
|
||||
//! time, at most 64 a tick, nearest need first; changes of the world marking
|
||||
//! sections stale; a bounded cache. A chunk the game doesn't have answers
|
||||
//! `Unknown`, which is never the same as air: the callers fall back to the
|
||||
//! height of the land there and the counters say how often.
|
||||
use glam::DVec3;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// A section: block coordinates divided by 16.
|
||||
pub type Sec = (i32, i32, i32);
|
||||
|
||||
/// Sections asked in one tick (the game's budget).
|
||||
pub const PER_TICK: usize = 64;
|
||||
/// Sections kept; the least recently used go first beyond it.
|
||||
const MAX_SECTIONS: usize = 12_000;
|
||||
/// Ticks before an unanswered or unknown section is asked again.
|
||||
const RETRY: u64 = 60;
|
||||
/// Ticks after which a section in use is read again, whether or not a change was reported (#62).
|
||||
const RECHECK: u64 = 1200;
|
||||
/// Longest stretch of a flight asked for ahead, in blocks.
|
||||
const AHEAD_MAX: f64 = 192.0;
|
||||
/// Questions waiting: more are dropped (they are asked again when needed).
|
||||
const QUEUE_MAX: usize = 512;
|
||||
|
||||
/// What a section holds, as the game answered.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Fill {
|
||||
/// The game has no such chunk.
|
||||
Unknown,
|
||||
Empty,
|
||||
Full,
|
||||
/// 512 bytes, one bit per block, index `(y & 15) * 256 + (z & 15) * 16 + (x & 15)`.
|
||||
Mixed(Box<[u8; 512]>),
|
||||
}
|
||||
|
||||
/// An answer for a section, as the game gives it.
|
||||
pub struct SectionAnswer {
|
||||
pub sec: Sec,
|
||||
pub fill: Fill,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
fill: Fill,
|
||||
/// A block changed (or may have) since it was read: asked again when next used.
|
||||
stale: bool,
|
||||
/// The tick it was last used in a sweep.
|
||||
used: u64,
|
||||
/// The tick it was answered.
|
||||
since: u64,
|
||||
}
|
||||
|
||||
/// How a swept segment ended.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Sweep {
|
||||
/// Every block along it is known and none stops it.
|
||||
Clear,
|
||||
/// The first solid block: where the segment meets it, which way the face looks, and the block.
|
||||
Hit { t: f64, point: DVec3, normal: [i32; 3], block: [i32; 3] },
|
||||
/// The walk reached blocks the game doesn't know (before any solid one), `t` of the way. `pending`: the
|
||||
/// section hasn't been answered yet (it is asked for, urgently); else the game said it has no chunk there.
|
||||
Unknown { t: f64, pending: bool },
|
||||
/// A solid block was met at `t` and its exact shape is being asked for (#63): the flight holds at its face.
|
||||
Wait { t: f64 },
|
||||
}
|
||||
|
||||
impl Sweep {
|
||||
pub fn hit(&self) -> Option<(f64, DVec3)> {
|
||||
match *self {
|
||||
Sweep::Hit { t, point, .. } => Some((t, point)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters for the report: questions, unknowns, memory, cost.
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct WorldStats {
|
||||
pub sweeps: u64,
|
||||
pub cells: u64,
|
||||
pub hits: u64,
|
||||
pub unknown_sweeps: u64,
|
||||
pub sections_asked: u64,
|
||||
pub sections_answered: u64,
|
||||
pub sections_unknown: u64,
|
||||
pub invalidated: u64,
|
||||
pub evicted: u64,
|
||||
pub world_changes: u64,
|
||||
/// Steps cut short at unread blocks (#61), and steps let through after waiting for too long.
|
||||
pub held: u64,
|
||||
pub passed_unknown: u64,
|
||||
/// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62).
|
||||
pub aged: u64,
|
||||
pub columns_changed: u64,
|
||||
}
|
||||
|
||||
/// What the game's exact ray said of a flight's step, once it answered (#63).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Exact {
|
||||
Pending,
|
||||
/// The collision shapes leave the way open.
|
||||
Clear,
|
||||
/// Where the shapes stop it: the point, the face and the block.
|
||||
Hit { point: DVec3, normal: [i32; 3], block: [i32; 3] },
|
||||
}
|
||||
|
||||
struct RayEntry {
|
||||
id: u32,
|
||||
a: DVec3,
|
||||
b: DVec3,
|
||||
state: Exact,
|
||||
asked: u64,
|
||||
}
|
||||
|
||||
/// The game's verdict on an exact ray, as `render.rays` gives it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RayReply {
|
||||
pub id: u32,
|
||||
pub clear: bool,
|
||||
pub hit: bool,
|
||||
pub fraction: f64,
|
||||
pub normal: [i32; 3],
|
||||
pub block: [i32; 3],
|
||||
}
|
||||
|
||||
/// Ticks an exact ray may take to be answered before the block counts as the solid cube the sections say.
|
||||
const RAY_WAIT: u64 = 30;
|
||||
/// Exact rays asked in one tick (the game's budget is 256).
|
||||
const RAYS_PER_TICK: usize = 128;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RayDesk {
|
||||
next: u32,
|
||||
/// By flight and block: a flight that meets the same block again finds its answer.
|
||||
entries: HashMap<(u32, [i32; 3]), RayEntry>,
|
||||
by_id: HashMap<u32, (u32, [i32; 3])>,
|
||||
out: Vec<(u32, DVec3, DVec3)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorldCache {
|
||||
rays: RayDesk,
|
||||
secs: HashMap<Sec, Entry>,
|
||||
urgent: Vec<Sec>,
|
||||
ahead: Vec<Sec>,
|
||||
queued: HashSet<Sec>,
|
||||
/// Sections asked, and when.
|
||||
asked: HashMap<Sec, u64>,
|
||||
epoch: u32,
|
||||
/// The game's count of block changes, as of the last note, and the one the section entries were read at.
|
||||
revision: u64,
|
||||
/// The last `solid` that could not tell: the section was not answered yet.
|
||||
unread_pending: bool,
|
||||
/// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones.
|
||||
quiet: bool,
|
||||
/// Columns (section x, z) where blocks changed since the land heights last looked (#64); `all`: too many to say.
|
||||
pub(super) changed_cols: HashSet<(i32, i32)>,
|
||||
pub(super) changed_all: bool,
|
||||
pub stats: WorldStats,
|
||||
}
|
||||
|
||||
fn sec_of(x: i32, y: i32, z: i32) -> Sec {
|
||||
(x >> 4, y >> 4, z >> 4)
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
/// Memory the cached sections hold, about.
|
||||
pub fn bytes(&self) -> usize {
|
||||
self.secs
|
||||
.values()
|
||||
.map(|e| 48 + if matches!(e.fill, Fill::Mixed(_)) { 512 } else { 0 })
|
||||
.sum()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.secs.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.secs.is_empty()
|
||||
}
|
||||
/// One line for the report.
|
||||
pub fn text(&self) -> String {
|
||||
let s = &self.stats;
|
||||
format!(
|
||||
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}",
|
||||
self.len(),
|
||||
self.bytes() / 1024,
|
||||
s.sections_asked,
|
||||
s.sections_unknown,
|
||||
s.sweeps,
|
||||
s.hits,
|
||||
s.unknown_sweeps,
|
||||
s.cells
|
||||
)
|
||||
}
|
||||
|
||||
/// The game's note of changes: another world voids everything; changed
|
||||
/// sections (or too many to say) are read again when next used.
|
||||
pub fn changed(&mut self, epoch: u32, overflow: bool, sections: &[Sec]) {
|
||||
self.changed_rev(epoch, self.revision, overflow, sections);
|
||||
}
|
||||
|
||||
/// The same with the game's change counter: a jump in it that the listed sections don't explain
|
||||
/// (changes the game didn't list, a chunk replaced behind the subscription's back) makes every section
|
||||
/// read before it due for a new read when it is next used (#62).
|
||||
pub fn changed_rev(&mut self, epoch: u32, revision: u64, overflow: bool, sections: &[Sec]) {
|
||||
let unexplained = self.epoch == epoch && revision != self.revision && sections.is_empty() && !overflow;
|
||||
self.revision = revision;
|
||||
if unexplained {
|
||||
// Not named, so not known which: a light update alone doesn't move the counter, a block change does.
|
||||
for e in self.secs.values_mut() {
|
||||
e.stale = true;
|
||||
}
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
if epoch != self.epoch {
|
||||
if self.epoch != 0 {
|
||||
self.secs.clear();
|
||||
self.urgent.clear();
|
||||
self.ahead.clear();
|
||||
self.queued.clear();
|
||||
self.asked.clear();
|
||||
self.changed_all = true;
|
||||
}
|
||||
self.epoch = epoch;
|
||||
}
|
||||
if overflow || unexplained {
|
||||
self.changed_all = true;
|
||||
}
|
||||
if overflow {
|
||||
for e in self.secs.values_mut() {
|
||||
e.stale = true;
|
||||
}
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
for s in sections {
|
||||
self.note_column(s.0, s.2);
|
||||
if let Some(e) = self.secs.get_mut(s) {
|
||||
e.stale = true;
|
||||
self.stats.invalidated += 1;
|
||||
}
|
||||
}
|
||||
self.stats.world_changes += sections.len() as u64;
|
||||
}
|
||||
|
||||
fn note_column(&mut self, sx: i32, sz: i32) {
|
||||
if self.changed_cols.len() >= 4096 {
|
||||
self.changed_all = true;
|
||||
} else {
|
||||
self.changed_cols.insert((sx, sz));
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunk columns (x, z in chunks) the game unloaded or replaced (#62): what is known of their sections is
|
||||
/// void (`Unknown`) until they are read again. A new chunk packet replaces the blocks wholesale, which no
|
||||
/// per-block note says.
|
||||
pub fn columns_changed(&mut self, columns: &[(i32, i32)]) {
|
||||
if columns.is_empty() {
|
||||
return;
|
||||
}
|
||||
let set: HashSet<(i32, i32)> = columns.iter().copied().collect();
|
||||
let mut n = 0u64;
|
||||
for &(cx, cz) in columns {
|
||||
self.note_column(cx, cz);
|
||||
}
|
||||
for (s, e) in self.secs.iter_mut() {
|
||||
if set.contains(&(s.0, s.2)) {
|
||||
e.fill = Fill::Unknown;
|
||||
e.stale = true;
|
||||
e.since = 0;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.stats.invalidated += n;
|
||||
self.stats.columns_changed += columns.len() as u64;
|
||||
}
|
||||
|
||||
/// An explosion is about to change the land: the sections round it are read again.
|
||||
pub fn touched(&mut self, at: DVec3, radius: f64) {
|
||||
let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32);
|
||||
let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(e) = self.secs.get_mut(&(x, y, z)) {
|
||||
e.stale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn answer(&mut self, answers: Vec<SectionAnswer>, now: u64) {
|
||||
for a in answers {
|
||||
self.asked.remove(&a.sec);
|
||||
self.stats.sections_answered += 1;
|
||||
if a.fill == Fill::Unknown {
|
||||
self.stats.sections_unknown += 1;
|
||||
}
|
||||
self.secs.insert(a.sec, Entry { fill: a.fill, stale: false, used: now, since: now });
|
||||
}
|
||||
if self.secs.len() > MAX_SECTIONS {
|
||||
// Whatever wasn't used for a minute goes; if that's not enough, the older half.
|
||||
self.secs.retain(|_, e| now.saturating_sub(e.used) < 1200);
|
||||
if self.secs.len() > MAX_SECTIONS {
|
||||
let mut used: Vec<u64> = self.secs.values().map(|e| e.used).collect();
|
||||
used.sort_unstable();
|
||||
let cut = used[used.len() / 2];
|
||||
self.secs.retain(|_, e| e.used >= cut);
|
||||
}
|
||||
self.stats.evicted += 1;
|
||||
}
|
||||
// Forget questions the game never answered, so they can be asked again.
|
||||
if now % 64 == 0 {
|
||||
self.asked.retain(|_, t| now.saturating_sub(*t) < RETRY);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sections to ask this tick (at most [`PER_TICK`]): the ones a sweep
|
||||
/// needed first, then those along flights.
|
||||
pub fn take_wanted(&mut self, now: u64) -> Vec<Sec> {
|
||||
let mut out = Vec::new();
|
||||
for list in [&mut self.urgent, &mut self.ahead] {
|
||||
let room = PER_TICK - out.len();
|
||||
let n = room.min(list.len());
|
||||
out.extend(list.drain(..n));
|
||||
}
|
||||
for s in &out {
|
||||
self.queued.remove(s);
|
||||
self.asked.insert(*s, now);
|
||||
}
|
||||
self.stats.sections_asked += out.len() as u64;
|
||||
out
|
||||
}
|
||||
|
||||
fn want(&mut self, s: Sec, urgent: bool, now: u64) {
|
||||
if self.queued.contains(&s) {
|
||||
// Already waiting as a look-ahead, and a step needs it now: it moves up (#61).
|
||||
if urgent && let Some(i) = self.ahead.iter().position(|a| *a == s) {
|
||||
self.ahead.swap_remove(i);
|
||||
self.urgent.push(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) {
|
||||
return;
|
||||
}
|
||||
if self.urgent.len() + self.ahead.len() >= QUEUE_MAX {
|
||||
// Full: an urgent need pushes a look-ahead out, a look-ahead waits.
|
||||
if !urgent || self.ahead.is_empty() {
|
||||
return;
|
||||
}
|
||||
let gone = self.ahead.swap_remove(0);
|
||||
self.queued.remove(&gone);
|
||||
}
|
||||
if urgent {
|
||||
self.urgent.push(s);
|
||||
} else {
|
||||
self.ahead.push(s);
|
||||
}
|
||||
self.queued.insert(s);
|
||||
}
|
||||
|
||||
/// Asks for the sections along a flight from `pos` at `vel` a tick, `ticks` ahead.
|
||||
pub fn look_ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
|
||||
let mut d = vel * ticks;
|
||||
let len = d.length();
|
||||
if len < 1e-6 {
|
||||
return;
|
||||
}
|
||||
if len > AHEAD_MAX {
|
||||
d *= AHEAD_MAX / len;
|
||||
}
|
||||
// A section at a time along the segment (a section is 16 wide: half-section steps meet each).
|
||||
let steps = (d.length() / 8.0).ceil().max(1.0) as i32;
|
||||
let mut last = None;
|
||||
for i in 0..=steps {
|
||||
let p = pos + d * (i as f64 / steps as f64);
|
||||
let s = sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if last == Some(s) {
|
||||
continue;
|
||||
}
|
||||
last = Some(s);
|
||||
self.touch(s, false, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Touches a section the way a sweep or a look needs it: used now, and
|
||||
/// asked again when stale or (after a while) unknown.
|
||||
fn touch(&mut self, s: Sec, urgent: bool, now: u64) -> bool {
|
||||
let Some(e) = self.secs.get_mut(&s) else {
|
||||
self.want(s, urgent, now);
|
||||
return false;
|
||||
};
|
||||
e.used = now;
|
||||
// Whatever is in use is read again now and then, bounded: a change nobody told of ends up seen (#62).
|
||||
let aged = e.fill != Fill::Unknown && now.saturating_sub(e.since) >= RECHECK;
|
||||
if aged {
|
||||
self.stats.aged += 1;
|
||||
}
|
||||
let refresh = e.stale || aged || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
|
||||
if refresh {
|
||||
self.want(s, urgent, now);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the block at `(x, y, z)` stops a projectile: `None` where the
|
||||
/// game hasn't said. `last` is the section just touched.
|
||||
fn solid(&mut self, last: &mut Option<Sec>, x: i32, y: i32, z: i32, now: u64) -> Option<bool> {
|
||||
let s = sec_of(x, y, z);
|
||||
if *last != Some(s) {
|
||||
*last = Some(s);
|
||||
self.touch(s, !self.quiet, now);
|
||||
}
|
||||
self.unread_pending = false;
|
||||
let Some(entry) = self.secs.get(&s) else {
|
||||
self.unread_pending = true;
|
||||
return None;
|
||||
};
|
||||
match &entry.fill {
|
||||
Fill::Unknown => {
|
||||
// Asked and answered unknown: no chunk there. Asked again (or not at all yet): waiting.
|
||||
self.unread_pending = entry.stale && entry.since == 0;
|
||||
None
|
||||
}
|
||||
Fill::Empty => Some(false),
|
||||
Fill::Full => Some(true),
|
||||
Fill::Mixed(bits) => {
|
||||
let i = ((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize;
|
||||
Some(bits[i >> 3] >> (i & 7) & 1 != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the block is known to hold no solid block, with the sky above it
|
||||
/// in view: used to tell a fluid surface from a roof.
|
||||
pub fn is_known_open(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
|
||||
self.solid(&mut None, x, y, z, now) == Some(false)
|
||||
}
|
||||
|
||||
/// Whether the block is known to be solid.
|
||||
pub fn is_known_solid(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
|
||||
self.solid(&mut None, x, y, z, now) == Some(true)
|
||||
}
|
||||
|
||||
/// A sweep made to plan a flight, not to move one: the sections it needs are asked after the urgent ones,
|
||||
/// and a start inside a block is not a hit.
|
||||
pub fn probe(&mut self, a: DVec3, b: DVec3, now: u64) -> Sweep {
|
||||
self.quiet = true;
|
||||
let r = self.sweep(a, b, true, now);
|
||||
self.quiet = false;
|
||||
r
|
||||
}
|
||||
|
||||
/// A step of flight `key` from `a` to `b` that met a solid block (`hit`): the occupancy says "stops
|
||||
/// something", the shape may leave room (slabs, stairs, fences, panes). The block's exact shapes are asked
|
||||
/// of the game (a ray over the step) and the flight holds at the face meanwhile; a `Hit` comes back as the
|
||||
/// exact contact, a `Clear` lets this flight through that block. An answer that doesn't come in
|
||||
/// [`RAY_WAIT`] ticks, or `unknown`, leaves the hit as the solid cube.
|
||||
pub fn narrow(&mut self, key: u32, hit: Sweep, a: DVec3, b: DVec3, now: u64) -> Sweep {
|
||||
let Sweep::Hit { t, block, .. } = hit else { return hit };
|
||||
let k = (key, block);
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else {
|
||||
if self.rays.entries.len() >= 4096 {
|
||||
return hit;
|
||||
}
|
||||
self.rays.next = self.rays.next.wrapping_add(1);
|
||||
let id = self.rays.next;
|
||||
self.rays.entries.insert(k, RayEntry { id, a, b, state: Exact::Pending, asked: now });
|
||||
self.rays.by_id.insert(id, k);
|
||||
self.rays.out.push((id, a, b));
|
||||
return Sweep::Wait { t };
|
||||
};
|
||||
if e.state == Exact::Pending && now.saturating_sub(e.asked) > RAY_WAIT {
|
||||
e.state = Exact::Hit { point: a + (b - a) * t, normal: [0; 3], block };
|
||||
}
|
||||
match e.state {
|
||||
Exact::Pending => Sweep::Wait { t },
|
||||
Exact::Clear => Sweep::Clear,
|
||||
Exact::Hit { point, normal, block } => {
|
||||
let d = b - a;
|
||||
let f = ((point - a).dot(d) / d.length_squared().max(1e-12)).max(0.0);
|
||||
if f > 1.0 {
|
||||
// The shape's face is beyond this step: nothing is met yet.
|
||||
Sweep::Clear
|
||||
} else {
|
||||
Sweep::Hit { t: f, point, normal, block }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact rays to ask this tick: id, start, end.
|
||||
pub fn take_rays(&mut self, now: u64) -> Vec<(u32, DVec3, DVec3)> {
|
||||
let n = RAYS_PER_TICK.min(self.rays.out.len());
|
||||
let out: Vec<_> = self.rays.out.drain(..n).collect();
|
||||
if now % 128 == 0 {
|
||||
let dead: Vec<_> = self.rays.entries.iter().filter(|(_, e)| now.saturating_sub(e.asked) > 600).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = self.rays.entries.remove(&k) {
|
||||
self.rays.by_id.remove(&e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The game's answers to the exact rays.
|
||||
pub fn answer_rays(&mut self, replies: &[RayReply]) {
|
||||
for r in replies {
|
||||
let Some(k) = self.rays.by_id.get(&r.id).copied() else { continue };
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else { continue };
|
||||
e.state = if r.clear {
|
||||
Exact::Clear
|
||||
} else if r.hit {
|
||||
Exact::Hit { point: e.a + (e.b - e.a) * r.fraction, normal: r.normal, block: r.block }
|
||||
} else {
|
||||
// No verdict: the solid cube the sections said.
|
||||
Exact::Hit { point: e.a, normal: [0; 3], block: k.1 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The first solid block along `a` to `b`, by walking the blocks it
|
||||
/// crosses; `skip_start` ignores the block it starts in (a launch from a
|
||||
/// pad that is part of the land).
|
||||
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> Sweep {
|
||||
self.stats.sweeps += 1;
|
||||
let d = b - a;
|
||||
let mut pos = [a.x.floor() as i32, a.y.floor() as i32, a.z.floor() as i32];
|
||||
let dv = [d.x, d.y, d.z];
|
||||
let av = [a.x, a.y, a.z];
|
||||
let step: [i32; 3] = dv.map(|v| if v > 0.0 { 1 } else { -1 });
|
||||
let mut t_max = [f64::INFINITY; 3];
|
||||
let mut t_delta = [f64::INFINITY; 3];
|
||||
for i in 0..3 {
|
||||
if dv[i] != 0.0 {
|
||||
let edge = f64::from(pos[i]) + if dv[i] > 0.0 { 1.0 } else { 0.0 };
|
||||
t_max[i] = (edge - av[i]) / dv[i];
|
||||
t_delta[i] = (1.0 / dv[i]).abs();
|
||||
}
|
||||
}
|
||||
let mut last: Option<Sec> = None;
|
||||
let mut face = [0i32; 3];
|
||||
let mut t = 0.0f64;
|
||||
let mut first = true;
|
||||
for _ in 0..4096 {
|
||||
self.stats.cells += 1;
|
||||
match self.solid(&mut last, pos[0], pos[1], pos[2], now) {
|
||||
None => {
|
||||
self.stats.unknown_sweeps += 1;
|
||||
return Sweep::Unknown { t, pending: self.unread_pending };
|
||||
}
|
||||
Some(true) if !(first && skip_start) => {
|
||||
self.stats.hits += 1;
|
||||
return Sweep::Hit { t, point: a + d * t, normal: face, block: pos };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
first = false;
|
||||
let axis = if t_max[0] < t_max[1] {
|
||||
if t_max[0] < t_max[2] { 0 } else { 2 }
|
||||
} else if t_max[1] < t_max[2] {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
t = t_max[axis];
|
||||
if t > 1.0 {
|
||||
return Sweep::Clear;
|
||||
}
|
||||
t_max[axis] += t_delta[axis];
|
||||
pos[axis] += step[axis];
|
||||
face = [0; 3];
|
||||
face[axis] = -step[axis];
|
||||
}
|
||||
self.stats.unknown_sweeps += 1;
|
||||
Sweep::Unknown { t, pending: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the game's blocks come from in tests and the native runner: any
|
||||
/// world that can say what a block is.
|
||||
pub trait WorldSource {
|
||||
/// Whether the block stops a projectile; `None` where there is no chunk.
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool>;
|
||||
/// The collision boxes of the block (`[x0, y0, z0, x1, y1, z1]` inside the block): a full cube where
|
||||
/// it is solid, unless the world has finer shapes.
|
||||
fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> {
|
||||
if self.solid(x, y, z) == Some(true) { vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]] } else { Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The first entry of segment `a` to `b` into the box (slab test): the fraction and the axis.
|
||||
fn enter_box(a: DVec3, d: DVec3, lo: DVec3, hi: DVec3) -> Option<(f64, usize)> {
|
||||
let (mut t0, mut t1, mut axis) = (0.0f64, 1.0f64, usize::MAX);
|
||||
for i in 0..3 {
|
||||
let (o, v) = (a[i], d[i]);
|
||||
if v.abs() < 1e-12 {
|
||||
if o < lo[i] || o > hi[i] {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let (mut n, mut f) = ((lo[i] - o) / v, (hi[i] - o) / v);
|
||||
if n > f {
|
||||
std::mem::swap(&mut n, &mut f);
|
||||
}
|
||||
if n > t0 {
|
||||
t0 = n;
|
||||
axis = i;
|
||||
}
|
||||
t1 = t1.min(f);
|
||||
if t0 > t1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((t0, axis))
|
||||
}
|
||||
|
||||
/// A [`WorldSource`] as the game would answer `render.query-sections` with it.
|
||||
pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
|
||||
let mut bits = Box::new([0u8; 512]);
|
||||
let (mut set, mut known) = (0u32, 0u32);
|
||||
for i in 0..4096usize {
|
||||
let (x, y, z) = (s.0 * 16 + (i & 15) as i32, s.1 * 16 + (i >> 8) as i32, s.2 * 16 + ((i >> 4) & 15) as i32);
|
||||
match source.solid(x, y, z) {
|
||||
Some(true) => {
|
||||
bits[i >> 3] |= 1 << (i & 7);
|
||||
set += 1;
|
||||
known += 1;
|
||||
}
|
||||
Some(false) => known += 1,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
match (known, set) {
|
||||
(0, _) => Fill::Unknown,
|
||||
(_, 0) if known == 4096 => Fill::Empty,
|
||||
(_, 4096) => Fill::Full,
|
||||
_ => Fill::Mixed(bits),
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
/// Answers the exact rays of the last tick from a synthetic world, as the game does one tick later.
|
||||
pub fn serve_rays(&mut self, source: &dyn WorldSource, rays: Vec<(u32, DVec3, DVec3)>) {
|
||||
let replies: Vec<RayReply> = rays
|
||||
.into_iter()
|
||||
.map(|(id, a, b)| {
|
||||
let d = b - a;
|
||||
let mut best: Option<(f64, usize, [i32; 3])> = None;
|
||||
let (lo, hi) = (a.min(b).floor(), a.max(b).floor());
|
||||
for x in lo.x as i32..=hi.x as i32 {
|
||||
for y in lo.y as i32..=hi.y as i32 {
|
||||
for z in lo.z as i32..=hi.z as i32 {
|
||||
for bx in source.boxes(x, y, z) {
|
||||
let o = DVec3::new(f64::from(x), f64::from(y), f64::from(z));
|
||||
if let Some((t, axis)) = enter_box(a, d, o + DVec3::new(bx[0], bx[1], bx[2]), o + DVec3::new(bx[3], bx[4], bx[5]))
|
||||
&& best.is_none_or(|b| t < b.0)
|
||||
{
|
||||
best = Some((t, axis, [x, y, z]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
None => RayReply { id, clear: true, hit: false, fraction: 0.0, normal: [0; 3], block: [0; 3] },
|
||||
Some((t, axis, block)) => {
|
||||
let mut normal = [0; 3];
|
||||
if axis < 3 {
|
||||
normal[axis] = if d[axis] > 0.0 { -1 } else { 1 };
|
||||
}
|
||||
RayReply { id, clear: false, hit: true, fraction: t, normal, block }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.answer_rays(&replies);
|
||||
}
|
||||
|
||||
/// Answers the questions of the last tick from a synthetic world, as the game does one tick later.
|
||||
pub fn serve(&mut self, source: &dyn WorldSource, wanted: Vec<Sec>, now: u64) {
|
||||
let answers = wanted.into_iter().map(|sec| SectionAnswer { sec, fill: section_of(source, sec) }).collect();
|
||||
self.answer(answers, now);
|
||||
let rays = self.take_rays(now);
|
||||
self.serve_rays(source, rays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Smoke for the HD renderer (`render.set-smoke`): exhaust trails of drones and missiles that leave
|
||||
//! a clear tunnel in fog and cloud, smoke columns over fires, and the smoke of blasts. The game
|
||||
//! derives everything from a segment's age, so the columns and blasts are a pure function of the
|
||||
//! tick (nothing grows twice when a frame is shown again); trails are the only state, and they are
|
||||
//! cut back when the tick goes backwards.
|
||||
use crate::sim::{Class, Sim};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::render::SmokeSegment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_TRAIL: usize = 2400;
|
||||
const MAX_TOTAL: usize = 4000;
|
||||
|
||||
struct Trail {
|
||||
seg: SmokeSegment,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Smoke {
|
||||
trails: Vec<Trail>,
|
||||
/// Where each flying thing last left a segment: (kind, id) -> (position, tick).
|
||||
last: HashMap<(u8, u32), (DVec3, u64)>,
|
||||
last_tick: u64,
|
||||
next: u32,
|
||||
/// The renderer draws the smoke (the answer of the last call).
|
||||
pub hd: bool,
|
||||
}
|
||||
|
||||
fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
|
||||
SmokeSegment {
|
||||
id,
|
||||
ax: a.x,
|
||||
ay: a.y,
|
||||
az: a.z,
|
||||
bx: b.x,
|
||||
by: b.y,
|
||||
bz: b.z,
|
||||
born: born as f64,
|
||||
life: life as f32,
|
||||
radius_start: 1.0,
|
||||
radius_end: 6.0,
|
||||
density: 0.5,
|
||||
heat: 0.0,
|
||||
soot: 0.4,
|
||||
rise: 0.2,
|
||||
clear: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Smoke {
|
||||
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) {
|
||||
let Some(&(from, when)) = self.last.get(&key) else {
|
||||
self.last.insert(key, (at, tick));
|
||||
return;
|
||||
};
|
||||
if at.distance(from) < 5.0 && tick - when < 10 {
|
||||
return;
|
||||
}
|
||||
// A jump (a teleport, a respawn) is no trail.
|
||||
if at.distance(from) < 400.0 {
|
||||
self.next = self.next.wrapping_add(1);
|
||||
let mut s = seg(self.next, from, at, tick, 1200);
|
||||
style(&mut s);
|
||||
self.trails.push(Trail { seg: s });
|
||||
}
|
||||
self.last.insert(key, (at, tick));
|
||||
}
|
||||
|
||||
/// Follows the simulation one tick: new trail segments for what flies, old ones dropped.
|
||||
pub fn update(&mut self, sim: &Sim) {
|
||||
let tick = sim.tick;
|
||||
if tick < self.last_tick {
|
||||
self.trails.retain(|t| t.seg.born as u64 <= tick);
|
||||
self.last.clear();
|
||||
}
|
||||
self.last_tick = tick;
|
||||
for d in &sim.drones {
|
||||
let class = d.kind.spec().class;
|
||||
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
|
||||
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
|
||||
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
|
||||
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
|
||||
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
|
||||
};
|
||||
self.trail((0, d.id), d.pos, tick, |s| {
|
||||
s.radius_start = r0;
|
||||
s.radius_end = r1;
|
||||
s.density = dens;
|
||||
s.heat = heat;
|
||||
s.soot = soot;
|
||||
s.clear = clear;
|
||||
s.life = life;
|
||||
});
|
||||
}
|
||||
for m in &sim.missiles {
|
||||
self.trail((1, m.id), m.pos, tick, |s| {
|
||||
s.radius_start = 0.6;
|
||||
s.radius_end = 5.0;
|
||||
s.density = 3.0;
|
||||
s.heat = 0.8;
|
||||
s.soot = 0.05;
|
||||
s.clear = 3.0;
|
||||
s.life = 1000.0;
|
||||
});
|
||||
}
|
||||
// Forget what is gone.
|
||||
if tick % 100 == 0 {
|
||||
let alive: std::collections::HashSet<(u8, u32)> = sim
|
||||
.drones
|
||||
.iter()
|
||||
.map(|d| (0, d.id))
|
||||
.chain(sim.missiles.iter().map(|m| (1, m.id)))
|
||||
.collect();
|
||||
self.last.retain(|k, _| alive.contains(k));
|
||||
}
|
||||
self.trails.retain(|t| tick < t.seg.born as u64 + t.seg.life as u64);
|
||||
if self.trails.len() > MAX_TRAIL {
|
||||
let cut = self.trails.len() - MAX_TRAIL;
|
||||
self.trails.drain(..cut);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every segment alive at the simulation's tick: trails, fire columns, blast smoke.
|
||||
pub fn segments(&self, sim: &Sim) -> Vec<SmokeSegment> {
|
||||
let tick = sim.tick;
|
||||
let mut out: Vec<SmokeSegment> = self.trails.iter().map(|t| t.seg.clone()).collect();
|
||||
// A column over each fire: a puff every 15 ticks that rises, spreads and thins.
|
||||
for f in &sim.fires.list {
|
||||
if f.quiet && f.size < 1.0 {
|
||||
continue;
|
||||
}
|
||||
// Only the strongest fires get a full column; the rest a thin puff now and then.
|
||||
let step = if f.shown { 15 } else { 90 };
|
||||
let first = f.start.max(tick.saturating_sub(520));
|
||||
let mut k = first.div_ceil(step) * step;
|
||||
while k <= tick && out.len() < MAX_TOTAL {
|
||||
let (flame, smoke) = f.level(k);
|
||||
if smoke > 0.05 && k >= f.start {
|
||||
let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
|
||||
let at = f.at + DVec3::Y * 0.6;
|
||||
let mut s = seg(id, at, at, k, 540);
|
||||
s.radius_start = 1.0 * f.size;
|
||||
s.radius_end = 8.0 * f.size + 2.0;
|
||||
s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 };
|
||||
s.heat = flame * 0.6;
|
||||
s.soot = 0.85;
|
||||
s.rise = 2.6;
|
||||
out.push(s);
|
||||
}
|
||||
k += step;
|
||||
}
|
||||
}
|
||||
// Blasts: a billow of puffs going up.
|
||||
for b in &sim.blasts {
|
||||
let age = tick.saturating_sub(b.start);
|
||||
if age > 900 {
|
||||
continue;
|
||||
}
|
||||
for j in 0..6u64 {
|
||||
let born = b.start + j * 3;
|
||||
if born > tick {
|
||||
break;
|
||||
}
|
||||
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
|
||||
let at = b.at + DVec3::Y * h;
|
||||
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
|
||||
s.radius_start = b.size * 0.6;
|
||||
s.radius_end = b.size * 3.2 + 4.0;
|
||||
s.density = 2.4 / (1.0 + j as f32 * 0.25);
|
||||
s.heat = if j < 3 { 1.0 } else { 0.4 };
|
||||
s.soot = 0.55;
|
||||
s.rise = 4.0 - 0.3 * j as f32;
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_TOTAL);
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,35 @@ impl WaveConfig {
|
||||
self.approach = p.approach;
|
||||
self.aim = p.aim;
|
||||
}
|
||||
/// The words of a scenario's `wave` line: counts, approach, distance, aim, preset.
|
||||
pub fn text(&self) -> String {
|
||||
let counts: Vec<String> = self.counts.iter().map(u32::to_string).collect();
|
||||
let approach = Approach::ALL.iter().position(|a| *a == self.approach).unwrap_or(0);
|
||||
let aim = Aim::ALL.iter().position(|a| *a == self.aim).unwrap_or(0);
|
||||
format!("{} {approach} {} {aim} {}", counts.join(","), self.dist, self.preset)
|
||||
}
|
||||
/// Reads `text`'s words; what does not parse keeps its value.
|
||||
pub fn parse(&mut self, w: &[&str]) {
|
||||
if let Some(c) = w.first() {
|
||||
for (i, v) in c.split(',').take(KINDS.len()).enumerate() {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.counts[i] = n.min(80);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = w.get(1).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Approach::ALL.get(i)) {
|
||||
self.approach = *a;
|
||||
}
|
||||
if let Some(d) = w.get(2).and_then(|v| v.parse::<f64>().ok()) {
|
||||
self.dist = d.clamp(60.0, 800.0);
|
||||
}
|
||||
if let Some(a) = w.get(3).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Aim::ALL.get(i)) {
|
||||
self.aim = *a;
|
||||
}
|
||||
if let Some(p) = w.get(4).and_then(|v| v.parse::<usize>().ok()) {
|
||||
self.preset = p % PRESETS.len();
|
||||
}
|
||||
}
|
||||
/// The next number for kind `i`, back to 0 after the last.
|
||||
pub fn step(&mut self, i: usize) {
|
||||
let n = self.counts[i];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Fixed headless scenarios on the native simulation: no NaN, the raid ends, no stuck jobs.
|
||||
//! Run: `cargo test --test scenarios` (also `cargo run --release --example sim_run -- --help`).
|
||||
#[path = "../examples/sim_run/harness.rs"]
|
||||
mod harness;
|
||||
|
||||
fn check(sc: harness::Scenario) -> harness::Report {
|
||||
let r = harness::run(&sc);
|
||||
eprintln!("{}", r.text());
|
||||
let problems = r.problems();
|
||||
assert!(problems.is_empty(), "scenario {} failed: {problems:?}\n{}", r.name, r.text());
|
||||
assert!(r.launched > 0, "nothing was launched");
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_smart_raid_on_61_systems() {
|
||||
let r = check(harness::big_smart());
|
||||
assert_eq!(r.units, 61);
|
||||
assert!(r.fired > 0, "air defence never fired");
|
||||
assert!(r.ground_queries > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logistics_trucks_depot_and_raid() {
|
||||
let r = check(harness::logistics());
|
||||
assert!(r.truck_busy_ticks > 0, "the trucks never had a job");
|
||||
assert!(r.fired > 0, "air defence never fired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ew_and_adaptive_enemy_over_three_raids() {
|
||||
let r = check(harness::ew_adaptive());
|
||||
assert_eq!(r.raids_ended, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_result() {
|
||||
let a = harness::run(&harness::logistics());
|
||||
let b = harness::run(&harness::logistics());
|
||||
assert_eq!((a.ticks_run, a.launched, a.kills, a.hits, a.fired, a.ground_queries), (b.ticks_run, b.launched, b.kills, b.hits, b.fired, b.ground_queries));
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
# HD-рендер AirDef: устройство, статус, план
|
||||
|
||||
Документ для передачи работы (человеку или агенту). Ведётся по ходу разработки: каждый этап, попавший в
|
||||
`main`, отмечается здесь вместе с коммитом. Если что-то в коде расходится с этим текстом — прав код, а текст
|
||||
надо поправить.
|
||||
|
||||
## Что это
|
||||
|
||||
HD — третий пресет графики мода (`/airdef shader off|on|hd`): отложенное освещение в стиле шейдерпаков
|
||||
(BSL/Complementary-класс), но написанное с нуля внутри нашего форка Horizon (Minecraft 26.3 на Rust/wgpu).
|
||||
Код шейдерпаков (BSL, Complementary, Iris/OptiFine и прочих) **не копируем**: их лицензии запрещают перенос, а в
|
||||
Horizon действует правило clean-room. Исключение — Photon (sixthsurge), коммит 26c2906ee02cc484359b835fba6993d443d45966: он под MIT, из него можно
|
||||
переносить идеи и код **с указанием авторства** (комментарий у кода + `gfx/THIRD_PARTY_PHOTON.md`). Остальное пишем по знанию техник (статьи, общеизвестные алгоритмы).
|
||||
|
||||
Пресеты должны оставаться: `off` (ваниль), `on` (пост-эффекты мода), `hd` (этот рендер), позже `rt` (аппаратная
|
||||
трассировка, отдельно).
|
||||
|
||||
## Где код
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Оркестровка HD: `Hd`, общие цели, буфер кадра, `resize`/`write`, сборка шейдеров (`assemble`: прелюдия + тело), контракты цвета/глубины/альф/времени (комментарий в начале файла) | `horizon/crates/horizon-client/src/gfx/hd/mod.rs` |
|
||||
| Униформ кадра `HdFrame` (Rust) и сцена `HdScene`/`HdLight`/`HdSettings`, свет солнца/луны/облаков (`light_of`, `cloud_light`); общая WGSL-прелюдия (`HdFrame`, `FullOut`, `vs_full`) | `gfx/hd/frame.rs`, `gfx/hd/prelude.wgsl` |
|
||||
| Тени (каскады, `Shadows`, `cascades()`); G-буфер (`GBuffer`, форматы) | `gfx/hd/shadows.rs`; `gfx/hd/gbuffer.rs` |
|
||||
| AO (GTAO + блюр); освещение `fs_light` (+ тайлы источников: `tiles.rs`); вода (проход геометрии и композит) | `gfx/hd/ao.rs` + `ao.wgsl`, `ao_blur.wgsl`; `gfx/hd/lighting.rs` + `light.wgsl`, `gfx/hd/tiles.rs`; `gfx/hd/water.rs` + `water.wgsl` |
|
||||
| TAA/TAAU и джиттер; bloom; tonemap (+ `Hd::finish`) | `gfx/hd/taa.rs` + `taa.wgsl`; `gfx/hd/bloom.rs` + `bloom.wgsl`; `gfx/hd/tonemap.rs` + `tonemap.wgsl` |
|
||||
| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман, их цели и проходы; CPU-шум (2D, 3D Worley) | `gfx/hd/volumes.rs` + `cloud_common.wgsl`, `cloud.wgsl`, `cloud_shadow.wgsl`, `fog.wgsl`; `gfx/hd/noise.rs` |
|
||||
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `gfx/hd/sky.rs` + `sky.wgsl`, `sky_funcs.wgsl` (+ `THIRD_PARTY_PHOTON.md`) |
|
||||
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
|
||||
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
|
||||
| G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` |
|
||||
| Материалы (классы спрайтов: листва, растения, металл, полированное), ветер, мокрые поверхности | `gfx/hd/materials.rs`; `terrain.wgsl` (`terrain_vertex`/`wind_offset`, `material_params`), `entity.wgsl`, `gfx/hd/light.wgsl` (`shade_surface`) |
|
||||
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
|
||||
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
|
||||
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
|
||||
|
||||
Форк ведётся в `horizon/` (ветка `airdef`), в репозиторий мода попадает **патчами**:
|
||||
|
||||
```bash
|
||||
cd horizon && git add -A crates && git commit -m "..." # коммит в форке
|
||||
cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat HORIZON_COMMIT)..airdef -o ../horizon-patches
|
||||
```
|
||||
|
||||
`tools/airdef.py fork` сверяет отпечаток `HORIZON_COMMIT` + патчей (`horizon/.git/airdef-stamp`) и пересобирает
|
||||
ветку, если они изменились. После ручной перегенерации патчей у себя обнови отпечаток (см. `fork_stamp` в
|
||||
`tools/airdef.py`), иначе следующий `airdef.py fork` пересоберёт ветку (безопасно: прежняя остаётся в `airdef-prev`).
|
||||
|
||||
## Конвейер кадра (HD включён)
|
||||
|
||||
Цели мира (G-буфер, AO, освещение, пост-проход мода) — в «масштабированном» разрешении мира (`world_post`; при `HORIZON_HD_SCALE` < 1 оно меньше окна), цели после TAA (история, bloom, tonemap) — в разрешении окна. HDR — `Rgba16Float`. Глубина камеры — reversed-Z
|
||||
(`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64`
|
||||
на CPU.
|
||||
|
||||
0. **Небо** (`hd.sky`, проход `hd sky`, перед освещением): текстура 128×72 (`Rgba16Float`): 128 азимутов (от солнца, 0..π) × 64 высот (v = √(h/(π/2))), плюс строка 64 с «окружающим светом» (текселы 0 — зенит, 1 — среднее у горизонта). Каждый тексел — марш по лучу (32 шага) однократного рассеяния Рэлея/Ми + озон, плоский член многократного рассеяния (`MULTI_SCATTER`), солнце и луна (луна — напротив) как источники; пропускание до источника — аналитическое (Chapman, как у Photon). Зависит только от высоты солнца → перерисовывается, лишь когда она сдвинулась больше чем на 0.001. Читают её `fs_light` и композит воды (привязки 15 и 10).
|
||||
1. **Каскады теней** (2 шт., `CASCADES = [40, 176]` блоков полуразмера, `SHADOW_SIZE = 2048`, массив глубины
|
||||
из 2 слоёв). Источник — солнце днём, луна ночью (`light_of`). Матрицы `cascades()` привязаны к текселям
|
||||
(тени не «ползут»). Во время прохода буфер глобалов мира подменяется на глобалы каскада (copy-трюк) и потом
|
||||
возвращается. Рисуются секции террейна в коробке дальнего каскада (`hd_shadow_visible`) и сущности-тенеотбрасыватели
|
||||
(`shadow_casters`: без оверлеев 4/9/10/40 — 40 это светящиеся модели мода).
|
||||
2. **G-буфер** (свой depth): albedo `Rgba8Unorm` (a = эмиссия), нормаль `Rgba16Float` (мировая, из `dpdx/dpdy`),
|
||||
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240). Террейн + дальние LOD-тайлы +
|
||||
сущности. Полупрозрачное в G-буфер **не** попадает (идёт ванильным путём); вода — отдельный путь, см. п. 3 и «Вода».
|
||||
3. **Вода** (`water_pass`, только если на экране есть полупрозрачные секции, нет `frame.underwater` и не `HORIZON_HD_WATER=0`):
|
||||
те же секции ещё раз с `fs_water` (terrain.wgsl), который отбрасывает всё, кроме слоёв спрайтов `water_still`/`water_flow`
|
||||
(номера слоёв передаются в `Renderer::new` из `sprite_layers` и ставятся override-константами `WATER_STILL/WATER_FLOW`; стекло,
|
||||
лёд и прочее остаются ванильными). Цели: цвет (спрайт × биомный тинт, a = свет блоков, `Rgba16Float`), нормаль грани (a = свет неба,
|
||||
`Rgba8Unorm`) и собственная глубина воды (`Depth32Float`, 0 = воды нет; тест глубины только среди воды, перекрытие
|
||||
землёй решают потребители). Пайплайны `gbuffer_water` (секции) и `flat.water` (flat-путь).
|
||||
3a. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
|
||||
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
|
||||
3b. **Облака и туман** (`hd.volumes`, до освещения, после прохода неба): `hd cloud shadows` (карта 256² оптической толщины облаков по ходу солнца, ±1200 блоков вокруг камеры), `hd clouds` (половина разрешения мира, `Rgba16Float`: свет + пропускание; рэймарч слоя 192.33…264 блока, 8–26 шагов с джиттером IGN, свет — 3 шага с удвоением, небо — 1, 4 октавы многократного рассеяния Photon; история ping-pong с репроекцией по направлению и сдвигу камеры, вес до 0.9, отбрасывается при расхождении) и `hd fog` (половина разрешения: 18 шагов от камеры до глубины мира, не дальше 192 блоков, плотность — высотная дымка + утренний туман у земли + дождь + шум, свет — солнце × тень каскадов × фаза Photon + свет неба; вторая цель — длина луча для апсэмплинга). Ванильные облака при этом не рисуются (`hd_volumetric_clouds` в `gfx.rs`). `fs_light` накладывает облака на небо (`c·T + S`), туман на всё (`fog_at`: 4 текселя с весом по близости длины луча), а тень облаков (`cloud_shadow`) умножает прямой солнечный свет. `HORIZON_HD_CLOUDS=0` / `HORIZON_HD_VOLFOG=0` выключают (облака тогда ванильные, лучи — старые 14 шагов).
|
||||
4. **Освещение** `fs_light` → `lit` (HDR): пиксель неба (глубина мира 0, Оверворлд, без env-тумана) — это небо атмосферы + то, что мир нарисовал поверх ванильного неба (`max(scene − vanilla_sky(view), 0)`: спрайты луны, звёзды; квадрат ванильного солнца вырезается вокруг направления солнца и заменяется круглым диском с гало); облака (глубина > 0, нет в G-буфере, y > 8, дальше 48 блоков) на закате/рассвете окрашиваются цветом солнца. Свет неба: ванильный `lightmap` × подкраска цветом неба (зенит сверху, горизонт сбоку; яркость 1) × форма по нормали (вверх 1.1, вниз 0.66), только там, где есть небесный свет. Тени: ближний каскад — PCSS-lite (поиск блокировщиков 8 выборок, ширина полутени от расстояния до блокировщика, 12 выборок спирали Фогеля, повёрнутых IGN; сглаживает TAA), дальний — 3×3. Туман: цвет — небо над горизонтом в направлении пикселя (`haze_colour`) + воздушная перспектива `1 − exp(−d/700)` × 0.5. Далее прежнее: пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет
|
||||
(`d - wd <= 0.2%`); тогда освещается **дно** по данным G-буфера (одна выборка тени, без AO), а в альфу `lit` пишется 0.5 (признак воды). `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
|
||||
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
|
||||
ореолы вокруг источников. Точечные источники — по тайлам 16×16 (`tile_lists`, буфер `tiles`: на тайл 4 слова —
|
||||
начало/длина списка для поверхностей и для ореолов, потом индексы; переполнение → «все источники»).
|
||||
4a. **Композит воды** `fs_water` (`hd.water_composite`, `lit` → `lit_water`, дальше везде `hd.lit_view()`; на пикселях без воды копия).
|
||||
Волны — сумма 10 синусоид в мировых координатах (векторы — целые кратные 2π/256, скорости — целые циклы за 600 с: период 256 блоков
|
||||
и 10 минут, не «плывут» с камерой; мелкие волны гасятся по размеру пикселя против мерцания); нормаль = нормаль грани + градиент
|
||||
(только на горизонтальных гранях). Преломление: смещение луча относительно плоской воды, выборка `lit` по uv со сдвигом (отбраковка
|
||||
по глубине: не берутся банк/сущности перед водой); толщина = расстояние от поверхности до дна; поглощение `exp(-(1-цвет)·0.8·(t+0.3))`
|
||||
+ рассеяние цветом воды при свете «объёма» (лайтмап + солнце с тенью). Отражение: SSR по глубине G-буфера (24 шага, растущие,
|
||||
джиттер IGN по кадру, 5 шагов бинарного уточнения, толщина `0.4+0.08·s`), при промахе — небо/туман (`sky_color`, `fog_color`, тусклее
|
||||
под навесом). Волны: к 10 крупным добавлены 16 мелких (длины 0.35–3 блока, уклон до 0.05, гасятся по размеру пикселя) — густая мелкая рябь для искорок блика; блик GGX с a=0.07. Цвет воды светлее: биом × 0.85, поглощение 0.8, рассеяние 0.85 (ночью до 0.4). Небо отражения и туман — из атмосферы. Френель Шлика F0=0.02 (SSR пропускается при F<0.04: взгляд вниз), блик солнца GGX (a=0.05) × тень, туман поверх.
|
||||
Подводный вид и `HORIZON_HD_WATER=0` — композит и проход воды не работают, `lit` как раньше.
|
||||
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
|
||||
«подсветка огнём» выключена — свет даёт HD).
|
||||
6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории —
|
||||
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к
|
||||
`view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат.
|
||||
**TAAU**: если мир меньше окна (`HORIZON_HD_SCALE=0.67`), тот же проход (`fs_taau`, в таймингах `hd taau`) восстанавливает
|
||||
полное разрешение. История, bloom и tonemap — в разрешении окна. Для каждого пикселя выхода берётся 3×3 выборки мира
|
||||
вокруг (с учётом джиттера в `taa_params.zw`), веса — полином, аппроксимирующий Lanczos-2, по расстоянию от центра
|
||||
пикселя выхода до выборки; variance clip в YCoCg по тем же 3×3; глубина — ближайшая из 2×2; история (Catmull-Rom,
|
||||
полное разрешение) вес 0.92, но ближе к ней там, где ближайшая выборка далеко от центра пикселя. Джиттер —
|
||||
Halton(2,3) на `max(16, ceil(8·ratio²))` кадров (0.67 → 18, 0.5 → 32). При масштабе 1 работает прежний `fs_taa`
|
||||
без изменений. С `HORIZON_HD_TAA=0` и масштабом < 1 остаётся простой билинейный апскейл в tonemap.
|
||||
7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид),
|
||||
выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая).
|
||||
|
||||
Bind groups кэшируются и пересоздаются только при resize/смене источника (`hd_bind_groups_last_frame()` → 0 в
|
||||
установившемся режиме).
|
||||
|
||||
### Привязки `fs_light` (group 0)
|
||||
|
||||
| # | Что |
|
||||
|---|---|
|
||||
| 0 | `HdFrame` (uniform) |
|
||||
| 1, 2 | цвет и глубина мира (ванильный проход) |
|
||||
| 3, 4, 5 | albedo, нормаль, глубина G-буфера |
|
||||
| 6 | источники света (storage, `HdLight`: pos+radius, color+intensity) |
|
||||
| 7 | уровни света (небо, блоки) |
|
||||
| 8, 9 | массив карт теней, comparison-сэмплер |
|
||||
| 10, 11 | лайтмап, линейный сэмплер |
|
||||
| 12 | тайлы источников (storage) |
|
||||
| 13 | AO |
|
||||
| 14 | глубина воды (`water_depth`) |
|
||||
| 15 | текстура неба (`sky_lut`) |
|
||||
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
|
||||
| 18, 19 | объёмные облака, карта теней облаков |
|
||||
| 20 | длина луча каждого текселя облаков (`cloud_dist_tex`, для апсэмплинга по глубине) |
|
||||
|
||||
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
|
||||
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
|
||||
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира), `clouds` (низ слоя относительно камеры, толщина, вкл, секунды ветра), `cloud_cam` (x, z камеры по модулю 65536, добавка покрытия от дождя, история облаков годна), `cloud_dir` / `cloud_sun` (откуда и каким цветом светят облаку: солнце до чуть ниже горизонта, ночью луна), `vfog` (туман вкл, высота камеры, утренний туман).
|
||||
|
||||
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
|
||||
|
||||
## Контракты (цвет, глубина, альфа, время)
|
||||
|
||||
Полная таблица «проход: что читает → что пишет» — в начале `gfx/hd/mod.rs`, подробности — в начале файла каждого прохода.
|
||||
|
||||
- **Цвет.** От освещения до tonemap всё в «показанном» пространстве ванили (гамма, основные цвета Rec. 709), значения могут быть > 1
|
||||
(`Rgba16Float`); tonemap не трогает ≤ 0.8. Исключения: текстура неба — линейная яркость Rec. 709 (марш в Rec. 2020; показывают
|
||||
читатели через `sky_show`); albedo G-буфера — `Rgba8Unorm` в том же показанном пространстве.
|
||||
- **Глубина.** Камера: reversed-Z, `Depth32Float`, 1 — близко, 0 — далеко/ничего (очистка в 0; глубина воды 0 — воды нет). Тени:
|
||||
обычная Z ортопроекции света (0 — у света, 1 — далеко), сравнение `<=`.
|
||||
- **Альфа.** albedo.a — эмиссия (свет добавляет `rgb·a·1.5`); `lit.a`: 1 — обычный пиксель, 0.5 — вода (в rgb дно без воздуха),
|
||||
0.6 — прозрачное перед водой; `air.a` — пропускание воздуха; облака и туман: `a` — пропускание (`c·a + rgb`); вода: цвет.a —
|
||||
свет блоков, нормаль.a — свет неба; история TAA: a — дистанция; AO: x — окклюзия, y — дистанция.
|
||||
- **Координаты.** Позиции относительно камеры; для узоров (волны, шум облаков, ветер) мировая координата передаётся по модулю периода
|
||||
(256, 65536). Мировая проекция с джиттером; `prev_view_proj` — с джиттером для `fs_taa` и без для `fs_taau` (выбирает `Hd::write`).
|
||||
- **Время.** Три разных часа: счётчик кадров (`params.w`, mod 64, вращение шума), время воды (`water.y`, секунды стенных часов mod 600),
|
||||
игровое время (`clouds.w`, секунды `HdScene.seconds` mod 300000, ветер облаков). Реплей, замораживающий игровое время, не
|
||||
останавливает воду.
|
||||
- **Истории.** Две цели TAA и две цели облаков принадлежат своим проходам, создаются в `resize`, сбрасываются при resize, телепорте
|
||||
(> 64 блоков), выключенном TAA; один `Hd` обслуживает одну камеру, истории разных камер не смешиваются.
|
||||
- **Размеры.** world — масштабированная цель мира (G-буфер, AO ½, свет, вода, облака/туман ½), out — окно (TAA, bloom, tonemap).
|
||||
- **Bind groups** кэшируются по видам, которые читают; пересоздаются при resize и смене входного вида; нет роста в установившемся кадре.
|
||||
|
||||
## Как проверять
|
||||
|
||||
```bash
|
||||
cd horizon && cargo build --profile fast -p horizon-client --features mods # без предупреждений
|
||||
cargo fmt -p horizon-client
|
||||
cargo test -p horizon-client --lib hd # naga-разбор всех HD-шейдеров + тест тайлов
|
||||
cd ../airdef-mod && cargo horizon-mod build && cargo test # мод и его шейдер
|
||||
```
|
||||
|
||||
WGSL-ошибки всплывают только в рантайме — поэтому каждый новый шейдер добавляй в список `shaders()` в тестах
|
||||
`gfx/hd/mod.rs`: `lighting_shader_parses` (naga-разбор каждого итогового шейдера), `every_wgsl_file_is_tested` (каждый `.wgsl`
|
||||
входит в проверенный шейдер), `bindings_match_the_layouts` (привязки WGSL = `*_BINDINGS` в Rust: номера, виды, размерности),
|
||||
`frame_layout_matches_the_prelude` (поля и размер `HdFrame` в Rust и в прелюдии).
|
||||
|
||||
Игра офскрин (только одна копия за раз; данные в `run-home/`, мир `Polygon`):
|
||||
|
||||
```bash
|
||||
cd run-home && HORIZON_HOME=$PWD HORIZON_NO_AUDIO=1 ../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock \
|
||||
launch --world Polygon --offscreen --wait --game ../horizon/target/fast/horizon-client \
|
||||
--resources "$(ls -d "$HOME/Library/Application Support/horizon-launcher/versions"/*/.local/horizon-client/26.3 | sort -V | tail -1)"
|
||||
H="../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock"
|
||||
$H command "time set 4500"; $H command "tp @s 40 76 40 135 25" # полигон с техникой; z ≥ 60 — море
|
||||
# команды мода — через чат: $H key t tap; $H type "/airdef shader hd"; $H key return tap
|
||||
$H key f1 tap; $H screenshot out.png; $H bench 4; $H quit
|
||||
```
|
||||
|
||||
Тайминги проходов на GPU: `HORIZON_SHADER_PACK_TIMING=1` (лог в `run-home/ctl.log`) или список F3.
|
||||
Ориентиры (M5, 2560×1440): тени 0.6 + 2.85 мс (дальний каскад через кадр, ≈1.4 в среднем), G-буфер 0.8,
|
||||
AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈ 15 мс GPU (замеры шумные).
|
||||
TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс; 0.67 — 13.4 мс; 0.5 — 10.4 мс (освещение 6.1 → 2.7 → 1.6, AO 3.5 → 1.4 → 0.8, `hd taau` ≈ 3.5 мс на любом масштабе, дальний каскад теней 3.4 мс не зависит от масштаба).
|
||||
Следующие цели по скорости: освещение (лучи 14 шагов на полном разрешении → полразрешения + апскейл) и AO.
|
||||
|
||||
Проверка мерцания: два скриншота с неподвижной камеры подряд и разница по пикселям (Pillow `ImageChops.difference`)
|
||||
— на мире `Hills` с травой и деревьями; после исправлений ≈2% пикселей с разницей > 20/255 (дрожание TAA на пиксельных
|
||||
текстурах и анимация воды), а при ошибке теней было 8.5%. Сравни с `HORIZON_HD_TAA=0` / `HORIZON_HD_AO=0` /
|
||||
`HORIZON_HD_SHADOW_FAST=0`, чтобы найти виновника.
|
||||
|
||||
Графический полигон: `python3 tools/hd_bench.py` (`--list` — сцены; `--only polygon_noon` — одна сцена; `--env
|
||||
HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home-bench/out/before` — diff ×6 и
|
||||
пороги; `--budget-ms 18` — GPU-бюджет). Сам копирует `Polygon`/`Hills` в `run-home-bench/home`, сокет
|
||||
`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами. Время и погода
|
||||
заморожены (`gamerule advance_time/advance_weather false`); вода и листва идут по часам стены, поэтому кадры сравниваются
|
||||
по статичным пикселям: 3 кадра подряд, пиксели с размахом > 16/255 (вода, облака, листва) не участвуют в сравнении с базой
|
||||
и в мерцании (маска `<сцена>_motion.png`); `--no-freeze` — живой замер производительности; `--help` — все пороги.
|
||||
|
||||
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
|
||||
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
|
||||
плоскости YZ.
|
||||
|
||||
## Статус
|
||||
|
||||
| Этап | Состояние | Коммит |
|
||||
|---|---|---|
|
||||
| 1. G-буфер, точечный свет мода в HDR, bloom, тон-кривая; API `set-lights`/`set-lighting` | готово | 0a56f56 (форк) |
|
||||
| 2. Тени солнца/луны, переосвещение мира, лучи, ореолы, туман, грейд | готово | патч 12 |
|
||||
| Кэш bind groups, тени только от тенеотбрасывателей (#12, #13) | готово | патч 13 |
|
||||
| Тайловые списки источников (#10) | готово | патч 14 |
|
||||
| Тайминги HD-проходов (#9), GTAO | готово | патч 15, ed2db85 |
|
||||
| Отсечение взрывов в `effects.wgsl` (#11), порядок дыма (#16), метрика перекрытия свечения (#14) | готово | 67a2a52, ed2db85 |
|
||||
| Тени: только грани к солнцу, без «закопанных» секций, дальний каскад через кадр (`HORIZON_HD_SHADOW_FAST=0` — по-старому) | готово | патч 16 |
|
||||
| TAA: джиттер Halton(2,3) ×16, перепроекция по глубине и сдвигу камеры, variance clip в YCoCg, история Catmull-Rom 0.9, лёгкий шарп в tonemap (`HORIZON_HD_TAA=0` — выкл) | готово | патч 16 |
|
||||
| TAAU: мир в `HORIZON_HD_SCALE` (0.25–1, по умолчанию 1 = как раньше), восстановление в TAA-проходе `fs_taau`; без LOD-bias текстур (не доступен) | готово | патч 18 |
|
||||
| Исправлено мерцание теней у игрока: ближний каскад терял секции в кадры, когда дальний держится; держимая матрица дальнего каскада сдвигается на шаг камеры; у растений (наклонные квады) нормаль «вверх», а не к камере; TAA держит 94% истории в покое | готово | патч 18 |
|
||||
| Вода: проход `fs_water`, преломление, SSR, Френель, блик (`HORIZON_HD_WATER=0` — выкл) | готово | патч 19 (+20: без ванильной текстуры на поверхности, цвет темнее и глубже) |
|
||||
| Вода: светлее, густая мелкая рябь (26 волн), блик a=0.07 (мерцание на реке ≈1.4% пикселей >20/255) | готово | патч 21 |
|
||||
| Небо и атмосфера (Рэлей/Ми/озон, текстура неба, воздушная перспектива, цвет солнца из пропускания, голубоватый ambient по нормали, PCSS-lite ближнего каскада, диск солнца, цвет облаков на закате; порт констант и Chapman из Photon, MIT) | готово | патч 22 |
|
||||
| Закат как у Photon: атмосфера в Rec. 2020 (коэффициенты и матрицы Photon), фаза Клейна-Нишины, тинт и усиление солнца на закате/рассвете/синем часе (`get_sun_tint`), насыщенность неба у низкого солнца | готово | патч 23 |
|
||||
| Объёмные облака (рэймарч, мультирассеяние Photon, тени на землю, TAA-история, ванильные скрыты) | готово | патч 24 |
|
||||
| Объёмный туман (лучи через тени каскадов, высотная дымка, утренний туман, полразрешения) | готово | патч 25 |
|
||||
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
|
||||
| #41: пасмурность неба и сила солнца берутся из мирового уровня дождя (`Sky.rain`, ставит `apply_weather`), а не из наличия геометрии осадков | готово | в рабочем дереве форка |
|
||||
| #38: TAA без апскейла ищет историю по матрице прошлого кадра **с джиттером** (`HdScene.prev_view_proj_jittered`); `fs_taau` по-прежнему берёт матрицу без джиттера | готово | в рабочем дереве форка |
|
||||
| #35: ванильные облака (HD-облака выключены) красятся закатным цветом в собственном проходе (`hd::vanilla_cloud_tint` → цвет в `clouds::Input`), эвристика по глубине G-буфера убрана из `fs_light` | готово | в рабочем дереве форка |
|
||||
| #37: воздух (ванильный туман, aerial, объёмный туман, лучи, ореолы) на воде считается до **поверхности** и кладётся один раз поверх готовой воды: `fs_light` пишет во вторую цель `air` (рассеяние + пропускание), `fs_water` применяет её после поглощения | готово | в рабочем дереве форка |
|
||||
| #36: ванильная вода не рисует цвет в HD (`fs_translucent` при `Globals.hd_flags.x`, глубину оставляет); прозрачное перед водой (`d` ближе `wd`) не отключает HD-воду: вода считается на освещённом дне и добавляется к сцене с весом 0.7; преломление не берёт тексели с таким прозрачным (`textureGather` по альфе) | готово (проверены стекло; частицы и scale 0.67 не проверялись) | в рабочем дереве форка |
|
||||
| #45: `gfx/hd.rs` разделён на модуль `gfx/hd/` (по файлу на проход: `shadows`, `gbuffer`, `ao`, `sky`, `volumes`, `lighting`+`tiles`, `water`, `taa`, `bloom`, `tonemap`, `frame`), WGSL вынесен в `.wgsl` (`include_str!`, общая прелюдия), у каждого прохода своя структура ресурсов/пайплайнов; контракты цвета, глубины, альф и времени записаны в `hd/mod.rs` и в начале файлов проходов; тесты привязок и размера `HdFrame`. Вид не менялся (сверка `tools/hd_bench.py`) | готово | в рабочем дереве форка |
|
||||
| #39: веер восхода `vanilla_sunrise_weight` отсекается до цикла по 16 треугольникам выше горизонта (знак x стороны солнца и наклон ≤ α/3; проверено перебором 200 тыс. лучей без пропусков); `hd lighting` на рассвете 5.3 → 3.3 мс (M-серия, 2560×1440) | готово | в рабочем дереве форка |
|
||||
|
||||
| Материалы и блики (план 5): G-буфер `levels.zw` = шероховатость, металличность; GGX-блик солнца с Френелем и отражение неба (`sky_along`/`atmosphere_sky`) в `shade_surface`; мокрые поверхности и лужи в дождь (`HdFrame.surface`); `HORIZON_HD_SPECULAR=0` — выкл. | готово | в рабочем дереве форка |
|
||||
| Ветер (план 7): листва и растения качаются в вершинной стадии террейна (`Globals.hd_wind`, `hd_materials`), те же смещения в тенях; `HORIZON_HD_WIND=0` — выкл. | готово | в рабочем дереве форка |
|
||||
| #67: облака марш идут до ближайшей поверхности (`fs_cloud` читает глубину мира, вторая цель — длина луча, история отбрасывается при другой длине); `fs_light` кладёт облака на все пиксели (`cloud_at`: 4 текселя с весом по близости длины луча), под туман и над поверхностью, для воды — в `air`; луч внутри слоя вдоль горизонта больше не пропускается | готово; проверено только пустой полигон (нет гор/техники в слое, снимки `scratchpad/c*.png`), бюджет GPU на Mac не мерился | в рабочем дереве форка |
|
||||
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
|
||||
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
|
||||
| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `gfx/hd/mod.rs` (контракты) и файл нужного прохода.
|
||||
|
||||
1. **TAAU — временной апскейлер** (поверх TAA) — СДЕЛАНО (патч 18, см. статус; масштаб — `HORIZON_HD_SCALE`, переопределяет «масштаб разрешения» настроек Horizon только для HD; поля в `HdSettings` нет). Осталось: LOD-bias текстур террейна, MetalFX. Исходное ТЗ: Мир и G-буфер рендерятся в `render_scale` (например 0.67),
|
||||
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
|
||||
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
|
||||
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
|
||||
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
|
||||
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
|
||||
2. **Вода** — СДЕЛАНО (патч 19, см. «Вода» ниже). Исходное ТЗ: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
|
||||
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
|
||||
3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
|
||||
(`weather`), закат, Незер/Энд (там — ванильное небо).
|
||||
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
|
||||
5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
|
||||
9. Опционально: motion blur, DoF (для кинокамеры повторов).
|
||||
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
|
||||
отражения, затем GI. Отдельный пресет, HD остаётся.
|
||||
|
||||
## Вода (патч 19): что знать преемнику
|
||||
|
||||
- Тайминги проходов: `hd water` (геометрия воды; на Metal его метка часто 0 — работа учитывается в соседних проходах) и `hd water composite`.
|
||||
Замеры 2560×1440, Hills, река на ~40% экрана, M5 (шумно: у меня GPU делят браузеры): композит 0.8–2 мс, `hd lighting` на 2–3 мс
|
||||
выше, чем без воды (теперь освещается дно под водой — раньше эти пиксели были ванильными); всего вода ≈ +3 мс при большой воде
|
||||
на экране, около 0.5–1 мс при малой. Цель «≤ 2 мс» при полной реке не достигнута. Идеи: отражения в полразрешения (с учётом маски воды
|
||||
на границе), SSR только при F>0.04 уже есть, ужать освещение дна (без точечных источников на глубине > N блоков).
|
||||
- Вода находится по слоям спрайтов. LOD-тайлы воды (`lod/mesh.rs`) идут тем же путём, если их слой — water_still; вода из resource pack с
|
||||
другим именем спрайта не распознается (останется ванильной). `water_overlay` (боковые грани у стекла) не трогаем.
|
||||
- Стекло/частицы/лёд перед водой: проверка `d - wd` возвращает ванильный вид пикселя (вода под стеклом без эффекта).
|
||||
- Известные ограничения: отражается только то, что есть на экране (и только непрозрачное: листва — да, вода/стекло — нет); дождь на воде,
|
||||
пена у берега, подводная каустика не сделаны; ночью вода слегка ярче неба (нужно привязать к модели неба — п. 4 плана «Небо»); отражение
|
||||
солнечного диска — только блик GGX (без диска в SSR). Проверка мерцания на реке: разница двух скриншотов ≈1.6% пикселей >20/255 (норма ~2%).
|
||||
|
||||
## Небо и свет (патч 22): что знать преемнику
|
||||
|
||||
- Тайминги (M5, 2560×1440, Hills, река в кадре): `hd sky` ≈ 2.4–4.7 мс по таймстемпам, но кадр не вырос (GPU avg 18.4 мс против 18.1) — на Metal метка крошечного прохода ловит ожидание соседних; с кэшем по высоте солнца в покое проход не выполняется. Освещение 4.0–5.1 мс (PCSS-lite и небо почти не добавили), композит воды 0.6–2.4, TAA 2.6–2.9.
|
||||
- Яркость: небо — физическая радиация → сжатие по максимальному каналу (`SKY_EXPOSURE` 0.85) → гамма 1/1.9 → насыщенность 1.2 (+0.45 у низкого солнца). Весь конвейер в гамма-пространстве, поэтому цвета неба и подкраски света — «показанные» значения; подкраска света неба имеет яркость 1 и не меняет экспозицию (яркость задаёт ванильный лайтмап × `ambient.x` = 0.62 днём, 0.95 ночью).
|
||||
- Цвет солнца (`light_of`): пропускание атмосферы вдоль солнца (`hd_sky::sun_transmittance`), нормировано на 1, степень 0.6, × (1, 0.97, 0.88). Луна — холодная, как раньше.
|
||||
- Дождь: `sky_extra.z` смешивает небо/дымку с ванильным градиентом (серым от ванили); Нижний мир/Энд/подводное — `sky_sun.w = 0`, всё как раньше (ванильное небо и туман).
|
||||
- Облака — по-прежнему ванильные блоки; объёмные облака — п. 4 плана. Окраска облаков цветом закатного солнца — эвристика по маске (глубина есть, G-буфера нет, высота и дальность), может задеть далёкие частицы.
|
||||
- Идеи дальше: многократное рассеяние по Hillaire вместо плоского члена, тени облаков, диск луны с фазой, звёзды/галактика по Photon (`sky.glsl`), розовый оттенок заката (`get_sun_tint` у Photon).
|
||||
|
||||
## Правила
|
||||
|
||||
- Код Photon (MIT) переносить можно только с атрибуцией (см. «Что это»); код других шейдерпаков — нельзя.
|
||||
- Ничего не ломать в `off`/`on`; HD выключен — код HD не работает вовсе (`hd = None`).
|
||||
- Без потери качества ради скорости без договорённости (шаги, разрешение, число эффектов).
|
||||
- Каждый новый WGSL — в naga-тест. Каждый новый проход — в тайминги (`PassTiming`).
|
||||
- Комментарии — короткие предложения о том «зачем»; в `gfx.rs` префикс `AirDef fork:`.
|
||||
- Коммиты: в форке, затем патчи; подпись `Co-Authored-By: Claude <модель> <noreply@anthropic.com>`.
|
||||
- Пуш в Gitea с машины без DNS: `ssh://git@100.92.6.40:2222/chexit/airdef.git` (ключ `~/Documents/claude-keys/deploy`).
|
||||
- Обнови этот документ (статус + что изменилось в конвейере) в том же коммите, что и код.
|
||||
|
||||
## Тюнинг после проверки на полигоне (патч 22)
|
||||
|
||||
Голубая заливка неба по умолчанию делала белый бетон «снегом»: оттенок неба в заливке ослаблен до 0.4
|
||||
(`sky_light_tint`, в `fs_light`), заливка днём `ambient.x` = 0.5 (+0.45 ночью), солнце 0.7 и теплее
|
||||
(`light_of`: множитель (1, 0.93, 0.78)). Проверять баланс на `Polygon` в полдень (`time set 6000`) — белое должно
|
||||
оставаться белым с тёплым оттенком.
|
||||
|
||||
|
||||
## Закат, облака и туман (патчи 23–25): что знать преемнику
|
||||
|
||||
- **Закат (23).** Небо марширует в Rec. 2020 (коэффициенты Photon сдвинуты матрицей `rec709_to_rec2020`, результат возвращается в 709): это убрало жёлто-зелёный горизонт. Добавлены фаза Клейна-Нишины для Ми и деполяризованная Рэлея, `sun_light_tint` (розоватый тинт и подъём яркости на закате, фиолетовый на синем часе, из `get_sun_tint`), сильнее насыщенность у низкого солнца (`sky_show`). Цвет прямого света (`light_of`) умножен на тот же тинт. Сверка: 12000/12500/13000 (запад) и 23000/23500 (восток) — оранжевый/розовый/пурпурный; полдень Polygon (6000) прежний.
|
||||
- **Облака (24).** Слой 192.33…264.33 блока (`CLOUD_BASE/CLOUD_THICKNESS`), ветер 3 блока/с (`CLOUD_WIND`), шум — текстуры, сгенерированные на CPU при создании HD (`hd_noise.rs`: 2D 256² покрытие/форма, 3D 64³ Worley). Плотность — по `clouds_cumulus_density` Photon (порог покрытия × форма, «яйцо» по высоте, эрозия Worley, резкий верх), рассеяние — `clouds_cumulus_scattering` (октавы, пудра, фазы). Цвет света — `cloud_light` (Rust): пропускание атмосферы на высоте солнца; солнце светит облакам до −0.2 по высоте, дальше луна. Воздушная перспектива `exp(−d/3600)` к цвету дымки. История сбрасывается при resize, телепорте и без TAA. Опция ванильных облаков «выкл» → `frame.clouds` пуст → нет ни облаков, ни теней. Тень: `cloud_shadow` в `fs_light` (карта оптической толщины; гаснет при солнце ниже ~0.1–0.25 по высоте).
|
||||
- **Туман (25).** `fs_fog`: 18 шагов, плотность `fog_sigma` (высотная дымка от y=62, масштаб 42; утренний туман — пока солнце на «утренней» стороне и низко; пятна из 3D-шума; дождь; затухание ниже y≈28–58, чтобы не светить в пещерах), свет — Photon-смесь двух HG, «вечернее свечение» ×1…3. Старые 14 шагов лучей остаются при `HORIZON_HD_VOLFOG=0`; ореолы вокруг источников мода — всегда.
|
||||
- **Тайминги.** Метки Metal (`HORIZON_SHADER_PACK_TIMING=1`, Hills, 2560×1440): `hd clouds` ≈ 6 мс (завышено: метка ловит ожидание соседних проходов), `hd cloud shadows` 0.2–3 (шум), `hd fog` ≈ 1.3. По среднему кадру GPU «оба выкл → оба вкл»: +0.2…3.7 мс (замеры шумные: рядом шла чужая игра на том же GPU). Как удешевить: у облаков — длина шага (14) и потолок шагов (26) в `fs_cloud`, 3 шага света, 4 октавы, облака в четверть разрешения; у тумана — `FOG_STEPS`.
|
||||
- **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет; это тело без прелюдии, она добавляется всегда) — правки видны после перезапуска игры без пересборки; готовое переносится в `gfx/hd/*.wgsl` вручную.
|
||||
- **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум).
|
||||
|
||||
## Заметки по исправлениям #35–#41 (преемнику)
|
||||
|
||||
- **Цели света.** `fs_light` пишет две цели: `lit` и `air` (`Rgba16Float`). Альфа `lit`: 1 — обычный пиксель, 0.5 — вода (в `lit` дно без воздуха, в `air` рассеяние воздуха до поверхности и пропускание в альфе), 0.6 — прозрачное перед водой (в `lit` сцена как нарисована, в `air.rgb` освещённое дно). Привязка 11 композита воды — `air`.
|
||||
- **Ванильная вода в HD.** Пока `hd.water_active()`, `fs_translucent` не рисует цвет воды (альфа 0), но пишет её глубину; флаг — `Globals.hd_flags.x` (поле добавлено в Rust-`Globals`, `terrain.wgsl`, `entity.wgsl`; пишется после `hd.write` и в `hd.main_globals`). Вода без дна (`g = 0`) считается водой толщиной 48.
|
||||
- **Не сделано.** Полупрозрачное перед водой берёт вес 0.7 на «вклад воды» (приближение: настоящий порядок «вода → прозрачное» потребовал бы разделить проход сцены). Частицы перед водой, `HORIZON_HD_SCALE=0.67`, поиск мерцания на рябь после этих правок не прогонялись.
|
||||
- **`HORIZON_HD_TAA=0`.** На Polygon в полдень (`tp @s 40 76 40 135 15`) HD с выключенным TAA выглядит так же (дымка, небо, тени на месте), GPU 16.5 мс против 18.1 мс с TAA (минус ≈ проход TAA); потери вида и падения до 4.8 мс не воспроизвелись. В коде `HORIZON_HD_TAA` читают только `taa_enabled()` (джиттер, проход `taa`, история), остальные проходы от него не зависят. Если проявится снова — проверь, что до шота прошёл `/airdef shader hd` (при неготовой команде кадр без HD ~5 мс).
|
||||
|
||||
## Материалы, мокрое, ветер: что знать преемнику
|
||||
|
||||
- **Класс спрайта.** `gfx/hd/materials.rs` по имени спрайта (`minecraft:block/<имя>`) даёт класс 0..8 (обычный, листва, растение, нижняя/верхняя половина двойного растения, металл, гладкое, глянец, дерево). Таблица — по 4 бита на слой, первые 2048 слоёв (`Globals.hd_materials`, 64 × `vec4<u32>`, считается в `game.rs` и кладётся в `Renderer`); спрайты модов дальше 2048 — обычные. Числа `MAT_*` в `terrain.wgsl` держать в синхроне. Добавить блок в класс — список имён в `classify`.
|
||||
- **G-буфер.** `levels.zw` (было не занято) = шероховатость и металличность (`material_params` в `terrain.wgsl`; для сущностей — по оверлею в `fs_entity_gbuffer`: обычные 0.9/0; модели мода `41` — крашеный металл 0.42/0.3 (по умолчанию для всех моделей мода, `game/mod_models.rs`), `42` — голый металл 0.28/1 (мод пока не умеет выбирать), `40` — свечение 0.5/0). Вода своим проходом, без материала.
|
||||
- **Блик.** В `shade_surface` (`light.wgsl`): GGX (α = rough², не ниже 0.03) × Смит × Френель Шлика, у шероховатых Френель почти не растёт (`max(gloss, F0)`); цвет — солнце × тень × `sky_reach`; потолок 6. Отражение неба: `atmosphere_sky(reflect)` × Френель × gloss² × доступ к небесному свету × AO, без SSR на террейне. Металл сохраняет 35% диффузного (карты окружения нет). Дно под водой блеск не получает.
|
||||
- **Мокро.** `HdFrame.surface.x` — влажность 0..1 (CPU, `Hd::wetness`): растёт за ~15 с дождя к уровню дождя мира, сохнет ~150 с. Под открытым небом (`lm.x` > 0.8) поверхности темнеют до ×0.7 (боковые грани слабее) и их шероховатость падает к 0.12; лужи — на плоских гранях вверх, по 2-октавному value-шуму в мировых координатах (`wet_noise`, период 256 блоков), с влажности ~0.3, альбедо ×0.6 и шероховатость 0.03, отражают небо.
|
||||
- **Ветер.** `terrain_vertex` смещает вершины листвы (±0.04 блока, непрерывно по мировой позиции, чтобы блоки не расходились) и растений (до 0.14 блока вверху спрайта; низ — по `uv.y`; двойные растения стыкуются на середине). Волны — целые циклы на 256 блоков и 600 секунд (как у воды). Данные ветра — в `Globals.hd_wind` (сила 1 + 1.2·дождь, секунды, камера mod 256); они же в глобалах каскадов теней, поэтому смещение одно и то же во всех проходах (ванильный, G-буфер, тени, глубина совпадают). Вне HD сила 0 — вершинный шейдер не меняется. TAA не знает о движении листвы (малые амплитуды).
|
||||
- **Переключатели.** `HORIZON_HD_WIND=0`, `HORIZON_HD_SPECULAR=0` (блик, отражения и мокрое).
|
||||
|
||||
## Проверка материалов/мокрого/ветра (что видно и что нет)
|
||||
|
||||
- Увидено в игре (Hills, `run-home-bench/out/mat1`, `out/rain`): ветер работает, листва и трава двигаются, артефактов швов нет; в дождь сцена заметно темнее (мокрое), небо серое. Не проверено глазами: блики на технике (в Polygon сцена бенча сейчас без техники, не сопоставима с базой `before-mat`), отдельные лужи (кадр дождя смотрит в небо), высыхание (`out/rain/drying_30s.png` снят, не разобран), ночь.
|
||||
- Мерцание: `hills_river_noon` 9% без ветра (`out/nowind`) против 31% с ветром — это движение листвы вблизи камеры, не шум; амплитуда листвы снижена с 0.04 до 0.025 после этого замера (повторно не мерилось).
|
||||
- Тайминги **не сняты**: параллельно шли игры других агентов (числа 25–155 мс недействительны). Перед замером брать `run-home/.gpu-lock`.
|
||||
- Известно: `shaders_translate_to_hlsl_for_directx_12` падает на `cloud.wgsl` (не мой файл).
|
||||
|
||||
## Камеры мода: полный вид и мониторы (#56, #51)
|
||||
|
||||
- **Главный вид** (`render.set-main-view`, API 15): мир экрана рисуется из камеры мода (`set-views`) полным конвейером — HD, небо, облака, туман, объёмный дым, TAA, пост-проход мода, разрешение окна. Руку, контур блока и ванильный HUD не рисуем (`game.rs`: `mod_main_view`, `show_hud`). Так показываются повторы Full/Reel/моменты; чанки остаются вокруг игрока, слушатель — игрок.
|
||||
- **Малые виды** (мониторы, `gfx/mod_views.rs`) остаются экономными: небо, твёрдая земля, сущности и вторым проходом пост-эффект мода с камерой этого вида (`ModPost::write_to/run_view`, режим `params[0].w = 0`: пост сам освещает землю и рисует дым). Бюджет по площади картинки (`post_budget`: <4096 px без пост-прохода, до 150k px — 4 взрыва/12 огней, до 600k — 12/32, больше — всё). HD-освещения, облаков и TAA у малых видов нет.
|
||||
- Мод в повторе берёт взрывы из записи (`record::replay_blasts`, чистая функция времени), время идёт по часам повтора (`params[255].x` — доля частичного тика, которую эффекты не используют при паузе/замедлении).
|
||||
- #51: на перехвате слои звука (`cinema.rs`), свет-«удар» (белая вспышка в HD-освещении, радиус до 256), пик экспозиции/bloom (`set-lighting`), в `effects.wgsl` широкое гало, подсветка неба/облаков и яркое кольцо ударной волны на экране.
|
||||
+4
-2
@@ -55,7 +55,7 @@ cd ~/Downloads/airdef-macos-arm64
|
||||
|
||||
```powershell
|
||||
rustup target add wasm32-unknown-unknown
|
||||
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
|
||||
git clone https://gitea.maoniao.team/chexit/airdef.git
|
||||
cd airdef
|
||||
py tools\airdef.py all # форк Horizon + сборка игры + мод + установка мода
|
||||
py tools\airdef.py play # запуск; мир: py tools\airdef.py play --world "Имя мира"
|
||||
@@ -69,12 +69,14 @@ py tools\airdef.py play # запуск; мир: py tools\airdef.py play
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
|
||||
git clone https://gitea.maoniao.team/chexit/airdef.git
|
||||
cd airdef
|
||||
python3 tools/airdef.py all
|
||||
python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "Имя мира"
|
||||
```
|
||||
|
||||
Клон по HTTPS не требует ключа. Если у тебя есть SSH-ключ в Gitea, можно и так: `git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git`.
|
||||
|
||||
Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру),
|
||||
`mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/10] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/31] 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/10] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/31] 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/10] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/31] 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/10] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/31] 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/10] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/31] 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/10] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/31] 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/10] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/31] 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/10] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/31] 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/10] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/31] 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/10] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/31] 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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,439 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/31] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 39 ++++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 247 +++++++++++++++++-----------
|
||||
2 files changed, 188 insertions(+), 98 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index e351763fd..f6bbe038c 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -459,6 +459,8 @@ pub struct Renderer {
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
entity_batch: EntityBatch,
|
||||
+ /// AirDef fork: the runs of entity quads (first, end) that cast shadows this frame.
|
||||
+ shadow_casters: Vec<(u32, u32)>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
lightmap: wgpu::Texture,
|
||||
@@ -2744,6 +2746,7 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
+ shadow_casters: Vec::new(),
|
||||
group,
|
||||
globals,
|
||||
lightmap,
|
||||
@@ -4980,6 +4983,30 @@ impl Renderer {
|
||||
}
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
if shadow_strength > 0.0 {
|
||||
+ // The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
|
||||
+ // glowing mod geometry), as runs, so those are not vertex-processed twice.
|
||||
+ self.shadow_casters.clear();
|
||||
+ let mut start = None;
|
||||
+ let quads = self.entity_batch.combined.as_chunks::<4>().0;
|
||||
+ for (i, quad) in quads.iter().take(entity_quads as usize).enumerate() {
|
||||
+ let caster = !matches!(quad[0].layer, 4 | 9 | 10 | 40);
|
||||
+ match (caster, start) {
|
||||
+ (true, None) => start = Some(i as u32),
|
||||
+ (false, Some(first)) => {
|
||||
+ self.shadow_casters.push((first, i as u32));
|
||||
+ start = None;
|
||||
+ }
|
||||
+ _ => {}
|
||||
+ }
|
||||
+ }
|
||||
+ if let Some(first) = start {
|
||||
+ self.shadow_casters.push((first, entity_quads));
|
||||
+ }
|
||||
+ // Many tiny runs cost more in draw calls than they save: draw everything.
|
||||
+ if self.shadow_casters.len() > 64 {
|
||||
+ self.shadow_casters.clear();
|
||||
+ self.shadow_casters.push((0, entity_quads));
|
||||
+ }
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
@@ -5021,11 +5048,13 @@ impl Renderer {
|
||||
let instance = hd_shadow_first + k as u32;
|
||||
pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
|
||||
}
|
||||
- if entity_quads > 0 {
|
||||
+ if !self.shadow_casters.is_empty() {
|
||||
pass.set_pipeline(&self.shadow_entity);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
- pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
+ for &(first, end) in &self.shadow_casters {
|
||||
+ pass.draw_indexed(first * 6..end * 6, 0, 0..1);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size);
|
||||
@@ -5586,7 +5615,7 @@ impl Renderer {
|
||||
);
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& hd_on
|
||||
- && let Some(hd) = &self.hd
|
||||
+ && let Some(hd) = &mut self.hd
|
||||
{
|
||||
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
|
||||
// that, then bloom and the tone curve onto the screen.
|
||||
@@ -5607,9 +5636,9 @@ impl Renderer {
|
||||
self.mod_params.1,
|
||||
);
|
||||
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
- hd.finish(&self.device, &mut encoder, &hd.fx, &view_target);
|
||||
+ hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
|
||||
}
|
||||
- _ => hd.finish(&self.device, &mut encoder, &hd.lit, &view_target),
|
||||
+ _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
|
||||
}
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& mod_post
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index c36c1795d..fe2634f2c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -359,6 +359,21 @@ fn target(
|
||||
Target { view }
|
||||
}
|
||||
|
||||
+/// The outside views and sampler the lighting group reads.
|
||||
+type LightKey = (
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::Sampler,
|
||||
+);
|
||||
+
|
||||
+/// What bloom and the tone curve read: the lit world, or the post pass's output.
|
||||
+#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
+pub enum HdSource {
|
||||
+ Lit,
|
||||
+ Fx,
|
||||
+}
|
||||
+
|
||||
pub struct Hd {
|
||||
size: [u32; 2],
|
||||
pub albedo: wgpu::TextureView,
|
||||
@@ -377,6 +392,15 @@ pub struct Hd {
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
bloom: Vec<Target>,
|
||||
+ /// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
+ /// lighting and the source-reading ones are keyed by the views they read.
|
||||
+ light_group: Option<(LightKey, wgpu::BindGroup)>,
|
||||
+ prefilter_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
|
||||
+ tone_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
|
||||
+ /// Reading bloom level k - 1 (for the down pass into level k, index k - 1) and level k (for
|
||||
+ /// the up pass onto level k - 1, index k - 1).
|
||||
+ down_groups: Vec<wgpu::BindGroup>,
|
||||
+ up_groups: Vec<wgpu::BindGroup>,
|
||||
frame: wgpu::Buffer,
|
||||
lights: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -656,6 +680,11 @@ impl Hd {
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
bloom: Vec::new(),
|
||||
+ light_group: None,
|
||||
+ prefilter_group: None,
|
||||
+ tone_group: None,
|
||||
+ down_groups: Vec::new(),
|
||||
+ up_groups: Vec::new(),
|
||||
frame,
|
||||
lights,
|
||||
sampler,
|
||||
@@ -690,6 +719,16 @@ impl Hd {
|
||||
target(device, "HD bloom", s, HDR)
|
||||
})
|
||||
.collect();
|
||||
+ // Every group names a view that was just replaced.
|
||||
+ self.light_group = None;
|
||||
+ self.prefilter_group = None;
|
||||
+ self.tone_group = None;
|
||||
+ self.down_groups = (1..self.bloom.len())
|
||||
+ .map(|k| self.bloom_group(device, &self.bloom[k - 1].view))
|
||||
+ .collect();
|
||||
+ self.up_groups = (1..self.bloom.len())
|
||||
+ .map(|k| self.bloom_group(device, &self.bloom[k].view))
|
||||
+ .collect();
|
||||
}
|
||||
|
||||
/// This frame's camera, sun, fog, lights and settings.
|
||||
@@ -829,73 +868,85 @@ impl Hd {
|
||||
|
||||
/// Lights the world (its colour and depth) into `lit`.
|
||||
pub fn light(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
|
||||
) {
|
||||
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("HD lighting"),
|
||||
- layout: &self.light_layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: self.frame.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::TextureView(world.0),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 2,
|
||||
- resource: wgpu::BindingResource::TextureView(world.1),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 3,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.albedo),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 4,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.normal),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 5,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.depth),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 6,
|
||||
- resource: self.lights.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 7,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.levels),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 8,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 9,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 10,
|
||||
- resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 11,
|
||||
- resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
+ let key = (
|
||||
+ world.0.clone(),
|
||||
+ world.1.clone(),
|
||||
+ lightmap.0.clone(),
|
||||
+ lightmap.1.clone(),
|
||||
+ );
|
||||
+ if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ layout: &self.light_layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: self.frame.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.1),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.albedo),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 4,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.normal),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 5,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 6,
|
||||
+ resource: self.lights.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 7,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.levels),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 8,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 9,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 10,
|
||||
+ resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 11,
|
||||
+ resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ self.light_group = Some((key, group));
|
||||
+ }
|
||||
+ let Some((_, group)) = &self.light_group else {
|
||||
+ return;
|
||||
+ };
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD lighting",
|
||||
&self.lit,
|
||||
false,
|
||||
&self.light_pipeline,
|
||||
- &group,
|
||||
+ group,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -922,12 +973,53 @@ impl Hd {
|
||||
|
||||
/// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`.
|
||||
pub fn finish(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
- source: &wgpu::TextureView,
|
||||
+ source: HdSource,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
+ let source = match source {
|
||||
+ HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Fx => self.fx.clone(),
|
||||
+ };
|
||||
+ if self
|
||||
+ .prefilter_group
|
||||
+ .as_ref()
|
||||
+ .is_none_or(|(v, _)| *v != source)
|
||||
+ {
|
||||
+ let group = self.bloom_group(device, &source);
|
||||
+ self.prefilter_group = Some((source.clone(), group));
|
||||
+ }
|
||||
+ if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD tone mapping"),
|
||||
+ layout: &self.tone_layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: self.frame.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(&source),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ self.tone_group = Some((source, group));
|
||||
+ }
|
||||
+ let (Some((_, prefilter)), Some((_, tone))) = (&self.prefilter_group, &self.tone_group)
|
||||
+ else {
|
||||
+ return;
|
||||
+ };
|
||||
// Down: the prefilter into the first level, then each level from the one above.
|
||||
Self::draw(
|
||||
encoder,
|
||||
@@ -935,61 +1027,30 @@ impl Hd {
|
||||
&self.bloom[0].view,
|
||||
false,
|
||||
&self.prefilter,
|
||||
- &self.bloom_group(device, source),
|
||||
+ prefilter,
|
||||
);
|
||||
for k in 1..self.bloom.len() {
|
||||
- let g = self.bloom_group(device, &self.bloom[k - 1].view);
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD bloom down",
|
||||
&self.bloom[k].view,
|
||||
false,
|
||||
&self.down,
|
||||
- &g,
|
||||
+ &self.down_groups[k - 1],
|
||||
);
|
||||
}
|
||||
// Up: each level added onto the bigger one.
|
||||
for k in (1..self.bloom.len()).rev() {
|
||||
- let g = self.bloom_group(device, &self.bloom[k].view);
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD bloom up",
|
||||
&self.bloom[k - 1].view,
|
||||
true,
|
||||
&self.up,
|
||||
- &g,
|
||||
+ &self.up_groups[k - 1],
|
||||
);
|
||||
}
|
||||
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("HD tone mapping"),
|
||||
- layout: &self.tone_layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: self.frame.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::TextureView(source),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 2,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 3,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
- Self::draw(
|
||||
- encoder,
|
||||
- "HD tone mapping",
|
||||
- screen,
|
||||
- false,
|
||||
- &self.tone,
|
||||
- &group,
|
||||
- );
|
||||
+ Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
|
||||
}
|
||||
}
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/31] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 242 +++++++++++++++++++++++++++-
|
||||
1 file changed, 236 insertions(+), 6 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index fe2634f2c..2776219fc 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -16,6 +16,12 @@ pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The most lights drawn a frame.
|
||||
pub const MAX_LIGHTS: usize = 512;
|
||||
+/// The side (pixels) of a tile of the screen with its own short list of lights.
|
||||
+const TILE: u32 = 16;
|
||||
+/// The indices all the tile lists may hold together; a tile past it loops over every light.
|
||||
+const TILE_INDICES: usize = 1 << 19;
|
||||
+/// A list length that means "every light": the list overflowed.
|
||||
+const EVERY_LIGHT: u32 = u32::MAX;
|
||||
/// Bloom's mip levels.
|
||||
const BLOOM_LEVELS: usize = 6;
|
||||
|
||||
@@ -55,7 +61,7 @@ struct HdFrame {
|
||||
fog: [f32; 4],
|
||||
/// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
|
||||
ambient: [f32; 4],
|
||||
- /// x: shadows on; y, z: the cascades' half sizes.
|
||||
+ /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -123,6 +129,10 @@ struct Light {
|
||||
@group(0) @binding(9) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(10) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(11) var linear: sampler;
|
||||
+// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
|
||||
+// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||||
+// every light.
|
||||
+@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
|
||||
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
let count = u32(frame.params.x);
|
||||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
+ let surface_start = tiles[tile];
|
||||
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
+ let halo_start = tiles[tile + 2u];
|
||||
+ let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
+ let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
var c = scene;
|
||||
let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
|
||||
if surface {
|
||||
@@ -199,8 +216,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
|
||||
// The mods' lights.
|
||||
- for (var i = 0u; i < count; i++) {
|
||||
- let l = lights[i];
|
||||
+ for (var k = 0u; k < surface_count; k++) {
|
||||
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
let to = l.pos.xyz - p;
|
||||
let dist = length(to);
|
||||
if dist >= l.pos.w { continue; }
|
||||
@@ -233,8 +250,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
|
||||
c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
}
|
||||
- for (var i = 0u; i < count; i++) {
|
||||
- let l = lights[i];
|
||||
+ for (var k = 0u; k < halo_count; k++) {
|
||||
+ let l = lights[select(tiles[halo_start + k], k, halo_all)];
|
||||
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
|
||||
let t0 = dot(l.pos.xyz, view);
|
||||
let hh = length(l.pos.xyz - view * t0) + 0.5;
|
||||
@@ -403,6 +420,11 @@ pub struct Hd {
|
||||
up_groups: Vec<wgpu::BindGroup>,
|
||||
frame: wgpu::Buffer,
|
||||
lights: wgpu::Buffer,
|
||||
+ /// The tiles' light lists (see `tile_lists`), sized for the screen, and the tiles per row.
|
||||
+ tiles: wgpu::Buffer,
|
||||
+ tiles_x: u32,
|
||||
+ tile_scratch: Vec<Vec<u16>>,
|
||||
+ tile_words: Vec<u32>,
|
||||
sampler: wgpu::Sampler,
|
||||
light_layout: wgpu::BindGroupLayout,
|
||||
light_pipeline: wgpu::RenderPipeline,
|
||||
@@ -414,6 +436,137 @@ pub struct Hd {
|
||||
tone: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
+/// The tiles' header (4 words each) and index lists for a screen of `size`.
|
||||
+fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffer {
|
||||
+ let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
|
||||
+ device.create_buffer(&wgpu::BufferDescriptor {
|
||||
+ label: Some("HD light tiles"),
|
||||
+ size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
|
||||
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
+ mapped_at_creation: false,
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// Where a camera-relative sphere can show on the screen.
|
||||
+enum Cover {
|
||||
+ /// Nowhere: it is wholly behind the camera.
|
||||
+ Nothing,
|
||||
+ /// Anywhere: it crosses the camera's plane, or holds the camera.
|
||||
+ Everywhere,
|
||||
+ /// Inside this pixel rectangle (min x, min y, max x, max y).
|
||||
+ Rect([f32; 4]),
|
||||
+}
|
||||
+
|
||||
+/// The screen cover of the sphere at `c` of radius `r`: the projection of the 8 corners of the
|
||||
+/// box round it. A box is convex and so is its projection while every corner is in front of the
|
||||
+/// camera, so the rectangle holds the whole sphere. With some corners behind, the projection
|
||||
+/// is not bounded and the cover is the whole screen; with all of them behind, no pixel ray
|
||||
+/// (they all point forward) can meet the sphere.
|
||||
+fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
|
||||
+ if !c.is_finite() || !r.is_finite() {
|
||||
+ return Cover::Everywhere;
|
||||
+ }
|
||||
+ // A little more than the shader's radius, against rounding.
|
||||
+ let r = r * 1.01 + 0.01;
|
||||
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
|
||||
+ let mut front = 0;
|
||||
+ for corner in 0..8 {
|
||||
+ let o = glam::Vec3::new(
|
||||
+ if corner & 1 == 0 { -r } else { r },
|
||||
+ if corner & 2 == 0 { -r } else { r },
|
||||
+ if corner & 4 == 0 { -r } else { r },
|
||||
+ );
|
||||
+ let h = *view_proj * (c + o).extend(1.0);
|
||||
+ if h.w > 0.0 {
|
||||
+ front += 1;
|
||||
+ let ndc = glam::Vec2::new(h.x, h.y) / h.w;
|
||||
+ let px = glam::Vec2::new(
|
||||
+ (ndc.x * 0.5 + 0.5) * size[0] as f32,
|
||||
+ (0.5 - ndc.y * 0.5) * size[1] as f32,
|
||||
+ );
|
||||
+ lo = lo.min(px);
|
||||
+ hi = hi.max(px);
|
||||
+ }
|
||||
+ }
|
||||
+ match front {
|
||||
+ 0 => Cover::Nothing,
|
||||
+ 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
|
||||
+ _ => Cover::Everywhere,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
|
||||
+/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
|
||||
+/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
|
||||
+/// surface list holds the lights whose sphere can touch what the tile sees. The halo list
|
||||
+/// holds the lights whose halo can reach a ray of the tile: the shader adds a halo for the
|
||||
+/// view *line* passing within half the radius of the light, so a ray pointing away from a light
|
||||
+/// behind it is lit too, and the mirrored sphere is covered as well. Lights stay in order, so
|
||||
+/// the sums are the ones of the full loop.
|
||||
+fn tile_lists(
|
||||
+ view_proj: &Mat4,
|
||||
+ lights: &[HdLight],
|
||||
+ size: [u32; 2],
|
||||
+ scratch: &mut Vec<Vec<u16>>,
|
||||
+ words: &mut Vec<u32>,
|
||||
+) {
|
||||
+ let tx = size[0].div_ceil(TILE).max(1) as usize;
|
||||
+ let ty = size[1].div_ceil(TILE).max(1) as usize;
|
||||
+ let tiles = tx * ty;
|
||||
+ scratch.resize_with(tiles * 2, Vec::new);
|
||||
+ scratch.truncate(tiles * 2);
|
||||
+ scratch.iter_mut().for_each(Vec::clear);
|
||||
+ let mut add = |list: usize, area: Cover, light: u16| match area {
|
||||
+ Cover::Nothing => {}
|
||||
+ Cover::Everywhere => {
|
||||
+ for t in 0..tiles {
|
||||
+ scratch[t * 2 + list].push(light);
|
||||
+ }
|
||||
+ }
|
||||
+ Cover::Rect([x0, y0, x1, y1]) => {
|
||||
+ let tile = TILE as f32;
|
||||
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
|
||||
+ for y in from(y0, ty)..=from(y1, ty) {
|
||||
+ for x in from(x0, tx)..=from(x1, tx) {
|
||||
+ scratch[(y * tx + x) * 2 + list].push(light);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
+ for (i, l) in lights.iter().enumerate() {
|
||||
+ let c = glam::Vec3::new(l.pos[0], l.pos[1], l.pos[2]);
|
||||
+ add(0, cover(view_proj, c, l.pos[3], size), i as u16);
|
||||
+ // The shader's halo reach: the line within `radius / 2 - 0.5` of the light.
|
||||
+ let reach = l.pos[3] * 0.5 - 0.5;
|
||||
+ if reach > 0.0 || !reach.is_finite() {
|
||||
+ add(1, cover(view_proj, c, reach, size), i as u16);
|
||||
+ add(1, cover(view_proj, -c, reach, size), i as u16);
|
||||
+ }
|
||||
+ }
|
||||
+ words.clear();
|
||||
+ words.resize(tiles * 4, 0);
|
||||
+ let mut room = TILE_INDICES;
|
||||
+ for t in 0..tiles {
|
||||
+ for list in 0..2 {
|
||||
+ let ids = &mut scratch[t * 2 + list];
|
||||
+ // Both the sphere and its mirror can name a light: once is enough.
|
||||
+ ids.dedup();
|
||||
+ let at = t * 4 + list * 2;
|
||||
+ if ids.len() > room {
|
||||
+ words[at + 1] = EVERY_LIGHT;
|
||||
+ } else {
|
||||
+ room -= ids.len();
|
||||
+ words[at] = words.len() as u32;
|
||||
+ words[at + 1] = ids.len() as u32;
|
||||
+ words.extend(ids.iter().map(|&i| u32::from(i)));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
@@ -527,6 +680,11 @@ impl Hd {
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
+ wgpu::BindingType::Buffer {
|
||||
+ ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
+ has_dynamic_offset: false,
|
||||
+ min_binding_size: None,
|
||||
+ },
|
||||
]),
|
||||
});
|
||||
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -687,6 +845,10 @@ impl Hd {
|
||||
up_groups: Vec::new(),
|
||||
frame,
|
||||
lights,
|
||||
+ tiles: tiles_buffer(device, [1, 1]),
|
||||
+ tiles_x: 1,
|
||||
+ tile_scratch: Vec::new(),
|
||||
+ tile_words: Vec::new(),
|
||||
sampler,
|
||||
light_layout,
|
||||
light_pipeline,
|
||||
@@ -713,6 +875,8 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.tiles = tiles_buffer(device, size);
|
||||
+ self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
.map(|k| {
|
||||
let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
|
||||
@@ -743,6 +907,15 @@ impl Hd {
|
||||
if n > 0 {
|
||||
queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n]));
|
||||
}
|
||||
+ // Written every frame, empty too: the shader reads the counts, not `params.x`.
|
||||
+ tile_lists(
|
||||
+ &scene.view_proj,
|
||||
+ &lights[..n],
|
||||
+ self.size,
|
||||
+ &mut self.tile_scratch,
|
||||
+ &mut self.tile_words,
|
||||
+ );
|
||||
+ queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
@@ -773,7 +946,7 @@ impl Hd {
|
||||
if strength > 0.0 { 1.0 } else { 0.0 },
|
||||
CASCADES[0],
|
||||
CASCADES[1],
|
||||
- 0.0,
|
||||
+ self.tiles_x as f32,
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
|
||||
@@ -933,6 +1106,10 @@ impl Hd {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 12,
|
||||
+ resource: self.tiles.as_entire_binding(),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1102,3 +1279,56 @@ pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
|
||||
(projection * view).as_mat4()
|
||||
})
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn lighting_shader_parses() {
|
||||
+ for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
|
||||
+ let source = format!("{COMMON}{body}");
|
||||
+ let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
+ panic!("{name}: {}", e.emit_to_string(&source));
|
||||
+ });
|
||||
+ naga::valid::Validator::new(
|
||||
+ naga::valid::ValidationFlags::all(),
|
||||
+ naga::valid::Capabilities::all(),
|
||||
+ )
|
||||
+ .validate(&module)
|
||||
+ .unwrap_or_else(|e| panic!("{name}: {e:?}"));
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
+ let size = [320, 160];
|
||||
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
|
||||
+ let light = |x: f32, y: f32, z: f32, r: f32| HdLight {
|
||||
+ pos: [x, y, z, r],
|
||||
+ color: [1.0; 4],
|
||||
+ };
|
||||
+ let lights = [
|
||||
+ // Dead ahead and small; far to the side; behind the camera; holding the camera.
|
||||
+ light(0.0, 0.0, -20.0, 4.0),
|
||||
+ light(30.0, 0.0, -5.0, 4.0),
|
||||
+ light(0.0, 0.0, 10.0, 8.0),
|
||||
+ light(1.0, 0.0, 0.0, 6.0),
|
||||
+ ];
|
||||
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
|
||||
+ tile_lists(&proj, &lights, size, &mut scratch, &mut words);
|
||||
+ let list = |tile: usize, which: usize| -> Vec<u32> {
|
||||
+ let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
|
||||
+ words[start as usize..(start + len) as usize].to_vec()
|
||||
+ };
|
||||
+ // The centre tile sees the first light and the one at the camera, in both lists.
|
||||
+ let centre = 5 * 20 + 10;
|
||||
+ assert_eq!(list(centre, 0), vec![0, 3]);
|
||||
+ assert!(list(centre, 1).contains(&0) && list(centre, 1).contains(&3));
|
||||
+ // A far corner sees only the one at the camera.
|
||||
+ assert_eq!(list(0, 0), vec![3]);
|
||||
+ // The light behind the camera shows in the halo list where the view points away.
|
||||
+ assert!(list(centre, 1).contains(&2));
|
||||
+ assert!(!list(0, 0).contains(&2));
|
||||
+ }
|
||||
+}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/31] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 23 +-
|
||||
crates/horizon-client/src/gfx/hd.rs | 430 +++++++++++++++++++++++++++-
|
||||
2 files changed, 443 insertions(+), 10 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index f6bbe038c..e14429b03 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3725,9 +3725,20 @@ impl Renderer {
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
- timer
|
||||
+ let mut times = timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
- .unwrap_or_default()
|
||||
+ .unwrap_or_default();
|
||||
+ // AirDef fork: the HD lighting's passes (shadows, G-buffer, occlusion, lighting, bloom,
|
||||
+ // tone curve) while it is on.
|
||||
+ if let Some(hd) = &self.hd {
|
||||
+ times.extend(hd.pass_times());
|
||||
+ }
|
||||
+ times
|
||||
+ }
|
||||
+
|
||||
+ /// AirDef fork: bind groups the HD lighting made in the last frame (0 in a steady state).
|
||||
+ pub fn hd_bind_groups_last_frame(&self) -> Option<u64> {
|
||||
+ self.hd.as_ref().map(|hd| hd.groups_last_frame)
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
@@ -4981,6 +4992,8 @@ impl Renderer {
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
+ hd.set_timing(device, &self.queue, self.pass_timing);
|
||||
+ hd.timing_begin(device, &self.queue);
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
if shadow_strength > 0.0 {
|
||||
// The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
|
||||
@@ -5090,6 +5103,9 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
+ drop(pass);
|
||||
+ // Contact shadows from the G-buffer's depth and normals, for the lighting.
|
||||
+ hd.ao(&mut encoder);
|
||||
} else if !hd_on {
|
||||
self.hd = None;
|
||||
}
|
||||
@@ -5831,6 +5847,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
+ if hd_on && let Some(hd) = &mut self.hd {
|
||||
+ hd.timing_submitted(&self.queue);
|
||||
+ }
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 2776219fc..d3944e97b 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -5,7 +5,9 @@
|
||||
//! into a high-dynamic-range target; the mod's post pass runs on that; then
|
||||
//! bright parts bloom and a tone curve brings it to the screen — vanilla's
|
||||
//! range (up to about 0.8) left as it is, only what is brighter rolled off.
|
||||
+use super::pack_timing::PassTiming;
|
||||
use glam::Mat4;
|
||||
+use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// The G-buffer's formats.
|
||||
pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
@@ -24,6 +26,25 @@ const TILE_INDICES: usize = 1 << 19;
|
||||
const EVERY_LIGHT: u32 = u32::MAX;
|
||||
/// Bloom's mip levels.
|
||||
const BLOOM_LEVELS: usize = 6;
|
||||
+/// The ambient occlusion's targets (half resolution): occlusion and the distance it was taken at.
|
||||
+pub const AO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;
|
||||
+/// The occlusion's default strength (`Hd::ao_strength`; 0 turns it off).
|
||||
+pub const AO_STRENGTH: f32 = 1.0;
|
||||
+/// The timed passes of bloom, by level (each is timed on its own; the report sums them).
|
||||
+const BLOOM_NAMES: [&str; 2 * BLOOM_LEVELS] = [
|
||||
+ "hd bloom#0",
|
||||
+ "hd bloom#1",
|
||||
+ "hd bloom#2",
|
||||
+ "hd bloom#3",
|
||||
+ "hd bloom#4",
|
||||
+ "hd bloom#5",
|
||||
+ "hd bloom#6",
|
||||
+ "hd bloom#7",
|
||||
+ "hd bloom#8",
|
||||
+ "hd bloom#9",
|
||||
+ "hd bloom#10",
|
||||
+ "hd bloom#11",
|
||||
+];
|
||||
|
||||
/// A light for this frame, relative to the camera.
|
||||
#[repr(C)]
|
||||
@@ -59,7 +80,8 @@ struct HdFrame {
|
||||
fog_color: [f32; 4],
|
||||
/// Environmental start, end; render-distance start, end.
|
||||
fog: [f32; 4],
|
||||
- /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
|
||||
+ /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos);
|
||||
+ /// w: the ambient occlusion's strength (0 off).
|
||||
ambient: [f32; 4],
|
||||
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
@@ -133,6 +155,34 @@ struct Light {
|
||||
// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||||
// every light.
|
||||
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
+// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
|
||||
+@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||||
+
|
||||
+// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
|
||||
+// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
|
||||
+fn ao_at(px: vec2<i32>, dist: f32) -> f32 {
|
||||
+ let size = vec2<i32>(textureDimensions(ao_tex));
|
||||
+ let f = vec2<f32>(px) * 0.5;
|
||||
+ let base = vec2<i32>(floor(f));
|
||||
+ let t = f - floor(f);
|
||||
+ var sum = 0.0;
|
||||
+ var total = 0.0;
|
||||
+ for (var j = 0; j < 2; j++) {
|
||||
+ for (var i = 0; i < 2; i++) {
|
||||
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
|
||||
+ let s = textureLoad(ao_tex, q, 0).xy;
|
||||
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
|
||||
+ let dz = (s.y - dist) / (0.03 * dist + 0.1);
|
||||
+ let w = select(0.0, bw / (1.0 + dz * dz * dz * dz), s.y > 0.0);
|
||||
+ sum += s.x * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ if total < 0.0001 {
|
||||
+ return textureLoad(ao_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0).x;
|
||||
+ }
|
||||
+ return sum / total;
|
||||
+}
|
||||
|
||||
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
@@ -214,7 +264,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
|
||||
// direct light by the face's angle.
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
- var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
|
||||
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
|
||||
+ // the mods' lights by half, not on the sun or on emission.
|
||||
+ var occlusion = 1.0;
|
||||
+ if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); }
|
||||
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
+ var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
// The mods' lights.
|
||||
for (var k = 0u; k < surface_count; k++) {
|
||||
let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
@@ -224,7 +280,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
|
||||
let falloff = l.color.w / (dist * dist + 1.0) * window;
|
||||
let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
|
||||
- light += l.color.rgb * falloff * k;
|
||||
+ light += l.color.rgb * falloff * k * ao_lights;
|
||||
}
|
||||
c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
// Vanilla's fog over it again.
|
||||
@@ -264,6 +320,131 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
+const AO: &str = r#"
|
||||
+const AO_RADIUS: f32 = 1.8;
|
||||
+const AO_SLICES: i32 = 3;
|
||||
+const AO_STEPS: i32 = 5;
|
||||
+const PI: f32 = 3.14159265;
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||||
+@group(0) @binding(2) var normal_tex: texture_2d<f32>;
|
||||
+
|
||||
+// Interleaved gradient noise, shifted per frame (params.w) and per use.
|
||||
+fn noise(px: vec2<f32>, shift: vec2<f32>) -> f32 {
|
||||
+ let p = px + shift + vec2<f32>(5.588238 * frame.params.w);
|
||||
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
|
||||
+}
|
||||
+
|
||||
+// The camera-relative position of a (continuous) full-resolution pixel at depth z.
|
||||
+fn position_at(px: vec2<f32>, z: f32) -> vec3<f32> {
|
||||
+ let uv = px * frame.resolution.zw;
|
||||
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
|
||||
+ return h.xyz / h.w;
|
||||
+}
|
||||
+
|
||||
+// Ground-truth ambient occlusion (horizon based): per slice through the view ray, the highest
|
||||
+// horizon on either side, integrated against the normal's cosine lobe. Half resolution: one
|
||||
+// pixel of each 2x2.
|
||||
+@fragment
|
||||
+fn fs_ao(in: FullOut) -> @location(0) vec2<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(depth_tex));
|
||||
+ let ic = min(vec2<i32>(in.position.xy) * 2, size - 1);
|
||||
+ let z = textureLoad(depth_tex, ic, 0);
|
||||
+ if z <= 0.0 { return vec2<f32>(1.0, 0.0); }
|
||||
+ let nrm = textureLoad(normal_tex, ic, 0).xyz;
|
||||
+ let c = vec2<f32>(ic) + 0.5;
|
||||
+ let p = position_at(c, z);
|
||||
+ let dist = length(p);
|
||||
+ if dot(nrm, nrm) < 0.01 { return vec2<f32>(1.0, dist); }
|
||||
+ let n = normalize(nrm);
|
||||
+ let v = -p / dist;
|
||||
+ // Blocks per pixel here (the plane of equal depth is square to the view), so how far in
|
||||
+ // pixels the radius reaches.
|
||||
+ let per_px = max(length(position_at(c + vec2<f32>(4.0, 0.0), z) - p) / 4.0, 0.00001);
|
||||
+ let reach = min(AO_RADIUS / per_px, 56.0);
|
||||
+ if reach < 2.0 { return vec2<f32>(1.0, dist); }
|
||||
+ let radius = reach * per_px;
|
||||
+ let rot = noise(in.position.xy, vec2<f32>(0.0));
|
||||
+ let jitter = noise(in.position.xy, vec2<f32>(37.0, 17.0));
|
||||
+ var visibility = 0.0;
|
||||
+ for (var s = 0; s < AO_SLICES; s++) {
|
||||
+ let phi = (f32(s) + rot) / f32(AO_SLICES) * PI;
|
||||
+ let d2 = vec2<f32>(cos(phi), sin(phi));
|
||||
+ // The slice's direction in space, square to the view ray, and its plane's axis.
|
||||
+ let q = position_at(c + d2 * 4.0, z) - p;
|
||||
+ let dir = normalize(q - v * dot(q, v));
|
||||
+ let axis = cross(dir, v);
|
||||
+ let projected = n - axis * dot(n, axis);
|
||||
+ let plen = length(projected);
|
||||
+ if plen < 0.0001 { visibility += 1.0; continue; }
|
||||
+ let cos_n = clamp(dot(projected, v) / plen, -1.0, 1.0);
|
||||
+ let nang = select(-1.0, 1.0, dot(projected, dir) >= 0.0) * acos(cos_n);
|
||||
+ var c_neg = -1.0;
|
||||
+ var c_pos = -1.0;
|
||||
+ for (var i = 0; i < AO_STEPS; i++) {
|
||||
+ let t = (f32(i) + jitter) / f32(AO_STEPS);
|
||||
+ let r = 1.5 + (reach - 1.5) * t * t;
|
||||
+ for (var side = 0; side < 2; side++) {
|
||||
+ let sg = select(-1.0, 1.0, side == 1);
|
||||
+ let ip = vec2<i32>(floor(c + d2 * (r * sg)));
|
||||
+ if any(ip < vec2<i32>(0)) || any(ip >= size) { continue; }
|
||||
+ let sz = textureLoad(depth_tex, ip, 0);
|
||||
+ if sz <= 0.0 { continue; }
|
||||
+ let to = position_at(vec2<f32>(ip) + 0.5, sz) - p;
|
||||
+ let len = length(to);
|
||||
+ // Points on (or under) the surface's own plane don't occlude it.
|
||||
+ if dot(to, n) < 0.1 * len { continue; }
|
||||
+ // Far points count less, to nothing at the radius.
|
||||
+ let fall = clamp((len - radius * 0.6) / (radius * 0.4), 0.0, 1.0);
|
||||
+ let ch = mix(dot(to / len, v), -1.0, fall);
|
||||
+ if side == 1 { c_pos = max(c_pos, ch); } else { c_neg = max(c_neg, ch); }
|
||||
+ }
|
||||
+ }
|
||||
+ var h_neg = -acos(clamp(c_neg, -1.0, 1.0));
|
||||
+ var h_pos = acos(clamp(c_pos, -1.0, 1.0));
|
||||
+ h_neg = nang + max(h_neg - nang, -0.5 * PI);
|
||||
+ h_pos = nang + min(h_pos - nang, 0.5 * PI);
|
||||
+ let sn = sin(nang);
|
||||
+ let arc_neg = cos_n + 2.0 * h_neg * sn - cos(2.0 * h_neg - nang);
|
||||
+ let arc_pos = cos_n + 2.0 * h_pos * sn - cos(2.0 * h_pos - nang);
|
||||
+ visibility += plen * 0.25 * (arc_neg + arc_pos);
|
||||
+ }
|
||||
+ let ao = clamp(visibility / f32(AO_SLICES), 0.0, 1.0);
|
||||
+ // Far away it is too coarse to show: fade out.
|
||||
+ return vec2<f32>(mix(1.0, ao, 1.0 - smoothstep(40.0, 96.0, dist)), dist);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+const AO_BLUR: &str = r#"
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var raw_ao: texture_2d<f32>;
|
||||
+
|
||||
+// A 5x5 blur that only takes texels at about the same distance (so not across an edge).
|
||||
+@fragment
|
||||
+fn fs_ao_blur(in: FullOut) -> @location(0) vec2<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(raw_ao));
|
||||
+ let ip = vec2<i32>(in.position.xy);
|
||||
+ let centre = textureLoad(raw_ao, ip, 0).xy;
|
||||
+ if centre.y <= 0.0 { return centre; }
|
||||
+ let tolerance = 0.03 * centre.y + 0.1;
|
||||
+ var sum = 0.0;
|
||||
+ var total = 0.0;
|
||||
+ for (var y = -2; y <= 2; y++) {
|
||||
+ for (var x = -2; x <= 2; x++) {
|
||||
+ let q = ip + vec2<i32>(x, y);
|
||||
+ if any(q < vec2<i32>(0)) || any(q >= size) { continue; }
|
||||
+ let s = textureLoad(raw_ao, q, 0).xy;
|
||||
+ if s.y <= 0.0 { continue; }
|
||||
+ let dz = (s.y - centre.y) / tolerance;
|
||||
+ let w = 1.0 / (1.0 + dz * dz * dz * dz);
|
||||
+ sum += s.x * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ return vec2<f32>(sum / max(total, 0.0001), centre.y);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
const BLOOM: &str = r#"
|
||||
@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
@@ -434,6 +615,23 @@ pub struct Hd {
|
||||
up: wgpu::RenderPipeline,
|
||||
tone_layout: wgpu::BindGroupLayout,
|
||||
tone: wgpu::RenderPipeline,
|
||||
+ /// The ambient occlusion: its strength in the lighting, the half-resolution targets (raw, then
|
||||
+ /// blurred), their passes and bind groups (made at `resize`).
|
||||
+ pub ao_strength: f32,
|
||||
+ ao_raw: wgpu::TextureView,
|
||||
+ ao_blurred: wgpu::TextureView,
|
||||
+ ao_layout: wgpu::BindGroupLayout,
|
||||
+ ao_blur_layout: wgpu::BindGroupLayout,
|
||||
+ ao_pipeline: wgpu::RenderPipeline,
|
||||
+ ao_blur_pipeline: wgpu::RenderPipeline,
|
||||
+ ao_group: Option<wgpu::BindGroup>,
|
||||
+ ao_blur_group: Option<wgpu::BindGroup>,
|
||||
+ /// GPU time of the HD passes while diagnostics ask for it (`set_timing`).
|
||||
+ timing: Option<PassTiming>,
|
||||
+ /// Bind groups made so far, and in the last frame (a steady state makes none).
|
||||
+ groups_made: AtomicU64,
|
||||
+ groups_seen: u64,
|
||||
+ pub groups_last_frame: u64,
|
||||
}
|
||||
|
||||
/// The tiles' header (4 words each) and index lists for a screen of `size`.
|
||||
@@ -567,6 +765,22 @@ fn tile_lists(
|
||||
}
|
||||
}
|
||||
|
||||
+/// The timestamps of the pass `name` while HD passes are timed.
|
||||
+fn stamp<'a>(
|
||||
+ timing: &'a mut Option<PassTiming>,
|
||||
+ name: &str,
|
||||
+) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
|
||||
+ timing.as_mut()?.writes(name)
|
||||
+}
|
||||
+
|
||||
+/// `HORIZON_HD_AO=0` turns the ambient occlusion off, another number sets its strength.
|
||||
+fn ao_strength_default() -> f32 {
|
||||
+ std::env::var("HORIZON_HD_AO")
|
||||
+ .ok()
|
||||
+ .and_then(|v| v.parse().ok())
|
||||
+ .unwrap_or(AO_STRENGTH)
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
@@ -685,8 +899,17 @@ impl Hd {
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
+ UNFILTERED,
|
||||
]),
|
||||
});
|
||||
+ let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD ambient occlusion"),
|
||||
+ entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
+ });
|
||||
+ let ao_blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD ambient occlusion blur"),
|
||||
+ entries: &entries(&[UNIFORM, UNFILTERED]),
|
||||
+ });
|
||||
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD bloom"),
|
||||
entries: &entries(&[UNIFORM, FLOAT, SAMPLER]),
|
||||
@@ -698,6 +921,26 @@ impl Hd {
|
||||
let light = module(device, "HD lighting", LIGHT);
|
||||
let bloom = module(device, "HD bloom", BLOOM);
|
||||
let tone = module(device, "HD tone mapping", TONEMAP);
|
||||
+ let ao = module(device, "HD ambient occlusion", AO);
|
||||
+ let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
|
||||
+ let ao_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD ambient occlusion",
|
||||
+ &ao_layout,
|
||||
+ &ao,
|
||||
+ "fs_ao",
|
||||
+ AO_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
+ let ao_blur_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &ao_blur_layout,
|
||||
+ &ao_blur,
|
||||
+ "fs_ao_blur",
|
||||
+ AO_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
let add = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
@@ -858,6 +1101,19 @@ impl Hd {
|
||||
up,
|
||||
tone_layout,
|
||||
tone,
|
||||
+ ao_strength: ao_strength_default(),
|
||||
+ ao_raw: target(device, "HD AO", [1, 1], AO_FORMAT).view,
|
||||
+ ao_blurred: target(device, "HD AO blurred", [1, 1], AO_FORMAT).view,
|
||||
+ ao_layout,
|
||||
+ ao_blur_layout,
|
||||
+ ao_pipeline,
|
||||
+ ao_blur_pipeline,
|
||||
+ ao_group: None,
|
||||
+ ao_blur_group: None,
|
||||
+ timing: None,
|
||||
+ groups_made: AtomicU64::new(0),
|
||||
+ groups_seen: 0,
|
||||
+ groups_last_frame: 0,
|
||||
};
|
||||
hd.resize(device, size);
|
||||
hd
|
||||
@@ -875,6 +1131,28 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
|
||||
+ self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
|
||||
+ self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
|
||||
+ self.ao_group = Some(self.make_group(
|
||||
+ device,
|
||||
+ "HD ambient occlusion",
|
||||
+ &self.ao_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.normal),
|
||||
+ ],
|
||||
+ ));
|
||||
+ self.ao_blur_group = Some(self.make_group(
|
||||
+ device,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &self.ao_blur_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.ao_raw),
|
||||
+ ],
|
||||
+ ));
|
||||
self.tiles = tiles_buffer(device, size);
|
||||
self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
@@ -917,6 +1195,15 @@ impl Hd {
|
||||
);
|
||||
queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
+ let made = self.groups_made.load(Ordering::Relaxed);
|
||||
+ self.groups_last_frame = made - self.groups_seen;
|
||||
+ self.groups_seen = made;
|
||||
+ if PassTiming::logging() && self.frames.is_multiple_of(240) {
|
||||
+ log::info!(
|
||||
+ "HD bind groups made in the last frame: {} ({made} in all)",
|
||||
+ self.groups_last_frame
|
||||
+ );
|
||||
+ }
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
let cascades = cascades(scene.camera, dir);
|
||||
@@ -941,7 +1228,12 @@ impl Hd {
|
||||
fog_color: scene.fog_color,
|
||||
fog: scene.fog,
|
||||
// In the shade by day a surface keeps half its sky light; at night almost all.
|
||||
- ambient: [0.5 + 0.45 * night, 1.0, 0.0018 + scene.rain * 0.006, 0.0],
|
||||
+ ambient: [
|
||||
+ 0.5 + 0.45 * night,
|
||||
+ 1.0,
|
||||
+ 0.0018 + scene.rain * 0.006,
|
||||
+ self.ao_strength.max(0.0),
|
||||
+ ],
|
||||
shadow: [
|
||||
if strength > 0.0 { 1.0 } else { 0.0 },
|
||||
CASCADES[0],
|
||||
@@ -954,13 +1246,14 @@ impl Hd {
|
||||
|
||||
/// The shadow pass of cascade `c`: its depth layer cleared, nothing else.
|
||||
pub fn shadow_pass<'e>(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
c: usize,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD shadow cascade"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, ["hd shadow 0", "hd shadow 1"][c.min(1)]),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.shadow_layers[c],
|
||||
@@ -976,7 +1269,10 @@ impl Hd {
|
||||
}
|
||||
|
||||
/// The G-buffer pass's attachments: albedo and normal cleared, its own depth.
|
||||
- pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> {
|
||||
+ pub fn gbuffer_pass<'e>(
|
||||
+ &mut self,
|
||||
+ encoder: &'e mut wgpu::CommandEncoder,
|
||||
+ ) -> wgpu::RenderPass<'e> {
|
||||
let clear = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -991,6 +1287,7 @@ impl Hd {
|
||||
encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD G-buffer"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd g-buffer"),
|
||||
color_attachments: &[
|
||||
clear(&self.albedo),
|
||||
clear(&self.normal),
|
||||
@@ -1016,9 +1313,11 @@ impl Hd {
|
||||
load: bool,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
+ timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
||||
) {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
+ timestamp_writes,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: to,
|
||||
resolve_target: None,
|
||||
@@ -1054,6 +1353,7 @@ impl Hd {
|
||||
lightmap.1.clone(),
|
||||
);
|
||||
if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD lighting"),
|
||||
layout: &self.light_layout,
|
||||
@@ -1110,6 +1410,10 @@ impl Hd {
|
||||
binding: 12,
|
||||
resource: self.tiles.as_entire_binding(),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 13,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1124,10 +1428,36 @@ impl Hd {
|
||||
false,
|
||||
&self.light_pipeline,
|
||||
group,
|
||||
+ stamp(&mut self.timing, "hd lighting"),
|
||||
);
|
||||
}
|
||||
|
||||
+ /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
|
||||
+ fn make_group(
|
||||
+ &self,
|
||||
+ device: &wgpu::Device,
|
||||
+ label: &str,
|
||||
+ layout: &wgpu::BindGroupLayout,
|
||||
+ resources: &[wgpu::BindingResource<'_>],
|
||||
+ ) -> wgpu::BindGroup {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
+ let entries: Vec<_> = resources
|
||||
+ .iter()
|
||||
+ .enumerate()
|
||||
+ .map(|(i, r)| wgpu::BindGroupEntry {
|
||||
+ binding: i as u32,
|
||||
+ resource: r.clone(),
|
||||
+ })
|
||||
+ .collect();
|
||||
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some(label),
|
||||
+ layout,
|
||||
+ entries: &entries,
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD bloom"),
|
||||
layout: &self.bloom_layout,
|
||||
@@ -1169,6 +1499,7 @@ impl Hd {
|
||||
self.prefilter_group = Some((source.clone(), group));
|
||||
}
|
||||
if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD tone mapping"),
|
||||
layout: &self.tone_layout,
|
||||
@@ -1205,6 +1536,7 @@ impl Hd {
|
||||
false,
|
||||
&self.prefilter,
|
||||
prefilter,
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[0]),
|
||||
);
|
||||
for k in 1..self.bloom.len() {
|
||||
Self::draw(
|
||||
@@ -1214,6 +1546,7 @@ impl Hd {
|
||||
false,
|
||||
&self.down,
|
||||
&self.down_groups[k - 1],
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[k]),
|
||||
);
|
||||
}
|
||||
// Up: each level added onto the bigger one.
|
||||
@@ -1225,9 +1558,84 @@ impl Hd {
|
||||
true,
|
||||
&self.up,
|
||||
&self.up_groups[k - 1],
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[BLOOM_LEVELS + k - 1]),
|
||||
);
|
||||
}
|
||||
- Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD tone mapping",
|
||||
+ screen,
|
||||
+ false,
|
||||
+ &self.tone,
|
||||
+ tone,
|
||||
+ stamp(&mut self.timing, "hd tonemap"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
|
||||
+ /// its blur. Run after the G-buffer pass; nothing when the strength is 0.
|
||||
+ pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
+ if self.ao_strength <= 0.0 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let (Some(raw), Some(blur)) = (&self.ao_group, &self.ao_blur_group) else {
|
||||
+ return;
|
||||
+ };
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD ambient occlusion",
|
||||
+ &self.ao_raw,
|
||||
+ false,
|
||||
+ &self.ao_pipeline,
|
||||
+ raw,
|
||||
+ stamp(&mut self.timing, "hd ao"),
|
||||
+ );
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &self.ao_blurred,
|
||||
+ false,
|
||||
+ &self.ao_blur_pipeline,
|
||||
+ blur,
|
||||
+ stamp(&mut self.timing, "hd ao blur"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// Times the HD passes on the GPU while `on` (and the device can), else not.
|
||||
+ pub fn set_timing(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, on: bool) {
|
||||
+ if on != self.timing.is_some() {
|
||||
+ self.timing = on.then(|| PassTiming::new(device, queue)).flatten();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// At the start of the frame's passes, before the first of them.
|
||||
+ pub fn timing_begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
|
||||
+ if let Some(t) = &mut self.timing {
|
||||
+ t.begin(device, queue);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// After the frame is submitted.
|
||||
+ pub fn timing_submitted(&mut self, queue: &wgpu::Queue) {
|
||||
+ if let Some(t) = &mut self.timing {
|
||||
+ t.submitted(queue);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Each HD pass's median GPU time in milliseconds (bloom's passes summed into one).
|
||||
+ pub fn pass_times(&self) -> Vec<(String, f32)> {
|
||||
+ let Some(t) = &self.timing else {
|
||||
+ return Vec::new();
|
||||
+ };
|
||||
+ let mut out: Vec<(String, f32)> = Vec::new();
|
||||
+ for (name, ms) in t.medians() {
|
||||
+ let name = name.split('#').next().unwrap_or(&name).to_owned();
|
||||
+ match out.iter_mut().find(|o| o.0 == name) {
|
||||
+ Some(o) => o.1 += ms,
|
||||
+ None => out.push((name, ms)),
|
||||
+ }
|
||||
+ }
|
||||
+ out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,7 +1694,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lighting_shader_parses() {
|
||||
- for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
|
||||
+ for (name, body) in [
|
||||
+ ("light", LIGHT),
|
||||
+ ("bloom", BLOOM),
|
||||
+ ("tonemap", TONEMAP),
|
||||
+ ("ao", AO),
|
||||
+ ("ao blur", AO_BLUR),
|
||||
+ ] {
|
||||
let source = format!("{COMMON}{body}");
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
panic!("{name}: {}", e.emit_to_string(&source));
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/31] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 126 +++++++++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 347 +++++++++++++++++++++++++++-
|
||||
2 files changed, 457 insertions(+), 16 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index e14429b03..eb152667c 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -569,6 +569,11 @@ pub struct Renderer {
|
||||
shadow_terrain: wgpu::RenderPipeline,
|
||||
shadow_entity: wgpu::RenderPipeline,
|
||||
hd_shadow_visible: Vec<resident::Visible>,
|
||||
+ /// The far cascade as last drawn (matrix, camera, light) and the frames since.
|
||||
+ hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
|
||||
+ /// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
|
||||
+ taa_frame: u32,
|
||||
+ taa_prev: Option<(Mat4, glam::DVec3)>,
|
||||
hd: Option<hd::Hd>,
|
||||
mod_params: (Vec<f32>, f32),
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
@@ -2815,6 +2820,9 @@ impl Renderer {
|
||||
shadow_terrain,
|
||||
shadow_entity,
|
||||
hd_shadow_visible: Vec::new(),
|
||||
+ hd_far: None,
|
||||
+ taa_frame: 0,
|
||||
+ taa_prev: None,
|
||||
hd: None,
|
||||
mod_params: (Vec::new(), 0.0),
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -3954,7 +3962,29 @@ impl Renderer {
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
- let view_proj = self.view_projection(frame);
|
||||
+ let hd_wanted = !frame.title
|
||||
+ && frame.hd.is_some()
|
||||
+ && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
|
||||
+ && !self.native.as_ref().is_some_and(|n| n.post.is_some());
|
||||
+ // AirDef fork: HD's temporal anti-aliasing shifts the whole projection by a sub-pixel
|
||||
+ // offset each frame; `unjittered` is what the next frame reprojects with.
|
||||
+ let unjittered = self.view_projection(frame);
|
||||
+ let taa = hd_wanted && hd::taa_enabled();
|
||||
+ let view_proj = if taa {
|
||||
+ let size = self
|
||||
+ .world_post
|
||||
+ .size()
|
||||
+ .unwrap_or([self.config.width, self.config.height]);
|
||||
+ let j = hd::jitter(self.taa_frame, size);
|
||||
+ self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
+ Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
|
||||
+ } else {
|
||||
+ unjittered
|
||||
+ };
|
||||
+ let taa_prev = std::mem::replace(
|
||||
+ &mut self.taa_prev,
|
||||
+ taa.then_some((unjittered, frame.camera)),
|
||||
+ );
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
&self.queue,
|
||||
@@ -4158,13 +4188,12 @@ impl Renderer {
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
// AirDef fork: the HD sun shadows draw every section in the farther cascade's box.
|
||||
- let hd_wanted = !frame.title
|
||||
- && frame.hd.is_some()
|
||||
- && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
|
||||
- && !self.native.as_ref().is_some_and(|n| n.post.is_some());
|
||||
- let hd_scene = hd::HdScene {
|
||||
+ let mut hd_scene = hd::HdScene {
|
||||
+ cascades: [Mat4::IDENTITY; 2],
|
||||
view_proj,
|
||||
- camera,
|
||||
+ taa: taa && taa_prev.is_some(),
|
||||
+ prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
|
||||
+ camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
|
||||
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
|
||||
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
|
||||
dark: 1.0 - frame.sky.sky_light_factor,
|
||||
@@ -4174,10 +4203,34 @@ impl Renderer {
|
||||
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
|
||||
};
|
||||
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
|
||||
- let hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
+ let mut hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
+ // The far cascade is redrawn every second frame (it is snapped to texels and the sun is
|
||||
+ // slow); between, the lighting pass gets the matrix its map was drawn with.
|
||||
+ let hd_far_fresh = if hd_wanted && shadow_strength > 0.0 {
|
||||
+ let fresh = match self.hd_far {
|
||||
+ Some((_, at, dir, age)) if hd_shadow_fast() => {
|
||||
+ age >= 1
|
||||
+ || (camera - at).length_squared() > 48.0 * 48.0
|
||||
+ || dir.dot(shadow_dir) < 0.99995
|
||||
+ || self.hd.is_none()
|
||||
+ }
|
||||
+ _ => true,
|
||||
+ };
|
||||
+ if fresh {
|
||||
+ self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
|
||||
+ } else if let Some((held, _, _, age)) = &mut self.hd_far {
|
||||
+ hd_cascades[1] = *held;
|
||||
+ *age += 1;
|
||||
+ }
|
||||
+ fresh
|
||||
+ } else {
|
||||
+ self.hd_far = None;
|
||||
+ true
|
||||
+ };
|
||||
+ hd_scene.cascades = hd_cascades;
|
||||
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
|
||||
hd_shadow_visible.clear();
|
||||
- if hd_wanted && shadow_strength > 0.0 {
|
||||
+ if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
|
||||
let radius =
|
||||
((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
|
||||
let light = Frustum {
|
||||
@@ -4191,6 +4244,11 @@ impl Renderer {
|
||||
|_, _| false,
|
||||
&mut hd_shadow_visible,
|
||||
);
|
||||
+ if hd_shadow_fast() {
|
||||
+ drop_buried(&mut hd_shadow_visible, |slot| {
|
||||
+ self.sections.get(slot).solid > 0
|
||||
+ });
|
||||
+ }
|
||||
}
|
||||
// Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's.
|
||||
let globals_size = std::mem::size_of::<Globals>() as u64;
|
||||
@@ -5022,7 +5080,12 @@ impl Renderer {
|
||||
}
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
+ // Facing the light only: the nearest surface to it is always one that does.
|
||||
+ let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
+ if c == 1 && !hd_far_fresh {
|
||||
+ continue;
|
||||
+ }
|
||||
let light = Globals {
|
||||
view_proj: cascade.to_cols_array_2d(),
|
||||
..globals
|
||||
@@ -5059,7 +5122,13 @@ impl Renderer {
|
||||
page = Some(s.page);
|
||||
}
|
||||
let instance = hd_shadow_first + k as u32;
|
||||
- pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
|
||||
+ facing_runs(&s.sides, toward, v.origin, |first, quads| {
|
||||
+ pass.draw_indexed(
|
||||
+ 0..quads * 6,
|
||||
+ s.start as i32 + first as i32 * 4,
|
||||
+ instance..instance + 1,
|
||||
+ );
|
||||
+ });
|
||||
}
|
||||
if !self.shadow_casters.is_empty() {
|
||||
pass.set_pipeline(&self.shadow_entity);
|
||||
@@ -5652,9 +5721,13 @@ impl Renderer {
|
||||
self.mod_params.1,
|
||||
);
|
||||
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
- hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
|
||||
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
|
||||
+ hd.finish(&self.device, &mut encoder, source, &view_target);
|
||||
+ }
|
||||
+ _ => {
|
||||
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Lit, depth);
|
||||
+ hd.finish(&self.device, &mut encoder, source, &view_target);
|
||||
}
|
||||
- _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
|
||||
}
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& mod_post
|
||||
@@ -6055,6 +6128,35 @@ fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: imp
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
+/// `HORIZON_HD_SHADOW_FAST=0` draws the HD shadow cascades the slow way (every section, both
|
||||
+/// faces, the far one every frame), for comparison.
|
||||
+fn hd_shadow_fast() -> bool {
|
||||
+ static FAST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *FAST.get_or_init(|| !std::env::var("HORIZON_HD_SHADOW_FAST").is_ok_and(|v| v == "0"))
|
||||
+}
|
||||
+/// Drops the sections that sit well under the ground of their own and all eight neighbouring
|
||||
+/// columns: shut in by rock, what they hold neither lights nor shades anything the sun reaches.
|
||||
+fn drop_buried(list: &mut Vec<resident::Visible>, has_solid: impl Fn(u32) -> bool) {
|
||||
+ let mut top: std::collections::HashMap<(i32, i32), i32> = std::collections::HashMap::new();
|
||||
+ for v in list.iter() {
|
||||
+ if has_solid(v.slot) {
|
||||
+ let e = top.entry((v.key.0, v.key.2)).or_insert(i32::MIN);
|
||||
+ *e = (*e).max(v.key.1);
|
||||
+ }
|
||||
+ }
|
||||
+ list.retain(|v| {
|
||||
+ let mut lowest = i32::MAX;
|
||||
+ for dx in -1..=1 {
|
||||
+ for dz in -1..=1 {
|
||||
+ match top.get(&(v.key.0 + dx, v.key.2 + dz)) {
|
||||
+ Some(&t) => lowest = lowest.min(t),
|
||||
+ None => return true,
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ v.key.1 > lowest - 3
|
||||
+ });
|
||||
+}
|
||||
fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
let r = |i| m.row(i);
|
||||
let planes = [
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index d3944e97b..16610cb02 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -85,14 +85,26 @@ struct HdFrame {
|
||||
ambient: [f32; 4],
|
||||
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
+ /// The previous frame's camera-relative view-projection without jitter (for TAA).
|
||||
+ prev_view_proj: [[f32; 4]; 4],
|
||||
+ /// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
|
||||
+ taa: [f32; 4],
|
||||
+ /// x: the history's weight; y: the sharpening (0 without TAA).
|
||||
+ taa_params: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the world around looks like this frame (from the renderer's globals).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct HdScene {
|
||||
+ /// The shadow cascades' matrices (`cascades`; the far one may be a frame or two old).
|
||||
+ pub cascades: [Mat4; 2],
|
||||
+ /// The (jittered) projection the world was drawn with.
|
||||
pub view_proj: Mat4,
|
||||
- /// The camera in the world, for steady shadows.
|
||||
- pub camera: glam::DVec3,
|
||||
+ /// Last frame's view-projection without jitter, and the camera's move since (TAA).
|
||||
+ pub prev_view_proj: Mat4,
|
||||
+ pub camera_delta: glam::Vec3,
|
||||
+ /// This frame is anti-aliased over time (the projection is jittered).
|
||||
+ pub taa: bool,
|
||||
/// Towards the sun, and the sky's darkening (0 day, 1 night).
|
||||
pub sun: glam::Vec3,
|
||||
pub dark: f32,
|
||||
@@ -119,6 +131,9 @@ struct HdFrame {
|
||||
fog: vec4<f32>,
|
||||
ambient: vec4<f32>,
|
||||
shadow: vec4<f32>,
|
||||
+ prev_view_proj: mat4x4<f32>,
|
||||
+ taa: vec4<f32>,
|
||||
+ taa_params: vec4<f32>,
|
||||
}
|
||||
struct FullOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -507,7 +522,21 @@ const TONEMAP: &str = r#"
|
||||
|
||||
@fragment
|
||||
fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
|
||||
- var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb * frame.params.y;
|
||||
+ var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb;
|
||||
+ // A slight sharpen after TAA (its history resampling softens): the centre against its four
|
||||
+ // neighbours, kept within their range so it can't ring.
|
||||
+ let k = frame.taa_params.y;
|
||||
+ if k > 0.0 {
|
||||
+ let t = 1.0 / vec2<f32>(textureDimensions(hdr));
|
||||
+ let n0 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(t.x, 0.0), 0.0).rgb;
|
||||
+ let n1 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(t.x, 0.0), 0.0).rgb;
|
||||
+ let n2 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(0.0, t.y), 0.0).rgb;
|
||||
+ let n3 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(0.0, t.y), 0.0).rgb;
|
||||
+ let lo = min(min(n0, n1), min(min(n2, n3), c));
|
||||
+ let hi = max(max(n0, n1), max(max(n2, n3), c));
|
||||
+ c = clamp(c * (1.0 + 4.0 * k) - (n0 + n1 + n2 + n3) * k, lo, hi);
|
||||
+ }
|
||||
+ c = c * frame.params.y;
|
||||
c += textureSampleLevel(bloom, linear, in.uv, 0.0).rgb * frame.params.z;
|
||||
// Up to 0.8 as it is (vanilla's look); above it, rolled off towards white by the
|
||||
// brightest channel, so hues hold instead of clipping.
|
||||
@@ -528,6 +557,130 @@ fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
+const TAA: &str = r#"
|
||||
+struct TaaOut {
|
||||
+ @location(0) color: vec4<f32>,
|
||||
+ @location(1) history: vec4<f32>,
|
||||
+}
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
+@group(0) @binding(2) var depth_tex: texture_depth_2d;
|
||||
+@group(0) @binding(3) var history: texture_2d<f32>;
|
||||
+@group(0) @binding(4) var linear: sampler;
|
||||
+
|
||||
+fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
|
||||
+ return vec3<f32>(c.r * 0.25 + c.g * 0.5 + c.b * 0.25, c.r * 0.5 - c.b * 0.5, c.g * 0.5 - c.r * 0.25 - c.b * 0.25);
|
||||
+}
|
||||
+fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
|
||||
+ let t = c.x - c.z;
|
||||
+ return vec3<f32>(t + c.y, c.x + c.z, t - c.y);
|
||||
+}
|
||||
+// Compress the range so a very bright pixel does not dominate the blend or the clamp.
|
||||
+fn squash(c: vec3<f32>) -> vec3<f32> {
|
||||
+ return c / (1.0 + max(c.r, max(c.g, c.b)));
|
||||
+}
|
||||
+fn unsquash(c: vec3<f32>) -> vec3<f32> {
|
||||
+ return c / max(1.0 - max(c.r, max(c.g, c.b)), 0.001);
|
||||
+}
|
||||
+
|
||||
+// The history at a uv, filtered with a Catmull-Rom spline (five bilinear taps) so that
|
||||
+// resampling it every frame doesn't blur.
|
||||
+fn history_color(uv: vec2<f32>) -> vec3<f32> {
|
||||
+ let size = vec2<f32>(textureDimensions(history));
|
||||
+ let pos = uv * size;
|
||||
+ let c1 = floor(pos - 0.5) + 0.5;
|
||||
+ let f = pos - c1;
|
||||
+ let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
+ let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
+ let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
+ let w3 = f * f * (-0.5 + 0.5 * f);
|
||||
+ let w12 = w1 + w2;
|
||||
+ let t0 = (c1 - 1.0) / size;
|
||||
+ let t3 = (c1 + 2.0) / size;
|
||||
+ let t12 = (c1 + w2 / w12) / size;
|
||||
+ var sum = textureSampleLevel(history, linear, vec2<f32>(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y);
|
||||
+ var total = w12.x * w0.y;
|
||||
+ sum += textureSampleLevel(history, linear, vec2<f32>(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y);
|
||||
+ total += w0.x * w12.y;
|
||||
+ sum += textureSampleLevel(history, linear, t12, 0.0).rgb * (w12.x * w12.y);
|
||||
+ total += w12.x * w12.y;
|
||||
+ sum += textureSampleLevel(history, linear, vec2<f32>(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y);
|
||||
+ total += w3.x * w12.y;
|
||||
+ sum += textureSampleLevel(history, linear, vec2<f32>(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
|
||||
+ total += w12.x * w3.y;
|
||||
+ return max(sum / total, vec3<f32>(0.0));
|
||||
+}
|
||||
+
|
||||
+// Pulls `p` towards the box's centre until it is inside.
|
||||
+fn clip_to_box(lo: vec3<f32>, hi: vec3<f32>, p: vec3<f32>) -> vec3<f32> {
|
||||
+ let centre = (lo + hi) * 0.5;
|
||||
+ let ext = max((hi - lo) * 0.5, vec3<f32>(0.00001));
|
||||
+ let off = (p - centre) / ext;
|
||||
+ let m = max(abs(off.x), max(abs(off.y), abs(off.z)));
|
||||
+ return select(p, centre + (p - centre) / m, m > 1.0);
|
||||
+}
|
||||
+
|
||||
+// Temporal anti-aliasing: the history, reprojected by depth and the camera's move, clamped to
|
||||
+// the colours round this pixel (so what moved or was uncovered can't ghost), blended in.
|
||||
+@fragment
|
||||
+fn fs_taa(in: FullOut) -> TaaOut {
|
||||
+ let size = vec2<i32>(textureDimensions(source));
|
||||
+ let px = vec2<i32>(in.position.xy);
|
||||
+ let d = textureLoad(depth_tex, px, 0);
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ var sum2 = vec3<f32>(0.0);
|
||||
+ var lo = vec3<f32>(1.0e9);
|
||||
+ var hi = vec3<f32>(-1.0e9);
|
||||
+ var centre = vec3<f32>(0.0);
|
||||
+ for (var y = -1; y <= 1; y++) {
|
||||
+ for (var x = -1; x <= 1; x++) {
|
||||
+ let q = clamp(px + vec2<i32>(x, y), vec2<i32>(0), size - 1);
|
||||
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
|
||||
+ sum += s;
|
||||
+ sum2 += s * s;
|
||||
+ lo = min(lo, s);
|
||||
+ hi = max(hi, s);
|
||||
+ if x == 0 && y == 0 { centre = s; }
|
||||
+ }
|
||||
+ }
|
||||
+ let sky = d <= 0.0;
|
||||
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
|
||||
+ let h = frame.inverse_view_proj * ndc;
|
||||
+ let p = h.xyz / h.w;
|
||||
+ let dist = select(length(p), 0.0, sky);
|
||||
+ var resolved = centre;
|
||||
+ if frame.taa.w > 0.5 {
|
||||
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
|
||||
+ if prev.w > 0.0 {
|
||||
+ let pn = prev.xy / prev.w;
|
||||
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
|
||||
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
|
||||
+ let hp = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - 1);
|
||||
+ let seen = textureLoad(history, hp, 0).a;
|
||||
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
|
||||
+ // The same surface as last frame (not uncovered), by its distance.
|
||||
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
|
||||
+ let mean = sum / 9.0;
|
||||
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
|
||||
+ let box_lo = min(max(mean - sigma * 1.25, lo), centre);
|
||||
+ let box_hi = max(min(mean + sigma * 1.25, hi), centre);
|
||||
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
|
||||
+ // Fast motion trusts the history less.
|
||||
+ let moved = length((uv - in.uv) * vec2<f32>(size));
|
||||
+ let keep = mix(frame.taa_params.x, frame.taa_params.x * 0.75, clamp(moved / 4.0, 0.0, 1.0));
|
||||
+ resolved = mix(centre, past, keep);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ let color = unsquash(from_ycocg(resolved));
|
||||
+ var out: TaaOut;
|
||||
+ out.color = vec4<f32>(color, 1.0);
|
||||
+ out.history = vec4<f32>(color, dist);
|
||||
+ return out;
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
struct Target {
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
@@ -570,6 +723,35 @@ type LightKey = (
|
||||
pub enum HdSource {
|
||||
Lit,
|
||||
Fx,
|
||||
+ /// The anti-aliased result of `taa`.
|
||||
+ Taa,
|
||||
+}
|
||||
+
|
||||
+/// TAA is on unless `HORIZON_HD_TAA=0`.
|
||||
+pub fn taa_enabled() -> bool {
|
||||
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
|
||||
+}
|
||||
+
|
||||
+/// The Halton sequence's `index`-th number in `base`, in (0, 1).
|
||||
+fn halton(mut index: u32, base: u32) -> f32 {
|
||||
+ let (mut f, mut r) = (1.0, 0.0);
|
||||
+ while index > 0 {
|
||||
+ f /= base as f32;
|
||||
+ r += f * (index % base) as f32;
|
||||
+ index /= base;
|
||||
+ }
|
||||
+ r
|
||||
+}
|
||||
+
|
||||
+/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
|
||||
+/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
|
||||
+pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
|
||||
+ let i = frame % 16 + 1;
|
||||
+ [
|
||||
+ (halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
|
||||
+ (halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
|
||||
+ ]
|
||||
}
|
||||
|
||||
pub struct Hd {
|
||||
@@ -589,6 +771,15 @@ pub struct Hd {
|
||||
/// The lit world, and the post pass's output.
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
+ /// TAA: the resolved frame, and the two histories (one read, one written, alternating).
|
||||
+ taa: wgpu::TextureView,
|
||||
+ history: [wgpu::TextureView; 2],
|
||||
+ history_ready: bool,
|
||||
+ history_flip: usize,
|
||||
+ taa_active: bool,
|
||||
+ taa_layout: wgpu::BindGroupLayout,
|
||||
+ taa_pipeline: wgpu::RenderPipeline,
|
||||
+ taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
|
||||
bloom: Vec<Target>,
|
||||
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
/// lighting and the source-reading ones are keyed by the views they read.
|
||||
@@ -902,6 +1093,44 @@ impl Hd {
|
||||
UNFILTERED,
|
||||
]),
|
||||
});
|
||||
+ let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
|
||||
+ });
|
||||
+ let taa_module = module(device, "HD TAA", TAA);
|
||||
+ let taa_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ bind_group_layouts: &[Some(&taa_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ let target = Some(wgpu::ColorTargetState {
|
||||
+ format: HDR,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &taa_module,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &taa_module,
|
||||
+ entry_point: Some("fs_taa"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[target.clone(), target],
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD ambient occlusion"),
|
||||
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
@@ -1080,6 +1309,17 @@ impl Hd {
|
||||
.view,
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
+ taa: target(device, "HD TAA", [1, 1], HDR).view,
|
||||
+ history: [
|
||||
+ target(device, "HD TAA history", [1, 1], HDR).view,
|
||||
+ target(device, "HD TAA history", [1, 1], HDR).view,
|
||||
+ ],
|
||||
+ history_ready: false,
|
||||
+ history_flip: 0,
|
||||
+ taa_active: false,
|
||||
+ taa_layout,
|
||||
+ taa_pipeline,
|
||||
+ taa_groups: None,
|
||||
bloom: Vec::new(),
|
||||
light_group: None,
|
||||
prefilter_group: None,
|
||||
@@ -1131,6 +1371,13 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.taa = target(device, "HD TAA", size, HDR).view;
|
||||
+ self.history = [
|
||||
+ target(device, "HD TAA history", size, HDR).view,
|
||||
+ target(device, "HD TAA history", size, HDR).view,
|
||||
+ ];
|
||||
+ self.history_ready = false;
|
||||
+ self.taa_groups = None;
|
||||
let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
|
||||
self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
|
||||
self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
|
||||
@@ -1206,9 +1453,22 @@ impl Hd {
|
||||
}
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
- let cascades = cascades(scene.camera, dir);
|
||||
+ let cascades = scene.cascades;
|
||||
let night = scene.dark.clamp(0.0, 1.0);
|
||||
+ self.taa_active = scene.taa && taa_enabled();
|
||||
+ // A long jump of the camera (a teleport) starts the history over.
|
||||
+ if !self.taa_active || scene.camera_delta.length() > 64.0 {
|
||||
+ self.history_ready = false;
|
||||
+ }
|
||||
let frame = HdFrame {
|
||||
+ prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
|
||||
+ taa: [
|
||||
+ scene.camera_delta.x,
|
||||
+ scene.camera_delta.y,
|
||||
+ scene.camera_delta.z,
|
||||
+ if self.history_ready { 1.0 } else { 0.0 },
|
||||
+ ],
|
||||
+ taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
|
||||
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
|
||||
resolution: [
|
||||
self.size[0] as f32,
|
||||
@@ -1489,6 +1749,7 @@ impl Hd {
|
||||
let source = match source {
|
||||
HdSource::Lit => self.lit.clone(),
|
||||
HdSource::Fx => self.fx.clone(),
|
||||
+ HdSource::Taa => self.taa.clone(),
|
||||
};
|
||||
if self
|
||||
.prefilter_group
|
||||
@@ -1572,6 +1833,70 @@ impl Hd {
|
||||
);
|
||||
}
|
||||
|
||||
+ /// Temporal anti-aliasing of `source` (with the world's `depth`): the source it left, to hand
|
||||
+ /// to `finish` — the same one when TAA is off this frame.
|
||||
+ pub fn taa(
|
||||
+ &mut self,
|
||||
+ device: &wgpu::Device,
|
||||
+ encoder: &mut wgpu::CommandEncoder,
|
||||
+ source: HdSource,
|
||||
+ depth: &wgpu::TextureView,
|
||||
+ ) -> HdSource {
|
||||
+ if !self.taa_active {
|
||||
+ return source;
|
||||
+ }
|
||||
+ let input = match source {
|
||||
+ HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Fx => self.fx.clone(),
|
||||
+ HdSource::Taa => return source,
|
||||
+ };
|
||||
+ let key = (input, depth.clone());
|
||||
+ if self.taa_groups.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let groups = [0, 1].map(|read| {
|
||||
+ self.make_group(
|
||||
+ device,
|
||||
+ "HD TAA",
|
||||
+ &self.taa_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&key.0),
|
||||
+ wgpu::BindingResource::TextureView(depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.history[read]),
|
||||
+ wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ ],
|
||||
+ )
|
||||
+ });
|
||||
+ self.taa_groups = Some((key, groups));
|
||||
+ }
|
||||
+ let Some((_, groups)) = &self.taa_groups else {
|
||||
+ return source;
|
||||
+ };
|
||||
+ let (read, write) = (self.history_flip, 1 - self.history_flip);
|
||||
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("HD TAA"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd taa"),
|
||||
+ color_attachments: &[&self.taa, &self.history[write]].map(|view| {
|
||||
+ Some(wgpu::RenderPassColorAttachment {
|
||||
+ view,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })
|
||||
+ }),
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ pass.set_pipeline(&self.taa_pipeline);
|
||||
+ pass.set_bind_group(0, &groups[read], &[]);
|
||||
+ pass.draw(0..3, 0..1);
|
||||
+ drop(pass);
|
||||
+ self.history_flip = write;
|
||||
+ self.history_ready = true;
|
||||
+ HdSource::Taa
|
||||
+ }
|
||||
+
|
||||
/// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
|
||||
/// its blur. Run after the G-buffer pass; nothing when the strength is 0.
|
||||
pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
@@ -1698,6 +2023,7 @@ mod tests {
|
||||
("light", LIGHT),
|
||||
("bloom", BLOOM),
|
||||
("tonemap", TONEMAP),
|
||||
+ ("taa", TAA),
|
||||
("ao", AO),
|
||||
("ao blur", AO_BLUR),
|
||||
] {
|
||||
@@ -1714,6 +2040,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn jitter_stays_within_a_pixel() {
|
||||
+ let size = [1920u32, 1080];
|
||||
+ let mut seen = std::collections::HashSet::new();
|
||||
+ for f in 0..32 {
|
||||
+ let j = jitter(f, size);
|
||||
+ assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
|
||||
+ assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
|
||||
+ seen.insert((j[0].to_bits(), j[1].to_bits()));
|
||||
+ }
|
||||
+ assert_eq!(seen.len(), 16);
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
let size = [320, 160];
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/31] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game/mod_models.rs | 21 +-
|
||||
crates/horizon-client/src/game/mods.rs | 10 +
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 31 +++
|
||||
crates/horizon-mods/src/api_level.rs | 7 +-
|
||||
crates/horizon-mods/src/runtime.rs | 212 +++++++++++++++----
|
||||
docs/api/mods-reference.md | 113 ++++++++++
|
||||
6 files changed, 351 insertions(+), 43 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
||||
index 2d1b4780a..f2329a123 100644
|
||||
--- a/crates/horizon-client/src/game/mod_models.rs
|
||||
+++ b/crates/horizon-client/src/game/mod_models.rs
|
||||
@@ -11,7 +11,7 @@ use {
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
},
|
||||
horizon_core::random::RandomSource,
|
||||
- horizon_mods::{CameraView, ParticleSpawn, PointLight},
|
||||
+ horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight},
|
||||
horizon_world::registry::BlockStateId,
|
||||
std::collections::HashMap,
|
||||
};
|
||||
@@ -84,10 +84,21 @@ impl Game<'_> {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let models = &mut self.mods.models;
|
||||
for (owner, m) in self.mods.mods.models() {
|
||||
- let state = *models
|
||||
- .states
|
||||
- .entry(m.model.clone())
|
||||
- .or_insert_with(|| super::render_control::parse_state(registry, &m.model).ok());
|
||||
+ let state = match &m.model {
|
||||
+ ModelRef::Handle(h) => {
|
||||
+ let id = BlockStateId(*h);
|
||||
+ registry.state(id).map(|_| id)
|
||||
+ }
|
||||
+ ModelRef::Name(name) => {
|
||||
+ if let Some(s) = models.states.get(name.as_str()) {
|
||||
+ *s
|
||||
+ } else {
|
||||
+ let s = super::render_control::parse_state(registry, name).ok();
|
||||
+ models.states.insert(name.clone(), s);
|
||||
+ s
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
let Some(state) = state else {
|
||||
continue;
|
||||
};
|
||||
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
|
||||
index a760c7763..db468c7cb 100644
|
||||
--- a/crates/horizon-client/src/game/mods.rs
|
||||
+++ b/crates/horizon-client/src/game/mods.rs
|
||||
@@ -615,6 +615,16 @@ impl Game<'_> {
|
||||
}
|
||||
let snapshot = self.mod_snapshot();
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
+ let registry = self.pack.assets.registry;
|
||||
+ self.mods
|
||||
+ .mods
|
||||
+ .set_model_resolver(registry as *const _ as usize, || {
|
||||
+ Box::new(move |name| {
|
||||
+ super::render_control::parse_state(registry, name)
|
||||
+ .ok()
|
||||
+ .map(|s| s.0)
|
||||
+ })
|
||||
+ });
|
||||
self.mods.mods.tick(self.ticks);
|
||||
self.update_mod_models();
|
||||
self.show_mod_output();
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index 6696fa134..8019aab95 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -467,6 +467,34 @@ interface render {
|
||||
/// each id between its last two positions and rotations every frame.
|
||||
/// An empty list removes them.
|
||||
set-models: func(models: list<model-instance>);
|
||||
+ /// A handle for a block state name, for `set-models-by-id` (AirDef
|
||||
+ /// fork, API level 11): the name crosses once, not every tick. None for
|
||||
+ /// a name that is no block state of the game's registry (nothing is
|
||||
+ /// remembered for it). Handles are valid until `model-generation`
|
||||
+ /// changes (the registry changed: a resource reload, another world's
|
||||
+ /// data); ask again for every name then.
|
||||
+ model-id: func(model: string) -> option<u32>;
|
||||
+ /// Moves on whenever the handles `model-id` gave stop being valid. A
|
||||
+ /// mod reads it before `set-models-by-id` and re-asks when it changed.
|
||||
+ model-generation: func() -> u32;
|
||||
+ /// `model-instance` with the model as a handle.
|
||||
+ record model-instance-id {
|
||||
+ id: u32,
|
||||
+ /// From `model-id`.
|
||||
+ model: u32,
|
||||
+ x: f64,
|
||||
+ y: f64,
|
||||
+ z: f64,
|
||||
+ rotation: tuple<f32, f32, f32, f32>,
|
||||
+ scale: f32,
|
||||
+ glow: bool,
|
||||
+ }
|
||||
+ /// `set-models` with handles instead of names: the same models, limits
|
||||
+ /// and interpolation, fewer bytes across the boundary. `generation` is
|
||||
+ /// the `model-generation` the handles were asked under; if it is not
|
||||
+ /// the current one the call is ignored and returns false (re-ask the
|
||||
+ /// handles and call again). Handles the game doesn't know draw nothing.
|
||||
+ set-models-by-id: func(generation: u32, models: list<model-instance-id>) -> bool;
|
||||
/// Particles to spawn on this client (AirDef fork).
|
||||
record particle-spawn {
|
||||
/// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`.
|
||||
@@ -508,6 +536,7 @@ interface render {
|
||||
/// Compile errors come back in the chat and the log; the last good pass
|
||||
/// keeps running.
|
||||
set-post: func(wgsl: string);
|
||||
+ /// Removes this mod's post pass: the world is shown as the game draws it.
|
||||
clear-post: func();
|
||||
/// The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until
|
||||
/// replaced; call it once a tick (the pass gets the partial tick in
|
||||
@@ -561,6 +590,8 @@ interface render {
|
||||
/// How much bright things glow (0 none, 1 strong).
|
||||
bloom: f32,
|
||||
}
|
||||
+ /// Switches the HD lighting on or off for this mod's world, with its
|
||||
+ /// exposure and bloom; off, the world is drawn as the game draws it.
|
||||
set-lighting: func(lighting: lighting);
|
||||
}
|
||||
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index 80ac74b6f..d71396f59 100644
|
||||
--- a/crates/horizon-mods/src/api_level.rs
|
||||
+++ b/crates/horizon-mods/src/api_level.rs
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
-pub const LEVEL: u32 = 10;
|
||||
+pub const LEVEL: u32 = 11;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
-pub const FUNCTIONS: [(&str, u32); 58] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 61] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -73,6 +73,9 @@ pub const FUNCTIONS: [(&str, u32); 58] = [
|
||||
("game.world-id", 9),
|
||||
("render.set-lights", 10),
|
||||
("render.set-lighting", 10),
|
||||
+ ("render.model-id", 11),
|
||||
+ ("render.model-generation", 11),
|
||||
+ ("render.set-models-by-id", 11),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||||
index 57713e228..951ff2e24 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -79,9 +79,10 @@ use render_bindings::horizon::mods::input;
|
||||
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
|
||||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||||
pub use render_bindings::horizon::mods::render::{
|
||||
- Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
|
||||
- Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
|
||||
- CameraView, Lighting as ModLighting, PointLight, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
|
||||
+ CameraView, Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate,
|
||||
+ ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId,
|
||||
+ ParticleSpawn, Point as RenderPoint, PointLight, QuadShape, Rect as RenderRect, Shape,
|
||||
+ SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||||
@@ -278,6 +279,42 @@ pub trait WorldView: std::any::Any + Send {
|
||||
/// The view lent for the current calls, shared by every mod's store.
|
||||
type WorldSlot = Arc<Mutex<Option<Box<dyn WorldView>>>>;
|
||||
|
||||
+/// How the game turns a block state name into a handle (`render.model-id`):
|
||||
+/// None for a name that is no state of its registry.
|
||||
+pub type ModelResolver = Box<dyn Fn(&str) -> Option<u32> + Send>;
|
||||
+
|
||||
+/// The handles of `render.model-id`: the game's registry decides which names
|
||||
+/// have one, so nothing grows with what mods ask; `generation` moves on
|
||||
+/// when the registry changes and the handles with it.
|
||||
+#[derive(Default)]
|
||||
+pub struct ModelTable {
|
||||
+ key: usize,
|
||||
+ generation: u32,
|
||||
+ resolve: Option<ModelResolver>,
|
||||
+}
|
||||
+
|
||||
+type ModelSlot = Arc<Mutex<ModelTable>>;
|
||||
+
|
||||
+/// A model as a mod set it: by name, or by a handle of the [`ModelTable`].
|
||||
+#[derive(Clone, Debug)]
|
||||
+pub enum ModelRef {
|
||||
+ Name(String),
|
||||
+ Handle(u32),
|
||||
+}
|
||||
+
|
||||
+/// One world model (`render.set-models` or `set-models-by-id`).
|
||||
+#[derive(Clone, Debug)]
|
||||
+pub struct HostModel {
|
||||
+ pub id: u32,
|
||||
+ pub model: ModelRef,
|
||||
+ pub x: f64,
|
||||
+ pub y: f64,
|
||||
+ pub z: f64,
|
||||
+ pub rotation: (f32, f32, f32, f32),
|
||||
+ pub scale: f32,
|
||||
+ pub glow: bool,
|
||||
+}
|
||||
+
|
||||
/// A mod's text panel, drawn every frame until it changes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Panel {
|
||||
@@ -429,7 +466,9 @@ struct State {
|
||||
/// Capability `render`'s far terrain, when the mod draws one.
|
||||
far_terrain: Option<ModFarTerrain>,
|
||||
/// Capability `render`'s world models (AirDef fork), drawn until replaced.
|
||||
- models: Vec<ModelInstance>,
|
||||
+ models: Vec<HostModel>,
|
||||
+ /// The game's block state names -> handles, shared by every mod's store.
|
||||
+ model_table: ModelSlot,
|
||||
/// `render.query-ground`'s columns, answered before the next tick.
|
||||
ground_queries: Vec<(i32, i32)>,
|
||||
/// The game's answers to them.
|
||||
@@ -464,6 +503,7 @@ pub struct Mods {
|
||||
snapshot: Arc<Snapshot>,
|
||||
out: Vec<Output>,
|
||||
world: WorldSlot,
|
||||
+ model_table: ModelSlot,
|
||||
/// Moves on whenever a mod starts or stops: the game resends the server
|
||||
/// mods' commands to players when it changes.
|
||||
generation: u64,
|
||||
@@ -492,6 +532,26 @@ impl Mods {
|
||||
*slot = Some(view);
|
||||
}
|
||||
}
|
||||
+ /// The game's registry for `render.model-id`. `key` names the registry
|
||||
+ /// (its address): the same key keeps the handles, another one makes
|
||||
+ /// `model-generation` move on and drops every mod's models. `make` runs
|
||||
+ /// only then.
|
||||
+ pub fn set_model_resolver(&mut self, key: usize, make: impl FnOnce() -> ModelResolver) {
|
||||
+ let Ok(mut t) = self.model_table.lock() else {
|
||||
+ return;
|
||||
+ };
|
||||
+ if t.key != key || t.resolve.is_none() {
|
||||
+ t.key = key;
|
||||
+ t.generation = t.generation.wrapping_add(1);
|
||||
+ t.resolve = Some(make());
|
||||
+ drop(t);
|
||||
+ for e in &mut self.entries {
|
||||
+ if let Some(r) = e.run.as_mut() {
|
||||
+ r.store.data_mut().models.clear();
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
/// The view [`Mods::lend_world`] lent, back to the game.
|
||||
pub fn take_world(&mut self) -> Option<Box<dyn WorldView>> {
|
||||
self.world.lock().ok()?.take()
|
||||
@@ -613,6 +673,7 @@ impl Mods {
|
||||
.collect();
|
||||
let snapshot = self.snapshot.clone();
|
||||
let world = self.world.clone();
|
||||
+ let model_table = self.model_table.clone();
|
||||
let (engine, linker) = self.engine()?;
|
||||
let component =
|
||||
Component::new(engine, wasm).map_err(|e| format!("not a component: {e}"))?;
|
||||
@@ -646,6 +707,7 @@ impl Mods {
|
||||
minimap: None,
|
||||
far_terrain: None,
|
||||
models: Vec::new(),
|
||||
+ model_table: model_table.clone(),
|
||||
ground_queries: Vec::new(),
|
||||
ground: Vec::new(),
|
||||
post: None,
|
||||
@@ -1209,7 +1271,7 @@ impl Mods {
|
||||
}
|
||||
/// Every running mod's world models (AirDef fork), with the mod's index
|
||||
/// so ids of different mods don't meet.
|
||||
- pub fn models(&self) -> impl Iterator<Item = (usize, &ModelInstance)> {
|
||||
+ pub fn models(&self) -> impl Iterator<Item = (usize, &HostModel)> {
|
||||
self.entries.iter().enumerate().flat_map(|(i, e)| {
|
||||
e.run
|
||||
.iter()
|
||||
@@ -1234,7 +1296,9 @@ impl Mods {
|
||||
}
|
||||
/// The first running mod's lighting, if it set one.
|
||||
pub fn lighting(&self) -> Option<ModLighting> {
|
||||
- self.entries.iter().find_map(|e| e.run.as_ref()?.store.data().lighting)
|
||||
+ self.entries
|
||||
+ .iter()
|
||||
+ .find_map(|e| e.run.as_ref()?.store.data().lighting)
|
||||
}
|
||||
/// The columns each running mod asked the ground height of, taken.
|
||||
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
|
||||
@@ -1435,6 +1499,29 @@ fn reason(e: &wasmtime::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
+/// The models a mod set, limited: finite, normalised, at most [`MODELS`].
|
||||
+fn clean_models(models: impl Iterator<Item = HostModel>) -> Vec<HostModel> {
|
||||
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
+ models
|
||||
+ .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
|
||||
+ .take(MODELS)
|
||||
+ .map(|mut m| {
|
||||
+ let (x, y, z, w) = m.rotation;
|
||||
+ let len = (x * x + y * y + z * z + w * w).sqrt();
|
||||
+ m.rotation = if len.is_finite() && len > 1e-6 {
|
||||
+ (x / len, y / len, z / len, w / len)
|
||||
+ } else {
|
||||
+ (0.0, 0.0, 0.0, 1.0)
|
||||
+ };
|
||||
+ m.scale = if m.scale.is_finite() {
|
||||
+ m.scale.clamp(0.0, MODEL_SCALE)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ };
|
||||
+ m
|
||||
+ })
|
||||
+ .collect()
|
||||
+}
|
||||
fn clip(text: String) -> String {
|
||||
clip_to(text, LINE_CHARS)
|
||||
}
|
||||
@@ -1949,28 +2036,45 @@ impl render::Host for State {
|
||||
}
|
||||
}
|
||||
fn set_models(&mut self, models: Vec<ModelInstance>) {
|
||||
- let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
- self.models = models
|
||||
- .into_iter()
|
||||
- .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
|
||||
- .take(MODELS)
|
||||
- .map(|mut m| {
|
||||
- let (x, y, z, w) = m.rotation;
|
||||
- let len = (x * x + y * y + z * z + w * w).sqrt();
|
||||
- m.rotation = if len.is_finite() && len > 1e-6 {
|
||||
- (x / len, y / len, z / len, w / len)
|
||||
- } else {
|
||||
- (0.0, 0.0, 0.0, 1.0)
|
||||
- };
|
||||
- m.scale = if m.scale.is_finite() {
|
||||
- m.scale.clamp(0.0, MODEL_SCALE)
|
||||
- } else {
|
||||
- 1.0
|
||||
- };
|
||||
- m.model = clip(m.model);
|
||||
- m
|
||||
- })
|
||||
- .collect();
|
||||
+ let models = models.into_iter().map(|m| HostModel {
|
||||
+ id: m.id,
|
||||
+ model: ModelRef::Name(clip(m.model)),
|
||||
+ x: m.x,
|
||||
+ y: m.y,
|
||||
+ z: m.z,
|
||||
+ rotation: m.rotation,
|
||||
+ scale: m.scale,
|
||||
+ glow: m.glow,
|
||||
+ });
|
||||
+ self.models = clean_models(models);
|
||||
+ }
|
||||
+ fn model_id(&mut self, model: String) -> Option<u32> {
|
||||
+ let t = self.model_table.lock().ok()?;
|
||||
+ (t.resolve.as_ref()?)(&model)
|
||||
+ }
|
||||
+ fn model_generation(&mut self) -> u32 {
|
||||
+ self.model_table.lock().map_or(0, |t| t.generation)
|
||||
+ }
|
||||
+ fn set_models_by_id(&mut self, generation: u32, models: Vec<ModelInstanceId>) -> bool {
|
||||
+ if self
|
||||
+ .model_table
|
||||
+ .lock()
|
||||
+ .map_or(true, |t| t.generation != generation)
|
||||
+ {
|
||||
+ return false;
|
||||
+ }
|
||||
+ let models = models.into_iter().map(|m| HostModel {
|
||||
+ id: m.id,
|
||||
+ model: ModelRef::Handle(m.model),
|
||||
+ x: m.x,
|
||||
+ y: m.y,
|
||||
+ z: m.z,
|
||||
+ rotation: m.rotation,
|
||||
+ scale: m.scale,
|
||||
+ glow: m.glow,
|
||||
+ });
|
||||
+ self.models = clean_models(models);
|
||||
+ true
|
||||
}
|
||||
fn spawn_particles(&mut self, particles: Vec<ParticleSpawn>) {
|
||||
let particles: Vec<ParticleSpawn> = particles
|
||||
@@ -2012,7 +2116,11 @@ impl render::Host for State {
|
||||
.map(|mut v| {
|
||||
v.yaw = angle(v.yaw);
|
||||
v.pitch = angle(v.pitch).clamp(-90.0, 90.0);
|
||||
- v.fov = if v.fov.is_finite() { v.fov.clamp(1.0, 110.0) } else { 70.0 };
|
||||
+ v.fov = if v.fov.is_finite() {
|
||||
+ v.fov.clamp(1.0, 110.0)
|
||||
+ } else {
|
||||
+ 70.0
|
||||
+ };
|
||||
v.width = v.width.clamp(16, 1920);
|
||||
v.height = v.height.clamp(16, 1080);
|
||||
v
|
||||
@@ -2021,22 +2129,44 @@ impl render::Host for State {
|
||||
}
|
||||
fn set_lights(&mut self, lights: Vec<PointLight>) {
|
||||
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
- let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
+ let unit = |v: f32| {
|
||||
+ if v.is_finite() {
|
||||
+ v.clamp(0.0, 1.0)
|
||||
+ } else {
|
||||
+ 0.0
|
||||
+ }
|
||||
+ };
|
||||
self.lights = lights
|
||||
.into_iter()
|
||||
.filter(|l| finite(l.x) && finite(l.y) && finite(l.z))
|
||||
.take(512)
|
||||
.map(|mut l| {
|
||||
l.color = (unit(l.color.0), unit(l.color.1), unit(l.color.2));
|
||||
- l.intensity = if l.intensity.is_finite() { l.intensity.clamp(0.0, 100_000.0) } else { 0.0 };
|
||||
- l.radius = if l.radius.is_finite() { l.radius.clamp(1.0, 256.0) } else { 1.0 };
|
||||
+ l.intensity = if l.intensity.is_finite() {
|
||||
+ l.intensity.clamp(0.0, 100_000.0)
|
||||
+ } else {
|
||||
+ 0.0
|
||||
+ };
|
||||
+ l.radius = if l.radius.is_finite() {
|
||||
+ l.radius.clamp(1.0, 256.0)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ };
|
||||
l
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
fn set_lighting(&mut self, mut lighting: ModLighting) {
|
||||
- lighting.exposure = if lighting.exposure.is_finite() { lighting.exposure.clamp(0.05, 8.0) } else { 1.0 };
|
||||
- lighting.bloom = if lighting.bloom.is_finite() { lighting.bloom.clamp(0.0, 1.0) } else { 0.0 };
|
||||
+ lighting.exposure = if lighting.exposure.is_finite() {
|
||||
+ lighting.exposure.clamp(0.05, 8.0)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ };
|
||||
+ lighting.bloom = if lighting.bloom.is_finite() {
|
||||
+ lighting.bloom.clamp(0.0, 1.0)
|
||||
+ } else {
|
||||
+ 0.0
|
||||
+ };
|
||||
self.lighting = Some(lighting);
|
||||
}
|
||||
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
|
||||
@@ -2050,8 +2180,18 @@ impl render::Host for State {
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
|
||||
return;
|
||||
}
|
||||
- let ok = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max) } else { 1.0 };
|
||||
- let sounds = self.out.iter().filter(|o| matches!(o, Output::Sound { .. })).count();
|
||||
+ let ok = |v: f32, max: f32| {
|
||||
+ if v.is_finite() {
|
||||
+ v.clamp(0.0, max)
|
||||
+ } else {
|
||||
+ 1.0
|
||||
+ }
|
||||
+ };
|
||||
+ let sounds = self
|
||||
+ .out
|
||||
+ .iter()
|
||||
+ .filter(|o| matches!(o, Output::Sound { .. }))
|
||||
+ .count();
|
||||
if sounds < 64 {
|
||||
self.out.push(Output::Sound {
|
||||
event: clip(event),
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index b25ef5b9e..150453ed5 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -125,6 +125,7 @@ A block along the player's view.
|
||||
| --- | --- | --- |
|
||||
| `get-player` | `func() -> option<player>` | None on the title screen. |
|
||||
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
|
||||
+| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
|
||||
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
|
||||
|
||||
## `chat`
|
||||
@@ -399,6 +400,16 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `centered` | `bool` | Centred on `at.x` instead of starting there. |
|
||||
| `shadow` | `bool` | |
|
||||
|
||||
+### record `view-shape`
|
||||
+
|
||||
+A picture of one of the mod's `set-views` cameras (AirDef fork).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `view` | `u32` | The camera's id; an unknown one draws nothing. |
|
||||
+| `rect` | `rect` | |
|
||||
+| `mode` | `u32` | 0 as seen, 1 thermal (white hot), 2 night vision (green). |
|
||||
+
|
||||
### variant `shape`
|
||||
|
||||
| Case | Payload | |
|
||||
@@ -408,6 +419,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `sprite` | `sprite-shape` | |
|
||||
| `item` | `item-shape` | |
|
||||
| `text` | `text-shape` | |
|
||||
+| `view` | `view-shape` | |
|
||||
|
||||
### record `frame-rate`
|
||||
|
||||
@@ -442,6 +454,93 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
|
||||
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
|
||||
|
||||
+### record `model-instance`
|
||||
+
|
||||
+A block model drawn in the world (AirDef fork).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | Stays the same while the object lives: the game moves an id smoothly from where the last `set-models` put it. |
|
||||
+| `model` | `string` | A block state as commands write it (`airdef:shahed`, `minecraft:stone`, `minecraft:furnace[facing=east]`): its block model is drawn. An unknown one draws nothing. |
|
||||
+| `x` | `f64` | Where the model's centre is, in blocks. |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `rotation` | `tuple<f32, f32, f32, f32>` | Rotation about the model's centre: a quaternion (x, y, z, w). |
|
||||
+| `scale` | `f32` | Uniform scale, 1.0 a block (0 to 64). |
|
||||
+| `glow` | `bool` | Drawn at full brightness, whatever the light where it is. |
|
||||
+
|
||||
+### record `model-instance-id`
|
||||
+
|
||||
+`model-instance` with the model as a handle.
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | |
|
||||
+| `model` | `u32` | From `model-id`. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `rotation` | `tuple<f32, f32, f32, f32>` | |
|
||||
+| `scale` | `f32` | |
|
||||
+| `glow` | `bool` | |
|
||||
+
|
||||
+### record `particle-spawn`
|
||||
+
|
||||
+Particles to spawn on this client (AirDef fork).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `particle` | `string` | As `/particle` names the type: `minecraft:flame`, `minecraft:dust`. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `spread` | `tuple<f32, f32, f32>` | Gaussian spread round the point, per axis, in blocks. |
|
||||
+| `velocity` | `tuple<f32, f32, f32>` | Every particle's starting velocity, blocks a tick. |
|
||||
+| `speed` | `f32` | Gaussian speed added to it per axis. |
|
||||
+| `count` | `u32` | 1 to 256. |
|
||||
+| `color` | `u32` | 0xRRGGBB for dust and coloured types (0: white). |
|
||||
+| `scale` | `f32` | Dust size (0.01 to 4). |
|
||||
+
|
||||
+### record `camera-view`
|
||||
+
|
||||
+A camera the game renders the world from besides the player's eye (AirDef fork): the land, the sky and the entities near the player.
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | Stays the same while the camera lives: the game turns it smoothly between the last two `set-views`. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `yaw` | `f32` | Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down. |
|
||||
+| `pitch` | `f32` | |
|
||||
+| `fov` | `f32` | Vertical field of view, degrees (1 to 110). |
|
||||
+| `width` | `u32` | The picture's size in pixels (16 to 1920 × 16 to 1080). |
|
||||
+| `height` | `u32` | |
|
||||
+
|
||||
+### record `point-light`
|
||||
+
|
||||
+A light the mod's things give off (AirDef fork): rocket flames, flares, blasts. Drawn by the HD lighting (`set-lighting`).
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | `u32` | Stays the same while the light lives: the game moves it smoothly. |
|
||||
+| `x` | `f64` | |
|
||||
+| `y` | `f64` | |
|
||||
+| `z` | `f64` | |
|
||||
+| `color` | `tuple<f32, f32, f32>` | Linear RGB, 0 to 1. |
|
||||
+| `intensity` | `f32` | Brightness: about what it lights one block away (0 to 100000). |
|
||||
+| `radius` | `f32` | Nothing past this many blocks (1 to 256). |
|
||||
+
|
||||
+### record `lighting`
|
||||
+
|
||||
+The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in high dynamic range, with bloom and tone mapping.
|
||||
+
|
||||
+| Field | Type | |
|
||||
+| --- | --- | --- |
|
||||
+| `hd` | `bool` | |
|
||||
+| `exposure` | `f32` | 1 as is; more brightens. |
|
||||
+| `bloom` | `f32` | How much bright things glow (0 none, 1 strong). |
|
||||
+
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
|
||||
@@ -454,6 +553,20 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
|
||||
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
|
||||
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
|
||||
+| `set-models` | `func(models: list<model-instance>)` | Replaces the mod's models: up to 4096, drawn with the world's entities until replaced. Call it once a tick: the game interpolates each id between its last two positions and rotations every frame. An empty list removes them. |
|
||||
+| `model-id` | `func(model: string) -> option<u32>` | A handle for a block state name, for `set-models-by-id` (AirDef fork, API level 11): the name crosses once, not every tick. None for a name that is no block state of the game's registry (nothing is remembered for it). Handles are valid until `model-generation` changes (the registry changed: a resource reload, another world's data); ask again for every name then. |
|
||||
+| `model-generation` | `func() -> u32` | Moves on whenever the handles `model-id` gave stop being valid. A mod reads it before `set-models-by-id` and re-asks when it changed. |
|
||||
+| `set-models-by-id` | `func(generation: u32, models: list<model-instance-id>) -> bool` | `set-models` with handles instead of names: the same models, limits and interpolation, fewer bytes across the boundary. `generation` is the `model-generation` the handles were asked under; if it is not the current one the call is ignored and returns false (re-ask the handles and call again). Handles the game doesn't know draw nothing. |
|
||||
+| `spawn-particles` | `func(particles: list<particle-spawn>)` | Spawns particles on this client only: no command, no server. At most 512 spawns a call. |
|
||||
+| `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `<mod>:<name>`) at a point on this client. |
|
||||
+| `query-ground` | `func(columns: list<tuple<s32, s32>>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. |
|
||||
+| `ground` | `func() -> list<tuple<s32, s32, s32>>` | The answers to the last tick's `query-ground`: (x, z, y), y one above the highest block that blocks motion or holds a fluid, as the motion blocking heightmap; -64 where the chunk isn't loaded. |
|
||||
+| `set-post` | `func(wgsl: string)` | A full-screen post pass (AirDef fork): WGSL defining `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, after a prelude with `frame` (camera matrices, time, `params`), the world's colour and depth and helpers (`scene`, `depth-at`, `view-dir`, `scene-distance`, `project`). It runs after the world, before the hand and the HUD. Compile errors come back in the chat and the log; the last good pass keeps running. |
|
||||
+| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
|
||||
+| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
|
||||
+| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
|
||||
+| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
|
||||
+| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
|
||||
|
||||
## `events`
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/31] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
camera's step, plants' normals turned with the camera); TAA keeps more
|
||||
history when still
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 33 ++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 211 ++++++++++++++++++--
|
||||
crates/horizon-client/src/gfx/world_post.rs | 8 +-
|
||||
crates/horizon-client/src/terrain.wgsl | 4 +
|
||||
4 files changed, 223 insertions(+), 33 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index eb152667c..b85c6e269 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3970,13 +3970,15 @@ impl Renderer {
|
||||
// offset each frame; `unjittered` is what the next frame reprojects with.
|
||||
let unjittered = self.view_projection(frame);
|
||||
let taa = hd_wanted && hd::taa_enabled();
|
||||
+ let mut jitter_px = [0.0; 2];
|
||||
let view_proj = if taa {
|
||||
- let size = self
|
||||
- .world_post
|
||||
- .size()
|
||||
- .unwrap_or([self.config.width, self.config.height]);
|
||||
- let j = hd::jitter(self.taa_frame, size);
|
||||
+ let window = [self.config.width, self.config.height];
|
||||
+ let size = self.world_post.size().unwrap_or(window);
|
||||
+ // Upscaling (the world smaller than the window) cycles through more positions.
|
||||
+ let ratio = window[0] as f32 / size[0].max(1) as f32;
|
||||
+ let j = hd::jitter(self.taa_frame, size, hd::jitter_count(ratio));
|
||||
self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
+ jitter_px = [j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5];
|
||||
Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
|
||||
} else {
|
||||
unjittered
|
||||
@@ -4192,6 +4194,7 @@ impl Renderer {
|
||||
cascades: [Mat4::IDENTITY; 2],
|
||||
view_proj,
|
||||
taa: taa && taa_prev.is_some(),
|
||||
+ jitter_px,
|
||||
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
|
||||
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
|
||||
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
|
||||
@@ -4218,8 +4221,11 @@ impl Renderer {
|
||||
};
|
||||
if fresh {
|
||||
self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
|
||||
- } else if let Some((held, _, _, age)) = &mut self.hd_far {
|
||||
- hd_cascades[1] = *held;
|
||||
+ } else if let Some((held, at, _, age)) = &mut self.hd_far {
|
||||
+ // The held matrix is relative to the camera it was drawn from: shifted by the
|
||||
+ // camera's step since, so the map stays put in the world as the player walks.
|
||||
+ let step = (camera - *at).as_vec3();
|
||||
+ hd_cascades[1] = *held * Mat4::from_translation(step);
|
||||
*age += 1;
|
||||
}
|
||||
fresh
|
||||
@@ -4230,11 +4236,14 @@ impl Renderer {
|
||||
hd_scene.cascades = hd_cascades;
|
||||
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
|
||||
hd_shadow_visible.clear();
|
||||
- if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
|
||||
+ if hd_wanted && shadow_strength > 0.0 {
|
||||
+ // The near cascade is drawn every frame: between the far one's redraws the list holds
|
||||
+ // only the sections in the near box.
|
||||
+ let c = if hd_far_fresh { 1 } else { 0 };
|
||||
let radius =
|
||||
- ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
|
||||
+ ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[c] / 16.0) as i32 + 2);
|
||||
let light = Frustum {
|
||||
- planes: frustum(hd_cascades[1]),
|
||||
+ planes: frustum(hd_cascades[c]),
|
||||
};
|
||||
self.sections.visible(
|
||||
camera,
|
||||
@@ -5028,6 +5037,8 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_weather());
|
||||
+ // AirDef fork: HD renders the world at its own scale and upscales it in the TAA pass.
|
||||
+ self.world_post.hd_scale = hd_on.then(hd::scale_override).flatten();
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
@@ -5046,7 +5057,7 @@ impl Renderer {
|
||||
let hd = self
|
||||
.hd
|
||||
.get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size));
|
||||
- hd.resize(device, size);
|
||||
+ hd.resize(device, size, [self.config.width, self.config.height]);
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 16610cb02..c54b7b961 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -89,7 +89,7 @@ struct HdFrame {
|
||||
prev_view_proj: [[f32; 4]; 4],
|
||||
/// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
|
||||
taa: [f32; 4],
|
||||
- /// x: the history's weight; y: the sharpening (0 without TAA).
|
||||
+ /// x: the history's weight; y: the sharpening (0 without TAA); zw: the jitter in world pixels.
|
||||
taa_params: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ pub struct HdScene {
|
||||
pub camera_delta: glam::Vec3,
|
||||
/// This frame is anti-aliased over time (the projection is jittered).
|
||||
pub taa: bool,
|
||||
+ /// The jitter in world-target pixels, screen orientation (x right, y down): the world's sample
|
||||
+ /// of a pixel lies this far from its centre (the upscaler weighs samples by it).
|
||||
+ pub jitter_px: [f32; 2],
|
||||
/// Towards the sun, and the sky's darkening (0 day, 1 night).
|
||||
pub sun: glam::Vec3,
|
||||
pub dark: f32,
|
||||
@@ -662,8 +665,8 @@ fn fs_taa(in: FullOut) -> TaaOut {
|
||||
if abs(seen - expected) <= expected * 0.03 + 0.25 {
|
||||
let mean = sum / 9.0;
|
||||
let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
|
||||
- let box_lo = min(max(mean - sigma * 1.25, lo), centre);
|
||||
- let box_hi = max(min(mean + sigma * 1.25, hi), centre);
|
||||
+ let box_lo = min(max(mean - sigma * 1.5, lo), centre);
|
||||
+ let box_hi = max(min(mean + sigma * 1.5, hi), centre);
|
||||
let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
|
||||
// Fast motion trusts the history less.
|
||||
let moved = length((uv - in.uv) * vec2<f32>(size));
|
||||
@@ -679,6 +682,99 @@ fn fs_taa(in: FullOut) -> TaaOut {
|
||||
out.history = vec4<f32>(color, dist);
|
||||
return out;
|
||||
}
|
||||
+
|
||||
+// The jittered samples' reconstruction filter (a polynomial fit of Lanczos-2, radius 2 in
|
||||
+// world-target pixels): 1 at 0, 0 at 1 and 2, a little negative between.
|
||||
+fn lanczos2(x: f32) -> f32 {
|
||||
+ let x2 = min(x * x, 4.0);
|
||||
+ let a = 0.4 * x2 - 1.0;
|
||||
+ let b = 0.25 * x2 - 1.0;
|
||||
+ return (1.5625 * a * a - 0.5625) * b * b;
|
||||
+}
|
||||
+
|
||||
+// Temporal upscaling: the world (and its depth) is smaller than the output. Each output pixel
|
||||
+// gathers the 3x3 world samples round it, each weighed by its distance from the pixel's centre
|
||||
+// (the samples lie off the pixel centres by the frame's jitter), clamps the reprojected full-size
|
||||
+// history to their colour box and blends: more history where the nearest sample is far away.
|
||||
+@fragment
|
||||
+fn fs_taau(in: FullOut) -> TaaOut {
|
||||
+ let low = vec2<f32>(textureDimensions(source));
|
||||
+ let lowi = vec2<i32>(low);
|
||||
+ let high = vec2<f32>(textureDimensions(history));
|
||||
+ let jit = frame.taa_params.zw;
|
||||
+ // The output pixel's place among the world's samples: sample i lies at index i - jit, and
|
||||
+ // the nearest to this pixel is `base`.
|
||||
+ let t = in.uv * low - 0.5 + jit;
|
||||
+ let base = vec2<i32>(floor(t + 0.5));
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ var sum2 = vec3<f32>(0.0);
|
||||
+ var lo = vec3<f32>(1.0e9);
|
||||
+ var hi = vec3<f32>(-1.0e9);
|
||||
+ var weighted = vec3<f32>(0.0);
|
||||
+ var total = 0.0;
|
||||
+ for (var y = -1; y <= 1; y++) {
|
||||
+ for (var x = -1; x <= 1; x++) {
|
||||
+ let q = clamp(base + vec2<i32>(x, y), vec2<i32>(0), lowi - 1);
|
||||
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
|
||||
+ sum += s;
|
||||
+ sum2 += s * s;
|
||||
+ lo = min(lo, s);
|
||||
+ hi = max(hi, s);
|
||||
+ let off = vec2<f32>(base + vec2<i32>(x, y)) - t;
|
||||
+ let w = lanczos2(off.x) * lanczos2(off.y);
|
||||
+ weighted += s * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ let current = weighted / max(total, 0.001);
|
||||
+ // Depth: the nearest of the 2x2 samples round the pixel (steady between frames at an edge).
|
||||
+ let corner = vec2<i32>(floor(in.uv * low - 0.5 + jit));
|
||||
+ var d = 0.0;
|
||||
+ for (var y = 0; y <= 1; y++) {
|
||||
+ for (var x = 0; x <= 1; x++) {
|
||||
+ d = max(d, textureLoad(depth_tex, clamp(corner + vec2<i32>(x, y), vec2<i32>(0), lowi - 1), 0));
|
||||
+ }
|
||||
+ }
|
||||
+ let sky = d <= 0.0;
|
||||
+ // The world was drawn shifted by the jitter in clip space; this pixel's unshifted point.
|
||||
+ let jc = vec2<f32>(jit.x * 2.0 / low.x, -jit.y * 2.0 / low.y);
|
||||
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0 + jc.x, 1.0 - in.uv.y * 2.0 + jc.y, max(d, 0.000001), 1.0);
|
||||
+ let h = frame.inverse_view_proj * ndc;
|
||||
+ let p = h.xyz / h.w;
|
||||
+ let dist = select(length(p), 0.0, sky);
|
||||
+ var resolved = current;
|
||||
+ if frame.taa.w > 0.5 {
|
||||
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
|
||||
+ if prev.w > 0.0 {
|
||||
+ let pn = prev.xy / prev.w;
|
||||
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
|
||||
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
|
||||
+ let hp = clamp(vec2<i32>(uv * high), vec2<i32>(0), vec2<i32>(high) - 1);
|
||||
+ let seen = textureLoad(history, hp, 0).a;
|
||||
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
|
||||
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
|
||||
+ let mean = sum / 9.0;
|
||||
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
|
||||
+ let box_lo = min(max(mean - sigma * 1.25, lo), current);
|
||||
+ let box_hi = max(min(mean + sigma * 1.25, hi), current);
|
||||
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
|
||||
+ // How far the nearest sample is from the pixel's centre (0 on it, 1 at the
|
||||
+ // corner of the world's pixel): the farther, the less the frame says.
|
||||
+ let near = clamp(length(t - floor(t + 0.5)) / 0.7071, 0.0, 1.0);
|
||||
+ let fresh = (1.0 - frame.taa_params.x) * mix(1.8, 0.5, near);
|
||||
+ let moved = length((uv - in.uv) * high);
|
||||
+ let keep = (1.0 - fresh) * mix(1.0, 0.8, clamp(moved / 4.0, 0.0, 1.0));
|
||||
+ resolved = mix(current, past, clamp(keep, 0.0, 0.98));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ let color = unsquash(from_ycocg(resolved));
|
||||
+ var out: TaaOut;
|
||||
+ out.color = vec4<f32>(color, 1.0);
|
||||
+ out.history = vec4<f32>(color, dist);
|
||||
+ return out;
|
||||
+}
|
||||
"#;
|
||||
|
||||
struct Target {
|
||||
@@ -744,10 +840,35 @@ fn halton(mut index: u32, base: u32) -> f32 {
|
||||
r
|
||||
}
|
||||
|
||||
+/// The history's weight when upscaling (the frames each pixel gathers are sparse, so more of them
|
||||
+/// are kept than without; `fs_taau` lowers it where the frame's sample is near).
|
||||
+const UPSCALE_HISTORY: f32 = 0.92;
|
||||
+
|
||||
+/// The world's size relative to the output (`HORIZON_HD_SCALE`, per axis, 0.25..=1) while HD is on, if
|
||||
+/// set: it replaces the resolution scale of Horizon's settings for HD. 1 renders at full size.
|
||||
+pub fn scale_override() -> Option<f32> {
|
||||
+ static SCALE: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||||
+ *SCALE.get_or_init(|| {
|
||||
+ let v: f32 = std::env::var("HORIZON_HD_SCALE").ok()?.parse().ok()?;
|
||||
+ v.is_finite().then(|| v.clamp(0.25, 1.0))
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// How many jitter positions the sequence cycles through for an output `ratio` times the world's
|
||||
+/// size per axis: 16 without upscaling, else 8 per output pixel's share of a world pixel
|
||||
+/// (ratio squared), so every output pixel gets samples all over the world pixel it lies in.
|
||||
+pub fn jitter_count(ratio: f32) -> u32 {
|
||||
+ if ratio <= 1.001 {
|
||||
+ 16
|
||||
+ } else {
|
||||
+ ((8.0 * ratio * ratio).ceil() as u32).clamp(16, 64)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
|
||||
-/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
|
||||
-pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
|
||||
- let i = frame % 16 + 1;
|
||||
+/// screen's size in x and y), from the Halton(2, 3) sequence over `count` frames.
|
||||
+pub fn jitter(frame: u32, size: [u32; 2], count: u32) -> [f32; 2] {
|
||||
+ let i = frame % count.max(1) + 1;
|
||||
[
|
||||
(halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
|
||||
(halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
|
||||
@@ -755,7 +876,10 @@ pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
|
||||
}
|
||||
|
||||
pub struct Hd {
|
||||
+ /// The world's size (the G-buffer, lighting, AO, post pass), and the output's (TAA, bloom,
|
||||
+ /// tone curve): bigger when the world is upscaled.
|
||||
size: [u32; 2],
|
||||
+ out: [u32; 2],
|
||||
pub albedo: wgpu::TextureView,
|
||||
pub normal: wgpu::TextureView,
|
||||
pub levels: wgpu::TextureView,
|
||||
@@ -779,6 +903,7 @@ pub struct Hd {
|
||||
taa_active: bool,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
+ taau_pipeline: wgpu::RenderPipeline,
|
||||
taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
|
||||
bloom: Vec<Target>,
|
||||
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
@@ -1098,7 +1223,7 @@ impl Hd {
|
||||
entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
|
||||
});
|
||||
let taa_module = module(device, "HD TAA", TAA);
|
||||
- let taa_pipeline = {
|
||||
+ let taa_pipeline = |entry: &str| {
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("HD TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
@@ -1120,7 +1245,7 @@ impl Hd {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &taa_module,
|
||||
- entry_point: Some("fs_taa"),
|
||||
+ entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[target.clone(), target],
|
||||
}),
|
||||
@@ -1131,6 +1256,7 @@ impl Hd {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
+ let (taa_pipeline, taau_pipeline) = (taa_pipeline("fs_taa"), taa_pipeline("fs_taau"));
|
||||
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD ambient occlusion"),
|
||||
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
@@ -1288,6 +1414,7 @@ impl Hd {
|
||||
};
|
||||
let mut hd = Hd {
|
||||
size: [0, 0],
|
||||
+ out: [0, 0],
|
||||
albedo: target(device, "HD albedo", [1, 1], ALBEDO).view,
|
||||
normal: target(device, "HD normal", [1, 1], NORMAL).view,
|
||||
levels: target(device, "HD light levels", [1, 1], LIGHT_LEVELS).view,
|
||||
@@ -1319,6 +1446,7 @@ impl Hd {
|
||||
taa_active: false,
|
||||
taa_layout,
|
||||
taa_pipeline,
|
||||
+ taau_pipeline,
|
||||
taa_groups: None,
|
||||
bloom: Vec::new(),
|
||||
light_group: None,
|
||||
@@ -1355,26 +1483,29 @@ impl Hd {
|
||||
groups_seen: 0,
|
||||
groups_last_frame: 0,
|
||||
};
|
||||
- hd.resize(device, size);
|
||||
+ hd.resize(device, size, size);
|
||||
hd
|
||||
}
|
||||
|
||||
- /// The targets, at the world target's size.
|
||||
- pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
- if self.size == size {
|
||||
+ /// The targets: the world's at `size` (the world target's), the output's (TAA history, bloom)
|
||||
+ /// at `out`, which is bigger when the world is upscaled.
|
||||
+ pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2], out: [u32; 2]) {
|
||||
+ let out = [out[0].max(size[0]), out[1].max(size[1])];
|
||||
+ if self.size == size && self.out == out {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
+ self.out = out;
|
||||
self.albedo = target(device, "HD albedo", size, ALBEDO).view;
|
||||
self.normal = target(device, "HD normal", size, NORMAL).view;
|
||||
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
- self.taa = target(device, "HD TAA", size, HDR).view;
|
||||
+ self.taa = target(device, "HD TAA", out, HDR).view;
|
||||
self.history = [
|
||||
- target(device, "HD TAA history", size, HDR).view,
|
||||
- target(device, "HD TAA history", size, HDR).view,
|
||||
+ target(device, "HD TAA history", out, HDR).view,
|
||||
+ target(device, "HD TAA history", out, HDR).view,
|
||||
];
|
||||
self.history_ready = false;
|
||||
self.taa_groups = None;
|
||||
@@ -1404,7 +1535,7 @@ impl Hd {
|
||||
self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
.map(|k| {
|
||||
- let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
|
||||
+ let s = [(out[0] >> (k + 1)).max(1), (out[1] >> (k + 1)).max(1)];
|
||||
target(device, "HD bloom", s, HDR)
|
||||
})
|
||||
.collect();
|
||||
@@ -1420,6 +1551,11 @@ impl Hd {
|
||||
.collect();
|
||||
}
|
||||
|
||||
+ /// The world is smaller than the output, so TAA upsamples it.
|
||||
+ fn upscaling(&self) -> bool {
|
||||
+ self.out != self.size
|
||||
+ }
|
||||
+
|
||||
/// This frame's camera, sun, fog, lights and settings.
|
||||
pub fn write(
|
||||
&mut self,
|
||||
@@ -1468,7 +1604,18 @@ impl Hd {
|
||||
scene.camera_delta.z,
|
||||
if self.history_ready { 1.0 } else { 0.0 },
|
||||
],
|
||||
- taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
|
||||
+ taa_params: [
|
||||
+ if self.upscaling() {
|
||||
+ UPSCALE_HISTORY
|
||||
+ } else {
|
||||
+ // A still picture keeps 94% of the past: the jitter's shimmer on pixel-art
|
||||
+ // textures averages out instead of flickering.
|
||||
+ 0.94
|
||||
+ },
|
||||
+ if self.taa_active { 0.12 } else { 0.0 },
|
||||
+ scene.jitter_px[0],
|
||||
+ scene.jitter_px[1],
|
||||
+ ],
|
||||
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
|
||||
resolution: [
|
||||
self.size[0] as f32,
|
||||
@@ -1872,9 +2019,13 @@ impl Hd {
|
||||
return source;
|
||||
};
|
||||
let (read, write) = (self.history_flip, 1 - self.history_flip);
|
||||
+ let upscaling = self.upscaling();
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD TAA"),
|
||||
- timestamp_writes: stamp(&mut self.timing, "hd taa"),
|
||||
+ timestamp_writes: stamp(
|
||||
+ &mut self.timing,
|
||||
+ if upscaling { "hd taau" } else { "hd taa" },
|
||||
+ ),
|
||||
color_attachments: &[&self.taa, &self.history[write]].map(|view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -1888,7 +2039,11 @@ impl Hd {
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
- pass.set_pipeline(&self.taa_pipeline);
|
||||
+ pass.set_pipeline(if upscaling {
|
||||
+ &self.taau_pipeline
|
||||
+ } else {
|
||||
+ &self.taa_pipeline
|
||||
+ });
|
||||
pass.set_bind_group(0, &groups[read], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
@@ -2045,7 +2200,7 @@ mod tests {
|
||||
let size = [1920u32, 1080];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for f in 0..32 {
|
||||
- let j = jitter(f, size);
|
||||
+ let j = jitter(f, size, 16);
|
||||
assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
|
||||
assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
|
||||
seen.insert((j[0].to_bits(), j[1].to_bits()));
|
||||
@@ -2053,6 +2208,22 @@ mod tests {
|
||||
assert_eq!(seen.len(), 16);
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn jitter_covers_the_upscale_ratio() {
|
||||
+ assert_eq!(jitter_count(1.0), 16);
|
||||
+ assert_eq!(jitter_count(1.0 / 0.67), 18);
|
||||
+ assert_eq!(jitter_count(2.0), 32);
|
||||
+ let size = [1280u32, 720];
|
||||
+ let count = jitter_count(2.0);
|
||||
+ let seen: std::collections::HashSet<_> = (0..count * 2)
|
||||
+ .map(|f| {
|
||||
+ let j = jitter(f, size, count);
|
||||
+ (j[0].to_bits(), j[1].to_bits())
|
||||
+ })
|
||||
+ .collect();
|
||||
+ assert_eq!(seen.len(), count as usize);
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
let size = [320, 160];
|
||||
diff --git a/crates/horizon-client/src/gfx/world_post.rs b/crates/horizon-client/src/gfx/world_post.rs
|
||||
index 17609d27d..a32bdc964 100644
|
||||
--- a/crates/horizon-client/src/gfx/world_post.rs
|
||||
+++ b/crates/horizon-client/src/gfx/world_post.rs
|
||||
@@ -18,6 +18,8 @@ pub struct Post {
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
+ /// HD's own scale for this frame (`HORIZON_HD_SCALE`), instead of `scale`.
|
||||
+ pub hd_scale: Option<f32>,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
}
|
||||
@@ -94,6 +96,7 @@ impl Post {
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
+ hd_scale: None,
|
||||
antialias: false,
|
||||
target: None,
|
||||
}
|
||||
@@ -108,11 +111,12 @@ impl Post {
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
- if self.scale == 1.0 && !self.antialias && !always {
|
||||
+ let scale = self.hd_scale.unwrap_or(self.scale);
|
||||
+ if scale == 1.0 && !self.antialias && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
- let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
+ let size = window.map(|v| ((v as f32 * scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index bb5c70f63..c44449cc7 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -166,6 +166,10 @@ struct GBufferOut {
|
||||
}
|
||||
fn face_normal(pos: vec3<f32>) -> vec3<f32> {
|
||||
var n = normalize(cross(dpdx(pos), dpdy(pos)));
|
||||
+ // Block faces are axis-aligned; only plants' crossed quads lean. Those have two
|
||||
+ // sides, so a normal turned to the camera would flip as the player walks by and
|
||||
+ // the sun and the shadow lookup with it: plants take the up normal instead.
|
||||
+ if max(abs(n.x), max(abs(n.y), abs(n.z))) < 0.9 { return vec3(0.0, 1.0, 0.0); }
|
||||
if dot(n, pos) > 0.0 { n = -n; }
|
||||
return n;
|
||||
}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/31] HD water: its own pass (colour, normal, depth), the
|
||||
floor under it lit, a composite with world-space waves, refraction with depth
|
||||
absorption in the biome colour, screen-space reflections, Fresnel and a
|
||||
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 9 +
|
||||
crates/horizon-client/src/gfx.rs | 109 +++++-
|
||||
crates/horizon-client/src/gfx/flat.rs | 4 +
|
||||
crates/horizon-client/src/gfx/hd.rs | 497 ++++++++++++++++++++++++-
|
||||
crates/horizon-client/src/terrain.wgsl | 19 +
|
||||
5 files changed, 627 insertions(+), 11 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 3a11ee283..5a173977f 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -14658,6 +14658,14 @@ fn build_renderer(
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
+ // AirDef fork, HD water: the layers of the water sprites.
|
||||
+ ["water_still", "water_flow"].map(|name| {
|
||||
+ assets
|
||||
+ .sprite_layers
|
||||
+ .get(&format!("minecraft:block/{name}"))
|
||||
+ .copied()
|
||||
+ .unwrap_or(0xffff)
|
||||
+ }),
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
@@ -14717,6 +14725,7 @@ fn loading_renderer(
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
+ [0xffff; 2],
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index b85c6e269..f9dab8abf 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -564,6 +564,8 @@ pub struct Renderer {
|
||||
mod_views: Option<mod_views::Views>,
|
||||
/// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets.
|
||||
gbuffer_terrain: wgpu::RenderPipeline,
|
||||
+ /// The water quads alone into the HD water targets (section draws; `flat.water` for flat ones).
|
||||
+ gbuffer_water: wgpu::RenderPipeline,
|
||||
gbuffer_entity: wgpu::RenderPipeline,
|
||||
/// The HD sun shadows: depth only, from the light, and the sections in its farther cascade.
|
||||
shadow_terrain: wgpu::RenderPipeline,
|
||||
@@ -1083,6 +1085,7 @@ impl Renderer {
|
||||
prepared: Option<PreparedTextures>,
|
||||
previous: Option<&Self>,
|
||||
api: GraphicsApi,
|
||||
+ water_layers: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let split = sprite_split();
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
@@ -1205,6 +1208,13 @@ impl Renderer {
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
];
|
||||
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
|
||||
+ let water_constants = [
|
||||
+ ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
+ ("SPRITE_SPLIT", f64::from(split)),
|
||||
+ ("WATER_STILL", f64::from(water_layers[0])),
|
||||
+ ("WATER_FLOW", f64::from(water_layers[1])),
|
||||
+ ];
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
@@ -1420,6 +1430,55 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
+ // AirDef fork, HD water: only the water quads (`fs_water`), into the colour and normal
|
||||
+ // targets and the water's own depth; `vertex` is the section or the flat vertex stage.
|
||||
+ let water_targets = [
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format: hd::WATER_TINT,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ }),
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format: hd::WATER_NORMAL,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ }),
|
||||
+ ];
|
||||
+ let water_pipeline =
|
||||
+ |layout: &wgpu::PipelineLayout,
|
||||
+ label: &str,
|
||||
+ vertex: &str,
|
||||
+ buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some(label),
|
||||
+ layout: Some(layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some(vertex),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers,
|
||||
+ },
|
||||
+ primitive: wgpu::PrimitiveState {
|
||||
+ cull_mode: Some(wgpu::Face::Back),
|
||||
+ ..Default::default()
|
||||
+ },
|
||||
+ depth_stencil: gbuffer_depth.clone(),
|
||||
+ multisample: Default::default(),
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some("fs_water"),
|
||||
+ compilation_options: wgpu::PipelineCompilationOptions {
|
||||
+ constants: &water_constants,
|
||||
+ ..Default::default()
|
||||
+ },
|
||||
+ targets: &water_targets,
|
||||
+ }),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
+ let gbuffer_water =
|
||||
+ water_pipeline(&layout, "HD water terrain", "vs_terrain", &terrain_buffers);
|
||||
let shadow_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("HD shadows terrain"),
|
||||
layout: Some(&layout),
|
||||
@@ -2597,6 +2656,7 @@ impl Renderer {
|
||||
&device,
|
||||
&bindings,
|
||||
arena.page_vertices(),
|
||||
+ |layout, label| water_pipeline(layout, label, "vs_terrain_flat", &buffers),
|
||||
|layout, label, fragment, blend| {
|
||||
pipeline_in(
|
||||
layout,
|
||||
@@ -2816,6 +2876,7 @@ impl Renderer {
|
||||
mod_post_hdr: false,
|
||||
mod_views: None,
|
||||
gbuffer_terrain,
|
||||
+ gbuffer_water,
|
||||
gbuffer_entity,
|
||||
shadow_terrain,
|
||||
shadow_entity,
|
||||
@@ -4204,6 +4265,16 @@ impl Renderer {
|
||||
fog_color,
|
||||
fog,
|
||||
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
|
||||
+ sky_color: [
|
||||
+ frame.sky.sky_color[0],
|
||||
+ frame.sky.sky_color[1],
|
||||
+ frame.sky.sky_color[2],
|
||||
+ ],
|
||||
+ camera_mod: [
|
||||
+ camera.x.rem_euclid(256.0) as f32,
|
||||
+ camera.z.rem_euclid(256.0) as f32,
|
||||
+ ],
|
||||
+ water: !frame.underwater,
|
||||
};
|
||||
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
|
||||
let mut hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
@@ -5058,6 +5129,11 @@ impl Renderer {
|
||||
.hd
|
||||
.get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size));
|
||||
hd.resize(device, size, [self.config.width, self.config.height]);
|
||||
+ // Translucent terrain on screen: the water pass has something to draw.
|
||||
+ hd.water_geometry = match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
+ Some(flat) => !flat.translucent_calls().is_empty(),
|
||||
+ None => translucent_draws > 0,
|
||||
+ };
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
@@ -5186,6 +5262,32 @@ impl Renderer {
|
||||
drop(pass);
|
||||
// Contact shadows from the G-buffer's depth and normals, for the lighting.
|
||||
hd.ao(&mut encoder);
|
||||
+ // The water quads alone, with their own depth: the lighting finds the water by it and
|
||||
+ // the composite after the lighting lays the surface over the lit floor.
|
||||
+ if hd.water_active() {
|
||||
+ let mut pass = hd.water_pass(&mut encoder);
|
||||
+ pass.set_bind_group(0, &self.group, &[]);
|
||||
+ match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
+ Some(flat) => {
|
||||
+ pass.set_pipeline(&flat.water);
|
||||
+ flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
+ self.arena.buffer(p)
|
||||
+ });
|
||||
+ }
|
||||
+ None => {
|
||||
+ pass.set_pipeline(&self.gbuffer_water);
|
||||
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
+ pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
+ self.draw_sections(
|
||||
+ &mut pass,
|
||||
+ &self.indirect,
|
||||
+ 0,
|
||||
+ solid_draws,
|
||||
+ translucent_draws,
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
} else if !hd_on {
|
||||
self.hd = None;
|
||||
}
|
||||
@@ -5721,6 +5823,11 @@ impl Renderer {
|
||||
(color, depth),
|
||||
(&self.lightmap_view, &self.lightmap_sampler),
|
||||
);
|
||||
+ hd.water_composite(
|
||||
+ &self.device,
|
||||
+ &mut encoder,
|
||||
+ (&self.lightmap_view, &self.lightmap_sampler),
|
||||
+ );
|
||||
match &mut self.mod_post {
|
||||
Some(post) if mod_post => {
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
@@ -5731,7 +5838,7 @@ impl Renderer {
|
||||
&self.mod_params.0,
|
||||
self.mod_params.1,
|
||||
);
|
||||
- post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
+ post.run(&self.device, &mut encoder, (hd.lit_view(), depth), &hd.fx);
|
||||
let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
|
||||
hd.finish(&self.device, &mut encoder, source, &view_target);
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/flat.rs b/crates/horizon-client/src/gfx/flat.rs
|
||||
index 5d86888da..7e2e20cad 100644
|
||||
--- a/crates/horizon-client/src/gfx/flat.rs
|
||||
+++ b/crates/horizon-client/src/gfx/flat.rs
|
||||
@@ -64,6 +64,8 @@ pub struct Call {
|
||||
pub struct Flat {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
+ /// The water quads alone, into HD's water targets.
|
||||
+ pub water: wgpu::RenderPipeline,
|
||||
flatten: wgpu::ComputePipeline,
|
||||
compute_layout: wgpu::BindGroupLayout,
|
||||
flatten_seen: wgpu::ComputePipeline,
|
||||
@@ -254,6 +256,7 @@ impl Flat {
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
+ water: impl Fn(&wgpu::PipelineLayout, &str) -> wgpu::RenderPipeline,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -320,6 +323,7 @@ impl Flat {
|
||||
"fs_translucent",
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
),
|
||||
+ water: water(&layout, "HD water terrain (flat)"),
|
||||
flatten,
|
||||
compute_layout,
|
||||
flatten_seen,
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index c54b7b961..f5caa2d0b 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -16,6 +16,9 @@ pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// The lit world and the post pass's output.
|
||||
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
+/// The water pass's targets: the surface colour with the block light, and the normal with the sky light.
|
||||
+pub const WATER_TINT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
+pub const WATER_NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// The most lights drawn a frame.
|
||||
pub const MAX_LIGHTS: usize = 512;
|
||||
/// The side (pixels) of a tile of the screen with its own short list of lights.
|
||||
@@ -91,6 +94,13 @@ struct HdFrame {
|
||||
taa: [f32; 4],
|
||||
/// x: the history's weight; y: the sharpening (0 without TAA); zw: the jitter in world pixels.
|
||||
taa_params: [f32; 4],
|
||||
+ /// The (jittered) world-to-clip matrix the world was drawn with (rays in the water pass).
|
||||
+ view_proj: [[f32; 4]; 4],
|
||||
+ /// x: the water effect runs this frame; y: seconds for the waves (wraps at 600); zw: the
|
||||
+ /// camera's x and z modulo 256, the waves' period.
|
||||
+ water: [f32; 4],
|
||||
+ /// rgb: the sky's colour; w: 1 when there is a sky.
|
||||
+ sky_color: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the world around looks like this frame (from the renderer's globals).
|
||||
@@ -116,6 +126,11 @@ pub struct HdScene {
|
||||
pub fog: [f32; 4],
|
||||
/// The sky has a sun at all (the Overworld).
|
||||
pub sky: bool,
|
||||
+ pub sky_color: [f32; 3],
|
||||
+ /// The camera's x and z modulo 256 (the waves' period), so the waves hold still in the world.
|
||||
+ pub camera_mod: [f32; 2],
|
||||
+ /// The water effect may run (not underwater, not disabled).
|
||||
+ pub water: bool,
|
||||
}
|
||||
|
||||
/// The cascades' half sizes (blocks) and shadow map size.
|
||||
@@ -137,6 +152,9 @@ struct HdFrame {
|
||||
prev_view_proj: mat4x4<f32>,
|
||||
taa: vec4<f32>,
|
||||
taa_params: vec4<f32>,
|
||||
+ view_proj: mat4x4<f32>,
|
||||
+ water: vec4<f32>,
|
||||
+ sky_color: vec4<f32>,
|
||||
}
|
||||
struct FullOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -175,6 +193,8 @@ struct 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 water pass's depth: the nearest water surface (0 where there is none).
|
||||
+@group(0) @binding(14) var water_depth: texture_depth_2d;
|
||||
|
||||
// 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.
|
||||
@@ -216,6 +236,12 @@ fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
|
||||
|
||||
// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow; soft-edged.
|
||||
fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
+ return shadow_taps(p, n, 1);
|
||||
+}
|
||||
+
|
||||
+// `shadow` with a (2 * r + 1) square of taps: the floor under water takes one (the water dims and
|
||||
+// blurs it anyway).
|
||||
+fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
|
||||
let dist = length(p);
|
||||
let c = select(1, 0, dist < frame.shadow.y * 0.92);
|
||||
let texel = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0;
|
||||
@@ -224,13 +250,13 @@ fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
|
||||
var lit = 0.0;
|
||||
let step = 1.0 / 2048.0;
|
||||
- for (var y = -1; y <= 1; y++) {
|
||||
- for (var x = -1; x <= 1; x++) {
|
||||
+ for (var y = -r; y <= r; y++) {
|
||||
+ for (var x = -r; x <= r; x++) {
|
||||
lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
|
||||
}
|
||||
}
|
||||
let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
- return mix(1.0, lit / 9.0, fade);
|
||||
+ return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
|
||||
}
|
||||
|
||||
// One shadow sample, for the rays through the air.
|
||||
@@ -252,7 +278,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let scene = textureLoad(scene_color, px, 0).rgb;
|
||||
let d = textureLoad(scene_depth, px, 0);
|
||||
let g = textureLoad(gbuffer_depth, px, 0);
|
||||
- let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
|
||||
+ // Water in front of the opaque world with nothing translucent over it: here the floor is lit
|
||||
+ // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
|
||||
+ var water = false;
|
||||
+ if frame.water.x > 0.5 && g > 0.0 {
|
||||
+ let wd = textureLoad(water_depth, px, 0);
|
||||
+ water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
|
||||
+ }
|
||||
+ let dd = select(d, g, water);
|
||||
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
|
||||
let h = frame.inverse_view_proj * ndc;
|
||||
let p = h.xyz / h.w;
|
||||
let view = normalize(p);
|
||||
@@ -267,7 +301,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
var c = scene;
|
||||
- let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
|
||||
+ let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
if surface {
|
||||
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
let albedo = textureLoad(albedo_tex, px, 0);
|
||||
@@ -278,14 +312,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sky_reach = smoothstep(0.35, 0.95, lm.x);
|
||||
var s = 1.0;
|
||||
let ndl = dot(n, sun);
|
||||
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow(p, n); } else { s = 0.0; }
|
||||
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)); } else { s = 0.0; }
|
||||
// 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;
|
||||
// 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)); }
|
||||
+ // (The water dims and blurs the floor under it: no occlusion there.)
|
||||
+ if frame.ambient.w > 0.0 && !water { 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;
|
||||
@@ -334,7 +369,253 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let a1 = atan((far - t0) / hh);
|
||||
c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
|
||||
}
|
||||
- return vec4<f32>(c, 1.0);
|
||||
+ return vec4<f32>(c, select(1.0, 0.5, water));
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+const WATER: &str = r#"
|
||||
+const PI: f32 = 3.14159265;
|
||||
+const REFLECT_STEPS: i32 = 24;
|
||||
+const REFLECT_RANGE: f32 = 96.0;
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var lit_tex: texture_2d<f32>;
|
||||
+@group(0) @binding(2) var gbuffer_depth: texture_depth_2d;
|
||||
+@group(0) @binding(3) var water_tint: texture_2d<f32>;
|
||||
+@group(0) @binding(4) var water_normal: texture_2d<f32>;
|
||||
+@group(0) @binding(5) var water_depth: texture_depth_2d;
|
||||
+@group(0) @binding(6) var shadow_map: texture_depth_2d_array;
|
||||
+@group(0) @binding(7) var shadow_sampler: sampler_comparison;
|
||||
+@group(0) @binding(8) var lightmap: texture_2d<f32>;
|
||||
+@group(0) @binding(9) var linear: sampler;
|
||||
+
|
||||
+fn ign(px: vec2<f32>) -> f32 {
|
||||
+ let p = px + 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 the screen point `uv` at depth z.
|
||||
+fn position_at(uv: vec2<f32>, z: f32) -> vec3<f32> {
|
||||
+ 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;
|
||||
+}
|
||||
+
|
||||
+// The screen point (uv, clip w) of a camera-relative position.
|
||||
+fn project(p: vec3<f32>) -> vec3<f32> {
|
||||
+ let c = frame.view_proj * vec4<f32>(p, 1.0);
|
||||
+ return vec3<f32>(c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5, c.w);
|
||||
+}
|
||||
+
|
||||
+fn on_screen(uv: vec2<f32>) -> bool {
|
||||
+ return all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0));
|
||||
+}
|
||||
+
|
||||
+fn texel(uv: vec2<f32>) -> vec2<i32> {
|
||||
+ return clamp(vec2<i32>(uv * frame.resolution.xy), vec2<i32>(0), vec2<i32>(frame.resolution.xy) - 1);
|
||||
+}
|
||||
+
|
||||
+// Sine waves in world space: wave vectors that are whole multiples of 2 pi / 256 and speeds that
|
||||
+// are whole cycles per 600 s, so the sum repeats every 256 blocks and 10 minutes and holds still
|
||||
+// in the world as the camera moves. x, y: the vector in units of 2 pi / 256; z: the slope the
|
||||
+// wave adds; w: its phase.
|
||||
+const WAVES = array<vec4<f32>, 10>(
|
||||
+ vec4<f32>(3.0, 1.0, 0.040, 0.3),
|
||||
+ vec4<f32>(-2.0, 5.0, 0.045, 2.1),
|
||||
+ vec4<f32>(7.0, 2.0, 0.040, 4.4),
|
||||
+ vec4<f32>(-5.0, -9.0, 0.040, 1.2),
|
||||
+ vec4<f32>(11.0, -4.0, 0.035, 5.6),
|
||||
+ vec4<f32>(13.0, 10.0, 0.035, 3.3),
|
||||
+ vec4<f32>(-17.0, 7.0, 0.030, 0.9),
|
||||
+ vec4<f32>(9.0, 23.0, 0.030, 2.7),
|
||||
+ vec4<f32>(-27.0, 19.0, 0.025, 4.9),
|
||||
+ vec4<f32>(31.0, 30.0, 0.025, 1.8),
|
||||
+);
|
||||
+
|
||||
+// The gradient of the water's height at world x, z: the waves that a pixel `footprint` blocks
|
||||
+// wide can still resolve (finer ones fade out, or they would shimmer).
|
||||
+fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
|
||||
+ var grad = vec2<f32>(0.0);
|
||||
+ for (var i = 0; i < 10; i++) {
|
||||
+ let w = WAVES[i];
|
||||
+ let k = w.xy * (2.0 * PI / 256.0);
|
||||
+ let len = length(k);
|
||||
+ let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.25 - 0.5, 0.0, 1.0);
|
||||
+ let cycles = round(sqrt(9.8 * len) * 0.5 * 600.0 / (2.0 * PI));
|
||||
+ let omega = cycles * 2.0 * PI / 600.0;
|
||||
+ let phase = dot(k, xz) - omega * frame.water.y + w.w;
|
||||
+ grad += (k / len) * (w.z * fade * cos(phase));
|
||||
+ }
|
||||
+ return grad;
|
||||
+}
|
||||
+
|
||||
+fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
|
||||
+ let s = frame.cascades[c] * vec4<f32>(p, 1.0);
|
||||
+ return vec3<f32>(s.x * 0.5 + 0.5, 0.5 - s.y * 0.5, s.z);
|
||||
+}
|
||||
+
|
||||
+// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow (as the lighting pass has it).
|
||||
+fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
+ let dist = length(p);
|
||||
+ let c = select(1, 0, dist < frame.shadow.y * 0.92);
|
||||
+ let texel_size = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0;
|
||||
+ let q = p + n * texel_size * 1.5;
|
||||
+ let s = cascade_at(q, c);
|
||||
+ if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
|
||||
+ var lit = 0.0;
|
||||
+ let step = 1.0 / 2048.0;
|
||||
+ for (var y = -1; y <= 1; y++) {
|
||||
+ for (var x = -1; x <= 1; x++) {
|
||||
+ lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
|
||||
+ }
|
||||
+ }
|
||||
+ let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
+ return mix(1.0, lit / 9.0, fade);
|
||||
+}
|
||||
+
|
||||
+fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
+ if d <= start { return 0.0; }
|
||||
+ if d >= end { return 1.0; }
|
||||
+ return (d - start) / (end - start);
|
||||
+}
|
||||
+
|
||||
+// What the sky shows along r when the ray leaves the screen: the horizon's colour rising to
|
||||
+// the zenith's, darker where the water lies under cover (`vis` is the sky light there).
|
||||
+fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
|
||||
+ var c = frame.fog_color.rgb;
|
||||
+ if frame.sky_color.w > 0.5 {
|
||||
+ c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
|
||||
+ }
|
||||
+ return c * mix(0.12, 1.0, vis);
|
||||
+}
|
||||
+
|
||||
+// Marches the ray from p along r over the depth buffer (28 steps, growing, jittered so TAA
|
||||
+// averages the placement; then a binary search for the crossing). xyz: the colour found,
|
||||
+// w: how much to trust it (0 for a miss, less near the screen's edge).
|
||||
+fn reflection(p: vec3<f32>, r: vec3<f32>, jitter: f32) -> vec4<f32> {
|
||||
+ var near = 0.0;
|
||||
+ for (var i = 0; i < REFLECT_STEPS; i++) {
|
||||
+ let u = (f32(i) + jitter) / f32(REFLECT_STEPS);
|
||||
+ let s = 0.12 + REFLECT_RANGE * u * u;
|
||||
+ let q = p + r * s;
|
||||
+ let sp = project(q);
|
||||
+ if sp.z <= 0.0 || !on_screen(sp.xy) { break; }
|
||||
+ let z = textureLoad(gbuffer_depth, texel(sp.xy), 0);
|
||||
+ if z > 0.0 {
|
||||
+ let c = frame.view_proj * vec4<f32>(q, 1.0);
|
||||
+ // Reversed Z: a larger depth is nearer; the ray is behind the surface when the
|
||||
+ // surface is nearer than it.
|
||||
+ if z > c.z / c.w {
|
||||
+ let behind = length(q) - length(position_at(sp.xy, z));
|
||||
+ if behind < 0.4 + 0.08 * s {
|
||||
+ var lo = near;
|
||||
+ var hi = s;
|
||||
+ for (var k = 0; k < 5; k++) {
|
||||
+ let m = 0.5 * (lo + hi);
|
||||
+ let mq = p + r * m;
|
||||
+ let mp = project(mq);
|
||||
+ let mc = frame.view_proj * vec4<f32>(mq, 1.0);
|
||||
+ let mz = textureLoad(gbuffer_depth, texel(mp.xy), 0);
|
||||
+ if mz > mc.z / mc.w { hi = m; } else { lo = m; }
|
||||
+ }
|
||||
+ let hp = project(p + r * hi);
|
||||
+ let edge = min(min(hp.x, 1.0 - hp.x), min(hp.y, 1.0 - hp.y));
|
||||
+ let colour = textureSampleLevel(lit_tex, linear, hp.xy, 0.0).rgb;
|
||||
+ return vec4<f32>(colour, smoothstep(0.0, 0.08, edge));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ near = s;
|
||||
+ }
|
||||
+ return vec4<f32>(0.0);
|
||||
+}
|
||||
+
|
||||
+// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing).
|
||||
+fn glint(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
|
||||
+ let h = normalize(l + v);
|
||||
+ let a = 0.05;
|
||||
+ let nh = max(dot(n, h), 0.0);
|
||||
+ let d = a * a / (PI * pow(nh * nh * (a * a - 1.0) + 1.0, 2.0));
|
||||
+ let f = 0.02 + 0.98 * pow(1.0 - max(dot(v, h), 0.0), 5.0);
|
||||
+ return min(d * f * 0.25 * max(dot(n, l), 0.0) / max(dot(n, v), 0.1), 12.0);
|
||||
+}
|
||||
+
|
||||
+// The water over the lit world: the world behind it (refracted by the waves, dimmed and tinted
|
||||
+// by the water's depth), the sky and the world above it (a screen-space reflection) by Fresnel,
|
||||
+// and the sun's glint. Pixels without water pass through.
|
||||
+@fragment
|
||||
+fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+ let px = vec2<i32>(in.position.xy);
|
||||
+ let base = textureLoad(lit_tex, px, 0);
|
||||
+ if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
|
||||
+ let wd = textureLoad(water_depth, px, 0);
|
||||
+ let g = textureLoad(gbuffer_depth, px, 0);
|
||||
+ let tint = textureLoad(water_tint, px, 0);
|
||||
+ let nt = textureLoad(water_normal, px, 0);
|
||||
+ let p = position_at(in.uv, wd);
|
||||
+ let dist = length(p);
|
||||
+ let v = -p / dist;
|
||||
+ let geometric = normalize(nt.xyz * 2.0 - 1.0);
|
||||
+ // The waves tilt the flat parts of the surface only.
|
||||
+ let footprint = dist * frame.resolution.w * 1.4;
|
||||
+ let grad = wave_gradient(p.xz + frame.water.zw, footprint);
|
||||
+ let flat_part = smoothstep(0.6, 0.95, geometric.y);
|
||||
+ var n = normalize(geometric + vec3<f32>(-grad.x, 0.0, -grad.y) * flat_part);
|
||||
+ n = normalize(n + v * max(0.03 - dot(n, v), 0.0));
|
||||
+ let ndv = max(dot(n, v), 0.02);
|
||||
+
|
||||
+ // Light on the water: vanilla's lightmap at the surface, and the sun where it reaches.
|
||||
+ let levels = vec2<f32>(nt.a, tint.a);
|
||||
+ let sky_reach = smoothstep(0.35, 0.95, levels.x);
|
||||
+ let ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(levels.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
+ let sun = frame.sun.xyz;
|
||||
+ let sun_on = frame.sun.w;
|
||||
+ var sunlit = 0.0;
|
||||
+ if sun_on > 0.0 && sun.y > 0.0 && sky_reach > 0.0 { sunlit = shadow(p, vec3<f32>(0.0, 1.0, 0.0)) * sky_reach; }
|
||||
+ let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
|
||||
+
|
||||
+ // Refraction: where the ray through the waves meets the world, against where the flat surface
|
||||
+ // would send it (so still water looks as vanilla's and only the waves distort).
|
||||
+ let floor0 = position_at(in.uv, g);
|
||||
+ let depth0 = max(length(floor0) - dist, 0.0);
|
||||
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
|
||||
+ var uv_floor = in.uv;
|
||||
+ var floor_z = g;
|
||||
+ let moved = project(floor0 + bent * min(depth0, 24.0));
|
||||
+ if moved.z > 0.0 && on_screen(moved.xy) {
|
||||
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
|
||||
+ // Only what lies behind the water: not the bank or an entity in front of it.
|
||||
+ if z > 0.0 && z <= wd {
|
||||
+ uv_floor = moved.xy;
|
||||
+ floor_z = z;
|
||||
+ }
|
||||
+ }
|
||||
+ let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
+ let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
+ let colour = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
+ // The colours the water takes up fastest are the ones it has the least of.
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3<f32>(1.0) - absorb);
|
||||
+
|
||||
+ // Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
+ var r = reflect(-v, n);
|
||||
+ r.y = abs(r.y);
|
||||
+ // Looking down onto it the reflection is a few percent of the picture: the sky alone does.
|
||||
+ let fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
|
||||
+ var hit = vec4<f32>(0.0);
|
||||
+ if fresnel > 0.04 { hit = reflection(p, r, ign(in.position.xy)); }
|
||||
+ let sky_vis = mix(0.15, 1.0, sky_reach);
|
||||
+ let reflected = mix(sky_along(r, sky_vis), hit.rgb, hit.w);
|
||||
+
|
||||
+ var c = refracted * (1.0 - fresnel) + reflected * fresnel;
|
||||
+ if sun_on > 0.0 && sunlit > 0.0 {
|
||||
+ c += frame.sun_color.rgb * sun_on * sunlit * glint(n, v, sun);
|
||||
+ }
|
||||
+ // Vanilla's fog over it, at the surface.
|
||||
+ let sph = dist;
|
||||
+ let cyl = max(length(p.xz), abs(p.y));
|
||||
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
+ c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -829,6 +1110,12 @@ pub fn taa_enabled() -> bool {
|
||||
*ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
|
||||
}
|
||||
|
||||
+/// The water effect is on unless `HORIZON_HD_WATER=0`.
|
||||
+pub fn water_enabled() -> bool {
|
||||
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_WATER").map_or(true, |v| v != "0"))
|
||||
+}
|
||||
+
|
||||
/// The Halton sequence's `index`-th number in `base`, in (0, 1).
|
||||
fn halton(mut index: u32, base: u32) -> f32 {
|
||||
let (mut f, mut r) = (1.0, 0.0);
|
||||
@@ -895,6 +1182,22 @@ pub struct Hd {
|
||||
/// The lit world, and the post pass's output.
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
+ /// Water: its surface colour and normal, its depth (the nearest water surface; 0 elsewhere),
|
||||
+ /// the lit world with the water over it, and what draws and composites them.
|
||||
+ water_tint: wgpu::TextureView,
|
||||
+ water_normal: wgpu::TextureView,
|
||||
+ water_depth: wgpu::TextureView,
|
||||
+ lit_water: wgpu::TextureView,
|
||||
+ water_layout: wgpu::BindGroupLayout,
|
||||
+ water_pipeline: wgpu::RenderPipeline,
|
||||
+ water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
|
||||
+ /// The water effect runs this frame (the scene allows it and there is translucent terrain on
|
||||
+ /// screen), and `lit_view` is the composite.
|
||||
+ water_active: bool,
|
||||
+ /// The frame's translucent terrain exists (set by the renderer before `write`).
|
||||
+ pub water_geometry: bool,
|
||||
+ water_composited: bool,
|
||||
+ started: std::time::Instant,
|
||||
/// TAA: the resolved frame, and the two histories (one read, one written, alternating).
|
||||
taa: wgpu::TextureView,
|
||||
history: [wgpu::TextureView; 2],
|
||||
@@ -1216,6 +1519,26 @@ impl Hd {
|
||||
min_binding_size: None,
|
||||
},
|
||||
UNFILTERED,
|
||||
+ DEPTH,
|
||||
+ ]),
|
||||
+ });
|
||||
+ let water_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD water"),
|
||||
+ entries: &entries(&[
|
||||
+ UNIFORM,
|
||||
+ FLOAT,
|
||||
+ DEPTH,
|
||||
+ UNFILTERED,
|
||||
+ UNFILTERED,
|
||||
+ DEPTH,
|
||||
+ wgpu::BindingType::Texture {
|
||||
+ sample_type: wgpu::TextureSampleType::Depth,
|
||||
+ view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
+ multisampled: false,
|
||||
+ },
|
||||
+ wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
+ FLOAT,
|
||||
+ SAMPLER,
|
||||
]),
|
||||
});
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1278,6 +1601,16 @@ impl Hd {
|
||||
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 water = module(device, "HD water", WATER);
|
||||
+ let water_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD water",
|
||||
+ &water_layout,
|
||||
+ &water,
|
||||
+ "fs_water",
|
||||
+ HDR,
|
||||
+ None,
|
||||
+ );
|
||||
let ao_pipeline = fullscreen(
|
||||
device,
|
||||
"HD ambient occlusion",
|
||||
@@ -1436,6 +1769,23 @@ impl Hd {
|
||||
.view,
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
+ water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
|
||||
+ water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
|
||||
+ water_depth: target(
|
||||
+ device,
|
||||
+ "HD water depth",
|
||||
+ [1, 1],
|
||||
+ wgpu::TextureFormat::Depth32Float,
|
||||
+ )
|
||||
+ .view,
|
||||
+ lit_water: target(device, "HD lit with water", [1, 1], HDR).view,
|
||||
+ water_layout,
|
||||
+ water_pipeline,
|
||||
+ water_group: None,
|
||||
+ water_active: false,
|
||||
+ water_geometry: false,
|
||||
+ water_composited: false,
|
||||
+ started: std::time::Instant::now(),
|
||||
taa: target(device, "HD TAA", [1, 1], HDR).view,
|
||||
history: [
|
||||
target(device, "HD TAA history", [1, 1], HDR).view,
|
||||
@@ -1502,6 +1852,17 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
|
||||
+ self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
|
||||
+ self.water_depth = target(
|
||||
+ device,
|
||||
+ "HD water depth",
|
||||
+ size,
|
||||
+ wgpu::TextureFormat::Depth32Float,
|
||||
+ )
|
||||
+ .view;
|
||||
+ self.lit_water = target(device, "HD lit with water", size, HDR).view;
|
||||
+ self.water_group = None;
|
||||
self.taa = target(device, "HD TAA", out, HDR).view;
|
||||
self.history = [
|
||||
target(device, "HD TAA history", out, HDR).view,
|
||||
@@ -1591,6 +1952,7 @@ impl Hd {
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
let cascades = scene.cascades;
|
||||
let night = scene.dark.clamp(0.0, 1.0);
|
||||
+ self.water_active = scene.water && water_enabled() && self.water_geometry;
|
||||
self.taa_active = scene.taa && taa_enabled();
|
||||
// A long jump of the camera (a teleport) starts the history over.
|
||||
if !self.taa_active || scene.camera_delta.length() > 64.0 {
|
||||
@@ -1604,6 +1966,19 @@ impl Hd {
|
||||
scene.camera_delta.z,
|
||||
if self.history_ready { 1.0 } else { 0.0 },
|
||||
],
|
||||
+ view_proj: scene.view_proj.to_cols_array_2d(),
|
||||
+ water: [
|
||||
+ if self.water_active { 1.0 } else { 0.0 },
|
||||
+ (self.started.elapsed().as_secs_f64() % 600.0) as f32,
|
||||
+ scene.camera_mod[0],
|
||||
+ scene.camera_mod[1],
|
||||
+ ],
|
||||
+ sky_color: [
|
||||
+ scene.sky_color[0],
|
||||
+ scene.sky_color[1],
|
||||
+ scene.sky_color[2],
|
||||
+ if scene.sky { 1.0 } else { 0.0 },
|
||||
+ ],
|
||||
taa_params: [
|
||||
if self.upscaling() {
|
||||
UPSCALE_HISTORY
|
||||
@@ -1821,6 +2196,10 @@ impl Hd {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 14,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.water_depth),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1828,6 +2207,7 @@ impl Hd {
|
||||
let Some((_, group)) = &self.light_group else {
|
||||
return;
|
||||
};
|
||||
+ self.water_composited = false;
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD lighting",
|
||||
@@ -1894,7 +2274,7 @@ impl Hd {
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
let source = match source {
|
||||
- HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Lit => self.lit_view().clone(),
|
||||
HdSource::Fx => self.fx.clone(),
|
||||
HdSource::Taa => self.taa.clone(),
|
||||
};
|
||||
@@ -1993,7 +2373,7 @@ impl Hd {
|
||||
return source;
|
||||
}
|
||||
let input = match source {
|
||||
- HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Lit => self.lit_view().clone(),
|
||||
HdSource::Fx => self.fx.clone(),
|
||||
HdSource::Taa => return source,
|
||||
};
|
||||
@@ -2052,6 +2432,102 @@ impl Hd {
|
||||
HdSource::Taa
|
||||
}
|
||||
|
||||
+ /// The lit world as later passes see it: with the water over it when the effect ran.
|
||||
+ pub fn lit_view(&self) -> &wgpu::TextureView {
|
||||
+ if self.water_composited {
|
||||
+ &self.lit_water
|
||||
+ } else {
|
||||
+ &self.lit
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// The water effect runs this frame (the renderer draws the water quads with `water_pass`).
|
||||
+ pub fn water_active(&self) -> bool {
|
||||
+ self.water_active
|
||||
+ }
|
||||
+
|
||||
+ /// The water pass's attachments: the surface colour and normal, and the water's own depth,
|
||||
+ /// all cleared. The caller draws the translucent terrain with `fs_water`.
|
||||
+ pub fn water_pass<'e>(
|
||||
+ &mut self,
|
||||
+ encoder: &'e mut wgpu::CommandEncoder,
|
||||
+ ) -> wgpu::RenderPass<'e> {
|
||||
+ let clear = |view| {
|
||||
+ Some(wgpu::RenderPassColorAttachment {
|
||||
+ view,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })
|
||||
+ };
|
||||
+ encoder
|
||||
+ .begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("HD water"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd water"),
|
||||
+ color_attachments: &[clear(&self.water_tint), clear(&self.water_normal)],
|
||||
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
+ view: &self.water_depth,
|
||||
+ depth_ops: Some(wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(0.0),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ }),
|
||||
+ stencil_ops: None,
|
||||
+ }),
|
||||
+ ..Default::default()
|
||||
+ })
|
||||
+ .forget_lifetime()
|
||||
+ }
|
||||
+
|
||||
+ /// Lays the water over the lit world (refraction, reflection, glint) into a target of its
|
||||
+ /// own; `lit_view` is that target after this.
|
||||
+ pub fn water_composite(
|
||||
+ &mut self,
|
||||
+ device: &wgpu::Device,
|
||||
+ encoder: &mut wgpu::CommandEncoder,
|
||||
+ lightmap: (&wgpu::TextureView, &wgpu::Sampler),
|
||||
+ ) {
|
||||
+ if !self.water_active {
|
||||
+ return;
|
||||
+ }
|
||||
+ let key = (lightmap.0.clone(), lightmap.1.clone());
|
||||
+ if self.water_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let group = self.make_group(
|
||||
+ device,
|
||||
+ "HD water",
|
||||
+ &self.water_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.lit),
|
||||
+ wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.water_tint),
|
||||
+ wgpu::BindingResource::TextureView(&self.water_normal),
|
||||
+ wgpu::BindingResource::TextureView(&self.water_depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
+ wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
+ wgpu::BindingResource::TextureView(lightmap.0),
|
||||
+ wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ ],
|
||||
+ );
|
||||
+ self.water_group = Some((key, group));
|
||||
+ }
|
||||
+ let Some((_, group)) = &self.water_group else {
|
||||
+ return;
|
||||
+ };
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD water composite",
|
||||
+ &self.lit_water,
|
||||
+ false,
|
||||
+ &self.water_pipeline,
|
||||
+ group,
|
||||
+ stamp(&mut self.timing, "hd water composite"),
|
||||
+ );
|
||||
+ self.water_composited = true;
|
||||
+ }
|
||||
+
|
||||
/// 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) {
|
||||
@@ -2181,6 +2657,7 @@ mod tests {
|
||||
("taa", TAA),
|
||||
("ao", AO),
|
||||
("ao blur", AO_BLUR),
|
||||
+ ("water", WATER),
|
||||
] {
|
||||
let source = format!("{COMMON}{body}");
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index c44449cc7..0d5d2fc89 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -182,6 +182,25 @@ fn face_normal(pos: vec3<f32>) -> vec3<f32> {
|
||||
out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
+// AirDef fork, HD water: the water quads once more, after the G-buffer, into small targets of
|
||||
+// their own: the surface colour (the water sprite times the biome tint) with the block light, and
|
||||
+// the face normal with the sky light. The layers are the water sprites' (gfx.rs fills them in;
|
||||
+// nothing else is water: glass, ice and the rest stay as vanilla draws them).
|
||||
+override WATER_STILL: u32 = 0xffffu;
|
||||
+override WATER_FLOW: u32 = 0xffffu;
|
||||
+struct WaterOut {
|
||||
+ @location(0) tint: vec4<f32>,
|
||||
+ @location(1) normal: vec4<f32>,
|
||||
+}
|
||||
+@fragment fn fs_water(in: TerrainOut) -> WaterOut {
|
||||
+ let layer = in.layer & 0x7fffu;
|
||||
+ if layer != WATER_STILL && layer != WATER_FLOW { discard; }
|
||||
+ let color = sprite(in.uv, layer) * in.tint;
|
||||
+ var out: WaterOut;
|
||||
+ out.tint = vec4(color.rgb, clamp(in.lm.y, 0.0, 1.0));
|
||||
+ out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
|
||||
+ return out;
|
||||
+}
|
||||
// AirDef fork: the sun's shadow map — depth only, cut-out leaves leave holes.
|
||||
@fragment fn fs_shadow(in: TerrainOut) {
|
||||
let a = sprite(in.uv, in.layer & 0x7fffu).a;
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:56:04 +0300
|
||||
Subject: [PATCH 20/31] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 9 ++++++---
|
||||
crates/horizon-client/src/terrain.wgsl | 6 ++++--
|
||||
2 files changed, 10 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index f5caa2d0b..8070aab3c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -591,10 +591,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
- let colour = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
+ // A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
+ // depth rather than as bright paint.
|
||||
+ let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.8) * vec3<f32>(0.55, 0.68, 0.8);
|
||||
// The colours the water takes up fastest are the ones it has the least of.
|
||||
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
- let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3<f32>(1.0) - absorb);
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 1.1 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3<f32>(1.0) - absorb);
|
||||
|
||||
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
var r = reflect(-v, n);
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index 0d5d2fc89..e892d5a0f 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -195,9 +195,11 @@ struct WaterOut {
|
||||
@fragment fn fs_water(in: TerrainOut) -> WaterOut {
|
||||
let layer = in.layer & 0x7fffu;
|
||||
if layer != WATER_STILL && layer != WATER_FLOW { discard; }
|
||||
- let color = sprite(in.uv, layer) * in.tint;
|
||||
+ // The biome's colour at the texture's mean brightness, not the texture: the HD water draws
|
||||
+ // its own surface (waves, reflections), and vanilla's ripples on top would look painted on.
|
||||
+ let color = vec3(0.62) * in.tint.rgb;
|
||||
var out: WaterOut;
|
||||
- out.tint = vec4(color.rgb, clamp(in.lm.y, 0.0, 1.0));
|
||||
+ out.tint = vec4(color, clamp(in.lm.y, 0.0, 1.0));
|
||||
out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
|
||||
return out;
|
||||
}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 18:38:19 +0300
|
||||
Subject: [PATCH 21/31] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
|
||||
and water reflections; warmer sunlight from the atmosphere's transmittance, a
|
||||
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
|
||||
round the sun; lighter water with fine ripples
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 4 +
|
||||
.../src/gfx/THIRD_PARTY_PHOTON.md | 34 ++
|
||||
crates/horizon-client/src/gfx/hd.rs | 259 +++++++++++++-
|
||||
crates/horizon-client/src/gfx/hd_sky.rs | 324 ++++++++++++++++++
|
||||
crates/horizon-client/src/terrain.wgsl | 2 +-
|
||||
5 files changed, 604 insertions(+), 19 deletions(-)
|
||||
create mode 100644 crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
||||
create mode 100644 crates/horizon-client/src/gfx/hd_sky.rs
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index f9dab8abf..d01131433 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -72,6 +72,7 @@ mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod hd;
|
||||
+mod hd_sky;
|
||||
mod hdr;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
@@ -4275,6 +4276,8 @@ impl Renderer {
|
||||
camera.z.rem_euclid(256.0) as f32,
|
||||
],
|
||||
water: !frame.underwater,
|
||||
+ sunrise: frame.sky.sunrise_color,
|
||||
+ sky_fog_end: frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end),
|
||||
};
|
||||
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
|
||||
let mut hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
@@ -5817,6 +5820,7 @@ impl Renderer {
|
||||
{
|
||||
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
|
||||
// that, then bloom and the tone curve onto the screen.
|
||||
+ hd.sky(&mut encoder);
|
||||
hd.light(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
diff --git a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
||||
new file mode 100644
|
||||
index 000000000..03d945001
|
||||
--- /dev/null
|
||||
+++ b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
||||
@@ -0,0 +1,34 @@
|
||||
+# Third-party code: Photon
|
||||
+
|
||||
+Parts of the HD renderer's atmosphere (`hd_sky.rs`) are ported from, or follow closely, the
|
||||
+Photon shader pack by sixthsurge, `shaders/include/sky/atmosphere.glsl` (and the idea of the water
|
||||
+waves in `shaders/include/misc/water_normal.glsl`) at commit
|
||||
+26c2906ee02cc484359b835fba6993d443d45966, https://github.com/sixthsurge/photon
|
||||
+
|
||||
+What is taken: the air's scattering and absorption coefficients (Rayleigh, Mie, ozone), the scale
|
||||
+heights, the ozone density profile, the Mie albedo and Henyey-Greenstein anisotropy, the sunlight
|
||||
+colour outside the atmosphere, and the Chapman-function approximation of the sun's transmittance.
|
||||
+The sky-view texture, its march, the sun's colour and everything else are our own.
|
||||
+
|
||||
+Photon is distributed under the MIT License:
|
||||
+
|
||||
+ The MIT License
|
||||
+
|
||||
+ Copyright © 2025 Benjamin Stott "sixthsurge"
|
||||
+
|
||||
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
+ and associated documentation files (the "Software"), to deal in the Software without
|
||||
+ restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
+ distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
+ Software is furnished to do so, subject to the following conditions:
|
||||
+
|
||||
+ The above copyright notice and this permission notice shall be included in all copies or
|
||||
+ substantial portions of the Software.
|
||||
+
|
||||
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
+ BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+
|
||||
+No other shader pack's code (BSL, Complementary and so on) may be used: their licences forbid it.
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 8070aab3c..031e9f2de 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -5,6 +5,7 @@
|
||||
//! 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::hd_sky;
|
||||
use super::pack_timing::PassTiming;
|
||||
use glam::Mat4;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -101,6 +102,12 @@ struct HdFrame {
|
||||
water: [f32; 4],
|
||||
/// rgb: the sky's colour; w: 1 when there is a sky.
|
||||
sky_color: [f32; 4],
|
||||
+ /// xyz: towards the sun itself (not the moon); w: 1 when the atmosphere draws the sky.
|
||||
+ sky_sun: [f32; 4],
|
||||
+ /// x: vanilla's sky fog end; z: how overcast the sky is (rain: 0 clear, 1 vanilla's colours).
|
||||
+ sky_extra: [f32; 4],
|
||||
+ /// Vanilla's sunrise colour fan (rgb, alpha), taken out of the sky before the atmosphere is put in.
|
||||
+ sunrise: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the world around looks like this frame (from the renderer's globals).
|
||||
@@ -131,6 +138,9 @@ pub struct HdScene {
|
||||
pub camera_mod: [f32; 2],
|
||||
/// The water effect may run (not underwater, not disabled).
|
||||
pub water: bool,
|
||||
+ /// Vanilla's sunrise fan colour and its sky fog end (to subtract vanilla's sky from the world).
|
||||
+ pub sunrise: [f32; 4],
|
||||
+ pub sky_fog_end: f32,
|
||||
}
|
||||
|
||||
/// The cascades' half sizes (blocks) and shadow map size.
|
||||
@@ -155,6 +165,9 @@ struct HdFrame {
|
||||
view_proj: mat4x4<f32>,
|
||||
water: vec4<f32>,
|
||||
sky_color: vec4<f32>,
|
||||
+ sky_sun: vec4<f32>,
|
||||
+ sky_extra: vec4<f32>,
|
||||
+ sunrise: vec4<f32>,
|
||||
}
|
||||
struct FullOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -195,6 +208,9 @@ struct Light {
|
||||
@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||||
// The water pass's depth: the nearest water surface (0 where there is none).
|
||||
@group(0) @binding(14) var water_depth: texture_depth_2d;
|
||||
+//SKY15
|
||||
+// The pixel being lit, for the shadow filter's noise.
|
||||
+var<private> frag_px: vec2<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.
|
||||
@@ -239,6 +255,42 @@ fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
return shadow_taps(p, n, 1);
|
||||
}
|
||||
|
||||
+// A point of a disc of radius 1 (Vogel's spiral), turned by `angle`.
|
||||
+fn vogel(i: i32, count: i32, angle: f32) -> vec2<f32> {
|
||||
+ let r = sqrt((f32(i) + 0.5) / f32(count));
|
||||
+ let a = f32(i) * 2.39996323 + angle;
|
||||
+ return vec2<f32>(cos(a), sin(a)) * r;
|
||||
+}
|
||||
+
|
||||
+// The near cascade's soft shadow: a blocker search sets the penumbra's width (wider the farther
|
||||
+// the shadow's caster is above the receiver), then a ring of taps turned by a per-pixel noise
|
||||
+// filters it; the temporal pass averages the noise. `s`: the point in the cascade (uv, depth),
|
||||
+// `texel`: the cascade's texel in blocks.
|
||||
+fn soft_shadow(s: vec3<f32>, texel: f32) -> f32 {
|
||||
+ let angle = ign(frag_px) * 6.2831853;
|
||||
+ var blocker = 0.0;
|
||||
+ var found = 0.0;
|
||||
+ for (var i = 0; i < 8; i++) {
|
||||
+ let o = vogel(i, 8, angle) * (6.0 / 2048.0);
|
||||
+ let q = clamp(vec2<i32>((s.xy + o) * 2048.0), vec2<i32>(0), vec2<i32>(2047));
|
||||
+ let z = textureLoad(shadow_map, q, 0, 0);
|
||||
+ if z < s.z - 0.0006 {
|
||||
+ blocker += z;
|
||||
+ found += 1.0;
|
||||
+ }
|
||||
+ }
|
||||
+ if found < 0.5 { return 1.0; }
|
||||
+ // The caster's height above the receiver, in blocks (the cascade's depth spans 799 blocks).
|
||||
+ let height = (s.z - blocker / found) * 799.0;
|
||||
+ let radius = clamp(height * 0.18 / texel, 1.6, 8.0);
|
||||
+ var lit = 0.0;
|
||||
+ for (var i = 0; i < 12; i++) {
|
||||
+ let o = vogel(i, 12, angle + 1.7) * (radius / 2048.0);
|
||||
+ lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + o, 0, s.z - 0.0005 - 0.00005 * radius);
|
||||
+ }
|
||||
+ return lit / 12.0;
|
||||
+}
|
||||
+
|
||||
// `shadow` with a (2 * r + 1) square of taps: the floor under water takes one (the water dims and
|
||||
// blurs it anyway).
|
||||
fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
|
||||
@@ -249,13 +301,16 @@ fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
|
||||
let s = cascade_at(q, c);
|
||||
if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
|
||||
var lit = 0.0;
|
||||
+ let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
+ if r == 1 && c == 0 {
|
||||
+ return mix(1.0, soft_shadow(s, texel), fade);
|
||||
+ }
|
||||
let step = 1.0 / 2048.0;
|
||||
for (var y = -r; y <= r; y++) {
|
||||
for (var x = -r; x <= r; x++) {
|
||||
lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
|
||||
}
|
||||
}
|
||||
- let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
|
||||
}
|
||||
|
||||
@@ -300,7 +355,31 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let halo_start = tiles[tile + 2u];
|
||||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
+ frag_px = in.position.xy;
|
||||
+ let atmosphere = frame.sky_sun.w > 0.5;
|
||||
var c = scene;
|
||||
+ // The sky: the atmosphere's, with what the world drew over vanilla's plain sky (sun, moon,
|
||||
+ // stars, clouds) kept on top.
|
||||
+ if d <= 0.0 && atmosphere {
|
||||
+ var kept = max(scene - vanilla_sky(view), vec3<f32>(0.0));
|
||||
+ var sky = atmosphere_sky(view);
|
||||
+ // The sun: vanilla's square sprite is taken out and a round disc with a soft haze round
|
||||
+ // it (warm, strongest low) is drawn in its place.
|
||||
+ let toward = dot(view, frame.sky_sun.xyz);
|
||||
+ kept *= 1.0 - smoothstep(0.965, 0.975, toward);
|
||||
+ let clear = 1.0 - frame.sky_extra.z;
|
||||
+ let shine = smoothstep(-0.04, 0.02, frame.sky_sun.y) * clear;
|
||||
+ let glow = max(toward, 0.0);
|
||||
+ let angle = acos(clamp(toward, -1.0, 1.0));
|
||||
+ sky += frame.sun_color.rgb * shine * (pow(glow, 24.0) * 0.07 + pow(glow, 400.0) * 0.4 + 6.0 * (1.0 - smoothstep(0.016, 0.022, angle)));
|
||||
+ c = sky + kept;
|
||||
+ }
|
||||
+ // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
|
||||
+ // sun's colour at dusk and dawn instead of vanilla's flat white.
|
||||
+ if atmosphere && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
|
||||
+ let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
|
||||
+ c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
|
||||
+ }
|
||||
let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
if surface {
|
||||
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
@@ -323,7 +402,17 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
if frame.ambient.w > 0.0 && !water { 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 sky's light is bluish from above and carries the horizon's colour from the sides;
|
||||
+ // faces turned up take more of it than the ones turned down.
|
||||
+ var sky_tint = vec3<f32>(1.0);
|
||||
+ if atmosphere {
|
||||
+ let reach = smoothstep(0.25, 0.9, lm.x);
|
||||
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
|
||||
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
|
||||
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||||
+ }
|
||||
+ var light = max(base * sky_tint * 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)];
|
||||
@@ -340,7 +429,12 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sph = length(p);
|
||||
let cyl = max(length(p.xz), abs(p.y));
|
||||
let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
- c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ let haze = haze_colour(view);
|
||||
+ c = mix(c, haze, f * frame.fog_color.a);
|
||||
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
|
||||
+ if atmosphere && frame.fog.x > 1.0e6 {
|
||||
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
|
||||
+ }
|
||||
}
|
||||
// Light in the air: the sun's rays between the shadows, and halos round the mods' lights.
|
||||
let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
|
||||
@@ -387,6 +481,7 @@ const REFLECT_RANGE: f32 = 96.0;
|
||||
@group(0) @binding(7) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(8) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(9) var linear: sampler;
|
||||
+//SKY10
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -417,7 +512,7 @@ fn texel(uv: vec2<f32>) -> vec2<i32> {
|
||||
// are whole cycles per 600 s, so the sum repeats every 256 blocks and 10 minutes and holds still
|
||||
// in the world as the camera moves. x, y: the vector in units of 2 pi / 256; z: the slope the
|
||||
// wave adds; w: its phase.
|
||||
-const WAVES = array<vec4<f32>, 10>(
|
||||
+const WAVES = array<vec4<f32>, 26>(
|
||||
vec4<f32>(3.0, 1.0, 0.040, 0.3),
|
||||
vec4<f32>(-2.0, 5.0, 0.045, 2.1),
|
||||
vec4<f32>(7.0, 2.0, 0.040, 4.4),
|
||||
@@ -428,17 +523,36 @@ const WAVES = array<vec4<f32>, 10>(
|
||||
vec4<f32>(9.0, 23.0, 0.030, 2.7),
|
||||
vec4<f32>(-27.0, 19.0, 0.025, 4.9),
|
||||
vec4<f32>(31.0, 30.0, 0.025, 1.8),
|
||||
+ // Fine ripples (wavelengths of 0.7 to 3 blocks) for the many small sun sparkles: slopes small,
|
||||
+ // faded out by distance and pixel size like the rest.
|
||||
+ vec4<f32>(83.0, 41.0, 0.042, 0.4),
|
||||
+ vec4<f32>(-97.0, 59.0, 0.042, 3.1),
|
||||
+ vec4<f32>(71.0, -113.0, 0.042, 5.0),
|
||||
+ vec4<f32>(131.0, 89.0, 0.040, 1.5),
|
||||
+ vec4<f32>(-157.0, -103.0, 0.040, 4.2),
|
||||
+ vec4<f32>(191.0, -67.0, 0.038, 2.4),
|
||||
+ vec4<f32>(-223.0, 149.0, 0.038, 0.8),
|
||||
+ vec4<f32>(101.0, 241.0, 0.036, 3.7),
|
||||
+ vec4<f32>(-263.0, -181.0, 0.034, 5.5),
|
||||
+ vec4<f32>(311.0, 127.0, 0.034, 2.0),
|
||||
+ // Finer still (0.35 to 0.6 blocks): the small sparkles.
|
||||
+ vec4<f32>(397.0, -263.0, 0.050, 1.1),
|
||||
+ vec4<f32>(-467.0, 331.0, 0.050, 4.6),
|
||||
+ vec4<f32>(521.0, 449.0, 0.045, 3.0),
|
||||
+ vec4<f32>(-613.0, -199.0, 0.045, 5.9),
|
||||
+ vec4<f32>(293.0, 577.0, 0.045, 0.2),
|
||||
+ vec4<f32>(-701.0, 263.0, 0.040, 2.8),
|
||||
);
|
||||
|
||||
// The gradient of the water's height at world x, z: the waves that a pixel `footprint` blocks
|
||||
// wide can still resolve (finer ones fade out, or they would shimmer).
|
||||
fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
|
||||
var grad = vec2<f32>(0.0);
|
||||
- for (var i = 0; i < 10; i++) {
|
||||
+ for (var i = 0; i < 26; i++) {
|
||||
let w = WAVES[i];
|
||||
let k = w.xy * (2.0 * PI / 256.0);
|
||||
let len = length(k);
|
||||
- let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.25 - 0.5, 0.0, 1.0);
|
||||
+ let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.2 - 0.6, 0.0, 1.0);
|
||||
let cycles = round(sqrt(9.8 * len) * 0.5 * 600.0 / (2.0 * PI));
|
||||
let omega = cycles * 2.0 * PI / 600.0;
|
||||
let phase = dot(k, xz) - omega * frame.water.y + w.w;
|
||||
@@ -481,7 +595,9 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
// the zenith's, darker where the water lies under cover (`vis` is the sky light there).
|
||||
fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
|
||||
var c = frame.fog_color.rgb;
|
||||
- if frame.sky_color.w > 0.5 {
|
||||
+ if frame.sky_sun.w > 0.5 {
|
||||
+ c = atmosphere_sky(r);
|
||||
+ } else if frame.sky_color.w > 0.5 {
|
||||
c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
|
||||
}
|
||||
return c * mix(0.12, 1.0, vis);
|
||||
@@ -531,7 +647,7 @@ fn reflection(p: vec3<f32>, r: vec3<f32>, jitter: f32) -> vec4<f32> {
|
||||
// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing).
|
||||
fn glint(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
|
||||
let h = normalize(l + v);
|
||||
- let a = 0.05;
|
||||
+ let a = 0.07;
|
||||
let nh = max(dot(n, h), 0.0);
|
||||
let d = a * a / (PI * pow(nh * nh * (a * a - 1.0) + 1.0, 2.0));
|
||||
let f = 0.02 + 0.98 * pow(1.0 - max(dot(v, h), 0.0), 5.0);
|
||||
@@ -565,7 +681,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// Light on the water: vanilla's lightmap at the surface, and the sun where it reaches.
|
||||
let levels = vec2<f32>(nt.a, tint.a);
|
||||
let sky_reach = smoothstep(0.35, 0.95, levels.x);
|
||||
- let ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ var ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ if frame.sky_sun.w > 0.5 {
|
||||
+ ambient *= mix(vec3<f32>(1.0), sky_light_tint(vec3<f32>(0.0, 1.0, 0.0)), 0.7 * sky_reach);
|
||||
+ }
|
||||
let block = textureSampleLevel(lightmap, linear, vec2<f32>(levels.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
@@ -594,10 +713,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
// depth rather than as bright paint.
|
||||
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.8) * vec3<f32>(0.55, 0.68, 0.8);
|
||||
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
|
||||
// The colours the water takes up fastest are the ones it has the least of.
|
||||
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 1.1 * (thickness + 0.3));
|
||||
- let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3<f32>(1.0) - absorb);
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
|
||||
|
||||
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
var r = reflect(-v, n);
|
||||
@@ -617,7 +736,11 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sph = dist;
|
||||
let cyl = max(length(p.xz), abs(p.y));
|
||||
let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
- c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ let haze = haze_colour(-v);
|
||||
+ c = mix(c, haze, f * frame.fog_color.a);
|
||||
+ if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
|
||||
+ c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
|
||||
+ }
|
||||
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
|
||||
}
|
||||
"#;
|
||||
@@ -1200,6 +1323,14 @@ pub struct Hd {
|
||||
/// The frame's translucent terrain exists (set by the renderer before `write`).
|
||||
pub water_geometry: bool,
|
||||
water_composited: bool,
|
||||
+ /// The sky-view texture (see `hd_sky`), the pass that marches it and its bind group.
|
||||
+ sky_lut: wgpu::TextureView,
|
||||
+ sky_pipeline: wgpu::RenderPipeline,
|
||||
+ sky_group: wgpu::BindGroup,
|
||||
+ /// The sun's height the sky-view texture was marched for (it depends on nothing else), and
|
||||
+ /// whether this frame's sun has moved enough to march it again.
|
||||
+ sky_drawn_for: f32,
|
||||
+ sky_stale: bool,
|
||||
started: std::time::Instant,
|
||||
/// TAA: the resolved frame, and the two histories (one read, one written, alternating).
|
||||
taa: wgpu::TextureView,
|
||||
@@ -1403,10 +1534,23 @@ fn ao_strength_default() -> f32 {
|
||||
.unwrap_or(AO_STRENGTH)
|
||||
}
|
||||
|
||||
+/// The shader's source: the common part, then the body with the sky-reading functions where it
|
||||
+/// has a marker (`//SKY15`: the sky-view texture is binding 15, `//SKY10`: binding 10).
|
||||
+fn assemble(body: &str) -> String {
|
||||
+ let mut body = body.to_owned();
|
||||
+ for binding in ["15", "10"] {
|
||||
+ body = body.replace(
|
||||
+ &format!("//SKY{binding}"),
|
||||
+ &hd_sky::SKY_FUNCS.replace("SKYBIND", binding),
|
||||
+ );
|
||||
+ }
|
||||
+ format!("{COMMON}{body}")
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
- source: wgpu::ShaderSource::Wgsl(format!("{COMMON}{body}").into()),
|
||||
+ source: wgpu::ShaderSource::Wgsl(assemble(body).into()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1523,6 +1667,7 @@ impl Hd {
|
||||
},
|
||||
UNFILTERED,
|
||||
DEPTH,
|
||||
+ FLOAT,
|
||||
]),
|
||||
});
|
||||
let water_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1542,8 +1687,13 @@ impl Hd {
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
+ FLOAT,
|
||||
]),
|
||||
});
|
||||
+ let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD sky"),
|
||||
+ entries: &entries(&[UNIFORM]),
|
||||
+ });
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD TAA"),
|
||||
entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
|
||||
@@ -1605,6 +1755,16 @@ impl Hd {
|
||||
let ao = module(device, "HD ambient occlusion", AO);
|
||||
let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
|
||||
let water = module(device, "HD water", WATER);
|
||||
+ let sky_module = module(device, "HD sky", hd_sky::SKY_PASS);
|
||||
+ let sky_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD sky",
|
||||
+ &sky_layout,
|
||||
+ &sky_module,
|
||||
+ "fs_sky_lut",
|
||||
+ hd_sky::SKY_LUT_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
let water_pipeline = fullscreen(
|
||||
device,
|
||||
"HD water",
|
||||
@@ -1691,6 +1851,21 @@ impl Hd {
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
+ let sky_lut = target(
|
||||
+ device,
|
||||
+ "HD sky",
|
||||
+ hd_sky::SKY_LUT_SIZE,
|
||||
+ hd_sky::SKY_LUT_FORMAT,
|
||||
+ )
|
||||
+ .view;
|
||||
+ let sky_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD sky"),
|
||||
+ layout: &sky_layout,
|
||||
+ entries: &[wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: frame.as_entire_binding(),
|
||||
+ }],
|
||||
+ });
|
||||
let lights = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("HD lights"),
|
||||
size: (MAX_LIGHTS * std::mem::size_of::<HdLight>()) as u64,
|
||||
@@ -1788,6 +1963,11 @@ impl Hd {
|
||||
water_active: false,
|
||||
water_geometry: false,
|
||||
water_composited: false,
|
||||
+ sky_lut,
|
||||
+ sky_pipeline,
|
||||
+ sky_group,
|
||||
+ sky_drawn_for: 2.0,
|
||||
+ sky_stale: true,
|
||||
started: std::time::Instant::now(),
|
||||
taa: target(device, "HD TAA", [1, 1], HDR).view,
|
||||
history: [
|
||||
@@ -1961,6 +2141,11 @@ impl Hd {
|
||||
if !self.taa_active || scene.camera_delta.length() > 64.0 {
|
||||
self.history_ready = false;
|
||||
}
|
||||
+ let sun_height = scene.sun.normalize_or(glam::Vec3::Y).y;
|
||||
+ self.sky_stale = (sun_height - self.sky_drawn_for).abs() > 0.001;
|
||||
+ if self.sky_stale {
|
||||
+ self.sky_drawn_for = sun_height;
|
||||
+ }
|
||||
let frame = HdFrame {
|
||||
prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
|
||||
taa: [
|
||||
@@ -1982,6 +2167,17 @@ impl Hd {
|
||||
scene.sky_color[2],
|
||||
if scene.sky { 1.0 } else { 0.0 },
|
||||
],
|
||||
+ sky_sun: {
|
||||
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
|
||||
+ [sun.x, sun.y, sun.z, if scene.sky { 1.0 } else { 0.0 }]
|
||||
+ },
|
||||
+ sky_extra: [
|
||||
+ scene.sky_fog_end,
|
||||
+ 0.0,
|
||||
+ (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85,
|
||||
+ 0.0,
|
||||
+ ],
|
||||
+ sunrise: scene.sunrise,
|
||||
taa_params: [
|
||||
if self.upscaling() {
|
||||
UPSCALE_HISTORY
|
||||
@@ -2123,6 +2319,23 @@ impl Hd {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
+ /// Marches the sky-view texture for this frame's sun (before the lighting and the water); skipped
|
||||
+ /// while the sun's height has moved less than a thousandth since the last march.
|
||||
+ pub fn sky(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
+ if !self.sky_stale {
|
||||
+ return;
|
||||
+ }
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD sky",
|
||||
+ &self.sky_lut,
|
||||
+ false,
|
||||
+ &self.sky_pipeline,
|
||||
+ &self.sky_group,
|
||||
+ stamp(&mut self.timing, "hd sky"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
/// Lights the world (its colour and depth) into `lit`.
|
||||
pub fn light(
|
||||
&mut self,
|
||||
@@ -2203,6 +2416,10 @@ impl Hd {
|
||||
binding: 14,
|
||||
resource: wgpu::BindingResource::TextureView(&self.water_depth),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 15,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.sky_lut),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -2512,6 +2729,7 @@ impl Hd {
|
||||
wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
wgpu::BindingResource::TextureView(lightmap.0),
|
||||
wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ wgpu::BindingResource::TextureView(&self.sky_lut),
|
||||
],
|
||||
);
|
||||
self.water_group = Some((key, group));
|
||||
@@ -2608,9 +2826,13 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
|
||||
let clear = 1.0 - scene.rain * 0.85;
|
||||
if sun.y > -0.02 {
|
||||
let up = smooth(-0.02, 0.18, sun.y);
|
||||
- let warm = smooth(0.0, 0.45, sun.y);
|
||||
- let color = glam::Vec3::new(1.0, 0.62, 0.38).lerp(glam::Vec3::new(1.0, 0.95, 0.86), warm);
|
||||
- (sun, 0.6 * up * clear, color)
|
||||
+ // The atmosphere's transmittance along the sun's ray (orange when low, a clean warm white
|
||||
+ // high), kept at full brightness and tempered, with a slight warm tint on top.
|
||||
+ let t = hd_sky::sun_transmittance(sun.y);
|
||||
+ let hue = t / t.max_element().max(1e-4);
|
||||
+ let color = glam::Vec3::new(hue.x.powf(0.6), hue.y.powf(0.6), hue.z.powf(0.6))
|
||||
+ * glam::Vec3::new(1.0, 0.93, 0.78);
|
||||
+ (sun, 0.7 * up * clear, color)
|
||||
} else {
|
||||
let moon = -sun;
|
||||
let up = smooth(-0.02, 0.18, moon.y);
|
||||
@@ -2661,8 +2883,9 @@ mod tests {
|
||||
("ao", AO),
|
||||
("ao blur", AO_BLUR),
|
||||
("water", WATER),
|
||||
+ ("sky", hd_sky::SKY_PASS),
|
||||
] {
|
||||
- let source = format!("{COMMON}{body}");
|
||||
+ let source = assemble(body);
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
panic!("{name}: {}", e.emit_to_string(&source));
|
||||
});
|
||||
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
new file mode 100644
|
||||
index 000000000..b5c231a7b
|
||||
--- /dev/null
|
||||
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
@@ -0,0 +1,324 @@
|
||||
+//! AirDef fork: the HD renderer's atmosphere. A small sky-view texture (azimuth from the sun by
|
||||
+//! elevation, plus a row of ambient samples) is marched each frame from single scattering of
|
||||
+//! Rayleigh and Mie particles and ozone absorption with a cheap multiple-scattering term; the
|
||||
+//! lighting and water shaders read it for the sky, the aerial-perspective haze, the water's
|
||||
+//! reflection fallback and the colour of the sky's light. The sunlight's colour is the same
|
||||
+//! atmosphere's transmittance at the sun's elevation (`sun_transmittance`).
|
||||
+//!
|
||||
+//! The coefficients, the density profiles (scale heights, the ozone layer), the Henyey-Greenstein
|
||||
+//! Mie parameters and the Chapman-function transmittance come from Photon by sixthsurge (MIT
|
||||
+//! licence, see THIRD_PARTY_PHOTON.md), shaders/include/sky/atmosphere.glsl at commit 26c2906.
|
||||
+
|
||||
+/// The sky-view texture: 128 azimuths by 64 elevations, then 8 rows for the ambient samples
|
||||
+/// (row 64: texel 0 the zenith, texel 1 the horizon's average).
|
||||
+pub const SKY_LUT_SIZE: [u32; 2] = [128, 72];
|
||||
+pub const SKY_LUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
+
|
||||
+/// The atmosphere's constants and the pass that marches the sky-view texture (after `COMMON`).
|
||||
+pub const SKY_PASS: &str = r#"
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+
|
||||
+// Photon's atmosphere (sixthsurge, MIT): metres, Earth's size; the camera stands on the surface.
|
||||
+const PI: f32 = 3.14159265;
|
||||
+const PLANET_R: f32 = 6371000.0;
|
||||
+const ATMOSPHERE_TOP: f32 = 6481000.0;
|
||||
+const GROUND_R: f32 = 6370000.0;
|
||||
+const SCALE_HEIGHTS: vec2<f32> = vec2<f32>(8400.0, 1250.0);
|
||||
+const RAYLEIGH: vec3<f32> = vec3<f32>(8.059375432e-06, 1.671209429e-05, 4.080133294e-05);
|
||||
+const MIE: vec3<f32> = vec3<f32>(1.666442358e-06, 1.812685127e-06, 1.958927896e-06);
|
||||
+const OZONE: vec3<f32> = vec3<f32>(8.304280072e-07, 1.314911970e-06, 5.440679729e-08);
|
||||
+const MIE_ALBEDO: f32 = 0.9;
|
||||
+const MIE_G: f32 = 0.77;
|
||||
+// Sunlight in space (Photon's AM0 colour) and how bright the sun and the moon shine here.
|
||||
+const SUN_I: vec3<f32> = vec3<f32>(1.051, 0.985, 0.940) * 24.0;
|
||||
+const MOON_I: vec3<f32> = vec3<f32>(0.62, 0.78, 1.0) * 0.07;
|
||||
+// The share of the light scattered a second time, spread evenly (the haze's lift).
|
||||
+const MULTI_SCATTER: f32 = 0.6;
|
||||
+const STEPS: i32 = 32;
|
||||
+
|
||||
+// Photon's density: Rayleigh, Mie (exponential) and the ozone layer, at an altitude in metres.
|
||||
+fn density_at(altitude: f32) -> vec3<f32> {
|
||||
+ let rm = exp(-max(altitude, 0.0) / SCALE_HEIGHTS);
|
||||
+ let km = altitude * 1e-3;
|
||||
+ let o1 = 12.5 * exp(-km / 8.0);
|
||||
+ let o2 = 30.0 * exp((18.0 - km) * (km - 18.0) / 80.0);
|
||||
+ let o3 = 75.0 * exp((23.5 - km) * (km - 23.5) / 50.0);
|
||||
+ let o4 = 50.0 * exp((30.0 - km) * (km - 30.0) / 150.0);
|
||||
+ return vec3<f32>(rm, 7.428e-3 * (o1 + o2 + o3 + o4));
|
||||
+}
|
||||
+
|
||||
+// Chapman function approximation (thetenthplanet.de/archives/4519, as Photon uses it).
|
||||
+fn chapman(x: f32, cos_theta: f32) -> f32 {
|
||||
+ let c = sqrt(1.5707963 * x);
|
||||
+ if cos_theta >= 0.0 {
|
||||
+ return c / ((c - 1.0) * cos_theta + 1.0);
|
||||
+ }
|
||||
+ let sin_theta = sqrt(clamp(1.0 - cos_theta * cos_theta, 0.0, 1.0));
|
||||
+ return c / ((c - 1.0) * cos_theta - 1.0) + 2.0 * c * exp(min(x - x * sin_theta, 80.0)) * sqrt(sin_theta);
|
||||
+}
|
||||
+
|
||||
+// The light reaching a point r from the planet's centre (altitude h, whose sky direction makes
|
||||
+// cos mu with the up there) from the sun or the moon; 0 when the planet is in the way.
|
||||
+fn light_transmittance(mu: f32, r: f32, h: f32) -> vec3<f32> {
|
||||
+ if mu < 0.0 && r * r * (1.0 - mu * mu) < PLANET_R * PLANET_R { return vec3<f32>(0.0); }
|
||||
+ let dens = exp(-max(h, 0.0) / SCALE_HEIGHTS);
|
||||
+ var air = SCALE_HEIGHTS * dens;
|
||||
+ air.x *= chapman(r / SCALE_HEIGHTS.x, mu);
|
||||
+ air.y *= chapman(r / SCALE_HEIGHTS.y, mu);
|
||||
+ return clamp(exp(-(RAYLEIGH * air.x + MIE * air.y + OZONE * air.x)), vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
+}
|
||||
+
|
||||
+fn rayleigh_phase(nu: f32) -> f32 {
|
||||
+ return 3.0 / (16.0 * PI) * (1.0 + nu * nu);
|
||||
+}
|
||||
+
|
||||
+fn mie_phase(nu: f32) -> f32 {
|
||||
+ let g = MIE_G;
|
||||
+ return (1.0 - g * g) / (4.0 * PI * pow(1.0 + g * g - 2.0 * g * nu, 1.5));
|
||||
+}
|
||||
+
|
||||
+// The radiance the camera sees along `view` (y up) with the sun and the moon in the directions
|
||||
+// given: single scattering plus a flat multiple-scattering term.
|
||||
+fn march(view: vec3<f32>, sun: vec3<f32>, moon: vec3<f32>) -> vec3<f32> {
|
||||
+ let b = PLANET_R * view.y;
|
||||
+ let t_max = -b + sqrt(b * b + ATMOSPHERE_TOP * ATMOSPHERE_TOP - PLANET_R * PLANET_R);
|
||||
+ let nu_sun = dot(view, sun);
|
||||
+ let nu_moon = dot(view, moon);
|
||||
+ let phase_r_sun = rayleigh_phase(nu_sun);
|
||||
+ let phase_m_sun = mie_phase(nu_sun);
|
||||
+ let phase_r_moon = rayleigh_phase(nu_moon);
|
||||
+ let phase_m_moon = mie_phase(nu_moon);
|
||||
+ var od = vec3<f32>(0.0);
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ var t0 = 0.0;
|
||||
+ for (var i = 0; i < STEPS; i++) {
|
||||
+ let u = (f32(i) + 1.0) / f32(STEPS);
|
||||
+ let t1 = t_max * u * u;
|
||||
+ let dt = t1 - t0;
|
||||
+ let t = 0.5 * (t0 + t1);
|
||||
+ t0 = t1;
|
||||
+ let r2 = PLANET_R * PLANET_R + 2.0 * PLANET_R * t * view.y + t * t;
|
||||
+ let r = sqrt(r2);
|
||||
+ // The altitude without the cancellation of r - planet radius.
|
||||
+ let altitude = (2.0 * PLANET_R * t * view.y + t * t) / (r + PLANET_R);
|
||||
+ let dens = density_at(altitude);
|
||||
+ let extinction = RAYLEIGH * dens.x + MIE * dens.y + OZONE * dens.z;
|
||||
+ let view_t = exp(-(od + 0.5 * extinction * dt));
|
||||
+ od += extinction * dt;
|
||||
+ let mu_sun = (PLANET_R * sun.y + t * nu_sun) / r;
|
||||
+ let mu_moon = (PLANET_R * moon.y + t * nu_moon) / r;
|
||||
+ let sun_t = light_transmittance(mu_sun, r, altitude);
|
||||
+ let moon_t = light_transmittance(mu_moon, r, altitude);
|
||||
+ let scatter_r = RAYLEIGH * dens.x;
|
||||
+ let scatter_m = MIE * MIE_ALBEDO * dens.y;
|
||||
+ let single = (scatter_r * phase_r_sun + scatter_m * phase_m_sun) * sun_t * SUN_I
|
||||
+ + (scatter_r * phase_r_moon + scatter_m * phase_m_moon) * moon_t * MOON_I;
|
||||
+ let multi = (scatter_r + scatter_m) * (1.0 / (4.0 * PI)) * MULTI_SCATTER
|
||||
+ * (sun_t * SUN_I + moon_t * MOON_I) * 0.5;
|
||||
+ sum += view_t * (single + multi) * dt;
|
||||
+ }
|
||||
+ return sum;
|
||||
+}
|
||||
+
|
||||
+@fragment
|
||||
+fn fs_sky_lut(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+ let px = vec2<i32>(in.position.xy);
|
||||
+ // The texture's frame: the sun in the xy plane, the moon opposite.
|
||||
+ let sin_e = clamp(frame.sky_sun.y, -1.0, 1.0);
|
||||
+ let sun = vec3<f32>(sqrt(1.0 - sin_e * sin_e), sin_e, 0.0);
|
||||
+ let moon = -sun;
|
||||
+ // A faint night glow, so the night sky is not black.
|
||||
+ let night = vec3<f32>(0.0006, 0.0011, 0.0026);
|
||||
+ if px.y < 64 {
|
||||
+ let phi = f32(px.x) / 127.0 * PI;
|
||||
+ let v = f32(px.y) / 63.0;
|
||||
+ let l = v * v * PI * 0.5;
|
||||
+ let dir = vec3<f32>(cos(l) * cos(phi), sin(l), cos(l) * sin(phi));
|
||||
+ return vec4<f32>(march(dir, sun, moon) + night, 1.0);
|
||||
+ }
|
||||
+ if px.y == 64 && px.x == 0 {
|
||||
+ return vec4<f32>(march(vec3<f32>(0.0, 1.0, 0.0), sun, moon) + night, 1.0);
|
||||
+ }
|
||||
+ if px.y == 64 && px.x == 1 {
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ for (var k = 0; k < 6; k++) {
|
||||
+ let phi = (f32(k) + 0.5) / 6.0 * PI;
|
||||
+ let l = 0.3;
|
||||
+ sum += march(vec3<f32>(cos(l) * cos(phi), sin(l), cos(l) * sin(phi)), sun, moon);
|
||||
+ }
|
||||
+ return vec4<f32>(sum / 6.0 + night, 1.0);
|
||||
+ }
|
||||
+ return vec4<f32>(0.0);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+/// Reading the sky-view texture, in the lighting and the water shaders (`SKYBIND` is replaced by
|
||||
+/// the binding of the texture; `frame` and the `linear` sampler come before it).
|
||||
+pub const SKY_FUNCS: &str = r#"
|
||||
+@group(0) @binding(SKYBIND) var sky_lut: texture_2d<f32>;
|
||||
+const SKY_PI: f32 = 3.14159265;
|
||||
+// How bright the sky is shown (the exposure of the physical radiance) and its gamma.
|
||||
+const SKY_EXPOSURE: f32 = 0.85;
|
||||
+
|
||||
+// Where the direction `dir` (world, y up) lies in the sky-view texture.
|
||||
+fn sky_uv(dir: vec3<f32>) -> vec2<f32> {
|
||||
+ let s = frame.sky_sun.xyz;
|
||||
+ let l = asin(clamp(dir.y, 0.0, 1.0));
|
||||
+ var cphi = 1.0;
|
||||
+ let dl = length(dir.xz);
|
||||
+ let sl = length(s.xz);
|
||||
+ if dl > 1e-4 && sl > 1e-4 { cphi = dot(dir.xz / dl, s.xz / sl); }
|
||||
+ let u = acos(clamp(cphi, -1.0, 1.0)) / SKY_PI;
|
||||
+ let v = sqrt(l / (SKY_PI * 0.5));
|
||||
+ return vec2<f32>((u * 127.0 + 0.5) / 128.0, (v * 63.0 + 0.5) / 72.0);
|
||||
+}
|
||||
+
|
||||
+// The physical sky's colour as it is shown: a soft shoulder, then the display's gamma.
|
||||
+fn sky_show(radiance: vec3<f32>) -> vec3<f32> {
|
||||
+ // Compressed by the brightest channel, so the hue stays as the atmosphere made it.
|
||||
+ let peak = max(max(radiance.r, radiance.g), max(radiance.b, 1e-6));
|
||||
+ let c = pow(radiance * ((1.0 - exp(-peak * SKY_EXPOSURE)) / peak), vec3<f32>(1.0 / 1.9));
|
||||
+ // A little more colour (Photon boosts the atmosphere's saturation too).
|
||||
+ // The low sun gets more, for orange and pink in place of pastel.
|
||||
+ let boost = 1.2 + 0.45 * (1.0 - smoothstep(0.0, 0.35, frame.sky_sun.y));
|
||||
+ return max(mix(vec3<f32>(dot(c, vec3<f32>(0.2126, 0.7152, 0.0722))), c, boost), vec3<f32>(0.0));
|
||||
+}
|
||||
+
|
||||
+// The vanilla sky's own gradient (the overcast and the rain keep it).
|
||||
+fn vanilla_gradient(dir: vec3<f32>) -> vec3<f32> {
|
||||
+ return mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, max(dir.y, 0.0)));
|
||||
+}
|
||||
+
|
||||
+// The sky along `dir`: the atmosphere, greyed towards vanilla's colours in rain.
|
||||
+fn atmosphere_sky(dir: vec3<f32>) -> vec3<f32> {
|
||||
+ let lin = textureSampleLevel(sky_lut, linear, sky_uv(dir), 0.0).rgb;
|
||||
+ var c = sky_show(lin);
|
||||
+ let overcast = frame.sky_extra.z;
|
||||
+ if overcast > 0.0 { c = mix(c, vanilla_gradient(dir), overcast); }
|
||||
+ return c;
|
||||
+}
|
||||
+
|
||||
+// The colour distant things fade to: the sky just above the horizon in their direction (so the
|
||||
+// haze leans towards the sun), or vanilla's fog colour where the environment sets its own fog.
|
||||
+fn haze_colour(dir: vec3<f32>) -> vec3<f32> {
|
||||
+ if frame.sky_sun.w < 0.5 || frame.fog.x < 1.0e6 { return frame.fog_color.rgb; }
|
||||
+ let up = max(dir.y, 0.0) * 0.5 + 0.035;
|
||||
+ return atmosphere_sky(normalize(vec3<f32>(dir.x, 0.0, dir.z) + vec3<f32>(0.0, up, 0.0)));
|
||||
+}
|
||||
+
|
||||
+// The sky's light for a surface of normal n: the horizon's colour from the sides, the zenith's
|
||||
+// from above, as a hue (luminance 1) to tint vanilla's sky light with.
|
||||
+fn sky_light_tint(n: vec3<f32>) -> vec3<f32> {
|
||||
+ let zenith = sky_show(textureLoad(sky_lut, vec2<i32>(0, 64), 0).rgb);
|
||||
+ let horizon = sky_show(textureLoad(sky_lut, vec2<i32>(1, 64), 0).rgb);
|
||||
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
+ let c = mix(horizon, zenith, smoothstep(0.35, 1.0, up));
|
||||
+ let luma = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
+ return c / max(luma, 0.003);
|
||||
+}
|
||||
+
|
||||
+// vanilla's sky (terrain.wgsl: fs_sky), to tell what the world drew on the sky pixel (the sun and
|
||||
+// moon sprites, the stars, the clouds) from its plain gradient.
|
||||
+fn vanilla_disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
|
||||
+ if d.y * y <= 0.0 { return vec2<f32>(-1.0); }
|
||||
+ let p = d * (y / d.y);
|
||||
+ let h = length(p.xz);
|
||||
+ let phi = atan2(p.z, p.x);
|
||||
+ let sector = SKY_PI / 4.0;
|
||||
+ let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
|
||||
+ let edge = 512.0 * cos(sector * 0.5) / cos(delta);
|
||||
+ if h > edge { return vec2<f32>(-1.0); }
|
||||
+ let s = h / edge;
|
||||
+ let ay = abs(y);
|
||||
+ let rim = sqrt(512.0 * 512.0 + y * y);
|
||||
+ return vec2<f32>(ay + s * (rim - ay), ay + s * (512.0 - ay));
|
||||
+}
|
||||
+fn vanilla_linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
+ if d <= start { return 0.0; }
|
||||
+ if d >= end { return 1.0; }
|
||||
+ return (d - start) / (end - start);
|
||||
+}
|
||||
+fn vanilla_rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3<f32>(v.x, -v.z, v.y); }
|
||||
+fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
+ let alpha = frame.sunrise.a;
|
||||
+ if alpha <= 0.001 { return 0.0; }
|
||||
+ let flip = select(0.0, 180.0, frame.sky_sun.x > 0.0);
|
||||
+ let theta = radians(flip + 90.0);
|
||||
+ let c = cos(theta);
|
||||
+ let s = sin(theta);
|
||||
+ var best = 0.0;
|
||||
+ let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
|
||||
+ for (var i = 0; i < 16; i++) {
|
||||
+ let a0 = f32(i) * 2.0 * SKY_PI / 16.0;
|
||||
+ let a1 = f32(i + 1) * 2.0 * SKY_PI / 16.0;
|
||||
+ let l0 = vec3<f32>(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
|
||||
+ let l1 = vec3<f32>(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
|
||||
+ let r0 = vanilla_rotate_x90(vec3<f32>(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
|
||||
+ let r1 = vanilla_rotate_x90(vec3<f32>(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
|
||||
+ let e1 = r0 - apex;
|
||||
+ let e2 = r1 - apex;
|
||||
+ let pv = cross(d, e2);
|
||||
+ let det = dot(e1, pv);
|
||||
+ if abs(det) < 1e-6 { continue; }
|
||||
+ let tv = -apex;
|
||||
+ let u = dot(tv, pv) / det;
|
||||
+ let qv = cross(tv, e1);
|
||||
+ let v = dot(d, qv) / det;
|
||||
+ let t = dot(e2, qv) / det;
|
||||
+ if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
+ best = max(best, 1.0 - u - v);
|
||||
+ }
|
||||
+ }
|
||||
+ return best;
|
||||
+}
|
||||
+fn vanilla_sky(d: vec3<f32>) -> vec3<f32> {
|
||||
+ var color = frame.fog_color.rgb;
|
||||
+ let dist = vanilla_disc_distance(d, 16.0);
|
||||
+ if dist.x >= 0.0 {
|
||||
+ let end = frame.sky_extra.x;
|
||||
+ let f = max(vanilla_linear_fog(dist.x, 0.0, end), vanilla_linear_fog(dist.y, end, end));
|
||||
+ color = mix(frame.sky_color.rgb, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ }
|
||||
+ let w = vanilla_sunrise_weight(d);
|
||||
+ if w > 0.0 {
|
||||
+ color = mix(color, frame.sunrise.rgb, w * frame.sunrise.a);
|
||||
+ }
|
||||
+ return color;
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+/// The sun's light at the ground: the atmosphere's transmittance along the sun's direction
|
||||
+/// (`sin_elevation`, the y of the unit vector towards it), linear, as a fraction of the light
|
||||
+/// outside the air. Same Chapman model as the sky pass.
|
||||
+pub fn sun_transmittance(sin_elevation: f32) -> glam::Vec3 {
|
||||
+ const PLANET: f32 = 6_371_000.0;
|
||||
+ const SCALE: [f32; 2] = [8400.0, 1250.0];
|
||||
+ const RAYLEIGH: glam::Vec3 = glam::Vec3::new(8.059_375e-6, 1.671_209_4e-5, 4.080_133_4e-5);
|
||||
+ const MIE: glam::Vec3 = glam::Vec3::new(1.666_442_4e-6, 1.812_685e-6, 1.958_927_9e-6);
|
||||
+ const OZONE: glam::Vec3 = glam::Vec3::new(8.304_28e-7, 1.314_912e-6, 5.440_679_7e-8);
|
||||
+ // A sun below the horizon keeps the horizon's colour (its strength is faded elsewhere).
|
||||
+ let mu = sin_elevation.clamp(0.0, 1.0);
|
||||
+ let chapman = |x: f32, c: f32| {
|
||||
+ let k = (std::f32::consts::FRAC_PI_2 * x).sqrt();
|
||||
+ k / ((k - 1.0) * c + 1.0)
|
||||
+ };
|
||||
+ let air = [
|
||||
+ SCALE[0] * chapman(PLANET / SCALE[0], mu),
|
||||
+ SCALE[1] * chapman(PLANET / SCALE[1], mu),
|
||||
+ ];
|
||||
+ let tau = RAYLEIGH * air[0] + MIE * air[1] + OZONE * air[0];
|
||||
+ glam::Vec3::new((-tau.x).exp(), (-tau.y).exp(), (-tau.z).exp())
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn the_low_sun_is_redder_than_the_high_one() {
|
||||
+ let noon = sun_transmittance(1.0);
|
||||
+ let low = sun_transmittance(0.1);
|
||||
+ assert!(noon.x > 0.85 && noon.z > 0.6, "{noon:?}");
|
||||
+ assert!(low.z / low.x < noon.z / noon.x * 0.6, "{low:?} vs {noon:?}");
|
||||
+ }
|
||||
+}
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index e892d5a0f..0a2a6cdba 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -197,7 +197,7 @@ struct WaterOut {
|
||||
if layer != WATER_STILL && layer != WATER_FLOW { discard; }
|
||||
// The biome's colour at the texture's mean brightness, not the texture: the HD water draws
|
||||
// its own surface (waves, reflections), and vanilla's ripples on top would look painted on.
|
||||
- let color = vec3(0.62) * in.tint.rgb;
|
||||
+ let color = vec3(0.85) * in.tint.rgb;
|
||||
var out: WaterOut;
|
||||
out.tint = vec4(color, clamp(in.lm.y, 0.0, 1.0));
|
||||
out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 19:02:16 +0300
|
||||
Subject: [PATCH 22/31] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 9 +++++----
|
||||
1 file changed, 5 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 031e9f2de..f078d423c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -558,7 +558,8 @@ fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
|
||||
let phase = dot(k, xz) - omega * frame.water.y + w.w;
|
||||
grad += (k / len) * (w.z * fade * cos(phase));
|
||||
}
|
||||
- return grad;
|
||||
+ // A little higher than the table's slopes.
|
||||
+ return grad * 1.25;
|
||||
}
|
||||
|
||||
fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
|
||||
@@ -713,10 +714,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
// depth rather than as bright paint.
|
||||
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
|
||||
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.78, 0.86, 0.95);
|
||||
// The colours the water takes up fastest are the ones it has the least of.
|
||||
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
- let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.6 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * mix(0.65, 0.3, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
|
||||
|
||||
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
var r = reflect(-v, n);
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,992 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 22:56:15 +0300
|
||||
Subject: [PATCH 25/31] HD fixes: rain level from the world not from rain
|
||||
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
|
||||
without a depth heuristic (#35), air fog once over water (#37), translucent
|
||||
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
|
||||
air capped; mods: render.set-time-override (API level 13) so replays show
|
||||
their recorded time of day (#57)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/entity.wgsl | 2 +
|
||||
crates/horizon-client/src/environment.rs | 5 +
|
||||
crates/horizon-client/src/game.rs | 18 +-
|
||||
crates/horizon-client/src/gfx.rs | 52 ++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 411 ++++++++++++++++-------
|
||||
crates/horizon-client/src/gfx/hd_sky.rs | 8 +
|
||||
crates/horizon-client/src/terrain.wgsl | 8 +
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 5 +
|
||||
crates/horizon-mods/src/api_level.rs | 6 +-
|
||||
crates/horizon-mods/src/runtime.rs | 12 +
|
||||
docs/api/mods-reference.md | 1 +
|
||||
11 files changed, 387 insertions(+), 141 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl
|
||||
index 61bc1f92a..b3fc9bc82 100644
|
||||
--- a/crates/horizon-client/src/entity.wgsl
|
||||
+++ b/crates/horizon-client/src/entity.wgsl
|
||||
@@ -20,6 +20,8 @@ struct Globals {
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
outline_params: vec4<f32>,
|
||||
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
|
||||
+ hd_flags: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/environment.rs b/crates/horizon-client/src/environment.rs
|
||||
index 20855b749..9b8acf13f 100644
|
||||
--- a/crates/horizon-client/src/environment.rs
|
||||
+++ b/crates/horizon-client/src/environment.rs
|
||||
@@ -141,6 +141,9 @@ pub struct Sky {
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
+ /// AirDef fork: the world's rain level (0..1), set by `apply_weather`. The HD sky reads this
|
||||
+ /// rather than the presence of rain geometry near the camera.
|
||||
+ pub rain: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -305,6 +308,7 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/fog_end_distance")
|
||||
.map_or(1024.0, |v| v[0]),
|
||||
+ rain: 0.0,
|
||||
cloud_fog_end: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
@@ -315,6 +319,7 @@ impl Environment {
|
||||
/// WeatherAttributes: rain then thunder layers lerped by their levels
|
||||
/// (rain counts only the part not covered by thunder).
|
||||
pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
+ sky.rain = rain.clamp(0.0, 1.0);
|
||||
let grey = |c: [f32; 3]| c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
|
||||
let lerp3 = |t: f32, a: [f32; 3], b: [f32; 3]| -> [f32; 3] {
|
||||
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 366be39c4..fad8de187 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -8370,6 +8370,14 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
+ /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's.
|
||||
+ fn shown_day_time(&self) -> i64 {
|
||||
+ self.mods
|
||||
+ .mods
|
||||
+ .time_override()
|
||||
+ .map_or(self.world.day_time, |t| t as i64)
|
||||
+ }
|
||||
+
|
||||
fn sky_darken(&self) -> i32 {
|
||||
if self.dim == Dim::Nether {
|
||||
return 11;
|
||||
@@ -8378,12 +8386,12 @@ impl Game<'_> {
|
||||
let sky = self
|
||||
.res
|
||||
.end_environment()
|
||||
- .sample(self.world.day_time, None, None);
|
||||
+ .sample(self.shown_day_time(), None, None);
|
||||
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
}
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Sampled once per (time, weather): every mob asks each tick.
|
||||
- let key = (self.world.day_time, rain.to_bits(), thunder.to_bits());
|
||||
+ let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
|
||||
if let Some((k, darken)) = self.sky_darken_memo.get()
|
||||
&& k == key
|
||||
{
|
||||
@@ -8392,7 +8400,7 @@ impl Game<'_> {
|
||||
let mut sky = self
|
||||
.res
|
||||
.environment()
|
||||
- .sample(self.world.day_time, None, None);
|
||||
+ .sample(self.shown_day_time(), None, None);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
@@ -8898,7 +8906,7 @@ impl Game<'_> {
|
||||
"first_quarter",
|
||||
"waxing_gibbous",
|
||||
];
|
||||
- let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
|
||||
+ let phase = phases[(self.shown_day_time() / 24000).rem_euclid(8) as usize];
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
@@ -11608,7 +11616,7 @@ impl Game<'_> {
|
||||
);
|
||||
// visual/water_fog_color: no dimension sets it, its default is #050533.
|
||||
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
|
||||
- let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
|
||||
+ let mut sky = environment.sample(self.shown_day_time(), Some(biome_sky), Some(biome_fog));
|
||||
let (rain, thunder) = if self.dim == Dim::Overworld {
|
||||
self.world.weather.levels(alpha as f32)
|
||||
} else {
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index 072090e33..a8031c1f6 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -194,6 +194,9 @@ struct Globals {
|
||||
crumbling_sprite: [f32; 4],
|
||||
/// Framebuffer size, selection stroke width, perspective layering scale.
|
||||
outline_params: [f32; 4],
|
||||
+ /// AirDef fork. x: 1 while the HD water draws the water (fs_translucent then leaves vanilla's
|
||||
+ /// water out of the colour and keeps its depth).
|
||||
+ hd_flags: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
}
|
||||
@@ -578,7 +581,7 @@ pub struct Renderer {
|
||||
hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
|
||||
/// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
|
||||
taa_frame: u32,
|
||||
- taa_prev: Option<(Mat4, glam::DVec3)>,
|
||||
+ taa_prev: Option<(Mat4, glam::DVec3, Mat4)>,
|
||||
hd: Option<hd::Hd>,
|
||||
mod_params: (Vec<f32>, f32),
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
@@ -1207,17 +1210,15 @@ impl Renderer {
|
||||
if sprite_arrays > 1 {
|
||||
log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays");
|
||||
}
|
||||
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water (and
|
||||
+ // fs_translucent clears while the HD water draws it instead).
|
||||
let sprite_constants = [
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
- ];
|
||||
- // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
|
||||
- let water_constants = [
|
||||
- ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
- ("SPRITE_SPLIT", f64::from(split)),
|
||||
("WATER_STILL", f64::from(water_layers[0])),
|
||||
("WATER_FLOW", f64::from(water_layers[1])),
|
||||
];
|
||||
+ let water_constants = sprite_constants;
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
@@ -2823,7 +2824,7 @@ impl Renderer {
|
||||
mip_levels,
|
||||
mipmaps: mip_levels - 1,
|
||||
terrain_layout: bindings,
|
||||
- terrain_sprites: sprite_constants.map(|(_, v)| v),
|
||||
+ terrain_sprites: [f64::from(sprite_arrays), f64::from(split)],
|
||||
sprite_sampler,
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
@@ -4049,7 +4050,7 @@ impl Renderer {
|
||||
};
|
||||
let taa_prev = std::mem::replace(
|
||||
&mut self.taa_prev,
|
||||
- taa.then_some((unjittered, frame.camera)),
|
||||
+ taa.then_some((unjittered, frame.camera, view_proj)),
|
||||
);
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
@@ -4135,6 +4136,7 @@ impl Renderer {
|
||||
frame.glint_ticks,
|
||||
],
|
||||
outline_params: outline_params(self.config.width, self.config.height),
|
||||
+ hd_flags: [0.0; 4],
|
||||
dynamic_lights: {
|
||||
let mut lights = [[0.0; 4]; crate::dynamic_light::MAX];
|
||||
for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) {
|
||||
@@ -4260,11 +4262,14 @@ impl Renderer {
|
||||
taa: taa && taa_prev.is_some(),
|
||||
jitter_px,
|
||||
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
|
||||
+ prev_view_proj_jittered: taa_prev.map_or(view_proj, |p| p.2),
|
||||
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
|
||||
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
|
||||
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
|
||||
dark: 1.0 - frame.sky.sky_light_factor,
|
||||
- rain: if frame.weather.is_empty() { 0.0 } else { 0.7 },
|
||||
+ // The world's rain level (fades in and out with the weather), not the rain geometry
|
||||
+ // near the camera: hiding the particles or standing under a roof leaves the sky alone.
|
||||
+ rain: frame.sky.rain.clamp(0.0, 1.0) * 0.7,
|
||||
fog_color,
|
||||
fog,
|
||||
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
|
||||
@@ -4827,7 +4832,20 @@ impl Renderer {
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads;
|
||||
- let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
+ // AirDef fork: vanilla's clouds (HD's volumetric ones off) take the low sun's colour in their
|
||||
+ // own colour, so a mountain or glass behind them does not change it.
|
||||
+ let cloud_input = frame.clouds.as_ref().map(|c| {
|
||||
+ if !hd_wanted || !hd_scene.sky || hd::clouds_enabled() {
|
||||
+ return crate::clouds::Input { ..*c };
|
||||
+ }
|
||||
+ let [r, g, b, a] = c.color;
|
||||
+ let [tr, tg, tb] = hd::vanilla_cloud_tint(&hd_scene);
|
||||
+ crate::clouds::Input {
|
||||
+ color: [r * tr, g * tg, b * tb, a],
|
||||
+ ..*c
|
||||
+ }
|
||||
+ });
|
||||
+ let cloud_quads = if let Some(input) = &cloud_input {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
let vertices = self.cloud_mesh.vertices.len() as u64;
|
||||
@@ -5154,6 +5172,15 @@ impl Renderer {
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
+ // vanilla's translucent water leaves the colour while the HD water draws it.
|
||||
+ let hd_flags_at = std::mem::offset_of!(Globals, hd_flags) as u64;
|
||||
+ let hd_flags: [f32; 4] = [if hd.water_active() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0];
|
||||
+ self.uploads.buffer(
|
||||
+ &mut encoder,
|
||||
+ &self.globals,
|
||||
+ hd_flags_at,
|
||||
+ bytemuck::bytes_of(&hd_flags),
|
||||
+ );
|
||||
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.
|
||||
@@ -5184,6 +5211,11 @@ impl Renderer {
|
||||
}
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
+ self.queue.write_buffer(
|
||||
+ &hd.main_globals,
|
||||
+ hd_flags_at,
|
||||
+ bytemuck::bytes_of(&hd_flags),
|
||||
+ );
|
||||
// Facing the light only: the nearest surface to it is always one that does.
|
||||
let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 3b9daabf4..e84a618a9 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -133,6 +133,10 @@ pub struct HdScene {
|
||||
pub view_proj: Mat4,
|
||||
/// Last frame's view-projection without jitter, and the camera's move since (TAA).
|
||||
pub prev_view_proj: Mat4,
|
||||
+ /// The same frame's matrix as it was drawn, jitter included. History kept on the jittered
|
||||
+ /// pixel grid (`fs_taa`, scale 1) is looked up with this; `fs_taau` resolves the history on the
|
||||
+ /// unjittered output grid and uses `prev_view_proj`.
|
||||
+ pub prev_view_proj_jittered: Mat4,
|
||||
pub camera_delta: glam::Vec3,
|
||||
/// This frame is anti-aliased over time (the projection is jittered).
|
||||
pub taa: bool,
|
||||
@@ -403,31 +407,104 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
return sum / total;
|
||||
}
|
||||
|
||||
+struct LightOut {
|
||||
+ // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
|
||||
+ // object in front of the water (the scene as drawn).
|
||||
+ @location(0) lit: vec4<f32>,
|
||||
+ // Water: the air between the camera and the surface (scattered light, transmittance);
|
||||
+ // in front of the water: the floor lit (rgb); else nothing.
|
||||
+ @location(1) air: vec4<f32>,
|
||||
+}
|
||||
+
|
||||
+// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo.
|
||||
+fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||||
+ let sun = frame.sun.xyz;
|
||||
+ let sun_on = frame.sun.w;
|
||||
+ let atmosphere = frame.sky_sun.w > 0.5;
|
||||
+ let count = u32(frame.params.x);
|
||||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
+ let surface_start = tiles[tile];
|
||||
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
+ let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
+ let albedo = textureLoad(albedo_tex, px, 0);
|
||||
+ let lm = textureLoad(light_tex, px, 0).xy;
|
||||
+ // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
|
||||
+ let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
+ let sky_reach = smoothstep(0.35, 0.95, lm.x);
|
||||
+ var s = 1.0;
|
||||
+ let ndl = dot(n, sun);
|
||||
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
|
||||
+ // 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;
|
||||
+ // 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;
|
||||
+ // (The water dims and blurs the floor under it: no occlusion there.)
|
||||
+ if frame.ambient.w > 0.0 && !water { 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);
|
||||
+ // The sky's light is bluish from above and carries the horizon's colour from the sides;
|
||||
+ // faces turned up take more of it than the ones turned down.
|
||||
+ var sky_tint = vec3<f32>(1.0);
|
||||
+ if atmosphere {
|
||||
+ let reach = smoothstep(0.25, 0.9, lm.x);
|
||||
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
|
||||
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
|
||||
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||||
+ }
|
||||
+ var light = max(base * sky_tint * 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)];
|
||||
+ let to = l.pos.xyz - p;
|
||||
+ let dist = length(to);
|
||||
+ if dist >= l.pos.w { continue; }
|
||||
+ 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 * ao_lights;
|
||||
+ }
|
||||
+ return albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
+}
|
||||
+
|
||||
@fragment
|
||||
-fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+fn fs_light(in: FullOut) -> LightOut {
|
||||
let px = vec2<i32>(in.position.xy);
|
||||
let scene = textureLoad(scene_color, px, 0).rgb;
|
||||
let d = textureLoad(scene_depth, px, 0);
|
||||
let g = textureLoad(gbuffer_depth, px, 0);
|
||||
- // Water in front of the opaque world with nothing translucent over it: here the floor is lit
|
||||
- // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
|
||||
- var water = false;
|
||||
- if frame.water.x > 0.5 && g > 0.0 {
|
||||
- let wd = textureLoad(water_depth, px, 0);
|
||||
- water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
|
||||
- }
|
||||
- let dd = select(d, g, water);
|
||||
+ // Water over the opaque world (or over nothing): the water composite lays the surface over
|
||||
+ // the floor, which is lit here with no air. Something translucent in front of the water
|
||||
+ // (`front`: nearer than its surface) keeps the scene as drawn; the floor goes beside it.
|
||||
+ var wd = 0.0;
|
||||
+ var water_here = false;
|
||||
+ if frame.water.x > 0.5 {
|
||||
+ wd = textureLoad(water_depth, px, 0);
|
||||
+ water_here = wd > 0.0 && (g <= 0.0 || wd >= g);
|
||||
+ }
|
||||
+ let front = water_here && g > 0.0 && d - wd > d * 0.002;
|
||||
+ let water = water_here && !front;
|
||||
+ let has_floor = water && g > 0.0;
|
||||
+ let dd = select(d, g, has_floor);
|
||||
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
|
||||
let h = frame.inverse_view_proj * ndc;
|
||||
let p = h.xyz / h.w;
|
||||
let view = normalize(p);
|
||||
+ // Where the air ends: the water's surface over water, else the pixel itself.
|
||||
+ var ps = p;
|
||||
+ if water {
|
||||
+ let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(wd, 0.000001), 1.0);
|
||||
+ ps = hf.xyz / hf.w;
|
||||
+ }
|
||||
+ let view_s = normalize(ps);
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
let count = u32(frame.params.x);
|
||||
let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
- let surface_start = tiles[tile];
|
||||
- let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
- let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
let halo_start = tiles[tile + 2u];
|
||||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
@@ -455,113 +532,81 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
c = c * cl.a + cl.rgb;
|
||||
}
|
||||
}
|
||||
- // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
|
||||
- // sun's colour at dusk and dawn instead of vanilla's flat white.
|
||||
- if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
|
||||
- let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
|
||||
- c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
|
||||
- }
|
||||
- let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
+ // (Vanilla's clouds, when the volumetric ones are off, take the low sun's colour in their own
|
||||
+ // pass: `vanilla_cloud_tint`, applied to their colour on the CPU.)
|
||||
+ let surface = has_floor || (!water_here && d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
if surface {
|
||||
- let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
- let albedo = textureLoad(albedo_tex, px, 0);
|
||||
- let lm = textureLoad(light_tex, px, 0).xy;
|
||||
- // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
|
||||
- let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
- let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
- let sky_reach = smoothstep(0.35, 0.95, lm.x);
|
||||
- var s = 1.0;
|
||||
- let ndl = dot(n, sun);
|
||||
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
|
||||
- // 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;
|
||||
- // 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;
|
||||
- // (The water dims and blurs the floor under it: no occlusion there.)
|
||||
- if frame.ambient.w > 0.0 && !water { 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);
|
||||
- // The sky's light is bluish from above and carries the horizon's colour from the sides;
|
||||
- // faces turned up take more of it than the ones turned down.
|
||||
- var sky_tint = vec3<f32>(1.0);
|
||||
- if atmosphere {
|
||||
- let reach = smoothstep(0.25, 0.9, lm.x);
|
||||
- let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
- let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
|
||||
- let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
|
||||
- sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||||
- }
|
||||
- var light = max(base * sky_tint * 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)];
|
||||
- let to = l.pos.xyz - p;
|
||||
- let dist = length(to);
|
||||
- if dist >= l.pos.w { continue; }
|
||||
- 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 * ao_lights;
|
||||
- }
|
||||
- c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
- // Vanilla's fog over it again.
|
||||
- let sph = length(p);
|
||||
- let cyl = max(length(p.xz), abs(p.y));
|
||||
- let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
- let haze = haze_colour(view);
|
||||
- c = mix(c, haze, f * frame.fog_color.a);
|
||||
- // Aerial perspective: far things take the horizon's colour a little before the fog does.
|
||||
- if atmosphere && frame.fog.x > 1.0e6 {
|
||||
- c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
|
||||
+ c = shade_surface(px, p, water);
|
||||
+ // Vanilla's fog over it again (the water composite does it at the surface).
|
||||
+ if !water {
|
||||
+ let sph = length(p);
|
||||
+ let cyl = max(length(p.xz), abs(p.y));
|
||||
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
+ let haze = haze_colour(view);
|
||||
+ c = mix(c, haze, f * frame.fog_color.a);
|
||||
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
|
||||
+ if atmosphere && frame.fog.x > 1.0e6 {
|
||||
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
- // The volumetric fog: the light the air scatters towards the camera (shafts through the trees,
|
||||
- // mist, haze), and how much of the pixel it hides.
|
||||
- let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
|
||||
+ // What lies behind a translucent thing in front of the water: the floor, lit.
|
||||
+ var floor_lit = vec3<f32>(0.0);
|
||||
+ if front {
|
||||
+ let hg = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(g, 0.000001), 1.0);
|
||||
+ floor_lit = shade_surface(px, hg.xyz / hg.w, true);
|
||||
+ }
|
||||
+ // The air between the camera and the pixel (the water's surface over water): the volumetric
|
||||
+ // fog (what it hides, what it scatters), the sun's rays between the shadows, and the halos
|
||||
+ // round the mods' lights. Over water they go to the composite, which lays them over the
|
||||
+ // finished water; elsewhere over the pixel here.
|
||||
+ var air_t = 1.0;
|
||||
+ var air_s = vec3<f32>(0.0);
|
||||
+ let far = select(min(length(ps), frame.shadow.z), frame.shadow.z, d <= 0.0);
|
||||
let density = frame.ambient.z;
|
||||
let volumetric = frame.vfog.x > 0.5 && atmosphere;
|
||||
if volumetric {
|
||||
var fog_len = 192.0;
|
||||
- if d > 0.0 {
|
||||
- var pf = p;
|
||||
- if water {
|
||||
- let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
|
||||
- pf = hf.xyz / hf.w;
|
||||
- }
|
||||
- fog_len = min(length(pf), 192.0);
|
||||
- }
|
||||
+ if d > 0.0 { fog_len = min(length(ps), 192.0); }
|
||||
let fg = fog_at(px, fog_len);
|
||||
- c = c * fg.a + fg.rgb;
|
||||
+ air_t = fg.a;
|
||||
+ air_s = fg.rgb;
|
||||
}
|
||||
- // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the
|
||||
- // halos round the mods' lights.
|
||||
if !volumetric && sun_on > 0.0 && density > 0.0 {
|
||||
let steps = 14;
|
||||
let jitter = ign(in.position.xy);
|
||||
var acc = 0.0;
|
||||
for (var i = 0; i < steps; i++) {
|
||||
let t = (f32(i) + jitter) / f32(steps) * far;
|
||||
- acc += lit_at(view * t);
|
||||
+ acc += lit_at(view_s * t);
|
||||
}
|
||||
// Henyey-Greenstein, forward scattering: brightest looking towards the sun.
|
||||
- let cos_t = dot(view, sun);
|
||||
+ let cos_t = dot(view_s, sun);
|
||||
let gk = 0.6;
|
||||
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
|
||||
- c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
+ air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
}
|
||||
for (var k = 0u; k < halo_count; k++) {
|
||||
let l = lights[select(tiles[halo_start + k], k, halo_all)];
|
||||
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
|
||||
- let t0 = dot(l.pos.xyz, view);
|
||||
- let hh = length(l.pos.xyz - view * t0) + 0.5;
|
||||
+ let t0 = dot(l.pos.xyz, view_s);
|
||||
+ let hh = length(l.pos.xyz - view_s * t0) + 0.5;
|
||||
if hh > l.pos.w * 0.5 { continue; }
|
||||
let a0 = atan((0.0 - t0) / hh);
|
||||
let a1 = atan((far - t0) / hh);
|
||||
- c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
|
||||
+ // Thicker air glows more round a light, but only so far: the mist at dusk and many fires
|
||||
+ // at once would otherwise wash the picture out.
|
||||
+ air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9));
|
||||
+ }
|
||||
+ var out: LightOut;
|
||||
+ if water {
|
||||
+ out.lit = vec4<f32>(c, 0.5);
|
||||
+ out.air = vec4<f32>(air_s, air_t);
|
||||
+ } else {
|
||||
+ out.lit = vec4<f32>(c * air_t + air_s, select(1.0, 0.6, front));
|
||||
+ out.air = vec4<f32>(floor_lit, 0.0);
|
||||
}
|
||||
- return vec4<f32>(c, select(1.0, 0.5, water));
|
||||
+ return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -580,6 +625,9 @@ const REFLECT_RANGE: f32 = 96.0;
|
||||
@group(0) @binding(8) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(9) var linear: sampler;
|
||||
//SKY10
|
||||
+// What the lighting left beside the lit world: over water the air in front of the surface (scattered
|
||||
+// light, transmittance), in front of the water the lit floor.
|
||||
+@group(0) @binding(11) var air_tex: texture_2d<f32>;
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -761,6 +809,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(in.position.xy);
|
||||
let base = textureLoad(lit_tex, px, 0);
|
||||
if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
|
||||
+ // Alpha 0.6: something translucent stands in front of the water here (base is the scene).
|
||||
+ let front = base.a > 0.55;
|
||||
+ let aux = textureLoad(air_tex, px, 0);
|
||||
let wd = textureLoad(water_depth, px, 0);
|
||||
let g = textureLoad(gbuffer_depth, px, 0);
|
||||
let tint = textureLoad(water_tint, px, 0);
|
||||
@@ -792,23 +843,35 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
|
||||
|
||||
// Refraction: where the ray through the waves meets the world, against where the flat surface
|
||||
- // would send it (so still water looks as vanilla's and only the waves distort).
|
||||
- let floor0 = position_at(in.uv, g);
|
||||
- let depth0 = max(length(floor0) - dist, 0.0);
|
||||
- let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
|
||||
+ // would send it (so still water looks as vanilla's and only the waves distort). Not where
|
||||
+ // something translucent is in front of the water, nor onto texels that hold such a thing.
|
||||
+ let has_floor = g > 0.0;
|
||||
var uv_floor = in.uv;
|
||||
var floor_z = g;
|
||||
- let moved = project(floor0 + bent * min(depth0, 24.0));
|
||||
- if moved.z > 0.0 && on_screen(moved.xy) {
|
||||
- let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
|
||||
- // Only what lies behind the water: not the bank or an entity in front of it.
|
||||
- if z > 0.0 && z <= wd {
|
||||
- uv_floor = moved.xy;
|
||||
- floor_z = z;
|
||||
+ if has_floor && !front {
|
||||
+ let floor0 = position_at(in.uv, g);
|
||||
+ let depth0 = max(length(floor0) - dist, 0.0);
|
||||
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
|
||||
+ let moved = project(floor0 + bent * min(depth0, 24.0));
|
||||
+ if moved.z > 0.0 && on_screen(moved.xy) {
|
||||
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
|
||||
+ // The four texels the filter blends: none may be a pixel with a translucent thing
|
||||
+ // in front of the water.
|
||||
+ let marks = textureGather(3, lit_tex, linear, moved.xy);
|
||||
+ let tainted = any((marks > vec4<f32>(0.55)) & (marks < vec4<f32>(0.75)));
|
||||
+ // Only what lies behind the water: not the bank or an entity in front of it.
|
||||
+ if z > 0.0 && z <= wd && !tainted {
|
||||
+ uv_floor = moved.xy;
|
||||
+ floor_z = z;
|
||||
+ }
|
||||
}
|
||||
}
|
||||
- let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
- let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
+ var thickness = 48.0;
|
||||
+ if has_floor {
|
||||
+ thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
+ }
|
||||
+ var behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
+ if front { behind = aux.rgb; }
|
||||
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
// depth rather than as bright paint.
|
||||
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
@@ -840,6 +903,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
|
||||
c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
|
||||
}
|
||||
+ if front {
|
||||
+ // The scene as drawn, with the water's change to the floor laid under the translucent thing.
|
||||
+ c = base.rgb + (c - aux.rgb) * 0.7;
|
||||
+ } else {
|
||||
+ // The air between the camera and the surface: volumetric fog, rays, halos.
|
||||
+ c = c * aux.a + aux.rgb;
|
||||
+ }
|
||||
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
|
||||
}
|
||||
"#;
|
||||
@@ -1413,6 +1483,8 @@ pub struct Hd {
|
||||
water_normal: wgpu::TextureView,
|
||||
water_depth: wgpu::TextureView,
|
||||
lit_water: wgpu::TextureView,
|
||||
+ /// The lighting's second target: the air over water, the floor beside translucent things.
|
||||
+ air: wgpu::TextureView,
|
||||
water_layout: wgpu::BindGroupLayout,
|
||||
water_pipeline: wgpu::RenderPipeline,
|
||||
water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
|
||||
@@ -1849,6 +1921,7 @@ impl Hd {
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
FLOAT,
|
||||
+ UNFILTERED,
|
||||
]),
|
||||
});
|
||||
let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1961,15 +2034,39 @@ impl Hd {
|
||||
},
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
};
|
||||
- let light_pipeline = fullscreen(
|
||||
- device,
|
||||
- "HD lighting",
|
||||
- &light_layout,
|
||||
- &light,
|
||||
- "fs_light",
|
||||
- HDR,
|
||||
- None,
|
||||
- );
|
||||
+ let light_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ bind_group_layouts: &[Some(&light_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ let target = Some(wgpu::ColorTargetState {
|
||||
+ format: HDR,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &light,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &light,
|
||||
+ entry_point: Some("fs_light"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[target.clone(), target],
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let prefilter = fullscreen(
|
||||
device,
|
||||
"HD bloom prefilter",
|
||||
@@ -2108,6 +2205,7 @@ impl Hd {
|
||||
)
|
||||
.view,
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
+ air: target(device, "HD air", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
|
||||
water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
|
||||
@@ -2199,6 +2297,7 @@ impl Hd {
|
||||
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
+ self.air = target(device, "HD air", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
|
||||
self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
|
||||
@@ -2322,7 +2421,12 @@ impl Hd {
|
||||
0.0
|
||||
};
|
||||
let frame = HdFrame {
|
||||
- prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
|
||||
+ prev_view_proj: if self.upscaling() {
|
||||
+ scene.prev_view_proj
|
||||
+ } else {
|
||||
+ scene.prev_view_proj_jittered
|
||||
+ }
|
||||
+ .to_cols_array_2d(),
|
||||
taa: [
|
||||
scene.camera_delta.x,
|
||||
scene.camera_delta.y,
|
||||
@@ -2685,15 +2789,28 @@ impl Hd {
|
||||
return;
|
||||
};
|
||||
self.water_composited = false;
|
||||
- Self::draw(
|
||||
- encoder,
|
||||
- "HD lighting",
|
||||
- &self.lit,
|
||||
- false,
|
||||
- &self.light_pipeline,
|
||||
- group,
|
||||
- stamp(&mut self.timing, "hd lighting"),
|
||||
- );
|
||||
+ let clear = |view| {
|
||||
+ Some(wgpu::RenderPassColorAttachment {
|
||||
+ view,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })
|
||||
+ };
|
||||
+ {
|
||||
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd lighting"),
|
||||
+ color_attachments: &[clear(&self.lit), clear(&self.air)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ pass.set_pipeline(&self.light_pipeline);
|
||||
+ pass.set_bind_group(0, group, &[]);
|
||||
+ pass.draw(0..3, 0..1);
|
||||
+ }
|
||||
}
|
||||
|
||||
/// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
|
||||
@@ -2987,6 +3104,7 @@ impl Hd {
|
||||
wgpu::BindingResource::TextureView(lightmap.0),
|
||||
wgpu::BindingResource::Sampler(&self.sampler),
|
||||
wgpu::BindingResource::TextureView(&self.sky_lut),
|
||||
+ wgpu::BindingResource::TextureView(&self.air),
|
||||
],
|
||||
);
|
||||
self.water_group = Some((key, group));
|
||||
@@ -3098,6 +3216,20 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
|
||||
}
|
||||
}
|
||||
|
||||
+/// The factor on vanilla's cloud colour that lights the clouds with the low sun's colour at dusk
|
||||
+/// and dawn instead of a flat white. It is the clouds' own colour (the cloud pass gets it), not a
|
||||
+/// guess from the depth buffer, so what stands behind a cloud cannot change it.
|
||||
+pub fn vanilla_cloud_tint(scene: &HdScene) -> [f32; 3] {
|
||||
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
|
||||
+ let low = 1.0 - smooth(0.05, 0.5, sun.y);
|
||||
+ let overcast = (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85;
|
||||
+ let (_, _, color) = light_of(scene);
|
||||
+ let warm = color.lerp(glam::Vec3::ONE, 0.45) * 1.1;
|
||||
+ let t = low * 0.7 * (1.0 - overcast);
|
||||
+ let tint = glam::Vec3::ONE.lerp(warm, t);
|
||||
+ [tint.x, tint.y, tint.z]
|
||||
+}
|
||||
+
|
||||
/// The x of the sun's direction: positive in the morning, negative in the evening.
|
||||
fn sun_dir_x(scene: &HdScene) -> f32 {
|
||||
scene.sun.normalize_or(glam::Vec3::Y).x
|
||||
@@ -3201,6 +3333,37 @@ mod tests {
|
||||
assert_eq!(seen.len(), 16);
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn taa_history_lookup_follows_the_jittered_grid() {
|
||||
+ // A static point under two frames' different Halton jitters: fs_taa finds this frame's
|
||||
+ // point in the history by the previous frame's matrix *as drawn*, which lands on the pixel
|
||||
+ // the previous frame wrote it to; the unjittered one is off by that frame's jitter.
|
||||
+ let size = [1920u32, 1080];
|
||||
+ let unjittered = Mat4::perspective_rh(1.0, 16.0 / 9.0, 0.05, 1000.0);
|
||||
+ let to_uv = |m: Mat4, p: glam::Vec3| {
|
||||
+ let c = m * p.extend(1.0);
|
||||
+ [c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5]
|
||||
+ };
|
||||
+ let drawn = |frame: u32| {
|
||||
+ let j = jitter(frame, size, 16);
|
||||
+ Mat4::from_translation(glam::Vec3::new(j[0], j[1], 0.0)) * unjittered
|
||||
+ };
|
||||
+ let p = glam::Vec3::new(0.0, 0.0, -20.0);
|
||||
+ for frame in 1..16 {
|
||||
+ let prev = drawn(frame - 1);
|
||||
+ // The pixel (in uv) the previous frame drew the point at: what the history holds there.
|
||||
+ let wrote = to_uv(prev, p);
|
||||
+ let old = to_uv(unjittered, p);
|
||||
+ let err = [
|
||||
+ (old[0] - wrote[0]).abs() * size[0] as f32,
|
||||
+ (old[1] - wrote[1]).abs() * size[1] as f32,
|
||||
+ ];
|
||||
+ let pj = jitter(frame - 1, size, 16);
|
||||
+ assert!((err[0] - pj[0].abs() * size[0] as f32 * 0.5).abs() < 1e-3);
|
||||
+ assert!((err[1] - pj[1].abs() * size[1] as f32 * 0.5).abs() < 1e-3);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn jitter_covers_the_upscale_ratio() {
|
||||
assert_eq!(jitter_count(1.0), 16);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
index ec55de4c8..dbe8c8853 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
@@ -283,6 +283,14 @@ fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
let theta = radians(flip + 90.0);
|
||||
let c = cos(theta);
|
||||
let s = sin(theta);
|
||||
+ // Above the horizon the fan reaches only the sun's side (x of the same sign as the apex's, -s)
|
||||
+ // and rises at most alpha/3 per block of that distance (its rim stands 40*alpha high over a
|
||||
+ // 120-block radius and the points above the horizon lie at least 120 * the share of the rim
|
||||
+ // from the axis); anything else cannot hit it, so most of the sky skips the 16 triangles.
|
||||
+ if d.y > 0.0 {
|
||||
+ let side = -s * d.x;
|
||||
+ if side <= 0.0 || d.y > alpha * (1.0 / 3.0) * side * 1.02 + 0.001 { return 0.0; }
|
||||
+ }
|
||||
var best = 0.0;
|
||||
let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
|
||||
for (var i = 0; i < 16; i++) {
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index 0a2a6cdba..cb098a64a 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -22,6 +22,8 @@ struct Globals {
|
||||
// Atlas rect of the current destroy stage (read by entity.wgsl).
|
||||
crumbling_sprite: vec4<f32>,
|
||||
outline_params: vec4<f32>,
|
||||
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
|
||||
+ hd_flags: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
}
|
||||
@@ -211,6 +213,12 @@ struct WaterOut {
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
if color.a < 0.01 { discard; }
|
||||
+ // The HD water composite draws the water itself: vanilla's stays out of the colour (alpha 0
|
||||
+ // blends to nothing) but keeps its depth, which the lighting reads.
|
||||
+ let layer = in.layer & 0x7fffu;
|
||||
+ if globals.hd_flags.x > 0.5 && (layer == WATER_STILL || layer == WATER_FLOW) {
|
||||
+ return vec4(0.0);
|
||||
+ }
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index 8411ac9aa..5fa5e8a27 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -694,6 +694,11 @@ interface render {
|
||||
/// Switches the HD lighting on or off for this mod's world, with its
|
||||
/// exposure and bloom; off, the world is drawn as the game draws it.
|
||||
set-lighting: func(lighting: lighting);
|
||||
+ /// Shows the world at this time of day (AirDef fork): the sky, the sun
|
||||
+ /// and moon, the sky light and the fog, as `day-time` ticks (24000 a day,
|
||||
+ /// as `clock`). Only what the player sees changes: the world's own clock,
|
||||
+ /// its saves and its rules do not. None shows the world's own time.
|
||||
+ set-time-override: func(day-time: option<u64>);
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index 8cf350044..bc1ade067 100644
|
||||
--- a/crates/horizon-mods/src/api_level.rs
|
||||
+++ b/crates/horizon-mods/src/api_level.rs
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
-pub const LEVEL: u32 = 12;
|
||||
+pub const LEVEL: u32 = 13;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
-pub const FUNCTIONS: [(&str, u32); 66] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 67] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -82,6 +82,8 @@ pub const FUNCTIONS: [(&str, u32); 66] = [
|
||||
("render.query-sections", 12),
|
||||
("render.sections", 12),
|
||||
("render.world-changes", 12),
|
||||
+ // AirDef fork: a replay shows its recorded time of day.
|
||||
+ ("render.set-time-override", 13),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||||
index 2189a99e1..fb6180f1c 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -507,6 +507,8 @@ struct State {
|
||||
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
|
||||
lights: Vec<PointLight>,
|
||||
lighting: Option<ModLighting>,
|
||||
+ /// `render.set-time-override` (AirDef fork).
|
||||
+ time_override: Option<u64>,
|
||||
input: InputState,
|
||||
browser: BrowserState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
@@ -749,6 +751,7 @@ impl Mods {
|
||||
views: Vec::new(),
|
||||
lights: Vec::new(),
|
||||
lighting: None,
|
||||
+ time_override: None,
|
||||
input: InputState::default(),
|
||||
browser: BrowserState::default(),
|
||||
data_dir,
|
||||
@@ -1334,6 +1337,12 @@ impl Mods {
|
||||
.iter()
|
||||
.find_map(|e| e.run.as_ref()?.store.data().lighting)
|
||||
}
|
||||
+ /// The time of day the first running mod asked to show, if any.
|
||||
+ pub fn time_override(&self) -> Option<u64> {
|
||||
+ self.entries
|
||||
+ .iter()
|
||||
+ .find_map(|e| e.run.as_ref()?.store.data().time_override)
|
||||
+ }
|
||||
/// The columns each running mod asked the ground height of, taken.
|
||||
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
|
||||
self.entries
|
||||
@@ -2237,6 +2246,9 @@ impl render::Host for State {
|
||||
};
|
||||
self.lighting = Some(lighting);
|
||||
}
|
||||
+ fn set_time_override(&mut self, day_time: Option<u64>) {
|
||||
+ self.time_override = day_time.map(|t| t.min(1 << 40));
|
||||
+ }
|
||||
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
|
||||
let room = 512usize.saturating_sub(self.ground_queries.len());
|
||||
self.ground_queries.extend(columns.into_iter().take(room));
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index 2afb5bf62..b58f863c2 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -646,6 +646,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
|
||||
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
|
||||
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
|
||||
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
|
||||
+| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
|
||||
|
||||
## `events`
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:22:19 +0300
|
||||
Subject: [PATCH 27/31] HD clouds: noise scales that divide the camera's
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd/cloud_common.wgsl | 12 +++++++-----
|
||||
1 file changed, 7 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
index 393ea564b..e2352f008 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
@@ -21,6 +21,8 @@ fn dampen(x: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Where a camera-relative point lies in the world's cloud pattern (the wind moves the pattern).
|
||||
+// The camera's x, z come wrapped to 65536 blocks: every noise scale below must divide that (powers
|
||||
+// of two), or the pattern jumps where the wrap happens (at x or z = 0).
|
||||
fn cloud_xz(pos: vec3<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(pos.x + frame.cloud_cam.x, pos.z + frame.cloud_cam.y)
|
||||
+ vec2<f32>(0.8, 0.6) * (CLOUD_WIND * frame.clouds.w);
|
||||
@@ -33,8 +35,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
let h = (pos.y - frame.clouds.x) / frame.clouds.y;
|
||||
if h <= 0.0 || h >= 1.0 { return 0.0; }
|
||||
let w = cloud_xz(pos);
|
||||
- let cover = textureSampleLevel(noise2, repeat_s, w / 16000.0, 0.0).r;
|
||||
- let shape = textureSampleLevel(noise2, repeat_s, w / 3000.0, 0.0).g;
|
||||
+ let cover = textureSampleLevel(noise2, repeat_s, w / 16384.0, 0.0).r;
|
||||
+ let shape = textureSampleLevel(noise2, repeat_s, w / 4096.0, 0.0).g;
|
||||
let cu = mix(0.56, 0.80, cover) + frame.cloud_cam.z;
|
||||
var d = linear_step(1.0 - cu, 1.0, shape);
|
||||
// Carve an egg shape, thin out the bottom.
|
||||
@@ -43,9 +45,9 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
if d < 0.002 { return 0.0; }
|
||||
if detail > 0.0 {
|
||||
let p3 = vec3<f32>(w.x, pos.y, w.y);
|
||||
- let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1100.0, 0.0).b - 0.5) * 0.4;
|
||||
- let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 420.0 + vec3<f32>(warp), 0.0).r;
|
||||
- let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 90.0 + vec3<f32>(warp * 3.0), 0.0).g;
|
||||
+ let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
|
||||
+ let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
|
||||
+ let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
|
||||
let fade = 0.20 * smoothstep(0.85, 1.0, 1.0 - h) - 0.35 * smoothstep(0.05, 0.5, h) + 0.6;
|
||||
d -= 0.55 * w0 * w0 * dampen(1.0 - d);
|
||||
d -= 0.45 * w1 * w1 * dampen(1.0 - d) * fade;
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,603 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/31] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
only, gameplay sky darkening uses the world's time (#70)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 37 +++--
|
||||
crates/horizon-client/src/game/render_tick.rs | 2 +-
|
||||
crates/horizon-client/src/gfx/hd/cloud.wgsl | 54 ++++++-
|
||||
.../src/gfx/hd/cloud_common.wgsl | 3 +-
|
||||
crates/horizon-client/src/gfx/hd/fog.wgsl | 6 +-
|
||||
crates/horizon-client/src/gfx/hd/light.wgsl | 42 +++++-
|
||||
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
|
||||
crates/horizon-client/src/gfx/hd/volumes.rs | 135 +++++++++++++++---
|
||||
8 files changed, 239 insertions(+), 44 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 2130d8d88..f21b3dcdd 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -1838,6 +1838,8 @@ struct Game<'a> {
|
||||
events: events::Events,
|
||||
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
|
||||
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
+ /// The same for the time a mod shows (a replay's): render only.
|
||||
+ shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
persist: persist::EntityMemory,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
remote: Option<remote::Remote>,
|
||||
@@ -8020,7 +8022,7 @@ impl Game<'_> {
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
- let darken = self.sky_darken();
|
||||
+ let darken = self.shown_sky_darken();
|
||||
let a = alpha;
|
||||
let lerp = |p: Vec3, q: Vec3| {
|
||||
glam::DVec3::new(
|
||||
@@ -8378,33 +8380,43 @@ impl Game<'_> {
|
||||
.map_or(self.world.day_time, |t| t as i64)
|
||||
}
|
||||
|
||||
+ /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always
|
||||
+ /// the world's real time, never a mod's render-only override.
|
||||
fn sky_darken(&self) -> i32 {
|
||||
+ self.sky_darken_at(self.world.day_time, &self.sky_darken_memo)
|
||||
+ }
|
||||
+
|
||||
+ /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's
|
||||
+ /// time override, so a replay's night looks dark without touching the rules.
|
||||
+ fn shown_sky_darken(&self) -> i32 {
|
||||
+ self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo)
|
||||
+ }
|
||||
+
|
||||
+ fn sky_darken_at(
|
||||
+ &self,
|
||||
+ time: i64,
|
||||
+ memo: &std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
+ ) -> i32 {
|
||||
if self.dim == Dim::Nether {
|
||||
return 11;
|
||||
}
|
||||
if self.dim == Dim::End {
|
||||
- let sky = self
|
||||
- .res
|
||||
- .end_environment()
|
||||
- .sample(self.shown_day_time(), None, None);
|
||||
+ let sky = self.res.end_environment().sample(time, None, None);
|
||||
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
}
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Sampled once per (time, weather): every mob asks each tick.
|
||||
- let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
|
||||
- if let Some((k, darken)) = self.sky_darken_memo.get()
|
||||
+ let key = (time, rain.to_bits(), thunder.to_bits());
|
||||
+ if let Some((k, darken)) = memo.get()
|
||||
&& k == key
|
||||
{
|
||||
return darken;
|
||||
}
|
||||
- let mut sky = self
|
||||
- .res
|
||||
- .environment()
|
||||
- .sample(self.shown_day_time(), None, None);
|
||||
+ let mut sky = self.res.environment().sample(time, None, None);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
- self.sky_darken_memo.set(Some((key, darken)));
|
||||
+ memo.set(Some((key, darken)));
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
@@ -13954,6 +13966,7 @@ impl<'a> Game<'a> {
|
||||
mods: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
sky_darken_memo: Default::default(),
|
||||
+ shown_darken_memo: Default::default(),
|
||||
persist: Default::default(),
|
||||
remote: None,
|
||||
lan: None,
|
||||
diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs
|
||||
index 6807d7718..9912970a5 100644
|
||||
--- a/crates/horizon-client/src/game/render_tick.rs
|
||||
+++ b/crates/horizon-client/src/game/render_tick.rs
|
||||
@@ -241,7 +241,7 @@ impl Game<'_> {
|
||||
eye[2].floor() as i32,
|
||||
];
|
||||
let packed = self.world.light(b[0], b[1], b[2]);
|
||||
- let sky = i32::from(packed >> 4) - self.sky_darken();
|
||||
+ let sky = i32::from(packed >> 4) - self.shown_sky_darken();
|
||||
let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8;
|
||||
lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw)
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
index fad234a46..39a239f4d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
@@ -1,6 +1,17 @@
|
||||
|
||||
@group(0) @binding(6) var history: texture_2d<f32>;
|
||||
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
|
||||
+@group(0) @binding(8) var scene_depth: texture_depth_2d;
|
||||
+@group(0) @binding(9) var history_dist: texture_2d<f32>;
|
||||
+
|
||||
+// What the sky pixels report as the length of their ray (nothing stops them).
|
||||
+const SKY_LEN: f32 = 20000.0;
|
||||
+
|
||||
+struct CloudOut {
|
||||
+ @location(0) cloud: vec4<f32>,
|
||||
+ // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
|
||||
+ @location(1) dist: vec4<f32>,
|
||||
+}
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -65,7 +76,26 @@ fn cloud_scatter(
|
||||
}
|
||||
|
||||
@fragment
|
||||
-fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+fn fs_cloud(in: FullOut) -> CloudOut {
|
||||
+ // The nearest of the four world pixels this texel stands for: the march ends at the surface
|
||||
+ // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front
|
||||
+ // and what stands behind it is hidden by it.
|
||||
+ let size = vec2<i32>(frame.resolution.xy);
|
||||
+ let base = vec2<i32>(in.position.xy) * 2;
|
||||
+ var zmax = 0.0;
|
||||
+ var best = base;
|
||||
+ for (var k = 0; k < 4; k++) {
|
||||
+ let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
|
||||
+ let z = textureLoad(scene_depth, q, 0);
|
||||
+ if z > zmax {
|
||||
+ zmax = z;
|
||||
+ best = q;
|
||||
+ }
|
||||
+ }
|
||||
+ let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
|
||||
+ let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
|
||||
+ let sh = frame.inverse_view_proj * sndc;
|
||||
+ let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
|
||||
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
|
||||
let hh = frame.inverse_view_proj * ndc;
|
||||
let dir = normalize(hh.xyz / hh.w);
|
||||
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
var hit_t = 900.0;
|
||||
let y0 = frame.clouds.x;
|
||||
let y1 = y0 + frame.clouds.y;
|
||||
- if abs(dir.y) > 0.004 {
|
||||
- let ta = y0 / dir.y;
|
||||
- let tb = y1 / dir.y;
|
||||
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
|
||||
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
|
||||
+ {
|
||||
+ let ta = y0 / dy;
|
||||
+ let tb = y1 / dy;
|
||||
let lo = max(min(ta, tb), 0.0);
|
||||
- let hi = min(max(ta, tb), 4200.0);
|
||||
+ let hi = min(max(ta, tb), min(4200.0, scene_len));
|
||||
if hi > lo {
|
||||
let dither = ign(in.position.xy);
|
||||
let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26);
|
||||
@@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
cur = vec4<f32>(colour, trans);
|
||||
}
|
||||
}
|
||||
+ var out: CloudOut;
|
||||
+ out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
|
||||
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
|
||||
if frame.cloud_cam.w > 0.5 {
|
||||
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
|
||||
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
|
||||
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(1.0)) {
|
||||
let old = textureSampleLevel(history, linear, huv, 0.0);
|
||||
+ // A history that was marched to another surface (an edge, something that moved in
|
||||
+ // front) does not describe this ray.
|
||||
+ let hs = vec2<f32>(textureDimensions(history_dist));
|
||||
+ let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
|
||||
+ let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
|
||||
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
|
||||
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
|
||||
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff));
|
||||
+ let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
|
||||
cur = mix(cur, old, keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
- return cur;
|
||||
+ out.cloud = cur;
|
||||
+ return out;
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
index e2352f008..8ee014420 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
d *= smoothstep(0.0, 0.2, h);
|
||||
if d < 0.002 { return 0.0; }
|
||||
if detail > 0.0 {
|
||||
- let p3 = vec3<f32>(w.x, pos.y, w.y);
|
||||
+ // Height above the layer's base, not the camera's height: the noise stays put in the world.
|
||||
+ let p3 = vec3<f32>(w.x, pos.y - frame.clouds.x, w.y);
|
||||
let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
|
||||
let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
|
||||
let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
index 38b9098e3..d035bbf0d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
|
||||
}
|
||||
|
||||
// The air's extinction per block at a camera-relative point: a haze that thins with height above
|
||||
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; none
|
||||
+// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
|
||||
// deep underground.
|
||||
fn fog_sigma(p: vec3<f32>) -> f32 {
|
||||
let wy = p.y + frame.vfog.y;
|
||||
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
|
||||
s += frame.sky_extra.z * 0.003;
|
||||
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
|
||||
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
|
||||
- let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b;
|
||||
+ let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
|
||||
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
|
||||
// Fade out far below the sea's height: caves keep their own dark air.
|
||||
return s * patchy * smoothstep(28.0, 58.0, wy);
|
||||
@@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
|
||||
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
|
||||
- vec3<f32>(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z;
|
||||
let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r;
|
||||
- let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g;
|
||||
+ let n2 = textureSampleLevel(noise3, repeat_s, w / 32.0, 0.0).g;
|
||||
let n = 0.55 * n1 + 0.45 * n2;
|
||||
// Thin smoke breaks up into wisps, thick smoke stays whole.
|
||||
let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
index 2534a3db8..870ae0d7d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
@@ -30,6 +30,9 @@ struct Light {
|
||||
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
|
||||
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
|
||||
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
|
||||
+// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
|
||||
+@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
|
||||
+const SKY_RAY: f32 = 20000.0;
|
||||
const CLOUD_SHADOW_R: f32 = 1200.0;
|
||||
// The pixel being lit, for the shadow filter's noise.
|
||||
var<private> frag_px: vec2<f32>;
|
||||
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
return sum / total;
|
||||
}
|
||||
|
||||
+// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four
|
||||
+// nearest half-resolution texels weighted by closeness and by how near the length they were
|
||||
+// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it.
|
||||
+fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(cloud_tex));
|
||||
+ let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
|
||||
+ let base = vec2<i32>(floor(f));
|
||||
+ let t = f - floor(f);
|
||||
+ var sum = vec4<f32>(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(cloud_tex, q, 0);
|
||||
+ let sd = textureLoad(cloud_dist_tex, q, 0).x;
|
||||
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
|
||||
+ let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4);
|
||||
+ let w = bw / (1.0 + dz * dz * dz * dz);
|
||||
+ sum += s * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ if total < 0.0001 {
|
||||
+ return textureLoad(cloud_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0);
|
||||
+ }
|
||||
+ return sum / total;
|
||||
+}
|
||||
+
|
||||
struct LightOut {
|
||||
// The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
|
||||
// object in front of the water (the scene as drawn).
|
||||
@@ -246,7 +277,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||||
let flat_up = smoothstep(0.9, 0.98, n.y);
|
||||
let soak = wet * open_sky * (0.35 + 0.65 * flat_up);
|
||||
let wp = frame.cloud_cam.xy + p.xz;
|
||||
- let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2<f32>(17.0, 5.0));
|
||||
+ let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(17.0, 5.0));
|
||||
puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up;
|
||||
surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle);
|
||||
rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal));
|
||||
@@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut {
|
||||
c = sky + kept;
|
||||
// The volumetric clouds over the sky and the sun.
|
||||
if frame.clouds.z > 0.5 {
|
||||
- let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0);
|
||||
+ let cl = cloud_at(px, SKY_RAY);
|
||||
c = c * cl.a + cl.rgb;
|
||||
}
|
||||
}
|
||||
@@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut {
|
||||
air_t = fg.a;
|
||||
air_s = fg.rgb;
|
||||
}
|
||||
+ // The clouds between the camera and the surface (inside the layer, or in front of a mountain
|
||||
+ // or a flying thing): laid over the surface and under the fog, as the sky's are.
|
||||
+ if d > 0.0 && atmosphere && frame.clouds.z > 0.5 {
|
||||
+ let cl = cloud_at(px, length(ps));
|
||||
+ air_s += air_t * cl.rgb;
|
||||
+ air_t *= cl.a;
|
||||
+ }
|
||||
if !volumetric && sun_on > 0.0 && density > 0.0 {
|
||||
let steps = 14;
|
||||
let jitter = ign(in.position.xy);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
index da56d00ba..d26f1e513 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
|
||||
|
||||
pub(super) const LIGHT: &str = include_str!("light.wgsl");
|
||||
/// The lighting's group 0, binding by binding (see the contract above).
|
||||
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
|
||||
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
|
||||
UNIFORM,
|
||||
UNFILTERED,
|
||||
DEPTH,
|
||||
@@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
|
||||
UNFILTERED,
|
||||
FLOAT,
|
||||
FLOAT,
|
||||
+ UNFILTERED,
|
||||
];
|
||||
|
||||
/// The outside views and sampler the lighting group reads.
|
||||
@@ -219,6 +220,7 @@ impl Hd {
|
||||
wgpu::BindingResource::TextureView(&self.volumes.fog_dist),
|
||||
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
|
||||
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
|
||||
+ wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
|
||||
];
|
||||
let entries: Vec<_> = resources
|
||||
.into_iter()
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
index a338d756c..d3b55e2f2 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
use super::noise as hd_noise;
|
||||
use super::{
|
||||
- DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module,
|
||||
- stamp, stamped_pass, target,
|
||||
+ DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
|
||||
+ fullscreen, module, stamp, stamped_pass, target,
|
||||
};
|
||||
|
||||
pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
@@ -86,6 +86,9 @@ pub struct HdVolumes {
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
/// The clouds' half-resolution target and its history (read one, write the other).
|
||||
pub clouds: [wgpu::TextureView; 2],
|
||||
+ /// How far each cloud texel's ray was marched (ping-pong with `clouds`).
|
||||
+ cloud_dist: [wgpu::TextureView; 2],
|
||||
+ cloud_depth: Option<wgpu::TextureView>,
|
||||
flip: usize,
|
||||
history_ready: bool,
|
||||
pub cloud_shadow: wgpu::TextureView,
|
||||
@@ -111,9 +114,10 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture {
|
||||
};
|
||||
|
||||
/// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky
|
||||
-/// texture, the previous cloud frame.
|
||||
-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] =
|
||||
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
|
||||
+/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths.
|
||||
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [
|
||||
+ UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED,
|
||||
+];
|
||||
/// `fs_cloud_shadow`'s group 0: as the clouds' without the history.
|
||||
pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
|
||||
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
|
||||
@@ -218,15 +222,40 @@ impl HdVolumes {
|
||||
let cloud_module = module(device, "HD clouds", &cloud_src);
|
||||
let shadow_module = module(device, "HD cloud shadows", &shadow_src);
|
||||
let fog_module = module(device, "HD fog", &fog_body());
|
||||
- let cloud_pipeline = fullscreen(
|
||||
- device,
|
||||
- "HD clouds",
|
||||
- &cloud_layout,
|
||||
- &cloud_module,
|
||||
- "fs_cloud",
|
||||
- CLOUD_FORMAT,
|
||||
- None,
|
||||
- );
|
||||
+ let cloud_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD clouds"),
|
||||
+ bind_group_layouts: &[Some(&cloud_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD clouds"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &cloud_module,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &cloud_module,
|
||||
+ entry_point: Some("fs_cloud"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| {
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ })
|
||||
+ }),
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let shadow_pipeline = fullscreen(
|
||||
device,
|
||||
"HD cloud shadows",
|
||||
@@ -286,6 +315,11 @@ impl HdVolumes {
|
||||
one("HD clouds", [1, 1], CLOUD_FORMAT),
|
||||
one("HD clouds", [1, 1], CLOUD_FORMAT),
|
||||
],
|
||||
+ cloud_dist: [
|
||||
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
||||
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
||||
+ ],
|
||||
+ cloud_depth: None,
|
||||
flip: 0,
|
||||
history_ready: false,
|
||||
cloud_shadow: one(
|
||||
@@ -316,6 +350,10 @@ impl HdVolumes {
|
||||
one("HD clouds", CLOUD_FORMAT),
|
||||
one("HD clouds", CLOUD_FORMAT),
|
||||
];
|
||||
+ self.cloud_dist = [
|
||||
+ one("HD cloud distance", FOG_DIST_FORMAT),
|
||||
+ one("HD cloud distance", FOG_DIST_FORMAT),
|
||||
+ ];
|
||||
self.fog = one("HD fog", FOG_FORMAT);
|
||||
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
|
||||
self.history_ready = false;
|
||||
@@ -342,6 +380,11 @@ impl HdVolumes {
|
||||
&self.clouds[self.flip]
|
||||
}
|
||||
|
||||
+ /// The march lengths that go with `cloud_view`.
|
||||
+ pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
|
||||
+ &self.cloud_dist[self.flip]
|
||||
+ }
|
||||
+
|
||||
fn group(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -372,7 +415,12 @@ impl HdVolumes {
|
||||
frame: &wgpu::Buffer,
|
||||
sky_lut: &wgpu::TextureView,
|
||||
atlas: &wgpu::TextureView,
|
||||
+ depth: &wgpu::TextureView,
|
||||
) {
|
||||
+ if self.cloud_depth.as_ref() != Some(depth) {
|
||||
+ self.cloud_groups = None;
|
||||
+ self.cloud_depth = Some(depth.clone());
|
||||
+ }
|
||||
if self.shadow_group.is_none() {
|
||||
let layout = self.shadow_layout.clone();
|
||||
let (n3, n2, clamp, repeat) = (
|
||||
@@ -405,6 +453,7 @@ impl HdVolumes {
|
||||
self.repeat.clone(),
|
||||
);
|
||||
let hist = self.clouds.clone();
|
||||
+ let hist_dist = self.cloud_dist.clone();
|
||||
let groups = [0, 1].map(|read| {
|
||||
self.group(
|
||||
device,
|
||||
@@ -419,6 +468,8 @@ impl HdVolumes {
|
||||
wgpu::BindingResource::TextureView(sky_lut),
|
||||
wgpu::BindingResource::TextureView(&hist[read]),
|
||||
wgpu::BindingResource::TextureView(atlas),
|
||||
+ wgpu::BindingResource::TextureView(depth),
|
||||
+ wgpu::BindingResource::TextureView(&hist_dist[read]),
|
||||
],
|
||||
)
|
||||
});
|
||||
@@ -455,7 +506,7 @@ impl HdVolumes {
|
||||
stamped_pass(
|
||||
encoder,
|
||||
"HD clouds",
|
||||
- &[&self.clouds[write]],
|
||||
+ &[&self.clouds[write], &self.cloud_dist[write]],
|
||||
&self.cloud_pipeline,
|
||||
&groups[read],
|
||||
stamp,
|
||||
@@ -525,8 +576,13 @@ impl Hd {
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
if self.clouds_active {
|
||||
- self.volumes
|
||||
- .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
|
||||
+ self.volumes.prepare_clouds(
|
||||
+ device,
|
||||
+ &self.frame,
|
||||
+ &self.sky.lut,
|
||||
+ &self.smoke.atlas,
|
||||
+ depth,
|
||||
+ );
|
||||
self.volumes
|
||||
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
|
||||
self.volumes
|
||||
@@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
|
||||
("fog", fog_body(), &FOG_BINDINGS),
|
||||
]
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to
|
||||
+ /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0).
|
||||
+ #[test]
|
||||
+ fn noise_scales_divide_the_camera_wrap() {
|
||||
+ let mut found = 0;
|
||||
+ for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
|
||||
+ for key in ["w / ", "p3 / "] {
|
||||
+ for part in src.split(key).skip(1) {
|
||||
+ let num: String = part
|
||||
+ .chars()
|
||||
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||
+ .collect();
|
||||
+ let Ok(scale) = num.parse::<f64>() else {
|
||||
+ continue;
|
||||
+ };
|
||||
+ found += 1;
|
||||
+ assert!(
|
||||
+ scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0,
|
||||
+ "{name}: the scale {scale} does not divide 65536"
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ assert!(found >= 8, "found only {found} noise lookups");
|
||||
+ }
|
||||
+
|
||||
+ /// The cloud noise's height is measured from the layer's base, not from the camera: the same
|
||||
+ /// world point gives the same noise coordinate at any camera height.
|
||||
+ #[test]
|
||||
+ fn cloud_noise_height_is_anchored_to_the_layer() {
|
||||
+ assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x"));
|
||||
+ // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera.
|
||||
+ for cam in [64.0f32, 210.0, 1000.0] {
|
||||
+ let world_y = 230.0f32;
|
||||
+ let rel = world_y - cam;
|
||||
+ let base_rel = CLOUD_BASE - cam;
|
||||
+ assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/31] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd/smoke.rs | 12 ++++++++++++
|
||||
crates/horizon-client/src/gfx/hd/smoke_grid.wgsl | 2 +-
|
||||
2 files changed, 13 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
index dee0932e8..c751df4f4 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
@@ -46,6 +46,8 @@ const MAX_INDICES: usize = 120_000;
|
||||
pub const MAX_LIGHTS: usize = 16;
|
||||
/// How fast the wind carries smoke (blocks per second, x and z).
|
||||
const WIND: [f32; 2] = [1.6, 1.2];
|
||||
+/// The thinnest a trail is drawn, in blocks of radius (a cell is `CELL`).
|
||||
+const MIN_TUBE: f32 = 3.6;
|
||||
|
||||
/// Smoke is on unless `HORIZON_HD_SMOKE=0`.
|
||||
pub fn smoke_enabled() -> bool {
|
||||
@@ -402,6 +404,16 @@ fn bake(s: &HdSmoke) -> Option<Baked> {
|
||||
// Spreading thins it (the core's mass over a wider tube), and it fades out towards its end.
|
||||
let thin = (r0 / radius).powf(0.5);
|
||||
let density = s.shape[2] * (1.0 - u).powi(2) * thin * (t / 0.25).min(1.0).max(0.2);
|
||||
+ // A tube thinner than the grid's cells is sampled unevenly (cells near its axis get it, their
|
||||
+ // neighbours miss it): a chain of blobs. Trails are kept at least a cell and a bit wide, with
|
||||
+ // the same mass per length, so a young trail is a continuous ribbon.
|
||||
+ let d = [s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]];
|
||||
+ let tube = d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25;
|
||||
+ let (radius, density) = if tube && radius < MIN_TUBE {
|
||||
+ (MIN_TUBE, density * radius / MIN_TUBE)
|
||||
+ } else {
|
||||
+ (radius, density)
|
||||
+ };
|
||||
let heat = s.shape[3] * (-t / 1.5).exp();
|
||||
let clear_amount = (1.0 - u).powf(0.8);
|
||||
let clear = s.motion[1] * (1.0 + 0.4 * u);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
index 01b6e082a..0adcd55f6 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
@@ -40,7 +40,7 @@ fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let seed = segs[i + 3u].x;
|
||||
let len = sqrt(len2);
|
||||
// Bulges along the tube, so it is not a pipe.
|
||||
- let r = pa.w * (1.0 + 0.22 * sin(along * len * 0.6 + seed * 40.0));
|
||||
+ let r = pa.w * (1.0 + 0.07 * sin(along * len * 0.35 + seed * 40.0));
|
||||
if d < r && pb.w > 0.0 {
|
||||
let q = d / r;
|
||||
var w = (1.0 - q * q);
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:59:41 +0300
|
||||
Subject: [PATCH 31/31] Mods: render.set-main-view (API level 15) renders the
|
||||
screen's world from a mod camera with the full pipeline, so replays get HD
|
||||
and the post effects; small mod views get the post pass with their own camera
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 31 +++-
|
||||
crates/horizon-client/src/game/mod_models.rs | 9 ++
|
||||
crates/horizon-client/src/gfx.rs | 39 ++++-
|
||||
crates/horizon-client/src/gfx/mod_post.rs | 152 +++++++++++++++----
|
||||
crates/horizon-client/src/gfx/mod_views.rs | 69 ++++++---
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 10 ++
|
||||
crates/horizon-mods/src/api_level.rs | 6 +-
|
||||
crates/horizon-mods/src/runtime.rs | 12 ++
|
||||
docs/api/mods-reference.md | 1 +
|
||||
9 files changed, 269 insertions(+), 60 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index f21b3dcdd..0e779aaa0 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -5950,7 +5950,7 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
- let show_hud = !self.ui.hide_gui;
|
||||
+ let show_hud = !self.ui.hide_gui && self.mod_main_view(self.clock.fraction()).is_none();
|
||||
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
|
||||
// the action bar sit above it.
|
||||
let mut bar_height = 39;
|
||||
@@ -11572,6 +11572,13 @@ impl Game<'_> {
|
||||
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
|
||||
self.audio
|
||||
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
|
||||
+ // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
|
||||
+ // player's, everything seen is drawn from the camera with the full pipeline.
|
||||
+ let main_view = self.mod_main_view(alpha);
|
||||
+ let (camera, underwater, view_yaw, view_pitch) = match &main_view {
|
||||
+ Some(v) => (v.camera, false, v.yaw, v.pitch),
|
||||
+ None => (camera, underwater, view_yaw, view_pitch),
|
||||
+ };
|
||||
let block = [
|
||||
camera.x.floor() as i32,
|
||||
camera.y.floor() as i32,
|
||||
@@ -11579,6 +11586,7 @@ impl Game<'_> {
|
||||
];
|
||||
// The camera inside an opaque block sees nothing through it: disable.
|
||||
let cave_culling = self.settings.horizon.cave_culling
|
||||
+ && main_view.is_none()
|
||||
&& !self
|
||||
.res
|
||||
.facts()
|
||||
@@ -11670,7 +11678,11 @@ impl Game<'_> {
|
||||
// player who may build. Adventure players only get one with a CAN_BREAK or
|
||||
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
|
||||
// components nor opens menus for spectators, so those never show it.
|
||||
- let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
|
||||
+ let outline = if self.screen == Screen::Playing
|
||||
+ && !self.ui.hide_gui
|
||||
+ && main_view.is_none()
|
||||
+ && self.may_build()
|
||||
+ {
|
||||
self.pick()
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
@@ -11733,17 +11745,19 @@ impl Game<'_> {
|
||||
};
|
||||
let fov_degrees = if title {
|
||||
85.0
|
||||
+ } else if let Some(v) = &main_view {
|
||||
+ v.fov
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
- let bob = if title {
|
||||
+ let bob = if title || main_view.is_some() {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.bob_matrix(camera_alpha as f32)
|
||||
};
|
||||
- let spin = if title {
|
||||
+ let spin = if title || main_view.is_some() {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.spinning_matrix(camera_alpha as f32)
|
||||
@@ -11791,7 +11805,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
- let hand = if title || self.scoping() {
|
||||
+ let hand = if title || self.scoping() || main_view.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.hand_geometry(camera_alpha)
|
||||
@@ -11829,7 +11843,12 @@ impl Game<'_> {
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
- let mod_views = self.mod_views(alpha);
|
||||
+ // The main view is the frame itself, not a picture of its own.
|
||||
+ let mod_views: Vec<_> = self
|
||||
+ .mod_views(alpha)
|
||||
+ .into_iter()
|
||||
+ .filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
|
||||
+ .collect();
|
||||
let hd_lights = self.mod_lights(camera, alpha);
|
||||
let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
|
||||
let frame = Frame {
|
||||
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
||||
index f5e68566a..7a4d742df 100644
|
||||
--- a/crates/horizon-client/src/game/mod_models.rs
|
||||
+++ b/crates/horizon-client/src/game/mod_models.rs
|
||||
@@ -71,6 +71,9 @@ impl Game<'_> {
|
||||
pub(super) fn mod_views(&self, _alpha: f64) -> Vec<horizon_client::gfx::ModView> {
|
||||
Vec::new()
|
||||
}
|
||||
+ pub(super) fn mod_main_view(&self, _alpha: f64) -> Option<horizon_client::gfx::ModView> {
|
||||
+ None
|
||||
+ }
|
||||
pub(super) fn mod_hd(&self) -> Option<horizon_client::gfx::HdSettings> {
|
||||
None
|
||||
}
|
||||
@@ -300,6 +303,12 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
+ /// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame.
|
||||
+ pub(super) fn mod_main_view(&self, alpha: f64) -> Option<horizon_client::gfx::ModView> {
|
||||
+ let id = self.mods.mods.main_view()?;
|
||||
+ self.mod_views(alpha).into_iter().find(|v| v.id == id)
|
||||
+ }
|
||||
+
|
||||
/// Draws every mod model between its last two poses, lit by the world
|
||||
/// (or at full brightness), its faces shaded by its own rotation.
|
||||
pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index 93a2f7c63..1a4e68b88 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3363,7 +3363,8 @@ impl Renderer {
|
||||
view: &t.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
- store: wgpu::StoreOp::Discard,
|
||||
+ // The mod's post pass reads it.
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -3411,6 +3412,33 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
+ drop(pass);
|
||||
+ // The mod's post pass with this camera: its blasts, flashes and glows in the picture,
|
||||
+ // as much as the picture's size is worth. The picture's own camera and world are
|
||||
+ // lit by the pass itself (no HD lighting here: it is the world's, not a picture's).
|
||||
+ t.post = false;
|
||||
+ if let Some(post) = &self.mod_post
|
||||
+ && let Some((blasts, lights)) = mod_views::post_budget(t.size)
|
||||
+ {
|
||||
+ let mut params = self.mod_params.0.clone();
|
||||
+ if params.len() >= 4 {
|
||||
+ // Mode 0: the pass lights the land and draws the smoke itself.
|
||||
+ params[3] = 0.0;
|
||||
+ params[1] = params[1].min(blasts as f32);
|
||||
+ params[2] = params[2].min(lights as f32);
|
||||
+ }
|
||||
+ post.write_to(
|
||||
+ &self.queue,
|
||||
+ &t.post_buffer,
|
||||
+ (d.view, d.projection, d.camera),
|
||||
+ frame.sky.sun_angle,
|
||||
+ t.size,
|
||||
+ ¶ms,
|
||||
+ self.mod_params.1,
|
||||
+ );
|
||||
+ post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx);
|
||||
+ t.post = true;
|
||||
+ }
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size);
|
||||
}
|
||||
@@ -3703,7 +3731,12 @@ impl Renderer {
|
||||
} else {
|
||||
self.config.format.remove_srgb_suffix()
|
||||
};
|
||||
- self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?);
|
||||
+ self.mod_post = Some(mod_post::ModPost::new(
|
||||
+ &self.device,
|
||||
+ source,
|
||||
+ format,
|
||||
+ self.config.format.remove_srgb_suffix(),
|
||||
+ )?);
|
||||
self.mod_post_source = Some(source.to_owned());
|
||||
}
|
||||
}
|
||||
@@ -4424,6 +4457,8 @@ impl Renderer {
|
||||
id: v.id,
|
||||
view_proj,
|
||||
view,
|
||||
+ projection: world_projection(v.fov, aspect, frame.depth_far),
|
||||
+ camera: v.camera,
|
||||
aspect,
|
||||
fov: v.fov,
|
||||
visible: seen,
|
||||
diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs
|
||||
index d1f54a4bb..3d67ff25b 100644
|
||||
--- a/crates/horizon-client/src/gfx/mod_post.rs
|
||||
+++ b/crates/horizon-client/src/gfx/mod_post.rs
|
||||
@@ -106,6 +106,8 @@ fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4<f32> {
|
||||
|
||||
pub struct ModPost {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
+ /// The same pass onto the mod cameras' pictures (the surface's format).
|
||||
+ view_pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
buffer: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -144,6 +146,7 @@ impl ModPost {
|
||||
device: &wgpu::Device,
|
||||
source: &str,
|
||||
format: wgpu::TextureFormat,
|
||||
+ view_format: wgpu::TextureFormat,
|
||||
) -> Result<Self, String> {
|
||||
let module = compile(source)?;
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
@@ -189,31 +192,35 @@ impl ModPost {
|
||||
label: Some("Mod post"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
- let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
- label: Some("Mod post"),
|
||||
- layout: Some(&pipeline_layout),
|
||||
- vertex: wgpu::VertexState {
|
||||
- module: &shader,
|
||||
- entry_point: Some("horizon_mod_post_vs"),
|
||||
- compilation_options: Default::default(),
|
||||
- buffers: &[],
|
||||
- },
|
||||
- primitive: Default::default(),
|
||||
- depth_stencil: None,
|
||||
- multisample: Default::default(),
|
||||
- fragment: Some(wgpu::FragmentState {
|
||||
- module: &shader,
|
||||
- entry_point: Some("horizon_mod_post_fs"),
|
||||
- compilation_options: Default::default(),
|
||||
- targets: &[Some(wgpu::ColorTargetState {
|
||||
- format,
|
||||
- blend: None,
|
||||
- write_mask: wgpu::ColorWrites::ALL,
|
||||
- })],
|
||||
- }),
|
||||
- multiview_mask: None,
|
||||
- cache: None,
|
||||
- });
|
||||
+ let make = |format: wgpu::TextureFormat| {
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("Mod post"),
|
||||
+ layout: Some(&pipeline_layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some("horizon_mod_post_vs"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some("horizon_mod_post_fs"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[Some(wgpu::ColorTargetState {
|
||||
+ format,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ })],
|
||||
+ }),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
+ let pipeline = make(format);
|
||||
+ let view_pipeline = make(view_format);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
@@ -231,6 +238,7 @@ impl ModPost {
|
||||
});
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
+ view_pipeline,
|
||||
layout,
|
||||
buffer,
|
||||
sampler,
|
||||
@@ -253,7 +261,30 @@ impl ModPost {
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
* frame.bob
|
||||
* frame.spin;
|
||||
- let a = frame.sky.sun_angle.to_radians();
|
||||
+ self.write_to(
|
||||
+ queue,
|
||||
+ &self.buffer,
|
||||
+ (view, projection, frame.camera),
|
||||
+ frame.sky.sun_angle,
|
||||
+ size,
|
||||
+ params,
|
||||
+ partial,
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// The same for another camera (a mod view's picture) into its own `buffer`.
|
||||
+ #[allow(clippy::too_many_arguments)]
|
||||
+ pub fn write_to(
|
||||
+ &self,
|
||||
+ queue: &wgpu::Queue,
|
||||
+ buffer: &wgpu::Buffer,
|
||||
+ (view, projection, camera): (Mat4, Mat4, glam::DVec3),
|
||||
+ sun_angle: f32,
|
||||
+ size: [u32; 2],
|
||||
+ params: &[f32],
|
||||
+ partial: f32,
|
||||
+ ) {
|
||||
+ let a = sun_angle.to_radians();
|
||||
let sun = [-a.sin(), a.cos(), 0.0, 0.0];
|
||||
let mut p = [[0.0f32; 4]; PARAMS / 4];
|
||||
for (i, v) in params.iter().take(PARAMS).enumerate() {
|
||||
@@ -264,12 +295,7 @@ impl ModPost {
|
||||
view_proj: m(projection * view),
|
||||
view_inverse: m(view.inverse()),
|
||||
projection_inverse: m(projection.inverse()),
|
||||
- camera: [
|
||||
- frame.camera.x as f32,
|
||||
- frame.camera.y as f32,
|
||||
- frame.camera.z as f32,
|
||||
- 0.0,
|
||||
- ],
|
||||
+ camera: [camera.x as f32, camera.y as f32, camera.z as f32, 0.0],
|
||||
resolution: [
|
||||
size[0] as f32,
|
||||
size[1] as f32,
|
||||
@@ -285,7 +311,67 @@ impl ModPost {
|
||||
sun,
|
||||
params: p,
|
||||
};
|
||||
- queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
+ queue.write_buffer(buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
+ }
|
||||
+
|
||||
+ /// A buffer for one more camera's `write_to`.
|
||||
+ pub fn new_buffer(device: &wgpu::Device) -> wgpu::Buffer {
|
||||
+ device.create_buffer(&wgpu::BufferDescriptor {
|
||||
+ label: Some("Mod post frame (view)"),
|
||||
+ size: size_of::<ModFrame>() as u64,
|
||||
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
+ mapped_at_creation: false,
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ /// Draws the pass over a mod camera's picture (`world`: colour and depth, `buffer`: what
|
||||
+ /// `write_to` filled) onto `target`, in the surface's format.
|
||||
+ pub fn run_view(
|
||||
+ &self,
|
||||
+ device: &wgpu::Device,
|
||||
+ encoder: &mut wgpu::CommandEncoder,
|
||||
+ buffer: &wgpu::Buffer,
|
||||
+ world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
+ target: &wgpu::TextureView,
|
||||
+ ) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("Mod post (view)"),
|
||||
+ layout: &self.layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: buffer.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.1),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("Mod post (view)"),
|
||||
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
+ view: target,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ pass.set_pipeline(&self.view_pipeline);
|
||||
+ pass.set_bind_group(0, &group, &[]);
|
||||
+ pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Draws the pass over the world target `world` (colour, depth) onto `screen`.
|
||||
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
|
||||
index aa2c5b971..bb65480ea 100644
|
||||
--- a/crates/horizon-client/src/gfx/mod_views.rs
|
||||
+++ b/crates/horizon-client/src/gfx/mod_views.rs
|
||||
@@ -26,15 +26,34 @@ pub(super) const PER_FRAME: usize = 3;
|
||||
pub(super) const RADIUS: i32 = 12;
|
||||
|
||||
pub(super) struct Target {
|
||||
- size: [u32; 2],
|
||||
+ pub size: [u32; 2],
|
||||
pub color: wgpu::TextureView,
|
||||
pub depth: wgpu::TextureView,
|
||||
pub globals: wgpu::Buffer,
|
||||
blit: wgpu::BindGroup,
|
||||
+ /// The picture after the mod's post pass (`post`), and what shows it.
|
||||
+ pub fx: wgpu::TextureView,
|
||||
+ blit_fx: wgpu::BindGroup,
|
||||
+ /// The post pass's uniform for this camera.
|
||||
+ pub post_buffer: wgpu::Buffer,
|
||||
+ /// The picture shown went through the post pass this time.
|
||||
+ pub post: bool,
|
||||
/// Drawn at least once: the HUD shows nothing before.
|
||||
pub drawn: bool,
|
||||
}
|
||||
|
||||
+/// How much of the mod's post pass a picture of `size` pays for: (blasts, lights) the pass
|
||||
+/// marches, or None for a picture too small to see them in. Full-screen pictures get all.
|
||||
+pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> {
|
||||
+ let area = size[0] as u64 * size[1] as u64;
|
||||
+ match area {
|
||||
+ 0..4096 => None,
|
||||
+ 4096..150_000 => Some((4, 12)),
|
||||
+ 150_000..600_000 => Some((12, 32)),
|
||||
+ _ => Some((48, 79)),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/// A camera drawn this frame.
|
||||
pub(super) struct Due {
|
||||
pub id: u32,
|
||||
@@ -42,6 +61,9 @@ pub(super) struct Due {
|
||||
/// world's other geometry are.
|
||||
pub view_proj: Mat4,
|
||||
pub view: Mat4,
|
||||
+ /// Its own projection and place, for the mod's post pass.
|
||||
+ pub projection: Mat4,
|
||||
+ pub camera: DVec3,
|
||||
pub aspect: f32,
|
||||
pub fov: f32,
|
||||
pub visible: Vec<super::resident::Visible>,
|
||||
@@ -239,22 +261,31 @@ impl Views {
|
||||
let depth = texture(
|
||||
"Mod view depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
- wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
- let blit = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("Mod view picture"),
|
||||
- layout: &self.layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: wgpu::BindingResource::TextureView(&color),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
+ let fx = texture(
|
||||
+ "Mod view post",
|
||||
+ self.format,
|
||||
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
+ );
|
||||
+ let group = |view: &wgpu::TextureView| {
|
||||
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("Mod view picture"),
|
||||
+ layout: &self.layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: wgpu::BindingResource::TextureView(view),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ })
|
||||
+ };
|
||||
+ let blit = group(&color);
|
||||
+ let blit_fx = group(&fx);
|
||||
let globals = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Mod view globals"),
|
||||
size: globals_size,
|
||||
@@ -267,6 +298,10 @@ impl Views {
|
||||
depth,
|
||||
globals,
|
||||
blit,
|
||||
+ fx,
|
||||
+ blit_fx,
|
||||
+ post_buffer: super::mod_post::ModPost::new_buffer(device),
|
||||
+ post: false,
|
||||
drawn: false,
|
||||
}
|
||||
}
|
||||
@@ -306,7 +341,7 @@ impl Views {
|
||||
pass.set_viewport(x, y, w, h, 0.0, 1.0);
|
||||
pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
- pass.set_bind_group(0, &t.blit, &[]);
|
||||
+ pass.set_bind_group(0, if t.post { &t.blit_fx } else { &t.blit }, &[]);
|
||||
let mode = view.mode.min(2);
|
||||
pass.draw(0..3, mode..mode + 1);
|
||||
}
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index fba8f162d..bae0d2134 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -664,6 +664,16 @@ interface render {
|
||||
/// Replaces the mod's cameras: up to 8. A `view` shape shows one; the
|
||||
/// game renders up to three a frame, in turn.
|
||||
set-views: func(views: list<camera-view>);
|
||||
+ /// Renders the screen's world from one of the `set-views` cameras
|
||||
+ /// (AirDef fork), with everything the player's own view has: the HD
|
||||
+ /// lighting, sky, clouds, fog, smoke, TAA and the post passes, at the
|
||||
+ /// window's resolution. The picture size of that camera is ignored, the
|
||||
+ /// player's hand, block outline and the game's HUD are not drawn (the mod's
|
||||
+ /// own `set-hud` shapes are); a `view` shape of that camera draws nothing.
|
||||
+ /// For a full-screen replay or a director's camera; the small pictures of
|
||||
+ /// monitors stay `view` shapes. None (or an unknown id) is the player's
|
||||
+ /// own view. The chunks stay those round the player.
|
||||
+ set-main-view: func(view: option<u32>);
|
||||
/// A light the mod's things give off (AirDef fork): rocket flames,
|
||||
/// flares, blasts. Drawn by the HD lighting (`set-lighting`).
|
||||
record point-light {
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index f438eadbd..e578de688 100644
|
||||
--- a/crates/horizon-mods/src/api_level.rs
|
||||
+++ b/crates/horizon-mods/src/api_level.rs
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
-pub const LEVEL: u32 = 14;
|
||||
+pub const LEVEL: u32 = 15;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
-pub const FUNCTIONS: [(&str, u32); 68] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 69] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -86,6 +86,8 @@ pub const FUNCTIONS: [(&str, u32); 68] = [
|
||||
("render.set-time-override", 13),
|
||||
// AirDef fork: volumetric smoke and exhaust trails.
|
||||
("render.set-smoke", 14),
|
||||
+ // AirDef fork: a mod camera as the screen's own view.
|
||||
+ ("render.set-main-view", 15),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||||
index 495ef6cae..74da34673 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -506,6 +506,8 @@ struct State {
|
||||
post_params: Vec<f32>,
|
||||
/// `render.set-views`' cameras (AirDef fork).
|
||||
views: Vec<CameraView>,
|
||||
+ /// `render.set-main-view`: the camera the screen's world is rendered from.
|
||||
+ main_view: Option<u32>,
|
||||
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
|
||||
lights: Vec<PointLight>,
|
||||
lighting: Option<ModLighting>,
|
||||
@@ -754,6 +756,7 @@ impl Mods {
|
||||
post: None,
|
||||
post_params: Vec::new(),
|
||||
views: Vec::new(),
|
||||
+ main_view: None,
|
||||
lights: Vec::new(),
|
||||
lighting: None,
|
||||
time_override: None,
|
||||
@@ -1363,6 +1366,12 @@ impl Mods {
|
||||
t.smoke_active = on;
|
||||
}
|
||||
}
|
||||
+ /// The camera the first running mod asked the screen's world from, if any.
|
||||
+ pub fn main_view(&self) -> Option<u32> {
|
||||
+ self.entries
|
||||
+ .iter()
|
||||
+ .find_map(|e| e.run.as_ref()?.store.data().main_view)
|
||||
+ }
|
||||
/// The time of day the first running mod asked to show, if any.
|
||||
pub fn time_override(&self) -> Option<u64> {
|
||||
self.entries
|
||||
@@ -2230,6 +2239,9 @@ impl render::Host for State {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
+ fn set_main_view(&mut self, view: Option<u32>) {
|
||||
+ self.main_view = view;
|
||||
+ }
|
||||
fn set_lights(&mut self, lights: Vec<PointLight>) {
|
||||
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
let unit = |v: f32| {
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index fefc4903f..1bd2c7eae 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -667,6 +667,7 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
|
||||
| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
|
||||
| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
|
||||
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
|
||||
+| `set-main-view` | `func(view: option<u32>)` | Renders the screen's world from one of the `set-views` cameras (AirDef fork), with everything the player's own view has: the HD lighting, sky, clouds, fog, smoke, TAA and the post passes, at the window's resolution. The picture size of that camera is ignored, the player's hand, block outline and the game's HUD are not drawn (the mod's own `set-hud` shapes are); a `view` shape of that camera draws nothing. For a full-screen replay or a director's camera; the small pictures of monitors stay `view` shapes. None (or an unknown id) is the player's own view. The chunks stay those round the player. |
|
||||
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
|
||||
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
|
||||
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
+28
-3
@@ -12,6 +12,7 @@
|
||||
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
|
||||
"""
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -51,21 +52,45 @@ def cargo() -> str:
|
||||
return found
|
||||
|
||||
|
||||
def fork_stamp(commit: str, patches) -> str:
|
||||
"""Что должно стоять в horizon/: коммит апстрима и содержимое патчей."""
|
||||
h = hashlib.sha256(commit.encode())
|
||||
for p in patches:
|
||||
h.update(p.name.encode())
|
||||
h.update(p.read_bytes())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def fork():
|
||||
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/."""
|
||||
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/.
|
||||
|
||||
Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория);
|
||||
прежняя остаётся в ветке airdef-prev."""
|
||||
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
|
||||
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
|
||||
stamp = fork_stamp(commit, patches)
|
||||
stamp_file = FORK / ".git" / "airdef-stamp"
|
||||
if not (FORK / ".git").exists():
|
||||
run(["git", "clone", "--filter=blob:none", UPSTREAM, FORK])
|
||||
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
|
||||
if branch == "airdef":
|
||||
print("horizon/: ветка airdef уже есть")
|
||||
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
|
||||
print("horizon/: ветка airdef на нужных коммите и патчах")
|
||||
return
|
||||
dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
if dirty:
|
||||
sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.")
|
||||
exists = subprocess.run(["git", "-C", FORK, "rev-parse", "--verify", "-q", "refs/heads/airdef"],
|
||||
capture_output=True).returncode == 0
|
||||
if exists:
|
||||
print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)")
|
||||
run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"])
|
||||
run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit])
|
||||
run(["git", "-C", FORK, "checkout", "-B", "airdef", commit])
|
||||
if patches:
|
||||
run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost",
|
||||
"am", "--3way", *patches])
|
||||
stamp_file.write_text(stamp + "\n")
|
||||
|
||||
|
||||
def game():
|
||||
|
||||
@@ -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, "Тягач"),
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
|
||||
|
||||
python3 tools/hd_bench.py --list # сцены и их параметры
|
||||
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
|
||||
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
|
||||
python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off
|
||||
python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after
|
||||
python3 tools/hd_bench.py --no-freeze # живой замер производительности: время и погода идут
|
||||
|
||||
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
|
||||
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
|
||||
Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток,
|
||||
ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4`
|
||||
(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
|
||||
Итог: report.md и results.json в папке вывода.
|
||||
|
||||
Анимация (#71). Игровое время и погоду мы замораживаем, но вода (волны, HD `water.y`) и листва (ветер,
|
||||
`wind_seconds`) идут по часам стены, поэтому два прогона одного бинарника разные и без изменений кода.
|
||||
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
|
||||
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
|
||||
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
|
||||
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
|
||||
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
|
||||
|
||||
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
|
||||
Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BENCH = ROOT / "run-home-bench"
|
||||
SOCK = BENCH / "c.sock" # короткий путь: лимит Unix-сокета около 104 байт
|
||||
LOG = SOCK.with_suffix(".log") # лог игры рядом с сокетом (как ctl.log в run-home)
|
||||
HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересоздаётся каждый раз
|
||||
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
|
||||
SAVES = ROOT / "run-home" / "saves"
|
||||
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
|
||||
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой)
|
||||
DIFF_GAIN = 6 # усиление разницы в diff-картинках
|
||||
MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва)
|
||||
FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание
|
||||
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
|
||||
FREEZE_RULES = ("advance_time", "advance_weather") # имена игровых правил в horizon-client-core/gamerules.rs
|
||||
|
||||
|
||||
@dataclass
|
||||
class Shot:
|
||||
name: str
|
||||
world: str
|
||||
time: int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь
|
||||
pose: tuple # x, y, z, yaw, pitch (телепорт `tp @s`)
|
||||
note: str
|
||||
|
||||
|
||||
# Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток
|
||||
# перед сравнением и поправь здесь, если кадр не то, что написано в note.
|
||||
SHOTS = [
|
||||
Shot("polygon_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 135.0, 25.0),
|
||||
"полигон с техникой, полдень, белые материалы"),
|
||||
Shot("hills_river_noon", "Hills", 6000, (-26.5, 72.0, 0.5, -150.0, 8.0),
|
||||
"река на Hills, полдень: вода, преломление, блики"),
|
||||
Shot("hills_river_sunset", "Hills", 12000, (-26.5, 72.0, 0.5, -150.0, 8.0),
|
||||
"река на Hills, закат: тинт неба и воды"),
|
||||
Shot("hills_river_night", "Hills", 18000, (-26.5, 72.0, 0.5, -150.0, 8.0),
|
||||
"река на Hills, ночь: луна, туман"),
|
||||
Shot("forest_morning_sun", "Hills", 1000, (-26.5, 71.0, 0.5, -90.0, 3.0),
|
||||
"лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом)"),
|
||||
Shot("sky_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 0.0, -40.0),
|
||||
"небо и облака, полдень, взгляд вверх"),
|
||||
]
|
||||
|
||||
|
||||
class BenchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def die(msg, code=2):
|
||||
print(f"hd_bench: {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
# --- процессы и игра -------------------------------------------------------------------------
|
||||
|
||||
def pgrep(pattern):
|
||||
return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def ctl_ok(*args):
|
||||
r = subprocess.run([str(CTL), "--socket", str(SOCK), "--timeout", "3", *args],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def bench_running():
|
||||
"""Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету."""
|
||||
if pgrep(str(SOCK.relative_to(ROOT))):
|
||||
return True
|
||||
if SOCK.exists():
|
||||
if ctl_ok("status"):
|
||||
return True
|
||||
SOCK.unlink() # мёртвый сокет от прошлого прогона
|
||||
return False
|
||||
|
||||
|
||||
def ctl(*args, timeout=30.0):
|
||||
cmd = [str(CTL), "--socket", str(SOCK), "--timeout", f"{timeout:g}", *map(str, args)]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: таймаут") from e
|
||||
if r.returncode != 0:
|
||||
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: "
|
||||
f"{(r.stdout or r.stderr).strip()[:300]}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def game_env(extra, timing):
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"HORIZON_HOME": str(HOME),
|
||||
"HORIZON_NO_AUDIO": "1",
|
||||
"HORIZON_LAUNCHER_NO_SELF_UPDATE": "1",
|
||||
})
|
||||
if timing:
|
||||
env["HORIZON_SHADER_PACK_TIMING"] = "1"
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def prepare_home():
|
||||
if HOME.exists():
|
||||
shutil.rmtree(HOME)
|
||||
(HOME / "saves").mkdir(parents=True)
|
||||
for world in sorted({s.world for s in SHOTS}):
|
||||
src = SAVES / world
|
||||
if not src.is_dir():
|
||||
die(f"нет мира {src}")
|
||||
shutil.copytree(src, HOME / "saves" / world,
|
||||
ignore=shutil.ignore_patterns("session.lock", "*.lock"))
|
||||
|
||||
|
||||
def install_mod(env):
|
||||
subprocess.run([sys.executable, str(ROOT / "tools" / "airdef.py"), "install"],
|
||||
env={**env, "HORIZON_HOME": str(HOME)}, check=True, cwd=ROOT)
|
||||
|
||||
|
||||
def launch(world, env, game, resources):
|
||||
if SOCK.exists():
|
||||
SOCK.unlink()
|
||||
cmd = [str(CTL), "--socket", str(SOCK), "launch", "--world", world, "--offscreen", "--wait",
|
||||
"--game", str(game), "--resources", str(resources)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=HOME, timeout=600)
|
||||
if r.returncode != 0:
|
||||
raise BenchError(f"launch {world}: {(r.stdout or r.stderr).strip()[:400]}")
|
||||
ctl("command", "gamemode creative")
|
||||
ctl("key", "t", "tap") # чат, как игрок: /airdef shader hd
|
||||
ctl("type", "/airdef shader hd")
|
||||
ctl("key", "return", "tap")
|
||||
time.sleep(0.5)
|
||||
ctl("key", "f1", "tap") # скрыть HUD (один раз на запуск)
|
||||
|
||||
|
||||
def stop_game():
|
||||
try:
|
||||
ctl("quit", timeout=20)
|
||||
except BenchError:
|
||||
pass
|
||||
for _ in range(100):
|
||||
if not ctl_ok("status"):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if SOCK.exists() and not ctl_ok("status"):
|
||||
SOCK.unlink()
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
|
||||
|
||||
def _paeth(a, b, c):
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
||||
|
||||
|
||||
def read_png_rgb(path):
|
||||
"""(w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра)."""
|
||||
data = Path(path).read_bytes()
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise BenchError(f"{path}: не PNG")
|
||||
pos, idat, w = 8, [], 0
|
||||
while pos < len(data):
|
||||
(ln,) = struct.unpack(">I", data[pos:pos + 4])
|
||||
kind = data[pos + 4:pos + 8]
|
||||
body = data[pos + 8:pos + 8 + ln]
|
||||
pos += 12 + ln
|
||||
if kind == b"IHDR":
|
||||
w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
idat.append(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
if depth != 8 or interlace or ctype not in (2, 6):
|
||||
raise BenchError(f"{path}: нужен 8-бит RGB/RGBA без чередования")
|
||||
bpp = 3 if ctype == 2 else 4
|
||||
stride = w * bpp
|
||||
raw = zlib.decompress(b"".join(idat))
|
||||
out = bytearray(w * h * 3)
|
||||
prev = bytearray(stride)
|
||||
p = 0
|
||||
for y in range(h):
|
||||
ft = raw[p]
|
||||
line = raw[p + 1:p + 1 + stride]
|
||||
p += 1 + stride
|
||||
cur = bytearray(stride)
|
||||
for i in range(stride):
|
||||
x = line[i]
|
||||
a = cur[i - bpp] if i >= bpp else 0
|
||||
b = prev[i]
|
||||
c = prev[i - bpp] if i >= bpp else 0
|
||||
if ft == 1:
|
||||
x += a
|
||||
elif ft == 2:
|
||||
x += b
|
||||
elif ft == 3:
|
||||
x += (a + b) >> 1
|
||||
elif ft == 4:
|
||||
x += _paeth(a, b, c)
|
||||
cur[i] = x & 255
|
||||
base = y * w * 3
|
||||
if bpp == 3:
|
||||
out[base:base + w * 3] = cur
|
||||
else:
|
||||
out[base:base + w * 3:3] = cur[0::4]
|
||||
out[base + 1:base + w * 3:3] = cur[1::4]
|
||||
out[base + 2:base + w * 3:3] = cur[2::4]
|
||||
prev = cur
|
||||
return w, h, bytes(out)
|
||||
|
||||
|
||||
def write_png_rgb(path, w, h, rgb):
|
||||
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||
|
||||
def chunk(kind, body):
|
||||
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
|
||||
|
||||
Path(path).write_bytes(b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 6))
|
||||
+ chunk(b"IEND", b""))
|
||||
|
||||
|
||||
try: # необязательно: быстрее декодирование
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover
|
||||
Image = None
|
||||
|
||||
|
||||
def load_rgb(path):
|
||||
if Image is not None:
|
||||
im = Image.open(path).convert("RGB")
|
||||
return im.size[0], im.size[1], im.tobytes()
|
||||
return read_png_rgb(path)
|
||||
|
||||
|
||||
def load_frames(paths):
|
||||
"""(w, h, [bytes RGB каждого кадра]); размеры должны совпадать."""
|
||||
w = h = None
|
||||
rgbs = []
|
||||
for p in paths:
|
||||
pw, ph, px = load_rgb(p)
|
||||
if w is None:
|
||||
w, h = pw, ph
|
||||
elif (pw, ph) != (w, h):
|
||||
raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})")
|
||||
rgbs.append(px)
|
||||
return w, h, rgbs
|
||||
|
||||
|
||||
def shot_frames(folder, name, n=None):
|
||||
"""Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть."""
|
||||
paths = []
|
||||
while n is None or len(paths) < n:
|
||||
k = len(paths) + 1
|
||||
p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png")
|
||||
if not p.exists():
|
||||
break
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
# --- сравнение кадров с учётом анимации (#71) ---------------------------------------------------
|
||||
|
||||
def _per_pixel_max(ch):
|
||||
"""Из байтов по каналам (RGB, по 3 на пиксель) — максимум по каналам на пиксель."""
|
||||
return bytes(map(max, ch[0::3], ch[1::3], ch[2::3]))
|
||||
|
||||
|
||||
def _expand3(mask):
|
||||
"""Маска на пиксель -> маска на байт (по 3 на пиксель)."""
|
||||
m = bytearray(3 * len(mask))
|
||||
m[0::3] = mask
|
||||
m[1::3] = mask
|
||||
m[2::3] = mask
|
||||
return m
|
||||
|
||||
|
||||
def motion_range(rgbs):
|
||||
"""Размах значения каждого пикселя между кадрами одного прогона (максимум по каналам, 0..255)."""
|
||||
lo = hi = rgbs[0]
|
||||
for f in rgbs[1:]:
|
||||
lo = bytes(map(min, lo, f))
|
||||
hi = bytes(map(max, hi, f))
|
||||
return _per_pixel_max(bytes(map(operator.sub, hi, lo)))
|
||||
|
||||
|
||||
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
|
||||
npx = w * h
|
||||
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
|
||||
moving = sum(1 for r in rng if r > motion_level)
|
||||
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
|
||||
"frames": n_frames, "size": [w, h]}
|
||||
|
||||
|
||||
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
|
||||
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
|
||||
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
|
||||
средняя разница (информация, не порог)."""
|
||||
npx = w * h
|
||||
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
|
||||
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
|
||||
dpix = _per_pixel_max(d)
|
||||
n_static = sum(static)
|
||||
n_moving = npx - n_static
|
||||
mean = sum(itertools.compress(d, _expand3(static))) / (3.0 * n_static) if n_static else 0.0
|
||||
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
|
||||
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
|
||||
if n_moving else 0.0)
|
||||
if diff_out:
|
||||
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
|
||||
for i, s in enumerate(static):
|
||||
if not s: # движущиеся пиксели — синим, чтобы не путать с регрессией
|
||||
j = 3 * i
|
||||
img[j], img[j + 1], img[j + 2] = 0, 0, 80
|
||||
write_png_rgb(diff_out, w, h, bytes(img))
|
||||
if motion_out:
|
||||
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
|
||||
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
|
||||
"animated_mean": moving_mean, "size": [w, h]}
|
||||
|
||||
|
||||
# --- тайминги -----------------------------------------------------------------------------------
|
||||
|
||||
def read_from(path, offset):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(offset)
|
||||
return f.read().decode("utf-8", "replace")
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_pass_reports(text):
|
||||
reports = []
|
||||
for line in text.splitlines():
|
||||
m = TIMING_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
passes = {}
|
||||
for part in m.group(1).split(", "):
|
||||
name, _, ms = part.rpartition(" ")
|
||||
try:
|
||||
passes[name] = float(ms)
|
||||
except ValueError:
|
||||
pass
|
||||
reports.append((passes, float(m.group(2))))
|
||||
return reports
|
||||
|
||||
|
||||
def average_passes(reports):
|
||||
if not reports:
|
||||
return {}, None
|
||||
sums, counts = {}, {}
|
||||
for passes, _ in reports:
|
||||
for name, ms in passes.items():
|
||||
sums[name] = sums.get(name, 0.0) + ms
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
avg = {n: sums[n] / counts[n] for n in sums}
|
||||
total = sum(avg.values())
|
||||
return avg, total
|
||||
|
||||
|
||||
def bench_gpu_avg(reply):
|
||||
try:
|
||||
return float(json.loads(reply)["gpu"]["avg_ms"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# --- сцена ------------------------------------------------------------------------------------
|
||||
|
||||
def run_shot(shot, out, settle, bench_secs, timing, stills, gap, warmup, freeze):
|
||||
x, y, z, yaw, pitch = shot.pose
|
||||
if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками
|
||||
for rule in FREEZE_RULES:
|
||||
ctl("command", f"gamerule {rule} false")
|
||||
ctl("command", f"time set {shot.time}")
|
||||
ctl("command", "weather clear")
|
||||
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
|
||||
ctl("wait-ready", 120, timeout=150)
|
||||
time.sleep(settle)
|
||||
frames = []
|
||||
for k in range(stills):
|
||||
if k:
|
||||
time.sleep(gap)
|
||||
path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png")
|
||||
ctl("screenshot", str(path.resolve()))
|
||||
frames.append(path.name)
|
||||
if warmup > 0: # прогрев перед замером, не попадает в отчёт
|
||||
ctl("bench", warmup, timeout=warmup + 60)
|
||||
result = {"frame": frames[0], "frames": frames, "gpu_avg_ms": None, "passes": {},
|
||||
"pass_total_ms": None, "bench_json": None}
|
||||
offset = LOG.stat().st_size if LOG.exists() else 0
|
||||
reply = ctl("bench", bench_secs, timeout=bench_secs + 60)
|
||||
(out / f"{shot.name}_bench.json").write_text(reply + "\n")
|
||||
result["bench_json"] = f"{shot.name}_bench.json"
|
||||
result["gpu_avg_ms"] = bench_gpu_avg(reply)
|
||||
if timing:
|
||||
time.sleep(1.0) # последний отчёт о проходах
|
||||
reports = parse_pass_reports(read_from(LOG, offset))
|
||||
result["passes"], result["pass_total_ms"] = average_passes(reports)
|
||||
if not reports:
|
||||
result["warning"] = "нет строк проходов в логе (мало таймед-кадров?)"
|
||||
return result
|
||||
|
||||
|
||||
def group_by_world(shots):
|
||||
order = []
|
||||
for s in shots:
|
||||
if s.world not in order:
|
||||
order.append(s.world)
|
||||
return [(w, [s for s in shots if s.world == w]) for w in order]
|
||||
|
||||
|
||||
def git_rev():
|
||||
r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
return r.stdout.strip() or "?"
|
||||
|
||||
|
||||
def judge(entry, args):
|
||||
reasons = []
|
||||
if entry.get("error"):
|
||||
reasons.append("ошибка: " + entry["error"])
|
||||
gpu = entry.get("gpu_avg_ms")
|
||||
if gpu is not None and gpu > args.budget_ms:
|
||||
reasons.append(f"GPU {gpu:.2f} мс > бюджета {args.budget_ms:g}")
|
||||
if gpu is None and not entry.get("error"):
|
||||
reasons.append("нет GPU-таймингов (bench)")
|
||||
fl = entry.get("flicker")
|
||||
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
|
||||
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
|
||||
base = entry.get("vs_baseline")
|
||||
if base:
|
||||
if base.get("error"):
|
||||
reasons.append(base["error"])
|
||||
else:
|
||||
if base["mean"] > args.max_mean:
|
||||
reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
|
||||
if base["pct"] > args.max_pct:
|
||||
reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
|
||||
return reasons
|
||||
|
||||
|
||||
def write_report(out, entries, args, env_extra, size, baseline, meta):
|
||||
lines = []
|
||||
add = lines.append
|
||||
add(f"# HD-полигон: {out.name}")
|
||||
add("")
|
||||
add(f"- движок: `{meta['game']}`, sha256 `{meta['game_sha256'][:16]}`; коммит мода (не движка): `{git_rev()}`")
|
||||
add(f"- ресурсы: `{meta['resources']}`")
|
||||
add(f"- дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
|
||||
add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
|
||||
add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"]
|
||||
else "идут (`--no-freeze`, живой замер)"))
|
||||
add(f"- кадров на сцену: {meta['stills']} через {meta['still_gap']:g} с; прогрев {meta['warmup_secs']:g} с")
|
||||
add(f"- движение: размах между кадрами > {meta['motion_level']}/255 (вода, облака, листва) не сравнивается с базой; "
|
||||
f"мерцание: статичные пиксели с размахом > {meta['flicker_level']}/255")
|
||||
add("- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
|
||||
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание: "
|
||||
+ (f"<= {args.max_flicker:g}%" if args.max_flicker is not None else "только в отчёте (порог не задан)"))
|
||||
if baseline:
|
||||
add(f"- сравнение с: `{baseline}` (статичные пиксели: средняя разница <= {args.max_mean:g}, "
|
||||
f"пикселей >20/255 <= {args.max_pct:g}%)")
|
||||
add("")
|
||||
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |")
|
||||
add("|---|---|---|---|---|---|---|---|---|---|")
|
||||
for e in entries:
|
||||
gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-"
|
||||
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
|
||||
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
|
||||
b = e.get("vs_baseline") or {}
|
||||
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
|
||||
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
|
||||
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
|
||||
budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-")
|
||||
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {mv} | {bm} | {bp} | {status} |")
|
||||
passes = []
|
||||
for e in entries:
|
||||
for p in e.get("passes", {}):
|
||||
if p not in passes:
|
||||
passes.append(p)
|
||||
if passes:
|
||||
add("")
|
||||
add("## Проходы GPU, среднее по отчётам лога, мс")
|
||||
add("")
|
||||
add("| проход | " + " | ".join(e["name"] for e in entries) + " |")
|
||||
add("|---|" + "---|" * len(entries))
|
||||
for p in passes:
|
||||
add(f"| {p} | " + " | ".join(f"{e['passes'][p]:.3f}" if p in e.get("passes", {}) else "-" for e in entries) + " |")
|
||||
totals = [f"{e['pass_total_ms']:.3f}" if e.get("pass_total_ms") is not None else "-" for e in entries]
|
||||
add("| **итого** | " + " | ".join(totals) + " |")
|
||||
add("")
|
||||
add("## Кадры")
|
||||
for e in entries:
|
||||
add("")
|
||||
add(f"### {e['name']}: {e['note']}")
|
||||
add("")
|
||||
add(f"![{e['name']}]({e['frame']})")
|
||||
if e.get("diff_image"):
|
||||
add("")
|
||||
add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ")
|
||||
if e.get("motion_image"):
|
||||
add("")
|
||||
add(f"Движущиеся пиксели (белым): ")
|
||||
if e.get("warning"):
|
||||
add("")
|
||||
add(f"> {e['warning']}")
|
||||
if e.get("error"):
|
||||
add("")
|
||||
add(f"> ошибка: {e['error']}")
|
||||
(out / "report.md").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.")
|
||||
p.add_argument("--list", action="store_true", help="показать сцены и выйти")
|
||||
p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)")
|
||||
p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)")
|
||||
p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров")
|
||||
p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0")
|
||||
p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)")
|
||||
p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
|
||||
p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
|
||||
p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
|
||||
p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
|
||||
p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)")
|
||||
p.add_argument("--motion-level", type=int, default=MOTION_LEVEL,
|
||||
help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})")
|
||||
p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL,
|
||||
help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})")
|
||||
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
|
||||
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
|
||||
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
|
||||
p.add_argument("--no-freeze", action="store_true",
|
||||
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
|
||||
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
|
||||
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
|
||||
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
|
||||
args = p.parse_args(argv)
|
||||
if args.stills < 2:
|
||||
p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
if args.list:
|
||||
for s in SHOTS:
|
||||
print(f"{s.name:22} {s.world:8} время {s.time:5} поза {s.pose} {s.note}")
|
||||
return 0
|
||||
shots = [s for s in SHOTS if not args.only or s.name in args.only]
|
||||
unknown = set(args.only) - {s.name for s in SHOTS}
|
||||
if unknown:
|
||||
die(f"нет сцен: {', '.join(sorted(unknown))} (см. --list)")
|
||||
env_extra = {}
|
||||
for item in args.env:
|
||||
if "=" not in item:
|
||||
die(f"--env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: {item}")
|
||||
k, v = item.split("=", 1)
|
||||
env_extra[k] = v
|
||||
if bench_running():
|
||||
die("уже запущена игра на run-home-bench/c.sock: один прогон за раз")
|
||||
if pgrep(OTHER_SOCK):
|
||||
if len(shots) > 1:
|
||||
die(f"работает чужая игра на {OTHER_SOCK}: запусти одну сцену (--only) или дождись конца теста")
|
||||
print(f"hd_bench: внимание: работает чужая игра на {OTHER_SOCK}, снимок будет под нагрузкой",
|
||||
file=sys.stderr)
|
||||
out = Path(args.out) if args.out else BENCH / "out" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = out.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import airdef # tools/airdef.py: ресурсы и путь к игре
|
||||
|
||||
game = Path(args.game) if args.game else airdef.built_game()
|
||||
resources = Path(args.resources) if args.resources else airdef.resources()
|
||||
try:
|
||||
game_sha = file_sha256(game)
|
||||
except OSError:
|
||||
game_sha = "?" * 64
|
||||
meta = {"game": str(game), "game_sha256": game_sha, "resources": str(resources),
|
||||
"freeze": not args.no_freeze, "stills": args.stills, "still_gap": args.still_gap,
|
||||
"warmup_secs": args.warmup_secs, "motion_level": args.motion_level,
|
||||
"flicker_level": args.flicker_level}
|
||||
timing = not args.no_pass_timing
|
||||
env = game_env(env_extra, timing)
|
||||
prepare_home()
|
||||
install_mod(env)
|
||||
|
||||
entries = []
|
||||
baseline = Path(args.baseline).resolve() if args.baseline else None
|
||||
try:
|
||||
for world, group in group_by_world(shots):
|
||||
try:
|
||||
launch(world, env, game, resources)
|
||||
except BenchError as e:
|
||||
for s in group:
|
||||
entries.append({**asdict(s), "error": str(e), "fail": [f"ошибка: {e}"]})
|
||||
stop_game()
|
||||
continue
|
||||
for s in group:
|
||||
entry = {**asdict(s), "frame": f"{s.name}.png"}
|
||||
try:
|
||||
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills,
|
||||
args.still_gap, args.warmup_secs, not args.no_freeze))
|
||||
w, h, rgbs = load_frames(shot_frames(out, s.name, args.stills))
|
||||
rng = motion_range(rgbs)
|
||||
entry["flicker"] = flicker_stats(rng, w, h, len(rgbs), args.motion_level, args.flicker_level)
|
||||
if baseline and not entry.get("error"):
|
||||
base_paths = shot_frames(baseline, s.name) # все кадры базы, сколько есть
|
||||
if not base_paths:
|
||||
entry["vs_baseline"] = {"error": f"в базе нет {s.name}.png"}
|
||||
else:
|
||||
bw, bh, brgbs = load_frames(base_paths)
|
||||
if (bw, bh) != (w, h):
|
||||
raise BenchError(f"размеры кадров разные: база {bw}x{bh}, прогон {w}x{h}")
|
||||
brng = motion_range(brgbs)
|
||||
diff_name, motion_name = f"{s.name}_diff.png", f"{s.name}_motion.png"
|
||||
entry["vs_baseline"] = compare_runs(rgbs, rng, brgbs, brng, w, h, args.motion_level,
|
||||
out / diff_name, out / motion_name)
|
||||
entry["vs_baseline"]["base_frames"] = len(brgbs)
|
||||
entry["diff_image"] = diff_name
|
||||
entry["motion_image"] = motion_name
|
||||
except BenchError as e:
|
||||
entry["error"] = str(e)
|
||||
entry["fail"] = judge(entry, args)
|
||||
entries.append(entry)
|
||||
stop_game()
|
||||
finally:
|
||||
stop_game()
|
||||
|
||||
size = None
|
||||
for e in entries:
|
||||
if e.get("vs_baseline") and "size" in e["vs_baseline"]:
|
||||
size = e["vs_baseline"]["size"]
|
||||
break
|
||||
if size is None and entries and entries[0].get("flicker"):
|
||||
size = entries[0]["flicker"]["size"]
|
||||
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, "meta": meta,
|
||||
"baseline": str(baseline) if baseline else None, "shots": entries}
|
||||
(out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
|
||||
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None, meta)
|
||||
|
||||
failed = [e for e in entries if e["fail"]]
|
||||
for e in entries:
|
||||
print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс "
|
||||
f"мерцание {e['flicker']['pct']:.2f}% движение {e['flicker']['animated_pct']:.2f}%" if e.get("flicker")
|
||||
else f"{e['name']:22} {e.get('error', '')}")
|
||||
for r in e["fail"]:
|
||||
print(f" FAIL: {r}")
|
||||
print(f"отчёт: {out / 'report.md'}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except BenchError as e:
|
||||
die(str(e), code=2)
|
||||
Reference in New Issue
Block a user