From 4761ae7d70ca82f343ea78c65ae863807e1c59c7 Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 01:09:21 +0300 Subject: [PATCH] Pin the standalone Horizon fork as a submodule (#104) Replace patch-stack reconstruction with safe pinned initialization and legacy checkout migration; use an engine-local packer and write artifact identities. Co-Authored-By: Codex --- .gitea/workflows/build.yml | 7 +- .gitea/workflows/desktop.yml | 1 + .gitea/workflows/game.yml | 1 + .gitea/workflows/release.yml | 1 + .gitignore | 1 - .gitmodules | 3 + HORIZON_COMMIT | 1 - docs/ENGINE.md | 40 + docs/INSTALL.md | 2 +- docs/REPO-MAP.md | 2 +- horizon | 1 + ...ork-world-models-client-particles-so.patch | 577 - ...izon-mod-report-particles-and-sounds.patch | 28 - ...-fork-full-screen-post-pass-for-mods.patch | 603 -- ...far-models-as-scaled-impostors-past-.patch | 48 - ...er.set-views-draws-the-world-from-ot.patch | 927 -- ...er-tick-and-per-event-AirDef-plans-r.patch | 30 - ...-up-to-1920x1080-full-screen-replays.patch | 42 - ...ant-terrain-tiles-fog-measured-from-.patch | 158 - ...id-so-mods-keep-state-per-world-API-.patch | 126 - ...d-trial-runs-behind-feature-full-so-.patch | 98 - ...ffer-pass-per-pixel-mod-point-lights.patch | 1152 -- ...e-2-sun-and-moon-shadow-cascades-the.patch | 1684 --- ...ached-until-a-resize-or-a-new-source.patch | 439 - ...-per-16x16-tile-surface-and-halo-lis.patch | 362 - ...pass-in-the-F3-timing-list-half-reso.patch | 712 -- ...only-sun-facing-quads-and-skip-burie.patch | 678 -- ...es-render.model-id-model-generation-.patch | 643 -- ...aling-HORIZON_HD_SCALE-world-G-buffe.patch | 527 - ...-pass-colour-normal-depth-the-floor-.patch | 982 -- ...lla-texture-on-the-surface-the-biome.patch | 54 - ...-a-physical-atmosphere-Rayleigh-Mie-.patch | 945 -- ...r-blue-a-little-clearer-ripples-a-li.patch | 42 - ...sunsets-Rec.-2020-atmosphere-sun-tin.patch | 1914 ---- ...es-for-simulations-render.query-rays.patch | 1160 -- ...vel-from-the-world-not-from-rain-geo.patch | 992 -- ...dules-gfx-hd-one-Rust-file-and-one-W.patch | 9341 ----------------- ...scales-that-divide-the-camera-s-6553.patch | 52 - ...oke-and-missile-trails-with-clear-tu.patch | 2075 ---- ...ited-by-depth-so-terrain-and-vehicle.patch | 603 -- ...rails-drawn-at-least-as-wide-as-a-gr.patch | 59 - ...main-view-API-level-15-renders-the-s.patch | 642 -- ...mods-render.set-spot-lights-API-leve.patch | 2736 ----- ...g-polish-cloud-panorama-cirrus-terra.patch | 2994 ------ ...through-glass-and-leaves-rain-ripple.patch | 1089 -- ...y-frame-in-flight-reaches-the-writer.patch | 446 - ...s-command.set-tree-and-set-suggestio.patch | 1925 ---- ...ound-sources-start-update-stop-with-.patch | 2000 ---- ...rom-a-far-point-so-the-view-bob-s-ey.patch | 28 - ...1-direct-path-occlusion-ported-from-.patch | 1684 --- ...s-through-the-server-thread-API-22-s.patch | 2202 ---- ...terrain-tiles-and-mouse-input-API-24.patch | 2641 ----- .../0042-game.world-seed-API-25.patch | 110 - ...locks-while-replay-shows-history-105.patch | 887 -- ...ency-and-voice-lifecycle-110-111-112.patch | 927 -- tools/airdef.py | 129 +- tools/test_engine_checkout.py | 76 + 57 files changed, 194 insertions(+), 47435 deletions(-) create mode 100644 .gitmodules delete mode 100644 HORIZON_COMMIT create mode 100644 docs/ENGINE.md create mode 160000 horizon delete mode 100644 horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch delete mode 100644 horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch delete mode 100644 horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch delete mode 100644 horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch delete mode 100644 horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch delete mode 100644 horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch delete mode 100644 horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch delete mode 100644 horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch delete mode 100644 horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch delete mode 100644 horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch delete mode 100644 horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch delete mode 100644 horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch delete mode 100644 horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch delete mode 100644 horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch delete mode 100644 horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch delete mode 100644 horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch delete mode 100644 horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch delete mode 100644 horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch delete mode 100644 horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch delete mode 100644 horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch delete mode 100644 horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch delete mode 100644 horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch delete mode 100644 horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch delete mode 100644 horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch delete mode 100644 horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch delete mode 100644 horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch delete mode 100644 horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch delete mode 100644 horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch delete mode 100644 horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch delete mode 100644 horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch delete mode 100644 horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch delete mode 100644 horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch delete mode 100644 horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch delete mode 100644 horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch delete mode 100644 horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch delete mode 100644 horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch delete mode 100644 horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch delete mode 100644 horizon-patches/0038-HD-clouds-rays-from-a-far-point-so-the-view-bob-s-ey.patch delete mode 100644 horizon-patches/0039-Acoustics-stage-1-direct-path-occlusion-ported-from-.patch delete mode 100644 horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch delete mode 100644 horizon-patches/0041-Mod-canvas-with-terrain-tiles-and-mouse-input-API-24.patch delete mode 100644 horizon-patches/0042-game.world-seed-API-25.patch delete mode 100644 horizon-patches/0043-Mods-read-live-blocks-while-replay-shows-history-105.patch delete mode 100644 horizon-patches/0044-Acoustics-follows-chunk-residency-and-voice-lifecycle-110-111-112.patch create mode 100644 tools/test_engine_checkout.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9867c1a..d6a70ec 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Rust run: | curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target wasm32-unknown-unknown @@ -46,7 +48,9 @@ jobs: SHA: ${{ github.sha }} run: | set -e - mkdir -p out && cp airdef-mod/target/horizon-mod/airdef.hmod out/airdef.hmod + mkdir -p out + cp airdef-mod/target/horizon-mod/airdef.hmod out/airdef.hmod + cp airdef-mod/target/horizon-mod/build-identity.json out/build-identity.json BODY=$(printf 'Сборка %s (%s).\n\n- airdef.hmod — мод, для всех ОС.\n\nИгра-форк Horizon (нужна для мода): собрать у себя — python3 tools/airdef.py all, или скачать из большого релиза v* (Windows), или запустить вручную workflow game (Linux) / desktop (macOS) с этим тегом.\nКак запускать: docs/INSTALL.md.' "$TAG" "$SHA") DATA=$(BODY="$BODY" python3 -c 'import json,os;print(json.dumps({"tag_name":os.environ["TAG"],"target_commitish":os.environ["SHA"],"name":os.environ["TAG"],"body":os.environ["BODY"]}))') if [ -z "$TOKEN" ]; then echo "::error::нет токена: добавь секрет RELEASE_TOKEN"; exit 1; fi @@ -57,3 +61,4 @@ jobs: fi ID=$(python3 -c 'import json;print(json.load(open("release.json"))["id"])') curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef.hmod" "$API/releases/$ID/assets?name=airdef.hmod" > /dev/null + curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/build-identity.json" "$API/releases/$ID/assets?name=build-identity.json" > /dev/null diff --git a/.gitea/workflows/desktop.yml b/.gitea/workflows/desktop.yml index 6b7e98c..9f578fe 100644 --- a/.gitea/workflows/desktop.yml +++ b/.gitea/workflows/desktop.yml @@ -41,6 +41,7 @@ jobs: PY - uses: actions/checkout@v4 with: + submodules: recursive ref: refs/tags/${{ inputs.tag }} - name: Проверка коммита env: diff --git a/.gitea/workflows/game.yml b/.gitea/workflows/game.yml index 6505179..ae4e7d3 100644 --- a/.gitea/workflows/game.yml +++ b/.gitea/workflows/game.yml @@ -49,6 +49,7 @@ jobs: PY - uses: actions/checkout@v4 with: + submodules: recursive ref: refs/tags/${{ inputs.tag }} - name: Проверка коммита env: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ec97ad7..584d594 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -37,6 +37,7 @@ jobs: if [ "$FREE" -lt 12 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 12"; exit 1; fi - uses: actions/checkout@v4 with: + submodules: recursive ref: refs/tags/${{ inputs.tag || github.ref_name }} - name: Инструменты run: | diff --git a/.gitignore b/.gitignore index c022fc4..4e91c62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -/horizon/ run-home/ .horizon-cache/ airdef-mod/target/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..83d7a73 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "horizon"] + path = horizon + url = https://gitea.maoniao.team/chexit/horizon-airdef.git diff --git a/HORIZON_COMMIT b/HORIZON_COMMIT deleted file mode 100644 index 31aff13..0000000 --- a/HORIZON_COMMIT +++ /dev/null @@ -1 +0,0 @@ -b6c936f82b08798057161bdfa577e3aac53fb91e diff --git a/docs/ENGINE.md b/docs/ENGINE.md new file mode 100644 index 0000000..87a9233 --- /dev/null +++ b/docs/ENGINE.md @@ -0,0 +1,40 @@ +# Закреплённый движок AirDef + +Движок: https://gitea.maoniao.team/chexit/horizon-airdef, ветка `airdef`. +AirDef хранит точный SHA как gitlink `horizon/`; новый push движка не меняет старые сборки мода. +История upstream и всех 44 прежних патчей сохранена в форке. Старые patch-файлы и HORIZON_COMMIT +доступны в истории AirDef и больше не являются вторым источником движка. Baseline описан в AIRDEF-FORK.md. + +## Windows: обновление и запуск + +```powershell +git fetch origin +git switch kimi-work +git pull --ff-only +python tools/airdef.py all +python tools/airdef.py play +``` + +Первое клонирование: `git clone --recurse-submodules https://gitea.maoniao.team/chexit/airdef.git`. +Обычный clone тоже поддерживается: `python tools/airdef.py fork` или команда submodule выше. +`all` инициализирует зависимость сам, включая переход со старой папки `horizon/`. +Для чистого checkout можно отдельно выполнить `git submodule update --init --recursive`. Нужны Git, Python и Rust согласно INSTALL.md. +Сборка клиента и мод устанавливаются в обычное место; ресурсы Minecraft готовятся отдельно по INSTALL.md. + +При первом переходе со старого отдельного clone `horizon/` скрипт сохраняет его Git-историю и ветки, +переводит в сабмодуль и получает закреплённый SHA. Если там есть изменения при другом SHA, +обновление останавливается с объяснением: сначала закоммитьте/сохраните свои изменения. +Скрипт не выполняет reset --hard или clean. Чистый checkout может остаться detached HEAD — это нормально для зависимости. + +## Изменение движка + +1. В `horizon/` создать рабочую ветку от закреплённого SHA. Проверить изменение и push в репозиторий движка. +2. В AirDef выполнить `git add horizon`, проверить мод/игру, коммитить обновление gitlink отдельно. +3. Push AirDef выполняется после движка. Не коммитить Git URL с токенами. +4. Для возврата выбрать прежний commit AirDef и выполнить `git submodule update --init --recursive`. + +Упаковщик собирается repo-local в `.horizon-cache/packer//`, глобальный +`cargo-horizon-mod` не используется. Изменения tracked-исходников движка входят в identity. +Рядом с airdef.hmod записывается build-identity.json: SHA AirDef, pin/identity движка, +SHA256 WIT и артефакта. API-диагностика остаётся в `python tools/airdef.py check`. +Нативные тесты не заменяют отдельную игровую проверку графики, звука и экспорта. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e63a5bc..1693f2a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -76,7 +76,7 @@ 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` (собрать игру), +Шаги по отдельности: `fork` (инициализировать закреплённый сабмодуль `horizon/`), `game` (собрать игру), `mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`. `check` говорит прямо, совпадают ли форк, упаковщик мода, уровень API мода с игрой и ресурсы, и подсказывает, что запустить, если нет (`python3 tools/airdef.py check`; Windows: `py tools\airdef.py check`). Версия Rust diff --git a/docs/REPO-MAP.md b/docs/REPO-MAP.md index 0e7811f..186fdf5 100644 --- a/docs/REPO-MAP.md +++ b/docs/REPO-MAP.md @@ -10,7 +10,7 @@ | `airdef-mod/Cargo.toml`, `horizon-mod.toml` | Сборка мода | | `airdef-mod/tests/`, `airdef-mod/examples/` | Тесты и примеры | | `airdef-mod/NOTES-terrain-guidance.md` | Рабочие заметки по рельефу | -| `horizon/`, `HORIZON_COMMIT`, `horizon-patches/` | Форк Horizon и патчи к нему | +| `horizon/`, `.gitmodules` | Закреплённый сабмодуль отдельного форка Horizon | | `tools/airdef.py` | Сборка мода и установка в папку игры | | `tools/gen_models.py`, `tools/gen_tex.py`, `tools/gen_sounds.py` | Генераторы контента (см. ниже) | | `tools/acoustics_check.py`, `tools/hd_bench.py` | Проверочные скрипты | diff --git a/horizon b/horizon new file mode 160000 index 0000000..eae00ea --- /dev/null +++ b/horizon @@ -0,0 +1 @@ +Subproject commit eae00eafc5479570797cdbb328c9f5b10102ebd3 diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch deleted file mode 100644 index bb2073d..0000000 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ /dev/null @@ -1,577 +0,0 @@ -From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/42] Mod API (AirDef fork): world models, client particles, - sounds, ground heights - -render.set-models draws block models with an interpolated pose per id; -render.spawn-particles and render.play-sound act on this client only; -render.query-ground / render.ground read the motion-blocking heightmap. - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/game.rs | 2 + - crates/horizon-client/src/game/mod_models.rs | 197 +++++++++++++++++++ - crates/horizon-client/src/game/mods.rs | 24 +++ - crates/horizon-mod-sdk/wit/horizon.wit | 58 ++++++ - crates/horizon-mods/src/api_level.rs | 10 +- - crates/horizon-mods/src/runtime.rs | 116 ++++++++++- - 6 files changed, 403 insertions(+), 4 deletions(-) - create mode 100644 crates/horizon-client/src/game/mod_models.rs - -diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs -index 1d7bbbbd6..7c342cd86 100644 ---- a/crates/horizon-client/src/game.rs -+++ b/crates/horizon-client/src/game.rs -@@ -115,6 +115,7 @@ mod mod_input; - #[cfg(feature = "mods")] - mod mod_recipes; - mod mod_render; -+mod mod_models; - mod mods; - mod mount_menu; - mod mounts; -@@ -8232,6 +8233,7 @@ impl Game<'_> { - } - } - self.mark_entity(out.len(), ""); -+ self.mod_model_geometry(camera, alpha, &mut out); - self.moving_piston_geometry(camera, alpha, &mut out); - self.mark_entity(out.len(), "minecraft:tnt"); - self.tnt_geometry(camera, alpha, &mut out); -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -new file mode 100644 -index 000000000..3c7f9345c ---- /dev/null -+++ b/crates/horizon-client/src/game/mod_models.rs -@@ -0,0 +1,197 @@ -+//! AirDef fork: capability `render`'s world models, client particles and -+//! sounds. A mod sets its models once a tick (`render.set-models`); each id -+//! keeps its last two poses and is drawn between them every frame, so models -+//! move smoothly at any frame rate. -+use super::Game; -+#[cfg(feature = "mods")] -+use { -+ glam::{DVec3, Mat4, Quat, Vec3}, -+ horizon_client::{ -+ mesher::Vertex, -+ particles::{ParticleOptions, ParticleType}, -+ }, -+ horizon_core::random::RandomSource, -+ horizon_mods::ParticleSpawn, -+ horizon_world::registry::BlockStateId, -+ std::collections::HashMap, -+}; -+ -+/// One model of a mod: where it was a tick ago and where it is now. -+#[cfg(feature = "mods")] -+struct Pose { -+ state: BlockStateId, -+ previous: DVec3, -+ position: DVec3, -+ old_rotation: Quat, -+ rotation: Quat, -+ old_scale: f32, -+ scale: f32, -+ glow: bool, -+} -+ -+/// The models the running mods keep, by (mod, id). -+#[cfg(feature = "mods")] -+#[derive(Default)] -+pub(super) struct ModModels { -+ poses: HashMap<(usize, u32), Pose>, -+ /// Block states by the text mods name them, parsed once. -+ states: HashMap>, -+} -+ -+/// Full brightness: sky and block light 15, as the entity shader packs them. -+#[cfg(feature = "mods")] -+const GLOW: u32 = 240 | (240 << 8); -+ -+/// A build without the `mods` feature draws no mod models. -+#[cfg(not(feature = "mods"))] -+impl Game<'_> { -+ pub(super) fn mod_model_geometry( -+ &self, -+ _camera: glam::DVec3, -+ _alpha: f64, -+ _out: &mut Vec, -+ ) { -+ } -+} -+ -+#[cfg(feature = "mods")] -+impl Game<'_> { -+ /// After the mods' tick: each model's new pose, its last one kept to -+ /// draw from. Ids no longer set are dropped; new ones start where they -+ /// are. -+ pub(super) fn update_mod_models(&mut self) { -+ let mut seen: Vec<(usize, u32)> = Vec::new(); -+ 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 Some(state) = state else { -+ continue; -+ }; -+ let key = (owner, m.id); -+ seen.push(key); -+ let position = DVec3::new(m.x, m.y, m.z); -+ let (x, y, z, w) = m.rotation; -+ let rotation = Quat::from_xyzw(x, y, z, w); -+ match models.poses.get_mut(&key) { -+ Some(p) => { -+ p.previous = p.position; -+ p.old_rotation = p.rotation; -+ p.old_scale = p.scale; -+ p.position = position; -+ p.rotation = rotation; -+ p.scale = m.scale; -+ p.state = state; -+ p.glow = m.glow; -+ } -+ None => { -+ models.poses.insert( -+ key, -+ Pose { -+ state, -+ previous: position, -+ position, -+ old_rotation: rotation, -+ rotation, -+ old_scale: m.scale, -+ scale: m.scale, -+ glow: m.glow, -+ }, -+ ); -+ } -+ } -+ } -+ if seen.len() != models.poses.len() { -+ let seen: std::collections::HashSet<_> = seen.into_iter().collect(); -+ models.poses.retain(|k, _| seen.contains(k)); -+ } -+ } -+ -+ /// 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) { -+ if self.mods.models.poses.is_empty() { -+ return; -+ } -+ let t = alpha.clamp(0.0, 1.0); -+ for p in self.mods.models.poses.values() { -+ let at = p.previous.lerp(p.position, t); -+ // Far models are culled by distance; the view frustum does the rest. -+ if at.distance_squared(camera) > 512.0 * 512.0 { -+ continue; -+ } -+ let rotation = p.old_rotation.slerp(p.rotation, t as f32); -+ let scale = p.old_scale + (p.scale - p.old_scale) * t as f32; -+ if scale <= 0.0 { -+ continue; -+ } -+ let light = if p.glow { -+ GLOW -+ } else { -+ let l = self.world.light( -+ at.x.floor() as i32, -+ at.y.floor() as i32, -+ at.z.floor() as i32, -+ ); -+ (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8) -+ }; -+ // The model's unit cube turned and scaled about its centre. -+ let m = Mat4::from_scale_rotation_translation( -+ Vec3::splat(scale), -+ rotation, -+ (at - camera).as_vec3(), -+ ) * Mat4::from_translation(Vec3::splat(-0.5)); -+ self.block_model_vertices(p.state, 0, m, light, true, out); -+ } -+ } -+ -+ /// `render.spawn-particles`: made on this client at once. -+ pub(super) fn mod_particles(&mut self, particles: Vec) { -+ for s in particles { -+ let Some(ty) = ParticleType::from_name(&s.particle) else { -+ continue; -+ }; -+ let rgb = [ -+ ((s.color >> 16) & 255) as f32 / 255.0, -+ ((s.color >> 8) & 255) as f32 / 255.0, -+ (s.color & 255) as f32 / 255.0, -+ ]; -+ let rgb = if s.color == 0 { [1.0; 3] } else { rgb }; -+ let options = match ty { -+ ParticleType::Dust => ParticleOptions::dust(rgb, s.scale), -+ ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => { -+ ParticleOptions::colored(ty, [rgb[0], rgb[1], rgb[2], 1.0]) -+ } -+ ParticleType::Block -+ | ParticleType::FallingDust -+ | ParticleType::DustPillar -+ | ParticleType::BlockMarker -+ | ParticleType::Item -+ | ParticleType::ItemSlime -+ | ParticleType::ItemSnowball -+ | ParticleType::ItemCobweb => continue, -+ _ => ParticleOptions::simple(ty), -+ }; -+ let (sx, sy, sz) = s.spread; -+ let (vx, vy, vz) = s.velocity; -+ let speed = f64::from(s.speed); -+ for _ in 0..s.count { -+ let r = self.particles.random(); -+ let p = [ -+ s.x + r.next_gaussian() * f64::from(sx), -+ s.y + r.next_gaussian() * f64::from(sy), -+ s.z + r.next_gaussian() * f64::from(sz), -+ ]; -+ let v = [ -+ f64::from(vx) + r.next_gaussian() * speed, -+ f64::from(vy) + r.next_gaussian() * speed, -+ f64::from(vz) + r.next_gaussian() * speed, -+ ]; -+ self.particles.create(&options, p, v); -+ } -+ } -+ } -+} -diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs -index 52dc46fe0..3eef96aa3 100644 ---- a/crates/horizon-client/src/game/mods.rs -+++ b/crates/horizon-client/src/game/mods.rs -@@ -46,6 +46,9 @@ pub(super) struct ModsState { - /// Capability `render`'s camera shakes. - #[cfg(feature = "mods")] - pub(super) shake: super::mod_render::Shake, -+ /// Capability `render`'s world models (AirDef fork), with their last poses. -+ #[cfg(feature = "mods")] -+ pub(super) models: super::mod_models::ModModels, - /// `--dev-mod PATH`: always on, reloaded when rebuilt. - pub(super) dev: Vec, - /// When dev mods were last checked for a rebuild. -@@ -596,9 +599,17 @@ impl Game<'_> { - return; - } - self.tick_mod_use(); -+ for (i, columns) in self.mods.mods.take_ground_queries() { -+ let ground = columns -+ .into_iter() -+ .map(|(x, z)| (x, z, self.rain_height(x, z))) -+ .collect(); -+ self.mods.mods.answer_ground(i, ground); -+ } - let snapshot = self.mod_snapshot(); - self.mods.mods.set_snapshot(snapshot); - self.mods.mods.tick(self.ticks); -+ self.update_mod_models(); - self.show_mod_output(); - } - /// Restarts the dev mods rebuilt since the last check, once a second. -@@ -716,6 +727,19 @@ impl Game<'_> { - self.mods.shake.add(strength, seconds); - continue; - } -+ Output::Particles { particles } => { -+ self.mod_particles(particles); -+ continue; -+ } -+ Output::Sound { -+ event, -+ at, -+ volume, -+ pitch, -+ } => { -+ self.audio.play(&event, Some(at), volume, pitch); -+ continue; -+ } - Output::PlaceRecipe { book_id, all } => { - self.mod_place_recipe(book_id, all); - continue; -diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit -index 0680cce92..89d1906a9 100644 ---- a/crates/horizon-mod-sdk/wit/horizon.wit -+++ b/crates/horizon-mod-sdk/wit/horizon.wit -@@ -429,6 +429,64 @@ interface render { - /// Distance setting, when on, wins. One far terrain shows at a time: the - /// first loaded mod's. - set-far-terrain: func(far-terrain: option); -+ /// A block model drawn in the world (AirDef fork). -+ record model-instance { -+ /// Stays the same while the object lives: the game moves an id -+ /// smoothly from where the last `set-models` put it. -+ id: u32, -+ /// 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. -+ model: string, -+ /// Where the model's centre is, in blocks. -+ x: f64, -+ y: f64, -+ z: f64, -+ /// Rotation about the model's centre: a quaternion (x, y, z, w). -+ rotation: tuple, -+ /// Uniform scale, 1.0 a block (0 to 64). -+ scale: f32, -+ /// Drawn at full brightness, whatever the light where it is. -+ glow: bool, -+ } -+ /// 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. -+ set-models: func(models: list); -+ /// Particles to spawn on this client (AirDef fork). -+ record particle-spawn { -+ /// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`. -+ particle: string, -+ x: f64, -+ y: f64, -+ z: f64, -+ /// Gaussian spread round the point, per axis, in blocks. -+ spread: tuple, -+ /// Every particle's starting velocity, blocks a tick. -+ velocity: tuple, -+ /// Gaussian speed added to it per axis. -+ speed: f32, -+ /// 1 to 256. -+ count: u32, -+ /// 0xRRGGBB for dust and coloured types (0: white). -+ color: u32, -+ /// Dust size (0.01 to 4). -+ scale: f32, -+ } -+ /// Spawns particles on this client only: no command, no server. -+ /// At most 512 spawns a call. -+ spawn-particles: func(particles: list); -+ /// Plays a sound event (`minecraft:entity.generic.explode`, -+ /// `:`) at a point on this client. -+ play-sound: func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32); -+ /// Asks for the ground height of these columns (x, z): at most 512 a -+ /// tick, answered by `ground` from the next tick on. -+ query-ground: func(columns: list>); -+ /// 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. -+ ground: func() -> list>; - } - - /// 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 045a9346b..b64792eaa 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 = 7; -+pub const LEVEL: u32 = 8; - - /// 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); 46] = [ -+pub const FUNCTIONS: [(&str, u32); 51] = [ - ("log.write", 0), - ("game.get-player", 0), - ("game.get-clock", 0), -@@ -60,6 +60,12 @@ pub const FUNCTIONS: [(&str, u32); 46] = [ - ("recipes.place", 6), - ("recipes.give", 6), - ("render.set-far-terrain", 7), -+ // AirDef fork: world models, client particles and sounds. -+ ("render.set-models", 8), -+ ("render.spawn-particles", 8), -+ ("render.play-sound", 8), -+ ("render.query-ground", 8), -+ ("render.ground", 8), - ]; - - /// 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 cff44722b..317cd4f06 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -71,6 +71,7 @@ mod render_bindings { - wasmtime::component::bindgen!({ - path: "../horizon-mod-sdk/wit", - world: "render-host", -+ additional_derives: [PartialEq], - }); - } - use render_bindings::horizon::mods::input; -@@ -79,8 +80,8 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I - /// 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, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape, -- TextShape, Zoom as ModZoom, -+ Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape, -+ Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom, - }; - use render_bindings::{RenderHost, horizon::mods::render}; - /// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items. -@@ -238,6 +239,15 @@ pub enum Output { - CloseScreen { owner: usize }, - /// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10). - Shake { strength: f32, seconds: f32 }, -+ /// `render.spawn-particles` (AirDef fork): particles to make on this client. -+ Particles { particles: Vec }, -+ /// `render.play-sound` (AirDef fork): a sound event at a point on this client. -+ Sound { -+ event: String, -+ at: [f64; 3], -+ volume: f32, -+ pitch: f32, -+ }, - /// `recipes.place`: the recipe book's click on this display id, all of - /// it when `all`. - PlaceRecipe { book_id: i32, all: bool }, -@@ -416,6 +426,12 @@ struct State { - minimap: Option, - /// Capability `render`'s far terrain, when the mod draws one. - far_terrain: Option, -+ /// Capability `render`'s world models (AirDef fork), drawn until replaced. -+ models: Vec, -+ /// `render.query-ground`'s columns, answered before the next tick. -+ ground_queries: Vec<(i32, i32)>, -+ /// The game's answers to them. -+ ground: Vec<(i32, i32, i32)>, - input: InputState, - browser: BrowserState, - /// Capability `storage`'s folder; made on first `set`. -@@ -619,6 +635,9 @@ impl Mods { - zoom: None, - minimap: None, - far_terrain: None, -+ models: Vec::new(), -+ ground_queries: Vec::new(), -+ ground: Vec::new(), - input: InputState::default(), - browser: BrowserState::default(), - data_dir, -@@ -1173,6 +1192,32 @@ impl Mods { - .iter() - .find_map(|e| e.run.as_ref()?.store.data().minimap) - } -+ /// 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 { -+ self.entries.iter().enumerate().flat_map(|(i, e)| { -+ e.run -+ .iter() -+ .flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m))) -+ }) -+ } -+ /// 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 -+ .iter_mut() -+ .enumerate() -+ .filter_map(|(i, e)| { -+ let q = std::mem::take(&mut e.run.as_mut()?.store.data_mut().ground_queries); -+ (!q.is_empty()).then_some((i, q)) -+ }) -+ .collect() -+ } -+ /// Mod `i`'s answers to its ground queries, for its next calls. -+ pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) { -+ if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) { -+ run.store.data_mut().ground = ground; -+ } -+ } - /// The far terrain the first running mod draws (capability `render`). - pub fn far_terrain(&self) -> Option { - self.entries -@@ -1734,6 +1779,12 @@ const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0); - const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0); - /// `set-far-terrain`'s distance in chunks, as the LOD Distance setting's. - const FAR_TERRAIN_DISTANCE: (u32, u32) = (64, 1024); -+/// `set-models`' most models a mod keeps, and their largest scale. -+const MODELS: usize = 4096; -+const MODEL_SCALE: f32 = 64.0; -+/// `spawn-particles`' most spawns a call and particles a spawn. -+const PARTICLE_SPAWNS: usize = 512; -+const PARTICLES_PER_SPAWN: u32 = 256; - - /// A mod's corner panel: shapes in a `width` × `height` box the game - /// places in `corner`, stacked with the other overlays. -@@ -1851,6 +1902,67 @@ impl render::Host for State { - self.out.push(Output::Shake { strength, seconds }); - } - } -+ fn set_models(&mut self, models: Vec) { -+ 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(); -+ } -+ fn spawn_particles(&mut self, particles: Vec) { -+ let particles: Vec = particles -+ .into_iter() -+ .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) -+ .take(PARTICLE_SPAWNS) -+ .map(|mut p| { -+ p.count = p.count.clamp(1, PARTICLES_PER_SPAWN); -+ p.particle = clip(p.particle); -+ p -+ }) -+ .collect(); -+ if !particles.is_empty() { -+ self.out.push(Output::Particles { particles }); -+ } -+ } -+ 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)); -+ } -+ fn ground(&mut self) -> Vec<(i32, i32, i32)> { -+ self.ground.clone() -+ } -+ fn play_sound(&mut self, event: String, x: f64, y: f64, z: f64, volume: f32, pitch: f32) { -+ 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(); -+ if sounds < 64 { -+ self.out.push(Output::Sound { -+ event: clip(event), -+ at: [x, y, z], -+ volume: ok(volume, 16.0), -+ pitch: ok(pitch, 2.0), -+ }); -+ } -+ } - } - - impl command::Host for State { --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch deleted file mode 100644 index c6ab2ca..0000000 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ /dev/null @@ -1,28 +0,0 @@ -From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/42] cargo-horizon-mod: report particles and sounds - -Co-Authored-By: Claude Opus 5.5 ---- - crates/cargo-horizon-mod/src/check.rs | 4 ++++ - 1 file changed, 4 insertions(+) - -diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs -index 988ba826d..6a91fda28 100644 ---- a/crates/cargo-horizon-mod/src/check.rs -+++ b/crates/cargo-horizon-mod/src/check.rs -@@ -280,6 +280,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { - Output::Shake { strength, seconds } => { - format!("shook the camera: {strength:.2} for {seconds:.1} s") - } -+ Output::Particles { particles } => { -+ format!("spawned {} particle group(s)", particles.len()) -+ } -+ Output::Sound { event, .. } => format!("played {event}"), - }); - } - let shapes = mods.hud_shapes().count(); --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch deleted file mode 100644 index 92f1e6d..0000000 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ /dev/null @@ -1,603 +0,0 @@ -From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/42] 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 -the world, before the hand and the HUD; render.set-params feeds it. - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/game.rs | 1 + - crates/horizon-client/src/game/mod_models.rs | 28 ++ - crates/horizon-client/src/gfx.rs | 35 +- - crates/horizon-client/src/gfx/mod_post.rs | 320 +++++++++++++++++++ - crates/horizon-mod-sdk/wit/horizon.wit | 13 + - crates/horizon-mods/src/api_level.rs | 5 +- - crates/horizon-mods/src/runtime.rs | 28 ++ - 7 files changed, 428 insertions(+), 2 deletions(-) - create mode 100644 crates/horizon-client/src/gfx/mod_post.rs - -diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs -index 7c342cd86..ca6a8c266 100644 ---- a/crates/horizon-client/src/game.rs -+++ b/crates/horizon-client/src/game.rs -@@ -11527,6 +11527,7 @@ impl Game<'_> { - return Ok(false); - }; - let alpha = self.clock.fraction(); -+ self.apply_mod_post(alpha as f32); - // Camera.getCameraEntityPartialTicks: the view, the hand and the - // player's own model stay smooth while the level is frozen. - let camera_alpha = self.clock.camera_fraction(); -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -index 3c7f9345c..be00ea4c7 100644 ---- a/crates/horizon-client/src/game/mod_models.rs -+++ b/crates/horizon-client/src/game/mod_models.rs -@@ -36,6 +36,8 @@ pub(super) struct ModModels { - poses: HashMap<(usize, u32), Pose>, - /// Block states by the text mods name them, parsed once. - states: HashMap>, -+ /// The version of the mod post pass the renderer has (0: none). -+ post: u64, - } - - /// Full brightness: sky and block light 15, as the entity shader packs them. -@@ -52,6 +54,7 @@ impl Game<'_> { - _out: &mut Vec, - ) { - } -+ pub(super) fn apply_mod_post(&mut self, _partial: f32) {} - } - - #[cfg(feature = "mods")] -@@ -148,6 +151,31 @@ impl Game<'_> { - } - } - -+ /// Before each frame: the renderer gets the mod's post pass when it changed, and its -+ /// parameters. A pass that doesn't compile is reported once; the last good one runs on. -+ pub(super) fn apply_mod_post(&mut self, partial: f32) { -+ let (version, source, params) = match self.mods.mods.post() { -+ Some((v, s, p)) => (v, Some(s.to_owned()), p.to_vec()), -+ None => (0, None, Vec::new()), -+ }; -+ let mut error = None; -+ let Some(renderer) = self.renderer.as_mut() else { return }; -+ if version != self.mods.models.post { -+ self.mods.models.post = version; -+ error = renderer.set_mod_post(source.as_deref()).err(); -+ } -+ if version != 0 { -+ renderer.set_mod_params(¶ms, partial); -+ } -+ if let Some(e) = error { -+ log::warn!("Horizon: mod post pass refused: {e}"); -+ self.system_message( -+ horizon_client::text::Text::literal(format!("Mod shader error: {e}")) -+ .with_color(horizon_client::text::RED), -+ ); -+ } -+ } -+ - /// `render.spawn-particles`: made on this client at once. - pub(super) fn mod_particles(&mut self, particles: Vec) { - for s in particles { -diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs -index beb8b9060..4c5005615 100644 ---- a/crates/horizon-client/src/gfx.rs -+++ b/crates/horizon-client/src/gfx.rs -@@ -77,6 +77,7 @@ mod lod; - mod native_material; - mod native_pack; - mod native_post; -+mod mod_post; - mod native_sky; - mod occlusion; - mod pack_atlas; -@@ -543,6 +544,9 @@ pub struct Renderer { - pack_post: Option<(i32, Option)>, - /// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s. - native: Option, -+ /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for. -+ mod_post: Option, -+ mod_params: (Vec, f32), - /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). - pass_timing: bool, - /// The entity vertices Horizon draws itself while a shader pack draws the others. -@@ -2646,6 +2650,8 @@ impl Renderer { - hdr_output, - pack_post: None, - native: None, -+ mod_post: None, -+ mod_params: (Vec::new(), 0.0), - pass_timing: pack_timing::PassTiming::logging(), - pack_entity_rest: Vec::new(), - cloud_bindings, -@@ -3287,6 +3293,24 @@ impl Renderer { - report - } - /// Drops the running shader pack, of either kind. -+ /// AirDef fork: runs a mod's post pass from now on (`None`: none). On an error the pass -+ /// that ran keeps running. -+ pub fn set_mod_post(&mut self, source: Option<&str>) -> std::result::Result<(), String> { -+ match source { -+ None => self.mod_post = None, -+ Some(source) => { -+ let format = self.config.format.remove_srgb_suffix(); -+ self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); -+ } -+ } -+ Ok(()) -+ } -+ /// AirDef fork: the mod post pass's parameters for the next frames. -+ pub fn set_mod_params(&mut self, params: &[f32], partial: f32) { -+ self.mod_params.0.clear(); -+ self.mod_params.0.extend_from_slice(¶ms[..params.len().min(mod_post::PARAMS)]); -+ self.mod_params.1 = partial; -+ } - pub fn drop_shader_packs(&mut self) { - self.pack_post = None; - self.native = None; -@@ -4456,7 +4480,9 @@ impl Renderer { - .then(|| pack_timing::PassTiming::new(&self.device, &self.queue)) - .flatten(); - } -- let pack_reads_world = pack_post || native_post; -+ // AirDef fork: a mod's post pass reads the world target too. -+ let mod_post = !frame.title && !pack_post && !native_post && self.mod_post.is_some(); -+ let pack_reads_world = pack_post || native_post || mod_post; - // The pack's program reads the vertices this frame's draws bind. - let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat; - // The shadow camera's lists, drawn by the pack's shadow programs. -@@ -5026,6 +5052,13 @@ impl Renderer { - size, - native.timing.as_mut(), - ); -+ } else if let Some((color, depth)) = &scaled -+ && mod_post -+ && let Some(post) = &mut self.mod_post -+ { -+ let size = self.world_post.size().unwrap_or([1, 1]); -+ post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); -+ post.run(&self.device, &mut encoder, (color, depth), &view_target); - } else if scaled.is_some() { - self.world_post.finish(&mut encoder, &view_target); - } -diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs -new file mode 100644 -index 000000000..497cff854 ---- /dev/null -+++ b/crates/horizon-client/src/gfx/mod_post.rs -@@ -0,0 +1,320 @@ -+//! AirDef fork: a mod's full-screen post pass (`render.set-post`). The mod's WGSL is put after -+//! a prelude that declares the frame (camera matrices, time, the mod's parameters) and the -+//! world's colour and depth; it defines `fn post(uv: vec2, pixel: vec2) -> vec4`. -+//! The pass runs after the world and before the hand and the HUD, reading the scaled world -+//! target and drawing onto the screen. -+ -+use glam::Mat4; -+ -+use super::Frame; -+ -+/// Floats of mod parameters (256 vec4). -+pub const PARAMS: usize = 1024; -+ -+/// The uniform the prelude's `frame` reads. -+#[repr(C)] -+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] -+struct ModFrame { -+ view_proj: [[f32; 4]; 4], -+ view_inverse: [[f32; 4]; 4], -+ projection_inverse: [[f32; 4]; 4], -+ /// xyz: the camera; w: unused. -+ camera: [f32; 4], -+ /// width, height, 1/width, 1/height. -+ resolution: [f32; 4], -+ /// seconds (wraps hourly), the partial tick, the sun's height, unused. -+ time: [f32; 4], -+ /// The sun's direction. -+ sun: [f32; 4], -+ params: [[f32; 4]; PARAMS / 4], -+} -+ -+pub const PRELUDE: &str = r#" -+struct ModFrame { -+ view_proj: mat4x4, -+ view_inverse: mat4x4, -+ projection_inverse: mat4x4, -+ camera: vec4, -+ resolution: vec4, -+ time: vec4, -+ sun: vec4, -+ params: array, 256>, -+} -+@group(0) @binding(0) var frame: ModFrame; -+@group(0) @binding(1) var scene_color: texture_2d; -+@group(0) @binding(2) var scene_depth: texture_depth_2d; -+@group(0) @binding(3) var linear_sampler: sampler; -+ -+struct HorizonModPostOut { -+ @builtin(position) position: vec4, -+ @location(0) uv: vec2, -+} -+ -+@vertex -+fn horizon_mod_post_vs(@builtin(vertex_index) i: u32) -> HorizonModPostOut { -+ let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); -+ var out: HorizonModPostOut; -+ out.position = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0); -+ out.uv = uv; -+ return out; -+} -+ -+/// The world's depth at `uv` (reversed: 1 near, 0 the sky). -+fn depth_at(uv: vec2) -> f32 { -+ let size = vec2(textureDimensions(scene_depth)); -+ let p = vec2(clamp(uv * size, vec2(0.0), size - 1.0)); -+ return textureLoad(scene_depth, p, 0); -+} -+ -+/// The point the pixel at `uv` sees at `depth`, relative to the camera (world axes). -+fn view_ray_point(uv: vec2, depth: f32) -> vec3 { -+ let ndc = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0); -+ let v = frame.projection_inverse * ndc; -+ return (frame.view_inverse * vec4(v.xyz / v.w, 0.0)).xyz; -+} -+ -+/// The unit direction of the view ray through `uv`. -+fn view_dir(uv: vec2) -> vec3 { -+ return normalize(view_ray_point(uv, 0.5)); -+} -+ -+/// How far along the view ray the world is at `uv` (a large number for the sky). -+fn scene_distance(uv: vec2) -> f32 { -+ let d = depth_at(uv); -+ if d <= 0.0 { return 1.0e6; } -+ return length(view_ray_point(uv, d)); -+} -+ -+/// A world point on the screen: xy in uv, z its distance (negative behind the camera). -+fn project(world: vec3) -> vec3 { -+ let c = frame.view_proj * vec4(world - frame.camera.xyz, 1.0); -+ let ndc = c.xyz / max(abs(c.w), 1e-5); -+ return vec3(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, c.w); -+} -+ -+fn scene(uv: vec2) -> vec3 { -+ return textureSampleLevel(scene_color, linear_sampler, uv, 0.0).rgb; -+} -+"#; -+ -+const ENTRY: &str = r#" -+@fragment -+fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4 { -+ return post(in.uv, in.position.xy); -+} -+"#; -+ -+pub struct ModPost { -+ pipeline: wgpu::RenderPipeline, -+ layout: wgpu::BindGroupLayout, -+ buffer: wgpu::Buffer, -+ sampler: wgpu::Sampler, -+ group: Option<((wgpu::TextureView, wgpu::TextureView), wgpu::BindGroup)>, -+ start: std::time::Instant, -+} -+ -+/// The mod's source with the prelude, checked by naga: errors name the mod's own lines. -+fn compile(source: &str) -> Result { -+ let full = format!("{PRELUDE}\n{source}\n{ENTRY}"); -+ let module = naga::front::wgsl::parse_str(&full).map_err(|e| { -+ let prelude_lines = PRELUDE.lines().count() + 1; -+ let text = e.emit_to_string(&full); -+ match e.location(&full) { -+ Some(l) if l.line_number as usize > prelude_lines => { -+ format!("line {}: {}", l.line_number as usize - prelude_lines, e.message()) -+ } -+ _ => text, -+ } -+ })?; -+ naga::valid::Validator::new( -+ naga::valid::ValidationFlags::all(), -+ naga::valid::Capabilities::default(), -+ ) -+ .validate(&module) -+ .map_err(|e| format!("{}", e.into_inner()))?; -+ Ok(module) -+} -+ -+impl ModPost { -+ pub fn new(device: &wgpu::Device, source: &str, format: wgpu::TextureFormat) -> Result { -+ let module = compile(source)?; -+ let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { -+ binding, -+ visibility: wgpu::ShaderStages::FRAGMENT, -+ ty: wgpu::BindingType::Texture { -+ sample_type, -+ view_dimension: wgpu::TextureViewDimension::D2, -+ multisampled: false, -+ }, -+ count: None, -+ }; -+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { -+ label: Some("Mod post"), -+ entries: &[ -+ wgpu::BindGroupLayoutEntry { -+ binding: 0, -+ visibility: wgpu::ShaderStages::FRAGMENT, -+ ty: wgpu::BindingType::Buffer { -+ ty: wgpu::BufferBindingType::Uniform, -+ has_dynamic_offset: false, -+ min_binding_size: wgpu::BufferSize::new(size_of::() as u64), -+ }, -+ count: None, -+ }, -+ texture(1, wgpu::TextureSampleType::Float { filterable: true }), -+ texture(2, wgpu::TextureSampleType::Depth), -+ wgpu::BindGroupLayoutEntry { -+ binding: 3, -+ visibility: wgpu::ShaderStages::FRAGMENT, -+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), -+ count: None, -+ }, -+ ], -+ }); -+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { -+ label: Some("Mod post"), -+ bind_group_layouts: &[Some(&layout)], -+ ..Default::default() -+ }); -+ let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); -+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { -+ 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, -+ }); -+ if let Some(e) = pollster::block_on(scope.pop()) { -+ return Err(e.to_string()); -+ } -+ let buffer = device.create_buffer(&wgpu::BufferDescriptor { -+ label: Some("Mod post frame"), -+ size: size_of::() as u64, -+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, -+ mapped_at_creation: false, -+ }); -+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor { -+ label: Some("Mod post linear"), -+ mag_filter: wgpu::FilterMode::Linear, -+ min_filter: wgpu::FilterMode::Linear, -+ ..Default::default() -+ }); -+ Ok(Self { -+ pipeline, -+ layout, -+ buffer, -+ sampler, -+ group: None, -+ start: std::time::Instant::now(), -+ }) -+ } -+ -+ /// This frame's camera and the mod's `params`, for a world of `size`. -+ pub fn write(&self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2], params: &[f32], partial: f32) { -+ let aspect = size[0] as f32 / size[1].max(1) as f32; -+ let view = crate::camera::view_matrix(frame.yaw, frame.pitch); -+ let projection = -+ super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob * frame.spin; -+ let a = frame.sky.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() { -+ p[i / 4][i % 4] = *v; -+ } -+ let m = |m: Mat4| m.to_cols_array_2d(); -+ let inputs = ModFrame { -+ 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], -+ resolution: [ -+ size[0] as f32, -+ size[1] as f32, -+ 1.0 / size[0].max(1) as f32, -+ 1.0 / size[1].max(1) as f32, -+ ], -+ time: [self.start.elapsed().as_secs_f32() % 3600.0, partial, sun[1], 0.0], -+ sun, -+ params: p, -+ }; -+ queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs)); -+ } -+ -+ /// Draws the pass over the world target `world` (colour, depth) onto `screen`. -+ pub fn run( -+ &mut self, -+ device: &wgpu::Device, -+ encoder: &mut wgpu::CommandEncoder, -+ world: (&wgpu::TextureView, &wgpu::TextureView), -+ screen: &wgpu::TextureView, -+ ) { -+ if self -+ .group -+ .as_ref() -+ .is_none_or(|((c, d), _)| c != world.0 || d != world.1) -+ { -+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor { -+ label: Some("Mod post"), -+ layout: &self.layout, -+ entries: &[ -+ wgpu::BindGroupEntry { binding: 0, resource: self.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) }, -+ ], -+ }); -+ self.group = Some(((world.0.clone(), world.1.clone()), group)); -+ } -+ let Some((_, group)) = &self.group else { return }; -+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { -+ label: Some("Mod post"), -+ color_attachments: &[Some(wgpu::RenderPassColorAttachment { -+ view: screen, -+ 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.pipeline); -+ pass.set_bind_group(0, group, &[]); -+ pass.draw(0..3, 0..1); -+ } -+} -+ -+#[cfg(test)] -+mod tests { -+ #[test] -+ fn a_shader_compiles_and_errors_name_its_line() { -+ let ok = "fn post(uv: vec2, pixel: vec2) -> vec4 { return vec4(scene(uv), 1.0); }"; -+ assert!(super::compile(ok).is_ok()); -+ let bad = "fn post(uv: vec2, pixel: vec2) -> vec4 {\n return nope;\n}"; -+ let e = super::compile(bad).unwrap_err(); -+ assert!(e.starts_with("line 2"), "{e}"); -+ } -+} -diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit -index 89d1906a9..1b167c7f7 100644 ---- a/crates/horizon-mod-sdk/wit/horizon.wit -+++ b/crates/horizon-mod-sdk/wit/horizon.wit -@@ -487,6 +487,19 @@ interface render { - /// the highest block that blocks motion or holds a fluid, as the motion - /// blocking heightmap; -64 where the chunk isn't loaded. - ground: func() -> list>; -+ /// A full-screen post pass (AirDef fork): WGSL defining -+ /// `fn post(uv: vec2, pixel: vec2) -> vec4`, 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. -+ set-post: func(wgsl: string); -+ 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 -+ /// `frame.time.y`). -+ set-params: func(params: list); - } - - /// 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 b64792eaa..68487e600 100644 ---- a/crates/horizon-mods/src/api_level.rs -+++ b/crates/horizon-mods/src/api_level.rs -@@ -13,7 +13,7 @@ pub const LEVEL: u32 = 8; - /// 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); 51] = [ -+pub const FUNCTIONS: [(&str, u32); 54] = [ - ("log.write", 0), - ("game.get-player", 0), - ("game.get-clock", 0), -@@ -66,6 +66,9 @@ pub const FUNCTIONS: [(&str, u32); 51] = [ - ("render.play-sound", 8), - ("render.query-ground", 8), - ("render.ground", 8), -+ ("render.set-post", 8), -+ ("render.clear-post", 8), -+ ("render.set-params", 8), - ]; - - /// 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 317cd4f06..55c1fd13f 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -432,6 +432,9 @@ struct State { - ground_queries: Vec<(i32, i32)>, - /// The game's answers to them. - ground: Vec<(i32, i32, i32)>, -+ /// `render.set-post`'s source, with a number that changes with it. -+ post: Option<(u64, String)>, -+ post_params: Vec, - input: InputState, - browser: BrowserState, - /// Capability `storage`'s folder; made on first `set`. -@@ -638,6 +641,8 @@ impl Mods { - models: Vec::new(), - ground_queries: Vec::new(), - ground: Vec::new(), -+ post: None, -+ post_params: Vec::new(), - input: InputState::default(), - browser: BrowserState::default(), - data_dir, -@@ -1212,6 +1217,14 @@ impl Mods { - }) - .collect() - } -+ /// The first running mod's post pass (version, WGSL) and its parameters. -+ pub fn post(&self) -> Option<(u64, &str, &[f32])> { -+ self.entries.iter().find_map(|e| { -+ let d = e.run.as_ref()?.store.data(); -+ let (v, src) = d.post.as_ref()?; -+ Some((*v, src.as_str(), d.post_params.as_slice())) -+ }) -+ } - /// Mod `i`'s answers to its ground queries, for its next calls. - pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) { - if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) { -@@ -1941,6 +1954,21 @@ impl render::Host for State { - self.out.push(Output::Particles { particles }); - } - } -+ fn set_post(&mut self, wgsl: String) { -+ static VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); -+ let v = VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); -+ self.post = Some((v, clip_to(wgsl, 256 * 1024))); -+ } -+ fn clear_post(&mut self) { -+ self.post = None; -+ } -+ fn set_params(&mut self, params: Vec) { -+ self.post_params = params -+ .into_iter() -+ .take(1024) -+ .map(|v| if v.is_finite() { v } else { 0.0 }) -+ .collect(); -+ } - 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)); --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch deleted file mode 100644 index 8977476..0000000 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ /dev/null @@ -1,48 +0,0 @@ -From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/42] Mod models: draw far models as scaled impostors past - the far plane, cull at 4096 - ---- - crates/horizon-client/src/game/mod_models.rs | 20 ++++++++++++++++---- - 1 file changed, 16 insertions(+), 4 deletions(-) - -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -index be00ea4c7..6cd55afc6 100644 ---- a/crates/horizon-client/src/game/mod_models.rs -+++ b/crates/horizon-client/src/game/mod_models.rs -@@ -120,14 +120,26 @@ impl Game<'_> { - return; - } - let t = alpha.clamp(0.0, 1.0); -+ // Missiles are seen from far off: past the far plane a model is drawn nearer and -+ // smaller, the same size on screen. -+ let depth_far = f64::from(horizon_client::camera::depth_far( -+ self.terrain_distance(), -+ self.settings.cloud_range, -+ )); -+ let reach = depth_far * 0.85; - for p in self.mods.models.poses.values() { -- let at = p.previous.lerp(p.position, t); -- // Far models are culled by distance; the view frustum does the rest. -- if at.distance_squared(camera) > 512.0 * 512.0 { -+ let mut at = p.previous.lerp(p.position, t); -+ let d = at.distance(camera); -+ if d > 4096.0 { - continue; - } -+ let mut shrink = 1.0f32; -+ if d > reach { -+ shrink = (reach / d) as f32; -+ at = camera + (at - camera) * (reach / d); -+ } - let rotation = p.old_rotation.slerp(p.rotation, t as f32); -- let scale = p.old_scale + (p.scale - p.old_scale) * t as f32; -+ let scale = (p.old_scale + (p.scale - p.old_scale) * t as f32) * shrink; - if scale <= 0.0 { - continue; - } --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch deleted file mode 100644 index 4d9d621..0000000 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ /dev/null @@ -1,927 +0,0 @@ -From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/42] 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 ---- - crates/horizon-client/src/game.rs | 3 + - crates/horizon-client/src/game/mod_models.rs | 39 ++- - crates/horizon-client/src/game/mod_render.rs | 9 + - crates/horizon-client/src/gfx.rs | 192 ++++++++++- - crates/horizon-client/src/gfx/mod_views.rs | 318 +++++++++++++++++++ - crates/horizon-client/src/hud.rs | 15 + - crates/horizon-mod-sdk/wit/horizon.wit | 30 ++ - crates/horizon-mods/src/api_level.rs | 5 +- - crates/horizon-mods/src/runtime.rs | 30 +- - 9 files changed, 624 insertions(+), 17 deletions(-) - create mode 100644 crates/horizon-client/src/gfx/mod_views.rs - -diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs -index ca6a8c266..e5ffb30c4 100644 ---- a/crates/horizon-client/src/game.rs -+++ b/crates/horizon-client/src/game.rs -@@ -11806,7 +11806,9 @@ 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); - let frame = Frame { -+ mod_views: &mod_views, - weather: &weather, - weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), - nether_lighting: self.dim == Dim::Nether, -@@ -14725,6 +14727,7 @@ fn loading_renderer( - /// A title-shaped frame: black clear and panorama, only the HUD is drawn. - fn loading_frame(hud: &HudBatch) -> Frame<'_> { - Frame { -+ mod_views: &[], - weather: &[], - weather_time: horizon_client::weather::Time::new(0, 0.0), - camera: glam::DVec3::new(0.5, 100.0, 0.5), -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -index 6cd55afc6..7ae58f664 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::ParticleSpawn, -+ horizon_mods::{CameraView, ParticleSpawn}, - horizon_world::registry::BlockStateId, - std::collections::HashMap, - }; -@@ -38,6 +38,8 @@ pub(super) struct ModModels { - states: HashMap>, - /// The version of the mod post pass the renderer has (0: none). - post: u64, -+ /// The mods' cameras by id: a tick ago and now. -+ views: Vec<(CameraView, CameraView)>, - } - - /// Full brightness: sky and block light 15, as the entity shader packs them. -@@ -55,6 +57,9 @@ impl Game<'_> { - ) { - } - pub(super) fn apply_mod_post(&mut self, _partial: f32) {} -+ pub(super) fn mod_views(&self, _alpha: f64) -> Vec { -+ Vec::new() -+ } - } - - #[cfg(feature = "mods")] -@@ -111,6 +116,38 @@ impl Game<'_> { - let seen: std::collections::HashSet<_> = seen.into_iter().collect(); - models.poses.retain(|k, _| seen.contains(k)); - } -+ let views: Vec = self.mods.mods.views().map(|(_, v)| *v).collect(); -+ let old = std::mem::take(&mut self.mods.models.views); -+ self.mods.models.views = views -+ .into_iter() -+ .map(|v| { -+ let before = old.iter().find(|(_, o)| o.id == v.id).map_or(v, |(_, o)| *o); -+ (before, v) -+ }) -+ .collect(); -+ } -+ -+ /// The mods' cameras between their last two settings, for this frame. -+ pub(super) fn mod_views(&self, alpha: f64) -> Vec { -+ let t = alpha.clamp(0.0, 1.0); -+ let lerp = |a: f32, b: f32| a + (b - a) * t as f32; -+ self.mods -+ .models -+ .views -+ .iter() -+ .map(|(a, b)| { -+ // The short way round. -+ let turn = (b.yaw - a.yaw + 540.0).rem_euclid(360.0) - 180.0; -+ horizon_client::gfx::ModView { -+ id: b.id, -+ camera: DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t), -+ yaw: a.yaw + turn * t as f32, -+ pitch: lerp(a.pitch, b.pitch), -+ fov: lerp(a.fov, b.fov), -+ size: [b.width, b.height], -+ } -+ }) -+ .collect() - } - - /// Draws every mod model between its last two poses, lit by the world -diff --git a/crates/horizon-client/src/game/mod_render.rs b/crates/horizon-client/src/game/mod_render.rs -index 931aec641..85aae67e7 100644 ---- a/crates/horizon-client/src/game/mod_render.rs -+++ b/crates/horizon-client/src/game/mod_render.rs -@@ -120,6 +120,15 @@ impl Game<'_> { - self.draw_item_model(batch, &stack, x, y); - batch.scale_since(start, x, y, i.scale, i.scale); - } -+ Shape::View(v) => { -+ let r = v.rect; -+ batch.views.push(horizon_client::hud::HudView { -+ at: batch.vertices.len() as u32, -+ id: v.view, -+ rect: [ox + r.x, oy + r.y, r.w, r.h], -+ mode: v.mode, -+ }); -+ } - Shape::Text(t) => { - let text = Text::literal(t.text.clone()); - let centre = if t.centered { -diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs -index 4c5005615..3ad257728 100644 ---- a/crates/horizon-client/src/gfx.rs -+++ b/crates/horizon-client/src/gfx.rs -@@ -78,6 +78,8 @@ mod native_material; - mod native_pack; - mod native_post; - mod mod_post; -+mod mod_views; -+pub use mod_views::ModView; - mod native_sky; - mod occlusion; - mod pack_atlas; -@@ -214,6 +216,8 @@ const SECTION_UPLOAD_CHUNK: u64 = 4 << 20; - const SECTION_COPY_LIMIT: u64 = 64 << 20; - pub struct Frame<'a> { - pub camera: DVec3, -+ /// Mods' cameras (AirDef fork), drawn into the pictures the HUD shows. -+ pub mod_views: &'a [ModView], - pub yaw: f32, - pub pitch: f32, - pub fov_degrees: f32, -@@ -546,6 +550,7 @@ pub struct Renderer { - native: Option, - /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for. - mod_post: Option, -+ mod_views: Option, - mod_params: (Vec, f32), - /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). - pass_timing: bool, -@@ -2651,6 +2656,7 @@ impl Renderer { - pack_post: None, - native: None, - mod_post: None, -+ mod_views: None, - mod_params: (Vec::new(), 0.0), - pass_timing: pack_timing::PassTiming::logging(), - pack_entity_rest: Vec::new(), -@@ -3019,6 +3025,128 @@ impl Renderer { - self.section_uploads.finish(&encoder); - Some(encoder.finish()) - } -+ /// The HUD's vertices in `range`, with the mod cameras' pictures between -+ /// them where their shapes were. -+ fn draw_hud( -+ &self, -+ encoder: &mut wgpu::CommandEncoder, -+ target: &wgpu::TextureView, -+ batch: &crate::hud::HudBatch, -+ range: std::ops::Range, -+ ) { -+ let size = [self.config.width, self.config.height]; -+ let last = batch.vertices.len() as u32; -+ let mut at = range.start; -+ if let Some(views) = &self.mod_views { -+ for v in batch.views.iter().filter(|v| { -+ v.at >= range.start && (v.at < range.end || (v.at == range.end && range.end == last)) -+ }) { -+ self.hud.draw_range(encoder, target, &self.depth, batch, size, at..v.at); -+ views.blit(encoder, target, size, v, batch.scale); -+ at = v.at; -+ } -+ } -+ self.hud.draw_range(encoder, target, &self.depth, batch, size, at..range.end); -+ } -+ /// The mod cameras due this frame, each into its picture: sky, solid -+ /// terrain and entities, through the world's own pipelines with the -+ /// camera's globals in the world's buffer (the player's put back after). -+ fn draw_mod_views( -+ &mut self, -+ encoder: &mut wgpu::CommandEncoder, -+ frame: &Frame, -+ globals: &Globals, -+ due: &[mod_views::Due], -+ entity_quads: u32, -+ ) { -+ let Some(views) = &mut self.mod_views else { -+ return; -+ }; -+ let size = std::mem::size_of::() as u64; -+ self.uploads -+ .buffer(encoder, &views.main, 0, bytemuck::bytes_of(globals)); -+ for d in due { -+ let Some(t) = views.targets.get(&d.id) else { -+ continue; -+ }; -+ let own = Globals { -+ view_proj: d.view_proj.to_cols_array_2d(), -+ inverse_view: d.view.inverse().to_cols_array_2d(), -+ misc: [ -+ globals.misc[0], -+ globals.misc[1], -+ d.aspect, -+ (d.fov.to_radians() / 2.0).tan(), -+ ], -+ ..*globals -+ }; -+ self.uploads -+ .buffer(encoder, &t.globals, 0, bytemuck::bytes_of(&own)); -+ } -+ let fog = globals.fog_color; -+ let clear = wgpu::Color { -+ r: f64::from(fog[0]), -+ g: f64::from(fog[1]), -+ b: f64::from(fog[2]), -+ a: 1.0, -+ }; -+ for d in due { -+ let Some(t) = views.targets.get_mut(&d.id) else { -+ continue; -+ }; -+ t.drawn = true; -+ encoder.copy_buffer_to_buffer(&t.globals, 0, &self.globals, 0, size); -+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { -+ label: Some("Mod view"), -+ color_attachments: &[Some(wgpu::RenderPassColorAttachment { -+ view: &t.color, -+ resolve_target: None, -+ depth_slice: None, -+ ops: wgpu::Operations { -+ load: wgpu::LoadOp::Clear(clear), -+ store: wgpu::StoreOp::Store, -+ }, -+ })], -+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { -+ view: &t.depth, -+ depth_ops: Some(wgpu::Operations { -+ load: wgpu::LoadOp::Clear(0.0), -+ store: wgpu::StoreOp::Discard, -+ }), -+ stencil_ops: None, -+ }), -+ ..Default::default() -+ }); -+ pass.set_bind_group(0, &self.group, &[]); -+ if frame.skybox { -+ pass.set_pipeline(&self.sky); -+ pass.draw(0..3, 0..1); -+ } -+ pass.set_pipeline(&self.solid); -+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); -+ pass.set_vertex_buffer(1, self.instances.slice(..)); -+ let mut page = None; -+ for (k, v) in d.visible.iter().enumerate() { -+ let s = self.sections.get(v.slot); -+ if s.solid == 0 { -+ continue; -+ } -+ if page != Some(s.page) { -+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); -+ page = Some(s.page); -+ } -+ let instance = d.first + k as u32; -+ pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); -+ } -+ if entity_quads > 0 { -+ pass.set_pipeline(&self.entity_pipeline); -+ 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); -+ } -+ } -+ encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size); -+ } - /// Draws `count` section draws from `first` with the arena and section - /// origins bound: one indirect multi-draw, or a loop without it. - /// Consecutive draws in one arena page share a vertex buffer binding, -@@ -3827,7 +3955,46 @@ impl Renderer { - }); - } - self.drawn_sections = visible.len() + visible_lods.len(); -- let instances = self.drawn_sections + shadow_visible.len(); -+ // Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's. -+ let globals_size = std::mem::size_of::() as u64; -+ let mut views_due = Vec::new(); -+ if !frame.title && !frame.mod_views.is_empty() { -+ let format = self.config.format.remove_srgb_suffix(); -+ let device = &self.device; -+ let views = self -+ .mod_views -+ .get_or_insert_with(|| mod_views::Views::new(device, format, globals_size)); -+ let mut first = (self.drawn_sections + shadow_visible.len()) as u32; -+ let radius = ((frame.detail_distance / 16.0) as i32).min(mod_views::RADIUS); -+ for v in views.choose(device, frame.mod_views, globals_size) { -+ let (view_proj, view, aspect) = mod_views::matrices(v, camera, frame.depth_far); -+ let own = world_projection(v.fov, aspect, frame.depth_far) * view; -+ let planes = Frustum { planes: frustum(own) }; -+ let mut seen = Vec::new(); -+ self.sections -+ .visible(v.camera, radius, &planes, |_| true, |_, _| false, &mut seen); -+ // Relative to the player's camera, as the rest of the frame. -+ let shift = (v.camera - camera).as_vec3(); -+ for s in &mut seen { -+ s.origin = [s.origin[0] + shift.x, s.origin[1] + shift.y, s.origin[2] + shift.z]; -+ } -+ let n = seen.len() as u32; -+ views_due.push(mod_views::Due { -+ id: v.id, -+ view_proj, -+ view, -+ aspect, -+ fov: v.fov, -+ visible: seen, -+ first, -+ }); -+ first += n; -+ } -+ } else if frame.mod_views.is_empty() { -+ self.mod_views = None; -+ } -+ let view_sections: usize = views_due.iter().map(|d| d.visible.len()).sum(); -+ let instances = self.drawn_sections + shadow_visible.len() + view_sections; - if instances as u64 > self.instance_capacity { - self.instance_capacity = (instances as u64).next_power_of_two(); - self.instances = self.device.create_buffer(&wgpu::BufferDescriptor { -@@ -3845,7 +4012,12 @@ impl Renderer { - .iter() - .map(|v| section_instance(v.origin)) - .chain(visible_lods.iter().map(|v| lod::instance(v.origin))) -- .chain(shadow_visible.iter().map(|v| section_instance(v.origin))), -+ .chain(shadow_visible.iter().map(|v| section_instance(v.origin))) -+ .chain( -+ views_due -+ .iter() -+ .flat_map(|d| d.visible.iter().map(|v| section_instance(v.origin))), -+ ), - ); - if !self.origins.is_empty() { - self.uploads.buffer( -@@ -4461,6 +4633,9 @@ impl Renderer { - bytemuck::cast_slice(&self.draws), - ); - } -+ if !views_due.is_empty() { -+ self.draw_mod_views(&mut encoder, frame, &globals, &views_due, entity_quads); -+ } - let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor { - format: Some(self.config.format.remove_srgb_suffix()), - ..Default::default() -@@ -5167,14 +5342,7 @@ impl Renderer { - .menu_blur - .filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)); - let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at); -- self.hud.draw_range( -- &mut encoder, -- &view_target, -- &self.depth, -- frame.hud, -- [self.config.width, self.config.height], -- 0..blur_at, -- ); -+ self.draw_hud(&mut encoder, &view_target, frame.hud, 0..blur_at); - if let Some(radius) = menu_blur { - let format = self.config.format; - let device = &self.device; -@@ -5190,12 +5358,10 @@ impl Renderer { - radius, - ); - } -- self.hud.draw_range( -+ self.draw_hud( - &mut encoder, - &view_target, -- &self.depth, - frame.hud, -- [self.config.width, self.config.height], - blur_at..frame.hud.vertices.len() as u32, - ); - if culling && let Some(cull) = &mut self.cull { -diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs -new file mode 100644 -index 000000000..91ae103a8 ---- /dev/null -+++ b/crates/horizon-client/src/gfx/mod_views.rs -@@ -0,0 +1,318 @@ -+//! AirDef fork: mods' cameras (`render.set-views`). The world is drawn from -+//! another point into a small picture (sky, solid terrain, entities), which -+//! the HUD's `view` shapes show. The picture's passes reuse the world's -+//! pipelines and bind groups: its globals are copied into the world's -+//! buffer before them and the camera's copied back after. -+use glam::{DVec3, Mat4}; -+use std::collections::HashMap; -+ -+/// One camera of a mod, as the game interpolated it for this frame. -+#[derive(Clone, Copy, Debug)] -+pub struct ModView { -+ pub id: u32, -+ pub camera: DVec3, -+ /// Degrees, as the player's. -+ pub yaw: f32, -+ pub pitch: f32, -+ pub fov: f32, -+ pub size: [u32; 2], -+} -+ -+pub use crate::hud::HudView; -+ -+/// The most cameras drawn a frame; the others wait their turn. -+pub(super) const PER_FRAME: usize = 3; -+/// Sections round a camera it draws, in chunks. -+pub(super) const RADIUS: i32 = 12; -+ -+pub(super) struct Target { -+ size: [u32; 2], -+ pub color: wgpu::TextureView, -+ pub depth: wgpu::TextureView, -+ pub globals: wgpu::Buffer, -+ blit: wgpu::BindGroup, -+ /// Drawn at least once: the HUD shows nothing before. -+ pub drawn: bool, -+} -+ -+/// A camera drawn this frame. -+pub(super) struct Due { -+ pub id: u32, -+ /// Camera-relative to the player's camera, as the entities and the -+ /// world's other geometry are. -+ pub view_proj: Mat4, -+ pub view: Mat4, -+ pub aspect: f32, -+ pub fov: f32, -+ pub visible: Vec, -+ /// The instance of `visible[0]` in the section origins. -+ pub first: u32, -+} -+ -+pub(super) struct Views { -+ pub targets: HashMap, -+ /// The player's camera's globals, copied back after the pictures. -+ pub main: wgpu::Buffer, -+ cursor: usize, -+ format: wgpu::TextureFormat, -+ layout: wgpu::BindGroupLayout, -+ sampler: wgpu::Sampler, -+ pipeline: wgpu::RenderPipeline, -+} -+ -+const BLIT: &str = r#" -+@group(0) @binding(0) var picture: texture_2d; -+@group(0) @binding(1) var picture_sampler: sampler; -+struct Out { -+ @builtin(position) position: vec4, -+ @location(0) uv: vec2, -+ @location(1) @interpolate(flat) mode: u32, -+} -+@vertex -+fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mode: u32) -> Out { -+ let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); -+ var out: Out; -+ out.position = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); -+ out.uv = uv; -+ out.mode = mode; -+ return out; -+} -+fn hash(p: vec2) -> f32 { -+ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.547); -+} -+@fragment -+fn fs(in: Out) -> @location(0) vec4 { -+ let c = textureSample(picture, picture_sampler, in.uv).rgb; -+ let l = dot(c, vec3(0.299, 0.587, 0.114)); -+ let grain = (hash(in.position.xy + vec2(c.r * 91.0, c.g * 37.0)) - 0.5) * 0.06; -+ if in.mode == 1u { -+ // White hot: the cold sky dark, the land grey, flames and engines burning white. -+ let warm = max(c.r - c.b, 0.0) * 2.2; -+ let cold = max(c.b - c.r, 0.0) * 2.5; -+ let glow = smoothstep(0.8, 1.0, max(c.r, c.g)); -+ // High gain: a thermal camera sees as well by night. -+ let v = clamp(0.06 + pow(l, 0.4) * 0.55 + warm + glow * 0.9 - cold + grain, 0.0, 1.0); -+ return vec4(v, v * 0.98, v * 0.94, 1.0); -+ } -+ if in.mode == 2u { -+ let v = clamp(pow(l, 0.45) * 1.5 + grain, 0.0, 1.0); -+ return vec4(v * 0.3, v, v * 0.35, 1.0); -+ } -+ return vec4(c, 1.0); -+} -+"#; -+ -+impl Views { -+ pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, globals_size: u64) -> Self { -+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { -+ label: Some("Mod view picture"), -+ entries: &[ -+ wgpu::BindGroupLayoutEntry { -+ binding: 0, -+ visibility: wgpu::ShaderStages::FRAGMENT, -+ ty: wgpu::BindingType::Texture { -+ sample_type: wgpu::TextureSampleType::Float { filterable: true }, -+ view_dimension: wgpu::TextureViewDimension::D2, -+ multisampled: false, -+ }, -+ count: None, -+ }, -+ wgpu::BindGroupLayoutEntry { -+ binding: 1, -+ visibility: wgpu::ShaderStages::FRAGMENT, -+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), -+ count: None, -+ }, -+ ], -+ }); -+ let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { -+ label: Some("Mod view blit"), -+ source: wgpu::ShaderSource::Wgsl(BLIT.into()), -+ }); -+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { -+ label: Some("Mod view blit"), -+ bind_group_layouts: &[Some(&layout)], -+ ..Default::default() -+ }); -+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { -+ label: Some("Mod view blit"), -+ layout: Some(&pipeline_layout), -+ vertex: wgpu::VertexState { -+ module: &module, -+ entry_point: Some("vs"), -+ compilation_options: Default::default(), -+ buffers: &[], -+ }, -+ fragment: Some(wgpu::FragmentState { -+ module: &module, -+ entry_point: Some("fs"), -+ compilation_options: Default::default(), -+ targets: &[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 sampler = device.create_sampler(&wgpu::SamplerDescriptor { -+ label: Some("Mod view picture"), -+ mag_filter: wgpu::FilterMode::Linear, -+ min_filter: wgpu::FilterMode::Linear, -+ ..Default::default() -+ }); -+ let main = device.create_buffer(&wgpu::BufferDescriptor { -+ label: Some("Camera globals"), -+ size: globals_size, -+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, -+ mapped_at_creation: false, -+ }); -+ Self { -+ targets: HashMap::new(), -+ main, -+ cursor: 0, -+ format, -+ layout, -+ sampler, -+ pipeline, -+ } -+ } -+ -+ /// The cameras to draw this frame, in turn, at most `PER_FRAME`; their -+ /// targets made or remade to size, the gone ones' dropped. -+ pub fn choose<'a>( -+ &mut self, -+ device: &wgpu::Device, -+ views: &'a [ModView], -+ globals_size: u64, -+ ) -> Vec<&'a ModView> { -+ self.targets.retain(|id, _| views.iter().any(|v| v.id == *id)); -+ for v in views { -+ if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) { -+ let t = self.target(device, v.size, globals_size); -+ self.targets.insert(v.id, t); -+ } -+ } -+ if views.is_empty() { -+ return Vec::new(); -+ } -+ let n = views.len().min(PER_FRAME); -+ let start = self.cursor % views.len(); -+ self.cursor = self.cursor.wrapping_add(n); -+ (0..n).map(|k| &views[(start + k) % views.len()]).collect() -+ } -+ -+ fn target(&self, device: &wgpu::Device, size: [u32; 2], globals_size: u64) -> Target { -+ let extent = wgpu::Extent3d { -+ width: size[0], -+ height: size[1], -+ depth_or_array_layers: 1, -+ }; -+ let texture = |label, format, usage| { -+ device -+ .create_texture(&wgpu::TextureDescriptor { -+ label: Some(label), -+ size: extent, -+ mip_level_count: 1, -+ sample_count: 1, -+ dimension: wgpu::TextureDimension::D2, -+ format, -+ usage, -+ view_formats: &[], -+ }) -+ .create_view(&Default::default()) -+ }; -+ let color = texture( -+ "Mod view colour", -+ self.format, -+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, -+ ); -+ let depth = texture( -+ "Mod view depth", -+ wgpu::TextureFormat::Depth32Float, -+ wgpu::TextureUsages::RENDER_ATTACHMENT, -+ ); -+ 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 globals = device.create_buffer(&wgpu::BufferDescriptor { -+ label: Some("Mod view globals"), -+ size: globals_size, -+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, -+ mapped_at_creation: false, -+ }); -+ Target { -+ size, -+ color, -+ depth, -+ globals, -+ blit, -+ drawn: false, -+ } -+ } -+ -+ /// Shows a camera's picture in `rect` (framebuffer pixels) of `screen`. -+ pub fn blit( -+ &self, -+ encoder: &mut wgpu::CommandEncoder, -+ screen: &wgpu::TextureView, -+ screen_size: [u32; 2], -+ view: &HudView, -+ scale: f32, -+ ) { -+ let Some(t) = self.targets.get(&view.id).filter(|t| t.drawn) else { -+ return; -+ }; -+ let [x, y, w, h] = view.rect.map(|v| v * scale); -+ let (x0, y0) = (x.max(0.0), y.max(0.0)); -+ let x1 = (x + w).min(screen_size[0] as f32); -+ let y1 = (y + h).min(screen_size[1] as f32); -+ if x1 - x0 < 1.0 || y1 - y0 < 1.0 { -+ return; -+ } -+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { -+ label: Some("Mod view picture"), -+ color_attachments: &[Some(wgpu::RenderPassColorAttachment { -+ view: screen, -+ resolve_target: None, -+ depth_slice: None, -+ ops: wgpu::Operations { -+ load: wgpu::LoadOp::Load, -+ store: wgpu::StoreOp::Store, -+ }, -+ })], -+ ..Default::default() -+ }); -+ 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, &[]); -+ let mode = view.mode.min(2); -+ pass.draw(0..3, mode..mode + 1); -+ } -+} -+ -+/// A camera's view and projection: the view from `camera`, moved so that -+/// geometry relative to `eye` (the player's camera) lands right. -+pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) { -+ let aspect = v.size[0] as f32 / v.size[1].max(1) as f32; -+ let view = crate::camera::view_matrix(v.yaw, v.pitch); -+ let projection = super::world_projection(v.fov, aspect, far); -+ let offset = (eye - v.camera).as_vec3(); -+ (projection * view * Mat4::from_translation(offset), view, aspect) -+} -diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs -index fda0094d8..41af0650e 100644 ---- a/crates/horizon-client/src/hud.rs -+++ b/crates/horizon-client/src/hud.rs -@@ -625,6 +625,17 @@ pub struct HudVertex { - } - /// The GUI Scale option (0 = auto). - pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); -+/// Where the HUD shows a mod camera's picture (AirDef fork): before its -+/// vertex `at`, over what was drawn before. -+#[derive(Clone, Copy, Debug)] -+pub struct HudView { -+ pub at: u32, -+ pub id: u32, -+ /// GUI pixels: x, y, width, height. -+ pub rect: [f32; 4], -+ /// 0 as seen, 1 thermal, 2 night vision. -+ pub mode: u32, -+} - /// One frame of overlay geometry in GUI pixels. - pub struct HudBatch { - pub vertices: Vec, -@@ -641,6 +652,8 @@ pub struct HudBatch { - pub glint_alpha: f32, - /// Screen's first vertex: the preceding HUD is part of its blurred background. - pub blur_at: u32, -+ /// Mod cameras' pictures, in vertex order. -+ pub views: Vec, - } - /// How a run of HUD vertices blends and tests depth. - #[derive(Clone, Copy, PartialEq, Eq, Debug)] -@@ -692,6 +705,7 @@ impl HudBatch { - end_portal: EndPortalFill::default(), - glint_alpha: 0.75, - blur_at: 0, -+ views: Vec::new(), - } - } - /// Draw the vertices added since `start` with the inverting blend. -@@ -2008,6 +2022,7 @@ mod tests { - end_portal: EndPortalFill::default(), - glint_alpha: 0.75, - blur_at: 0, -+ views: Vec::new(), - }; - for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] { - assert_eq!( -diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit -index 1b167c7f7..436455a19 100644 ---- a/crates/horizon-mod-sdk/wit/horizon.wit -+++ b/crates/horizon-mod-sdk/wit/horizon.wit -@@ -338,12 +338,21 @@ interface render { - centered: bool, - shadow: bool, - } -+ /// A picture of one of the mod's `set-views` cameras (AirDef fork). -+ record view-shape { -+ /// The camera's id; an unknown one draws nothing. -+ view: u32, -+ rect: rect, -+ /// 0 as seen, 1 thermal (white hot), 2 night vision (green). -+ mode: u32, -+ } - variant shape { - fill(fill-shape), - quad(quad-shape), - sprite(sprite-shape), - item(item-shape), - text(text-shape), -+ view(view-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 -@@ -500,6 +509,27 @@ interface render { - /// replaced; call it once a tick (the pass gets the partial tick in - /// `frame.time.y`). - set-params: func(params: list); -+ /// 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. -+ record camera-view { -+ /// Stays the same while the camera lives: the game turns it smoothly -+ /// between the last two `set-views`. -+ id: u32, -+ x: f64, -+ y: f64, -+ z: f64, -+ /// Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down. -+ yaw: f32, -+ pitch: f32, -+ /// Vertical field of view, degrees (1 to 110). -+ fov: f32, -+ /// The picture's size in pixels (16 to 640 × 16 to 480). -+ width: u32, -+ height: u32, -+ } -+ /// 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); - } - - /// 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 68487e600..9d15851b4 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 = 8; -+pub const LEVEL: u32 = 9; - - /// 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); 54] = [ -+pub const FUNCTIONS: [(&str, u32); 55] = [ - ("log.write", 0), - ("game.get-player", 0), - ("game.get-clock", 0), -@@ -69,6 +69,7 @@ pub const FUNCTIONS: [(&str, u32); 54] = [ - ("render.set-post", 8), - ("render.clear-post", 8), - ("render.set-params", 8), -+ ("render.set-views", 9), - ]; - - /// 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 55c1fd13f..01000dfeb 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -81,7 +81,7 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I - 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, -- Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom, -+ CameraView, 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. -@@ -435,6 +435,8 @@ struct State { - /// `render.set-post`'s source, with a number that changes with it. - post: Option<(u64, String)>, - post_params: Vec, -+ /// `render.set-views`' cameras (AirDef fork). -+ views: Vec, - input: InputState, - browser: BrowserState, - /// Capability `storage`'s folder; made on first `set`. -@@ -643,6 +645,7 @@ impl Mods { - ground: Vec::new(), - post: None, - post_params: Vec::new(), -+ views: Vec::new(), - input: InputState::default(), - browser: BrowserState::default(), - data_dir, -@@ -1206,6 +1209,14 @@ impl Mods { - .flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m))) - }) - } -+ /// Every running mod's cameras (AirDef fork), with the mod's index. -+ pub fn views(&self) -> impl Iterator { -+ self.entries.iter().enumerate().flat_map(|(i, e)| { -+ e.run -+ .iter() -+ .flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v))) -+ }) -+ } - /// 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 -@@ -1969,6 +1980,23 @@ impl render::Host for State { - .map(|v| if v.is_finite() { v } else { 0.0 }) - .collect(); - } -+ fn set_views(&mut self, views: Vec) { -+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0; -+ let angle = |v: f32| if v.is_finite() { v } else { 0.0 }; -+ self.views = views -+ .into_iter() -+ .filter(|v| finite(v.x) && finite(v.y) && finite(v.z)) -+ .take(8) -+ .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.width = v.width.clamp(16, 640); -+ v.height = v.height.clamp(16, 480); -+ v -+ }) -+ .collect(); -+ } - 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)); --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch deleted file mode 100644 index e6d368d..0000000 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ /dev/null @@ -1,30 +0,0 @@ -From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/42] Mods: more fuel per tick and per event (AirDef plans - raids) - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-mods/src/runtime.rs | 4 ++-- - 1 file changed, 2 insertions(+), 2 deletions(-) - -diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs -index 01000dfeb..a551b7376 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -108,9 +108,9 @@ const MEMORY: usize = 64 << 20; - /// Fuel (about one unit per WebAssembly instruction) per call: a bound on - /// runaway loops, far above the hooks' time budgets. - const FUEL_INIT: u64 = 2_000_000_000; --const FUEL_TICK: u64 = 50_000_000; -+const FUEL_TICK: u64 = 300_000_000; - const FUEL_FRAME: u64 = 10_000_000; --const FUEL_EVENT: u64 = 100_000_000; -+const FUEL_EVENT: u64 = 1_000_000_000; - /// Blocks a mod may set per `world.set-blocks` call. - pub const BLOCKS_PER_CALL: usize = 4096; - /// Chat lines a mod may show per call, HUD lines and their length. --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch deleted file mode 100644 index 58a9478..0000000 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ /dev/null @@ -1,42 +0,0 @@ -From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 -From: chexit -Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/42] Mod cameras up to 1920x1080 (full-screen replays) - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-mod-sdk/wit/horizon.wit | 2 +- - crates/horizon-mods/src/runtime.rs | 4 ++-- - 2 files changed, 3 insertions(+), 3 deletions(-) - -diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit -index 436455a19..e30f3a30e 100644 ---- a/crates/horizon-mod-sdk/wit/horizon.wit -+++ b/crates/horizon-mod-sdk/wit/horizon.wit -@@ -523,7 +523,7 @@ interface render { - pitch: f32, - /// Vertical field of view, degrees (1 to 110). - fov: f32, -- /// The picture's size in pixels (16 to 640 × 16 to 480). -+ /// The picture's size in pixels (16 to 1920 × 16 to 1080). - width: u32, - height: u32, - } -diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs -index a551b7376..1389407be 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -1991,8 +1991,8 @@ impl render::Host for State { - 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.width = v.width.clamp(16, 640); -- v.height = v.height.clamp(16, 480); -+ v.width = v.width.clamp(16, 1920); -+ v.height = v.height.clamp(16, 1080); - v - }) - .collect(); --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch deleted file mode 100644 index c26a7b5..0000000 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ /dev/null @@ -1,158 +0,0 @@ -From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/42] Mod cameras: distant terrain tiles, fog measured from - the camera, no impostors while one is shown - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/game/mod_models.rs | 4 +- - crates/horizon-client/src/gfx.rs | 53 +++++++++++++++++--- - crates/horizon-client/src/gfx/mod_views.rs | 4 ++ - 3 files changed, 52 insertions(+), 9 deletions(-) - -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -index 7ae58f664..87d46c843 100644 ---- a/crates/horizon-client/src/game/mod_models.rs -+++ b/crates/horizon-client/src/game/mod_models.rs -@@ -164,6 +164,8 @@ impl Game<'_> { - self.settings.cloud_range, - )); - let reach = depth_far * 0.85; -+ // A mod camera (a replay's, a system's) must see them where they are: no impostors then. -+ let impostors = self.mods.models.views.is_empty(); - for p in self.mods.models.poses.values() { - let mut at = p.previous.lerp(p.position, t); - let d = at.distance(camera); -@@ -171,7 +173,7 @@ impl Game<'_> { - continue; - } - let mut shrink = 1.0f32; -- if d > reach { -+ if d > reach && impostors { - shrink = (reach / d) as f32; - at = camera + (at - camera) * (reach / d); - } -diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs -index 3ad257728..9772e3a83 100644 ---- a/crates/horizon-client/src/gfx.rs -+++ b/crates/horizon-client/src/gfx.rs -@@ -3078,6 +3078,9 @@ impl Renderer { - d.aspect, - (d.fov.to_radians() / 2.0).tan(), - ], -+ // Fog goes by the distance from the player's camera: pushed out by how far this -+ // camera is from it, so what is near this one stays clear. -+ fog: globals.fog.map(|f| f + d.away), - ..*globals - }; - self.uploads -@@ -3138,6 +3141,19 @@ impl Renderer { - let instance = d.first + k as u32; - pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); - } -+ for (k, v) in d.lods.iter().enumerate() { -+ let Some(tile) = self.lods.get(&v.key) else { continue }; -+ let s = &tile.mesh; -+ if s.solid == 0 { -+ continue; -+ } -+ if page != Some(s.page) { -+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); -+ page = Some(s.page); -+ } -+ let instance = d.first + (d.visible.len() + k) as u32; -+ pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); -+ } - if entity_quads > 0 { - pass.set_pipeline(&self.entity_pipeline); - pass.set_bind_group(0, &self.entity_group, &[]); -@@ -3971,14 +3987,30 @@ impl Renderer { - let own = world_projection(v.fov, aspect, frame.depth_far) * view; - let planes = Frustum { planes: frustum(own) }; - let mut seen = Vec::new(); -- self.sections -- .visible(v.camera, radius, &planes, |_| true, |_, _| false, &mut seen); -+ // Where distant terrain covers a column, its tile draws instead, as in the world pass. -+ self.sections.visible( -+ v.camera, -+ radius, -+ &planes, -+ |_| true, -+ |x, z| !cutout.is_empty() && cutout.contains(&(x, z)), -+ &mut seen, -+ ); -+ let mut tiles: Vec = self -+ .lods -+ .iter() -+ .filter_map(|(&key, tile)| tile.visible(key, v.camera, frame.distance, &planes)) -+ .collect(); - // Relative to the player's camera, as the rest of the frame. - let shift = (v.camera - camera).as_vec3(); -+ let moved = |o: [f32; 3]| [o[0] + shift.x, o[1] + shift.y, o[2] + shift.z]; - for s in &mut seen { -- s.origin = [s.origin[0] + shift.x, s.origin[1] + shift.y, s.origin[2] + shift.z]; -+ s.origin = moved(s.origin); -+ } -+ for t in &mut tiles { -+ t.origin = moved(t.origin); - } -- let n = seen.len() as u32; -+ let n = (seen.len() + tiles.len()) as u32; - views_due.push(mod_views::Due { - id: v.id, - view_proj, -@@ -3986,14 +4018,16 @@ impl Renderer { - aspect, - fov: v.fov, - visible: seen, -+ lods: tiles, - first, -+ away: shift.length(), - }); - first += n; - } - } else if frame.mod_views.is_empty() { - self.mod_views = None; - } -- let view_sections: usize = views_due.iter().map(|d| d.visible.len()).sum(); -+ let view_sections: usize = views_due.iter().map(|d| d.visible.len() + d.lods.len()).sum(); - let instances = self.drawn_sections + shadow_visible.len() + view_sections; - if instances as u64 > self.instance_capacity { - self.instance_capacity = (instances as u64).next_power_of_two(); -@@ -4014,9 +4048,12 @@ impl Renderer { - .chain(visible_lods.iter().map(|v| lod::instance(v.origin))) - .chain(shadow_visible.iter().map(|v| section_instance(v.origin))) - .chain( -- views_due -- .iter() -- .flat_map(|d| d.visible.iter().map(|v| section_instance(v.origin))), -+ views_due.iter().flat_map(|d| { -+ d.visible -+ .iter() -+ .map(|v| section_instance(v.origin)) -+ .chain(d.lods.iter().map(|v| lod::instance(v.origin))) -+ }), - ), - ); - if !self.origins.is_empty() { -diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs -index 91ae103a8..c0d886afd 100644 ---- a/crates/horizon-client/src/gfx/mod_views.rs -+++ b/crates/horizon-client/src/gfx/mod_views.rs -@@ -45,8 +45,12 @@ pub(super) struct Due { - pub aspect: f32, - pub fov: f32, - pub visible: Vec, -+ /// Distant terrain tiles in its view, after the sections. -+ pub lods: Vec, - /// The instance of `visible[0]` in the section origins. - pub first: u32, -+ /// How far it is from the player's camera: fog is measured from there. -+ pub away: f32, - } - - pub(super) struct Views { --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch deleted file mode 100644 index cc5fee9..0000000 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ /dev/null @@ -1,126 +0,0 @@ -From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/42] Mods: game.world-id, so mods keep state per world (API - level 9) - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/game.rs | 1 + - crates/horizon-client/src/game/mods.rs | 3 +++ - crates/horizon-client/src/game/remote.rs | 4 ++++ - crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++ - crates/horizon-mods/src/api_level.rs | 3 ++- - crates/horizon-mods/src/runtime.rs | 5 +++++ - 6 files changed, 19 insertions(+), 1 deletion(-) - -diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs -index e5ffb30c4..c6b642345 100644 ---- a/crates/horizon-client/src/game.rs -+++ b/crates/horizon-client/src/game.rs -@@ -8918,6 +8918,7 @@ impl Game<'_> { - create: Option<&horizon_client::title::WorldOptions>, - ) -> Result<()> { - world_loading::world_data_used(); -+ self.mods.world_id = Some(format!("world:{folder}")); - if self.defer_world_entry(PendingWorldEntry::Local { - folder: folder.to_owned(), - name: name.map(str::to_owned), -diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs -index 3eef96aa3..ebdd18a24 100644 ---- a/crates/horizon-client/src/game/mods.rs -+++ b/crates/horizon-client/src/game/mods.rs -@@ -38,6 +38,8 @@ use { - - #[derive(Default)] - pub(super) struct ModsState { -+ /// The open world, for `game.world-id` (AirDef fork): its folder, or the server's address. -+ pub(super) world_id: Option, - #[cfg(feature = "mods")] - pub(super) mods: Mods, - /// Capability `input`: a claimed item's use while the button is down. -@@ -482,6 +484,7 @@ impl Game<'_> { - known_recipes, - placing: self.mod_recipe_screen(), - creative: self.creative, -+ world: self.player.is_some().then(|| self.mods.world_id.clone()).flatten(), - } - } - /// The first block along the player's view within -diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs -index 22b7c13df..89df0616b 100644 ---- a/crates/horizon-client/src/game/remote.rs -+++ b/crates/horizon-client/src/game/remote.rs -@@ -342,6 +342,10 @@ impl Game<'_> { - cookies: Option, - ) -> Result<()> { - self.perf.world_opening(); -+ // A server's own world; joining the world this window hosts keeps its folder. -+ if local.is_none() { -+ self.mods.world_id = Some(format!("server:{address}")); -+ } - let root = self.options.resources.clone(); - let builtin = Builtin::find(&root) - .and_then(|p| Builtin::load(&p).ok()) -diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit -index e30f3a30e..b6758d9fb 100644 ---- a/crates/horizon-mod-sdk/wit/horizon.wit -+++ b/crates/horizon-mod-sdk/wit/horizon.wit -@@ -47,6 +47,10 @@ interface game { - get-player: func() -> option; - /// None on the title screen. - get-clock: func() -> option; -+ /// Which world is open (AirDef fork): `world:` for a saved -+ /// world, `server:
` on a server; none on the title screen. -+ /// Mods keep their state apart by it. -+ world-id: func() -> option; - /// A block along the player's view. - record hit { - /// The block hit. -diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs -index 9d15851b4..cf2993186 100644 ---- a/crates/horizon-mods/src/api_level.rs -+++ b/crates/horizon-mods/src/api_level.rs -@@ -13,7 +13,7 @@ pub const LEVEL: u32 = 9; - /// 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); 55] = [ -+pub const FUNCTIONS: [(&str, u32); 56] = [ - ("log.write", 0), - ("game.get-player", 0), - ("game.get-clock", 0), -@@ -70,6 +70,7 @@ pub const FUNCTIONS: [(&str, u32); 55] = [ - ("render.clear-post", 8), - ("render.set-params", 8), - ("render.set-views", 9), -+ ("game.world-id", 9), - ]; - - /// 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 1389407be..abed5b31f 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -168,6 +168,8 @@ pub struct Snapshot { - pub placing: bool, - /// The player is in creative mode (`recipes.give`). - pub creative: bool, -+ /// The open world (`game.world-id`, AirDef fork). -+ pub world: Option, - } - - /// Capability `recipes`'s tables: `items` in the registry's order, which -@@ -1475,6 +1477,9 @@ impl game::Host for State { - fn get_clock(&mut self) -> Option { - self.snapshot.clock - } -+ fn world_id(&mut self) -> Option { -+ self.snapshot.world.clone() -+ } - fn raycast(&mut self, distance: f32) -> Option { - let (at, hit) = self.snapshot.view_hit.as_ref()?; - // NaN compares false: no hit. --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch deleted file mode 100644 index 0c411c6..0000000 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ /dev/null @@ -1,98 +0,0 @@ -From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/42] cargo-horizon-mod: trial runs behind feature full, so a - packing-only tool skips wasmtime - -Co-Authored-By: Claude Opus 5.5 ---- - crates/cargo-horizon-mod/Cargo.toml | 8 +++++++- - crates/cargo-horizon-mod/src/main.rs | 16 ++++++++++++++-- - 2 files changed, 21 insertions(+), 3 deletions(-) - -diff --git a/crates/cargo-horizon-mod/Cargo.toml b/crates/cargo-horizon-mod/Cargo.toml -index 34eefe227..21b8a9221 100644 ---- a/crates/cargo-horizon-mod/Cargo.toml -+++ b/crates/cargo-horizon-mod/Cargo.toml -@@ -6,8 +6,14 @@ license.workspace = true - rust-version = "1.86" - description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)" - -+[features] -+default = ["full"] -+# The trial runs (`check`, `test`) and the registry's CI need wasmtime. Without this feature the -+# tool is new, build and run only, and builds in a fraction of the time (CI that only packs mods). -+full = ["horizon-mods/runtime", "horizon-mods/index"] -+ - [dependencies] --horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] } -+horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] } - serde_json = "1.0.151" - - [lints] -diff --git a/crates/cargo-horizon-mod/src/main.rs b/crates/cargo-horizon-mod/src/main.rs -index 95d0a9eb3..ff4ab663b 100644 ---- a/crates/cargo-horizon-mod/src/main.rs -+++ b/crates/cargo-horizon-mod/src/main.rs -@@ -11,12 +11,15 @@ - //! - `check [--json]`: what the registry checks of a submission - //! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod. - //! - `registry build|review|check|list`: the registry repository's CI. -+#[cfg(feature = "full")] -+use horizon_mods::testing::{Script, TestGame}; - use horizon_mods::{ - Capability, Content, Manifest, Package, - manifest::{MANIFEST, component_imports, componentize}, -- testing::{Script, TestGame}, - }; -+#[cfg(feature = "full")] - mod check; -+#[cfg(feature = "full")] - mod registry; - - use std::{ -@@ -39,9 +42,16 @@ fn main() -> ExitCode { - println!("Built {}", p.display()); - }), - Some("run") => run(&args[1..]), -+ #[cfg(feature = "full")] - Some("test") => test(&args[1..]), -+ #[cfg(feature = "full")] - Some("check") => check(&args[1..]), -+ #[cfg(feature = "full")] - Some("registry") => registry::main(&args[1..]), -+ #[cfg(not(feature = "full"))] -+ Some("test" | "check" | "registry") => { -+ Err("this cargo-horizon-mod was built without trial runs (feature `full`)".into()) -+ } - _ => Err("usage: cargo horizon-mod new [--template code|content] | build [--debug] | run [--game ] [-- ] | test [--debug] [--hmod ] [] | check [--kind ] [--key --signature ] [--json] | registry build|review|check|list".into()), - }; - match result { -@@ -249,6 +259,7 @@ fn compile(root: &Path, release: bool) -> Result<(Vec, PathBuf), String> { - - /// `check `: the registry's checks of a submission, printed; fails - /// when one does. -+#[cfg(feature = "full")] - fn check(args: &[String]) -> Result<(), String> { - let file = args - .first() -@@ -280,6 +291,7 @@ fn check(args: &[String]) -> Result<(), String> { - - /// `test`: every `tests/*.toml` script (those whose name holds ``) - /// against the built mod, or the `.hmod` given with `--hmod`. -+#[cfg(feature = "full")] - fn test(args: &[String]) -> Result<(), String> { - let hmod = match flag(args, "--hmod") { - Some(p) => PathBuf::from(p), -@@ -360,7 +372,7 @@ fn run(args: &[String]) -> Result<(), String> { - .ok_or_else(|| format!("the game exited with {status}")) - } - --#[cfg(test)] -+#[cfg(all(test, feature = "full"))] - mod tests { - use super::*; - --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch deleted file mode 100644 index f4f55c9..0000000 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ /dev/null @@ -1,1152 +0,0 @@ -From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/42] HD lighting: G-buffer pass, per-pixel mod point lights - in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API - level 10) - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/entity.wgsl | 22 + - crates/horizon-client/src/game.rs | 5 + - crates/horizon-client/src/game/mod_models.rs | 58 ++- - crates/horizon-client/src/gfx.rs | 149 +++++- - crates/horizon-client/src/gfx/hd.rs | 520 +++++++++++++++++++ - crates/horizon-client/src/terrain.wgsl | 25 + - crates/horizon-mod-sdk/wit/horizon.wit | 28 + - crates/horizon-mods/src/api_level.rs | 6 +- - crates/horizon-mods/src/runtime.rs | 39 +- - 9 files changed, 845 insertions(+), 7 deletions(-) - create mode 100644 crates/horizon-client/src/gfx/hd.rs - -diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl -index 919c5edbb..01c64def9 100644 ---- a/crates/horizon-client/src/entity.wgsl -+++ b/crates/horizon-client/src/entity.wgsl -@@ -56,6 +56,8 @@ struct Out { - @location(11) glint_uv: vec2, - // 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type. - @location(12) @interpolate(flat) cull: u32, -+ // AirDef fork, HD lighting's G-buffer: the camera-relative position. -+ @location(13) pos: vec3, - } - // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. - fn entity_light(normal: vec3, nether: bool) -> f32 { -@@ -118,6 +120,7 @@ fn entity_vertex(position: vec3, uv: vec2, - out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u); - out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u); - out.overlay = overlay; -+ out.pos = position; - out.glint_uv = vec2(0.0); - if overlay == 4u { out.spherical = length(position); } - return out; -@@ -261,6 +264,25 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, - return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a); - } - -+// AirDef fork: the HD lighting's G-buffer for entities (and the mods' models): the textured -+// colour without the lightmap; full-bright ones (unlit overlays) glow. -+struct EntityGBufferOut { -+ @location(0) albedo: vec4, -+ @location(1) normal: vec4, -+} -+@fragment fn fs_entity_gbuffer(in: Out, @builtin(front_facing) front: bool) -> EntityGBufferOut { -+ let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front); -+ if color.a < 0.1 || (in.cull != 0u && !front) || in.overlay == 4u || in.overlay == 9u || in.overlay == 10u { discard; } -+ var n = normalize(cross(dpdx(in.pos), dpdy(in.pos))); -+ if dot(n, in.pos) > 0.0 { n = -n; } -+ // The mods' glowing parts carry overlay 40 (game/mod_models.rs): they glow. -+ let glow = select(0.0, 1.0, in.overlay == 40u); -+ var out: EntityGBufferOut; -+ out.albedo = vec4(color.rgb, glow); -+ out.normal = vec4(n, 1.0); -+ return out; -+} -+ - // rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no - // normal, so no cardinal light) wherever the texture is not fully clear. - @fragment fn fs_outline(in: Out) -> @location(0) vec4 { -diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs -index c6b642345..3c97bbfb6 100644 ---- a/crates/horizon-client/src/game.rs -+++ b/crates/horizon-client/src/game.rs -@@ -11808,8 +11808,11 @@ impl Game<'_> { - 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); -+ let hd_lights = self.mod_lights(camera, alpha); - let frame = Frame { - mod_views: &mod_views, -+ hd: self.mod_hd(), -+ hd_lights: &hd_lights, - weather: &weather, - weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), - nether_lighting: self.dim == Dim::Nether, -@@ -14729,6 +14732,8 @@ fn loading_renderer( - fn loading_frame(hud: &HudBatch) -> Frame<'_> { - Frame { - mod_views: &[], -+ hd: None, -+ hd_lights: &[], - weather: &[], - weather_time: horizon_client::weather::Time::new(0, 0.0), - camera: glam::DVec3::new(0.5, 100.0, 0.5), -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -index 87d46c843..e6dbc7953 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}, -+ horizon_mods::{CameraView, ParticleSpawn, PointLight}, - horizon_world::registry::BlockStateId, - std::collections::HashMap, - }; -@@ -40,6 +40,8 @@ pub(super) struct ModModels { - post: u64, - /// The mods' cameras by id: a tick ago and now. - views: Vec<(CameraView, CameraView)>, -+ /// The mods' lights by (mod, id): a tick ago and now. -+ lights: HashMap<(usize, u32), (PointLight, PointLight)>, - } - - /// Full brightness: sky and block light 15, as the entity shader packs them. -@@ -60,6 +62,12 @@ impl Game<'_> { - pub(super) fn mod_views(&self, _alpha: f64) -> Vec { - Vec::new() - } -+ pub(super) fn mod_hd(&self) -> Option { -+ None -+ } -+ pub(super) fn mod_lights(&self, _camera: glam::DVec3, _alpha: f64) -> Vec { -+ Vec::new() -+ } - } - - #[cfg(feature = "mods")] -@@ -116,6 +124,13 @@ impl Game<'_> { - let seen: std::collections::HashSet<_> = seen.into_iter().collect(); - models.poses.retain(|k, _| seen.contains(k)); - } -+ let mut lights: HashMap<(usize, u32), (PointLight, PointLight)> = HashMap::new(); -+ for (owner, l) in self.mods.mods.lights() { -+ let key = (owner, l.id); -+ let before = self.mods.models.lights.get(&key).map_or(*l, |(_, now)| *now); -+ lights.insert(key, (before, *l)); -+ } -+ self.mods.models.lights = lights; - let views: Vec = self.mods.mods.views().map(|(_, v)| *v).collect(); - let old = std::mem::take(&mut self.mods.models.views); - self.mods.models.views = views -@@ -127,6 +142,40 @@ impl Game<'_> { - .collect(); - } - -+ /// The HD lighting a mod asked for, if any. -+ pub(super) fn mod_hd(&self) -> Option { -+ let l = self.mods.mods.lighting()?; -+ l.hd.then_some(horizon_client::gfx::HdSettings { exposure: l.exposure, bloom: l.bloom }) -+ } -+ -+ /// The mods' lights between their last two settings, relative to the camera; the nearest and -+ /// brightest first when there are too many. -+ pub(super) fn mod_lights(&self, camera: DVec3, alpha: f64) -> Vec { -+ let t = alpha.clamp(0.0, 1.0); -+ let mut out: Vec<(f32, horizon_client::gfx::HdLight)> = self -+ .mods -+ .models -+ .lights -+ .values() -+ .filter_map(|(a, b)| { -+ let at = DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t) - camera; -+ let radius = b.radius; -+ let d = at.length() as f32; -+ // Too far to light anything in sight. -+ if d > radius + 512.0 { -+ return None; -+ } -+ let light = horizon_client::gfx::HdLight { -+ pos: [at.x as f32, at.y as f32, at.z as f32, radius], -+ color: [b.color.0, b.color.1, b.color.2, a.intensity + (b.intensity - a.intensity) * t as f32], -+ }; -+ Some((light.color[3] / (1.0 + d * d * 0.001), light)) -+ }) -+ .collect(); -+ out.sort_by(|x, y| y.0.total_cmp(&x.0)); -+ out.into_iter().take(512).map(|(_, l)| l).collect() -+ } -+ - /// The mods' cameras between their last two settings, for this frame. - pub(super) fn mod_views(&self, alpha: f64) -> Vec { - let t = alpha.clamp(0.0, 1.0); -@@ -198,7 +247,14 @@ impl Game<'_> { - rotation, - (at - camera).as_vec3(), - ) * Mat4::from_translation(Vec3::splat(-0.5)); -+ let start = out.len(); - self.block_model_vertices(p.state, 0, m, light, true, out); -+ // Overlay 40 marks a glowing part for the HD lighting (entity.wgsl): drawn as any other. -+ if p.glow { -+ for v in &mut out[start..] { -+ v.layer = 40; -+ } -+ } - } - } - -diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs -index 9772e3a83..be51a0289 100644 ---- a/crates/horizon-client/src/gfx.rs -+++ b/crates/horizon-client/src/gfx.rs -@@ -77,8 +77,10 @@ mod lod; - mod native_material; - mod native_pack; - mod native_post; -+mod hd; - mod mod_post; - mod mod_views; -+pub use hd::{HdLight, HdSettings}; - pub use mod_views::ModView; - mod native_sky; - mod occlusion; -@@ -218,6 +220,9 @@ pub struct Frame<'a> { - pub camera: DVec3, - /// Mods' cameras (AirDef fork), drawn into the pictures the HUD shows. - pub mod_views: &'a [ModView], -+ /// The HD lighting (AirDef fork): on with its settings, and the mods' lights. -+ pub hd: Option, -+ pub hd_lights: &'a [HdLight], - pub yaw: f32, - pub pitch: f32, - pub fov_degrees: f32, -@@ -550,7 +555,15 @@ pub struct Renderer { - native: Option, - /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for. - mod_post: Option, -+ /// The mod post pass's WGSL, and whether it was built for the HD target (it is rebuilt when -+ /// the HD lighting comes or goes). -+ mod_post_source: Option, -+ mod_post_hdr: bool, - mod_views: Option, -+ /// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets. -+ gbuffer_terrain: wgpu::RenderPipeline, -+ gbuffer_entity: wgpu::RenderPipeline, -+ hd: Option, - mod_params: (Vec, f32), - /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). - pass_timing: bool, -@@ -1332,6 +1345,39 @@ impl Renderer { - ) - }; - let tri = wgpu::PrimitiveTopology::TriangleList; -+ // AirDef fork: the HD lighting's G-buffer (terrain.wgsl's fs_gbuffer): albedo and normal. -+ let gbuffer_targets = [ -+ Some(wgpu::ColorTargetState { format: hd::ALBEDO, blend: None, write_mask: wgpu::ColorWrites::ALL }), -+ Some(wgpu::ColorTargetState { format: hd::NORMAL, blend: None, write_mask: wgpu::ColorWrites::ALL }), -+ ]; -+ let gbuffer_depth = Some(wgpu::DepthStencilState { -+ format: wgpu::TextureFormat::Depth32Float, -+ depth_write_enabled: Some(true), -+ depth_compare: Some(wgpu::CompareFunction::GreaterEqual), -+ stencil: Default::default(), -+ bias: Default::default(), -+ }); -+ let gbuffer_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { -+ label: Some("HD G-buffer terrain"), -+ layout: Some(&layout), -+ vertex: wgpu::VertexState { -+ module: &shader, -+ entry_point: Some("vs_terrain"), -+ compilation_options: Default::default(), -+ buffers: &terrain_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_gbuffer"), -+ compilation_options: wgpu::PipelineCompilationOptions { constants: &sprite_constants, ..Default::default() }, -+ targets: &gbuffer_targets, -+ }), -+ multiview_mask: None, -+ cache: None, -+ }); - let solid = pipeline( - "Terrain solid", - "vs_terrain", -@@ -1928,6 +1974,28 @@ impl Renderer { - cache: None, - }) - }; -+ // AirDef fork: the entities' G-buffer (entity.wgsl's fs_entity_gbuffer). -+ let gbuffer_entity = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { -+ label: Some("HD G-buffer entities"), -+ layout: Some(&entity_layout), -+ vertex: wgpu::VertexState { -+ module: &entity_shader, -+ entry_point: Some("vs_entity"), -+ compilation_options: Default::default(), -+ buffers: &entity_vertex, -+ }, -+ primitive: Default::default(), -+ depth_stencil: gbuffer_depth.clone(), -+ multisample: Default::default(), -+ fragment: Some(wgpu::FragmentState { -+ module: &entity_shader, -+ entry_point: Some("fs_entity_gbuffer"), -+ compilation_options: Default::default(), -+ targets: &gbuffer_targets, -+ }), -+ multiview_mask: None, -+ cache: None, -+ }); - let entity_pipeline = |label, cull_mode, depth_write, compare, bias, blend, clouds| { - entity_pipeline_with( - label, -@@ -2656,7 +2724,12 @@ impl Renderer { - pack_post: None, - native: None, - mod_post: None, -+ mod_post_source: None, -+ mod_post_hdr: false, - mod_views: None, -+ gbuffer_terrain, -+ gbuffer_entity, -+ hd: None, - mod_params: (Vec::new(), 0.0), - pass_timing: pack_timing::PassTiming::logging(), - pack_entity_rest: Vec::new(), -@@ -3441,10 +3514,15 @@ impl Renderer { - /// that ran keeps running. - pub fn set_mod_post(&mut self, source: Option<&str>) -> std::result::Result<(), String> { - match source { -- None => self.mod_post = None, -+ None => { -+ self.mod_post = None; -+ self.mod_post_source = None; -+ } - Some(source) => { -- let format = self.config.format.remove_srgb_suffix(); -+ // Under the HD lighting it draws into the HD target, else onto the screen. -+ let format = if self.mod_post_hdr { hd::HDR } else { self.config.format.remove_srgb_suffix() }; - self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); -+ self.mod_post_source = Some(source.to_owned()); - } - } - Ok(()) -@@ -4694,7 +4772,15 @@ impl Renderer { - } - // AirDef fork: a mod's post pass reads the world target too. - let mod_post = !frame.title && !pack_post && !native_post && self.mod_post.is_some(); -- let pack_reads_world = pack_post || native_post || mod_post; -+ // AirDef fork: the HD lighting (its own pipeline; a shader pack's replaces it). -+ let hd_on = !frame.title && !pack_post && !native_post && frame.hd.is_some(); -+ if hd_on != self.mod_post_hdr { -+ self.mod_post_hdr = hd_on; -+ if let Some(source) = self.mod_post_source.clone() { -+ let _ = self.set_mod_post(Some(&source)); -+ } -+ } -+ let pack_reads_world = pack_post || native_post || mod_post || hd_on; - // The pack's program reads the vertices this frame's draws bind. - let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat; - // The shadow camera's lists, drawn by the pack's shadow programs. -@@ -4744,6 +4830,47 @@ impl Renderer { - Some((color, depth)) => (color, depth), - None => (&view_target, &self.depth), - }; -+ // AirDef fork: the HD lighting's G-buffer — the solid terrain, distant tiles and -+ // entities once more, their colour without light and their normals. -+ if hd_on && let Some(size) = self.world_post.size() { -+ let format = self.config.format.remove_srgb_suffix(); -+ let device = &self.device; -+ let hd = self.hd.get_or_insert_with(|| hd::Hd::new(device, size, format)); -+ hd.resize(device, size); -+ if let Some(settings) = frame.hd { -+ hd.write(&self.queue, view_proj, frame.hd_lights, settings); -+ } -+ let mut pass = hd.gbuffer_pass(&mut encoder); -+ pass.set_bind_group(0, &self.group, &[]); -+ pass.set_pipeline(&self.gbuffer_terrain); -+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); -+ pass.set_vertex_buffer(1, self.instances.slice(..)); -+ let mut page = None; -+ let sections = visible.iter().enumerate().map(|(k, v)| (k, self.sections.get(v.slot))); -+ let tiles = visible_lods -+ .iter() -+ .enumerate() -+ .filter_map(|(k, v)| Some((visible.len() + k, &self.lods.get(&v.key)?.mesh))); -+ for (instance, s) in sections.chain(tiles) { -+ if s.solid == 0 { -+ continue; -+ } -+ if page != Some(s.page) { -+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); -+ page = Some(s.page); -+ } -+ let instance = instance as u32; -+ pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); -+ } -+ if entity_quads > 0 { -+ pass.set_pipeline(&self.gbuffer_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); -+ } -+ } else if !hd_on { -+ self.hd = None; -+ } - if culling && let Some(cull) = &mut self.cull { - cull.bind_depth(&self.device, world_depth); - } -@@ -5264,6 +5391,22 @@ impl Renderer { - size, - native.timing.as_mut(), - ); -+ } else if let Some((color, depth)) = &scaled -+ && hd_on -+ && let Some(hd) = &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. -+ hd.light(&self.device, &mut encoder, (color, depth)); -+ match &mut self.mod_post { -+ Some(post) if mod_post => { -+ let size = self.world_post.size().unwrap_or([1, 1]); -+ post.write(&self.queue, frame, size, &self.mod_params.0, 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.lit, &view_target), -+ } - } else if let Some((color, depth)) = &scaled - && mod_post - && let Some(post) = &mut self.mod_post -diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs -new file mode 100644 -index 000000000..b40484e5e ---- /dev/null -+++ b/crates/horizon-client/src/gfx/hd.rs -@@ -0,0 +1,520 @@ -+//! AirDef fork: HD lighting. Before the world pass the solid terrain and the -+//! entities (with the mods' models) are drawn once more into a G-buffer: the -+//! surface colour without light and its normal. After the world pass the -+//! mods' point lights (rocket flames, flares, blasts) light every pixel of it -+//! 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 glam::Mat4; -+ -+/// The G-buffer's formats. -+pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; -+pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; -+/// The lit world and the post pass's output. -+pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; -+/// The most lights drawn a frame. -+pub const MAX_LIGHTS: usize = 512; -+/// Bloom's mip levels. -+const BLOOM_LEVELS: usize = 6; -+ -+/// A light for this frame, relative to the camera. -+#[repr(C)] -+#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] -+pub struct HdLight { -+ /// xyz: camera-relative position; w: radius. -+ pub pos: [f32; 4], -+ /// rgb: colour; w: intensity. -+ pub color: [f32; 4], -+} -+ -+/// The frame's HD settings. -+#[derive(Clone, Copy, Debug)] -+pub struct HdSettings { -+ pub exposure: f32, -+ pub bloom: f32, -+} -+ -+#[repr(C)] -+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] -+struct HdFrame { -+ /// Clip space to camera-relative position. -+ inverse_view_proj: [[f32; 4]; 4], -+ /// width, height, 1/width, 1/height. -+ resolution: [f32; 4], -+ /// lights, exposure, bloom, -. -+ params: [f32; 4], -+} -+ -+const COMMON: &str = r#" -+struct HdFrame { -+ inverse_view_proj: mat4x4, -+ resolution: vec4, -+ params: vec4, -+} -+struct FullOut { -+ @builtin(position) position: vec4, -+ @location(0) uv: vec2, -+} -+@vertex -+fn vs_full(@builtin(vertex_index) i: u32) -> FullOut { -+ let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); -+ var out: FullOut; -+ out.position = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0); -+ out.uv = uv; -+ return out; -+} -+"#; -+ -+const LIGHT: &str = r#" -+struct Light { -+ pos: vec4, -+ color: vec4, -+} -+@group(0) @binding(0) var frame: HdFrame; -+@group(0) @binding(1) var scene_color: texture_2d; -+@group(0) @binding(2) var scene_depth: texture_depth_2d; -+@group(0) @binding(3) var albedo_tex: texture_2d; -+@group(0) @binding(4) var normal_tex: texture_2d; -+@group(0) @binding(5) var gbuffer_depth: texture_depth_2d; -+@group(0) @binding(6) var lights: array; -+ -+@fragment -+fn fs_light(in: FullOut) -> @location(0) vec4 { -+ let px = vec2(in.position.xy); -+ var c = textureLoad(scene_color, px, 0).rgb; -+ let d = textureLoad(scene_depth, px, 0); -+ let g = textureLoad(gbuffer_depth, px, 0); -+ // Only where the G-buffer holds what the world shows (not the sky, water, particles). -+ if d <= 0.0 || abs(d - g) > max(d, g) * 0.002 { -+ return vec4(c, 1.0); -+ } -+ let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, d, 1.0); -+ let h = frame.inverse_view_proj * ndc; -+ let p = h.xyz / h.w; -+ let n = normalize(textureLoad(normal_tex, px, 0).xyz); -+ let albedo = textureLoad(albedo_tex, px, 0); -+ var light = vec3(0.0); -+ let count = u32(frame.params.x); -+ for (var i = 0u; i < count; i++) { -+ let l = lights[i]; -+ let to = l.pos.xyz - p; -+ let dist = length(to); -+ if dist >= l.pos.w { continue; } -+ // Inverse square, softened near the light, faded to nothing at its radius. -+ 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 ndl = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); -+ light += l.color.rgb * falloff * ndl; -+ } -+ c += albedo.rgb * light; -+ // Glowing parts (flames, flares) shine past white, for the bloom. -+ c += albedo.rgb * albedo.a * 1.5; -+ return vec4(c, 1.0); -+} -+"#; -+ -+const BLOOM: &str = r#" -+@group(0) @binding(0) var frame: HdFrame; -+@group(0) @binding(1) var source: texture_2d; -+@group(0) @binding(2) var linear: sampler; -+ -+// The first step down keeps only what is brighter than white (a soft knee). -+@fragment -+fn fs_prefilter(in: FullOut) -> @location(0) vec4 { -+ let c = textureSampleLevel(source, linear, in.uv, 0.0).rgb; -+ let peak = max(c.r, max(c.g, c.b)); -+ let knee = 0.35; -+ let soft = clamp(peak - 0.85 + knee, 0.0, 2.0 * knee); -+ let weight = max(soft * soft / (4.0 * knee + 0.0001), peak - 0.85) / max(peak, 0.0001); -+ return vec4(c * weight, 1.0); -+} -+// 13 taps (Call of Duty: Advanced Warfare's bloom), half size each step. -+@fragment -+fn fs_down(in: FullOut) -> @location(0) vec4 { -+ let t = 1.0 / vec2(textureDimensions(source)); -+ let s = in.uv; -+ let a = textureSampleLevel(source, linear, s + t * vec2(-2.0, -2.0), 0.0).rgb; -+ let b = textureSampleLevel(source, linear, s + t * vec2(0.0, -2.0), 0.0).rgb; -+ let c = textureSampleLevel(source, linear, s + t * vec2(2.0, -2.0), 0.0).rgb; -+ let d = textureSampleLevel(source, linear, s + t * vec2(-2.0, 0.0), 0.0).rgb; -+ let e = textureSampleLevel(source, linear, s, 0.0).rgb; -+ let f = textureSampleLevel(source, linear, s + t * vec2(2.0, 0.0), 0.0).rgb; -+ let g = textureSampleLevel(source, linear, s + t * vec2(-2.0, 2.0), 0.0).rgb; -+ let h = textureSampleLevel(source, linear, s + t * vec2(0.0, 2.0), 0.0).rgb; -+ let i = textureSampleLevel(source, linear, s + t * vec2(2.0, 2.0), 0.0).rgb; -+ let j = textureSampleLevel(source, linear, s + t * vec2(-1.0, -1.0), 0.0).rgb; -+ let k = textureSampleLevel(source, linear, s + t * vec2(1.0, -1.0), 0.0).rgb; -+ let l = textureSampleLevel(source, linear, s + t * vec2(-1.0, 1.0), 0.0).rgb; -+ let m = textureSampleLevel(source, linear, s + t * vec2(1.0, 1.0), 0.0).rgb; -+ var o = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125; -+ return vec4(o, 1.0); -+} -+// A 3×3 tent, added onto the bigger level. -+@fragment -+fn fs_up(in: FullOut) -> @location(0) vec4 { -+ let t = 1.0 / vec2(textureDimensions(source)); -+ let s = in.uv; -+ var o = textureSampleLevel(source, linear, s, 0.0).rgb * 4.0; -+ o += (textureSampleLevel(source, linear, s + t * vec2(-1.0, 0.0), 0.0).rgb -+ + textureSampleLevel(source, linear, s + t * vec2(1.0, 0.0), 0.0).rgb -+ + textureSampleLevel(source, linear, s + t * vec2(0.0, -1.0), 0.0).rgb -+ + textureSampleLevel(source, linear, s + t * vec2(0.0, 1.0), 0.0).rgb) * 2.0; -+ o += textureSampleLevel(source, linear, s + t * vec2(-1.0, -1.0), 0.0).rgb -+ + textureSampleLevel(source, linear, s + t * vec2(1.0, -1.0), 0.0).rgb -+ + textureSampleLevel(source, linear, s + t * vec2(-1.0, 1.0), 0.0).rgb -+ + textureSampleLevel(source, linear, s + t * vec2(1.0, 1.0), 0.0).rgb; -+ return vec4(o / 16.0, 1.0); -+} -+"#; -+ -+const TONEMAP: &str = r#" -+@group(0) @binding(0) var frame: HdFrame; -+@group(0) @binding(1) var hdr: texture_2d; -+@group(0) @binding(2) var bloom: texture_2d; -+@group(0) @binding(3) var linear: sampler; -+ -+@fragment -+fn fs_tonemap(in: FullOut) -> @location(0) vec4 { -+ var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb * 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. -+ let peak = max(c.r, max(c.g, c.b)); -+ let t = 0.8; -+ if peak > t { -+ let x = (peak - t) / (1.0 - t); -+ let rolled = t + (1.0 - t) * (x / (1.0 + x)); -+ c = c * (rolled / peak); -+ // Very bright light bleaches towards white, as film does. -+ c = mix(c, vec3(rolled), clamp((peak - 2.0) / 8.0, 0.0, 0.6)); -+ } -+ return vec4(c, 1.0); -+} -+"#; -+ -+struct Target { -+ view: wgpu::TextureView, -+} -+ -+fn target(device: &wgpu::Device, label: &str, size: [u32; 2], format: wgpu::TextureFormat) -> Target { -+ let view = device -+ .create_texture(&wgpu::TextureDescriptor { -+ label: Some(label), -+ size: wgpu::Extent3d { width: size[0].max(1), height: size[1].max(1), depth_or_array_layers: 1 }, -+ mip_level_count: 1, -+ sample_count: 1, -+ dimension: wgpu::TextureDimension::D2, -+ format, -+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, -+ view_formats: &[], -+ }) -+ .create_view(&Default::default()); -+ Target { view } -+} -+ -+pub struct Hd { -+ size: [u32; 2], -+ pub albedo: wgpu::TextureView, -+ pub normal: wgpu::TextureView, -+ pub depth: wgpu::TextureView, -+ /// The lit world, and the post pass's output. -+ pub lit: wgpu::TextureView, -+ pub fx: wgpu::TextureView, -+ bloom: Vec, -+ frame: wgpu::Buffer, -+ lights: wgpu::Buffer, -+ sampler: wgpu::Sampler, -+ light_layout: wgpu::BindGroupLayout, -+ light_pipeline: wgpu::RenderPipeline, -+ bloom_layout: wgpu::BindGroupLayout, -+ prefilter: wgpu::RenderPipeline, -+ down: wgpu::RenderPipeline, -+ up: wgpu::RenderPipeline, -+ tone_layout: wgpu::BindGroupLayout, -+ tone: wgpu::RenderPipeline, -+} -+ -+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()), -+ }) -+} -+ -+fn fullscreen( -+ device: &wgpu::Device, -+ label: &str, -+ layout: &wgpu::BindGroupLayout, -+ module: &wgpu::ShaderModule, -+ entry: &str, -+ format: wgpu::TextureFormat, -+ blend: Option, -+) -> wgpu::RenderPipeline { -+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { -+ label: Some(label), -+ bind_group_layouts: &[Some(layout)], -+ ..Default::default() -+ }); -+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { -+ label: Some(label), -+ layout: Some(&pipeline_layout), -+ vertex: wgpu::VertexState { module, entry_point: Some("vs_full"), compilation_options: Default::default(), buffers: &[] }, -+ fragment: Some(wgpu::FragmentState { -+ module, -+ entry_point: Some(entry), -+ compilation_options: Default::default(), -+ targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], -+ }), -+ primitive: Default::default(), -+ depth_stencil: None, -+ multisample: Default::default(), -+ multiview_mask: None, -+ cache: None, -+ }) -+} -+ -+fn entries(kinds: &[wgpu::BindingType]) -> Vec { -+ kinds -+ .iter() -+ .enumerate() -+ .map(|(i, ty)| wgpu::BindGroupLayoutEntry { -+ binding: i as u32, -+ visibility: wgpu::ShaderStages::FRAGMENT, -+ ty: *ty, -+ count: None, -+ }) -+ .collect() -+} -+ -+const UNIFORM: wgpu::BindingType = -+ wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }; -+const FLOAT: wgpu::BindingType = wgpu::BindingType::Texture { -+ sample_type: wgpu::TextureSampleType::Float { filterable: true }, -+ view_dimension: wgpu::TextureViewDimension::D2, -+ multisampled: false, -+}; -+const UNFILTERED: wgpu::BindingType = wgpu::BindingType::Texture { -+ sample_type: wgpu::TextureSampleType::Float { filterable: false }, -+ view_dimension: wgpu::TextureViewDimension::D2, -+ multisampled: false, -+}; -+const DEPTH: wgpu::BindingType = wgpu::BindingType::Texture { -+ sample_type: wgpu::TextureSampleType::Depth, -+ view_dimension: wgpu::TextureViewDimension::D2, -+ multisampled: false, -+}; -+const SAMPLER: wgpu::BindingType = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering); -+ -+impl Hd { -+ pub fn new(device: &wgpu::Device, size: [u32; 2], screen: wgpu::TextureFormat) -> Self { -+ let light_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { -+ label: Some("HD lighting"), -+ entries: &entries(&[ -+ UNIFORM, -+ UNFILTERED, -+ DEPTH, -+ UNFILTERED, -+ UNFILTERED, -+ DEPTH, -+ 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 { -+ label: Some("HD bloom"), -+ entries: &entries(&[UNIFORM, FLOAT, SAMPLER]), -+ }); -+ let tone_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { -+ label: Some("HD tone mapping"), -+ entries: &entries(&[UNIFORM, FLOAT, FLOAT, SAMPLER]), -+ }); -+ let light = module(device, "HD lighting", LIGHT); -+ let bloom = module(device, "HD bloom", BLOOM); -+ let tone = module(device, "HD tone mapping", TONEMAP); -+ let add = wgpu::BlendState { -+ color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add }, -+ alpha: wgpu::BlendComponent::REPLACE, -+ }; -+ let light_pipeline = fullscreen(device, "HD lighting", &light_layout, &light, "fs_light", HDR, None); -+ let prefilter = fullscreen(device, "HD bloom prefilter", &bloom_layout, &bloom, "fs_prefilter", HDR, None); -+ let down = fullscreen(device, "HD bloom down", &bloom_layout, &bloom, "fs_down", HDR, None); -+ let up = fullscreen(device, "HD bloom up", &bloom_layout, &bloom, "fs_up", HDR, Some(add)); -+ let tone = fullscreen(device, "HD tone mapping", &tone_layout, &tone, "fs_tonemap", screen, None); -+ let frame = device.create_buffer(&wgpu::BufferDescriptor { -+ label: Some("HD frame"), -+ size: std::mem::size_of::() as u64, -+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, -+ mapped_at_creation: false, -+ }); -+ let lights = device.create_buffer(&wgpu::BufferDescriptor { -+ label: Some("HD lights"), -+ size: (MAX_LIGHTS * std::mem::size_of::()) as u64, -+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, -+ mapped_at_creation: false, -+ }); -+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor { -+ label: Some("HD"), -+ mag_filter: wgpu::FilterMode::Linear, -+ min_filter: wgpu::FilterMode::Linear, -+ address_mode_u: wgpu::AddressMode::ClampToEdge, -+ address_mode_v: wgpu::AddressMode::ClampToEdge, -+ ..Default::default() -+ }); -+ let mut hd = Hd { -+ size: [0, 0], -+ albedo: target(device, "HD albedo", [1, 1], ALBEDO).view, -+ normal: target(device, "HD normal", [1, 1], NORMAL).view, -+ depth: target(device, "HD depth", [1, 1], wgpu::TextureFormat::Depth32Float).view, -+ lit: target(device, "HD lit", [1, 1], HDR).view, -+ fx: target(device, "HD post", [1, 1], HDR).view, -+ bloom: Vec::new(), -+ frame, -+ lights, -+ sampler, -+ light_layout, -+ light_pipeline, -+ bloom_layout, -+ prefilter, -+ down, -+ up, -+ tone_layout, -+ tone, -+ }; -+ hd.resize(device, 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 { -+ return; -+ } -+ self.size = size; -+ self.albedo = target(device, "HD albedo", size, ALBEDO).view; -+ self.normal = target(device, "HD normal", size, NORMAL).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.bloom = (0..BLOOM_LEVELS) -+ .map(|k| { -+ let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)]; -+ target(device, "HD bloom", s, HDR) -+ }) -+ .collect(); -+ } -+ -+ /// This frame's camera, lights and settings. -+ pub fn write(&mut self, queue: &wgpu::Queue, view_proj: Mat4, lights: &[HdLight], settings: HdSettings) { -+ let n = lights.len().min(MAX_LIGHTS); -+ if n > 0 { -+ queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n])); -+ } -+ let frame = HdFrame { -+ inverse_view_proj: view_proj.inverse().to_cols_array_2d(), -+ resolution: [self.size[0] as f32, self.size[1] as f32, 1.0 / self.size[0] as f32, 1.0 / self.size[1] as f32], -+ params: [n as f32, settings.exposure, settings.bloom, 0.0], -+ }; -+ queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame)); -+ } -+ -+ /// 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> { -+ 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 G-buffer"), -+ color_attachments: &[clear(&self.albedo), clear(&self.normal)], -+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { -+ view: &self.depth, -+ depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Store }), -+ stencil_ops: None, -+ }), -+ ..Default::default() -+ }) -+ .forget_lifetime() -+ } -+ -+ fn draw(encoder: &mut wgpu::CommandEncoder, label: &str, to: &wgpu::TextureView, load: bool, pipeline: &wgpu::RenderPipeline, group: &wgpu::BindGroup) { -+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { -+ label: Some(label), -+ color_attachments: &[Some(wgpu::RenderPassColorAttachment { -+ view: to, -+ resolve_target: None, -+ depth_slice: None, -+ ops: wgpu::Operations { -+ load: if load { wgpu::LoadOp::Load } else { wgpu::LoadOp::Clear(wgpu::Color::BLACK) }, -+ store: wgpu::StoreOp::Store, -+ }, -+ })], -+ ..Default::default() -+ }); -+ pass.set_pipeline(pipeline); -+ pass.set_bind_group(0, group, &[]); -+ pass.draw(0..3, 0..1); -+ } -+ -+ /// Lights the world (its colour and depth) into `lit`. -+ pub fn light(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: (&wgpu::TextureView, &wgpu::TextureView)) { -+ 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() }, -+ ], -+ }); -+ Self::draw(encoder, "HD lighting", &self.lit, false, &self.light_pipeline, &group); -+ } -+ -+ fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup { -+ device.create_bind_group(&wgpu::BindGroupDescriptor { -+ label: Some("HD bloom"), -+ layout: &self.bloom_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::Sampler(&self.sampler) }, -+ ], -+ }) -+ } -+ -+ /// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`. -+ pub fn finish(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, screen: &wgpu::TextureView) { -+ // Down: the prefilter into the first level, then each level from the one above. -+ Self::draw(encoder, "HD bloom prefilter", &self.bloom[0].view, false, &self.prefilter, &self.bloom_group(device, source)); -+ 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); -+ } -+ // 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); -+ } -+ 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); -+ } -+} -diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl -index 3aea6fc1a..293729e33 100644 ---- a/crates/horizon-client/src/terrain.wgsl -+++ b/crates/horizon-client/src/terrain.wgsl -@@ -71,6 +71,9 @@ struct TerrainOut { - @location(4) cylindrical: f32, - // The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`). - @location(5) @interpolate(flat) emission: u32, -+ // AirDef fork, HD lighting's G-buffer: the camera-relative position and the unlit colour. -+ @location(6) pos: vec3, -+ @location(7) tint: vec4, - } - // Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units - // (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one -@@ -127,6 +130,8 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4 f32 { -@@ -148,6 +153,26 @@ fn apply_fog(color: vec4, spherical: f32, cylindrical: f32) -> vec4 { - let emissive = select(1.0, 0.0, in.emission > 0u); - return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical); - } -+// AirDef fork: the HD lighting's G-buffer. The surface's colour without light (alpha: how much -+// it glows) and its normal, found from how the position changes across the pixel (faces are -+// flat, so this is exact). -+struct GBufferOut { -+ @location(0) albedo: vec4, -+ @location(1) normal: vec4, -+} -+fn face_normal(pos: vec3) -> vec3 { -+ var n = normalize(cross(dpdx(pos), dpdy(pos))); -+ if dot(n, pos) > 0.0 { n = -n; } -+ return n; -+} -+@fragment fn fs_gbuffer(in: TerrainOut) -> GBufferOut { -+ let color = sprite(in.uv, in.layer & 0x7fffu) * in.tint; -+ if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } -+ var out: GBufferOut; -+ out.albedo = vec4(color.rgb, f32(in.emission) / 15.0); -+ out.normal = vec4(face_normal(in.pos), 1.0); -+ return out; -+} - @fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4 { - let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; - if color.a < 0.01 { discard; } -diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit -index b6758d9fb..6696fa134 100644 ---- a/crates/horizon-mod-sdk/wit/horizon.wit -+++ b/crates/horizon-mod-sdk/wit/horizon.wit -@@ -534,6 +534,34 @@ 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); -+ /// A light the mod's things give off (AirDef fork): rocket flames, -+ /// flares, blasts. Drawn by the HD lighting (`set-lighting`). -+ record point-light { -+ /// Stays the same while the light lives: the game moves it smoothly. -+ id: u32, -+ x: f64, -+ y: f64, -+ z: f64, -+ /// Linear RGB, 0 to 1. -+ color: tuple, -+ /// Brightness: about what it lights one block away (0 to 100000). -+ intensity: f32, -+ /// Nothing past this many blocks (1 to 256). -+ radius: f32, -+ } -+ /// Replaces the mod's lights: up to 512, interpolated per id between -+ /// the last two calls. -+ set-lights: func(lights: list); -+ /// The HD lighting (AirDef fork): the world lit per pixel by the mods' -+ /// lights, in high dynamic range, with bloom and tone mapping. -+ record lighting { -+ hd: bool, -+ /// 1 as is; more brightens. -+ exposure: f32, -+ /// How much bright things glow (0 none, 1 strong). -+ bloom: f32, -+ } -+ set-lighting: func(lighting: lighting); - } - - /// 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 cf2993186..80ac74b6f 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 = 9; -+pub const LEVEL: u32 = 10; - - /// 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); 56] = [ -+pub const FUNCTIONS: [(&str, u32); 58] = [ - ("log.write", 0), - ("game.get-player", 0), - ("game.get-clock", 0), -@@ -71,6 +71,8 @@ pub const FUNCTIONS: [(&str, u32); 56] = [ - ("render.set-params", 8), - ("render.set-views", 9), - ("game.world-id", 9), -+ ("render.set-lights", 10), -+ ("render.set-lighting", 10), - ]; - - /// 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 abed5b31f..57713e228 100644 ---- a/crates/horizon-mods/src/runtime.rs -+++ b/crates/horizon-mods/src/runtime.rs -@@ -81,7 +81,7 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I - 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, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom, -+ CameraView, Lighting as ModLighting, PointLight, 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. -@@ -439,6 +439,9 @@ struct State { - post_params: Vec, - /// `render.set-views`' cameras (AirDef fork). - views: Vec, -+ /// `render.set-lights` and `render.set-lighting` (AirDef fork). -+ lights: Vec, -+ lighting: Option, - input: InputState, - browser: BrowserState, - /// Capability `storage`'s folder; made on first `set`. -@@ -648,6 +651,8 @@ impl Mods { - post: None, - post_params: Vec::new(), - views: Vec::new(), -+ lights: Vec::new(), -+ lighting: None, - input: InputState::default(), - browser: BrowserState::default(), - data_dir, -@@ -1219,6 +1224,18 @@ impl Mods { - .flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v))) - }) - } -+ /// Every running mod's lights (AirDef fork), with the mod's index. -+ pub fn lights(&self) -> impl Iterator { -+ self.entries.iter().enumerate().flat_map(|(i, e)| { -+ e.run -+ .iter() -+ .flat_map(move |r| r.store.data().lights.iter().map(move |l| (i, l))) -+ }) -+ } -+ /// The first running mod's lighting, if it set one. -+ pub fn lighting(&self) -> Option { -+ 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)>)> { - self.entries -@@ -2002,6 +2019,26 @@ impl render::Host for State { - }) - .collect(); - } -+ fn set_lights(&mut self, lights: Vec) { -+ 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 }; -+ 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 -+ }) -+ .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 }; -+ self.lighting = Some(lighting); -+ } - 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)); --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch deleted file mode 100644 index d9d5f87..0000000 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ /dev/null @@ -1,1684 +0,0 @@ -From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/42] HD lighting stage 2: sun and moon shadow cascades, the - world relit (sun by face angle and shadow, sky and block light through the - lightmap), light shafts and halos, fog over it again, a light colour grade - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/entity.wgsl | 11 +- - crates/horizon-client/src/game.rs | 2 +- - crates/horizon-client/src/game/mod_models.rs | 40 +- - crates/horizon-client/src/game/mods.rs | 6 +- - crates/horizon-client/src/gfx.rs | 271 +++++++- - crates/horizon-client/src/gfx/hd.rs | 673 ++++++++++++++++--- - crates/horizon-client/src/gfx/mod_post.rs | 64 +- - crates/horizon-client/src/gfx/mod_views.rs | 9 +- - crates/horizon-client/src/terrain.wgsl | 10 + - 9 files changed, 957 insertions(+), 129 deletions(-) - -diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl -index 01c64def9..61bc1f92a 100644 ---- a/crates/horizon-client/src/entity.wgsl -+++ b/crates/horizon-client/src/entity.wgsl -@@ -56,8 +56,9 @@ struct Out { - @location(11) glint_uv: vec2, - // 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type. - @location(12) @interpolate(flat) cull: u32, -- // AirDef fork, HD lighting's G-buffer: the camera-relative position. -+ // AirDef fork, HD lighting's G-buffer: the camera-relative position, sky and block light. - @location(13) pos: vec3, -+ @location(14) lm: vec2, - } - // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. - fn entity_light(normal: vec3, nether: bool) -> f32 { -@@ -121,6 +122,7 @@ fn entity_vertex(position: vec3, uv: vec2, - out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u); - out.overlay = overlay; - out.pos = position; -+ out.lm = vec2(l.y, l.x) / 240.0; - out.glint_uv = vec2(0.0); - if overlay == 4u { out.spherical = length(position); } - return out; -@@ -269,6 +271,7 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, - struct EntityGBufferOut { - @location(0) albedo: vec4, - @location(1) normal: vec4, -+ @location(2) light: vec4, - } - @fragment fn fs_entity_gbuffer(in: Out, @builtin(front_facing) front: bool) -> EntityGBufferOut { - let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front); -@@ -280,8 +283,14 @@ struct EntityGBufferOut { - var out: EntityGBufferOut; - out.albedo = vec4(color.rgb, glow); - out.normal = vec4(n, 1.0); -+ out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0); - return out; - } -+// AirDef fork: entities in the sun's shadow map. -+@fragment fn fs_entity_shadow(in: Out) { -+ let a = textureSample(atlas, atlas_sampler, in.uv).a * in.color.a; -+ if a < 0.1 || in.overlay == 4u || in.overlay == 9u || in.overlay == 10u || in.overlay == 40u { discard; } -+} - - // rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no - // normal, so no cardinal light) wherever the texture is not fully clear. -diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs -index 3c97bbfb6..3a11ee283 100644 ---- a/crates/horizon-client/src/game.rs -+++ b/crates/horizon-client/src/game.rs -@@ -112,10 +112,10 @@ mod mod_browser; - #[cfg(feature = "mods")] - mod mod_browser_recipes; - mod mod_input; -+mod mod_models; - #[cfg(feature = "mods")] - mod mod_recipes; - mod mod_render; --mod mod_models; - mod mods; - mod mount_menu; - mod mounts; -diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs -index e6dbc7953..2d1b4780a 100644 ---- a/crates/horizon-client/src/game/mod_models.rs -+++ b/crates/horizon-client/src/game/mod_models.rs -@@ -65,7 +65,11 @@ impl Game<'_> { - pub(super) fn mod_hd(&self) -> Option { - None - } -- pub(super) fn mod_lights(&self, _camera: glam::DVec3, _alpha: f64) -> Vec { -+ pub(super) fn mod_lights( -+ &self, -+ _camera: glam::DVec3, -+ _alpha: f64, -+ ) -> Vec { - Vec::new() - } - } -@@ -127,7 +131,12 @@ impl Game<'_> { - let mut lights: HashMap<(usize, u32), (PointLight, PointLight)> = HashMap::new(); - for (owner, l) in self.mods.mods.lights() { - let key = (owner, l.id); -- let before = self.mods.models.lights.get(&key).map_or(*l, |(_, now)| *now); -+ let before = self -+ .mods -+ .models -+ .lights -+ .get(&key) -+ .map_or(*l, |(_, now)| *now); - lights.insert(key, (before, *l)); - } - self.mods.models.lights = lights; -@@ -136,7 +145,10 @@ impl Game<'_> { - self.mods.models.views = views - .into_iter() - .map(|v| { -- let before = old.iter().find(|(_, o)| o.id == v.id).map_or(v, |(_, o)| *o); -+ let before = old -+ .iter() -+ .find(|(_, o)| o.id == v.id) -+ .map_or(v, |(_, o)| *o); - (before, v) - }) - .collect(); -@@ -145,12 +157,19 @@ impl Game<'_> { - /// The HD lighting a mod asked for, if any. - pub(super) fn mod_hd(&self) -> Option { - let l = self.mods.mods.lighting()?; -- l.hd.then_some(horizon_client::gfx::HdSettings { exposure: l.exposure, bloom: l.bloom }) -+ l.hd.then_some(horizon_client::gfx::HdSettings { -+ exposure: l.exposure, -+ bloom: l.bloom, -+ }) - } - - /// The mods' lights between their last two settings, relative to the camera; the nearest and - /// brightest first when there are too many. -- pub(super) fn mod_lights(&self, camera: DVec3, alpha: f64) -> Vec { -+ pub(super) fn mod_lights( -+ &self, -+ camera: DVec3, -+ alpha: f64, -+ ) -> Vec { - let t = alpha.clamp(0.0, 1.0); - let mut out: Vec<(f32, horizon_client::gfx::HdLight)> = self - .mods -@@ -167,7 +186,12 @@ impl Game<'_> { - } - let light = horizon_client::gfx::HdLight { - pos: [at.x as f32, at.y as f32, at.z as f32, radius], -- color: [b.color.0, b.color.1, b.color.2, a.intensity + (b.intensity - a.intensity) * t as f32], -+ color: [ -+ b.color.0, -+ b.color.1, -+ b.color.2, -+ a.intensity + (b.intensity - a.intensity) * t as f32, -+ ], - }; - Some((light.color[3] / (1.0 + d * d * 0.001), light)) - }) -@@ -266,7 +290,9 @@ impl Game<'_> { - None => (0, None, Vec::new()), - }; - let mut error = None; -- let Some(renderer) = self.renderer.as_mut() else { return }; -+ let Some(renderer) = self.renderer.as_mut() else { -+ return; -+ }; - if version != self.mods.models.post { - self.mods.models.post = version; - error = renderer.set_mod_post(source.as_deref()).err(); -diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs -index ebdd18a24..a760c7763 100644 ---- a/crates/horizon-client/src/game/mods.rs -+++ b/crates/horizon-client/src/game/mods.rs -@@ -484,7 +484,11 @@ impl Game<'_> { - known_recipes, - placing: self.mod_recipe_screen(), - creative: self.creative, -- world: self.player.is_some().then(|| self.mods.world_id.clone()).flatten(), -+ world: self -+ .player -+ .is_some() -+ .then(|| self.mods.world_id.clone()) -+ .flatten(), - } - } - /// The first block along the player's view within -diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs -index be51a0289..e351763fd 100644 ---- a/crates/horizon-client/src/gfx.rs -+++ b/crates/horizon-client/src/gfx.rs -@@ -71,15 +71,15 @@ mod arena; - mod cull; - mod end_sky; - mod flat; -+mod hd; - mod hdr; - pub mod lightning; - mod lod; -+mod mod_post; -+mod mod_views; - mod native_material; - mod native_pack; - mod native_post; --mod hd; --mod mod_post; --mod mod_views; - pub use hd::{HdLight, HdSettings}; - pub use mod_views::ModView; - mod native_sky; -@@ -563,6 +563,10 @@ pub struct Renderer { - /// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets. - gbuffer_terrain: 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, -+ shadow_entity: wgpu::RenderPipeline, -+ hd_shadow_visible: Vec, - hd: Option, - mod_params: (Vec, f32), - /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). -@@ -1347,9 +1351,34 @@ impl Renderer { - let tri = wgpu::PrimitiveTopology::TriangleList; - // AirDef fork: the HD lighting's G-buffer (terrain.wgsl's fs_gbuffer): albedo and normal. - let gbuffer_targets = [ -- Some(wgpu::ColorTargetState { format: hd::ALBEDO, blend: None, write_mask: wgpu::ColorWrites::ALL }), -- Some(wgpu::ColorTargetState { format: hd::NORMAL, blend: None, write_mask: wgpu::ColorWrites::ALL }), -+ Some(wgpu::ColorTargetState { -+ format: hd::ALBEDO, -+ blend: None, -+ write_mask: wgpu::ColorWrites::ALL, -+ }), -+ Some(wgpu::ColorTargetState { -+ format: hd::NORMAL, -+ blend: None, -+ write_mask: wgpu::ColorWrites::ALL, -+ }), -+ Some(wgpu::ColorTargetState { -+ format: hd::LIGHT_LEVELS, -+ blend: None, -+ write_mask: wgpu::ColorWrites::ALL, -+ }), - ]; -+ // The shadow maps: plain depth from the light, biased against acne, both faces. -+ let shadow_depth = Some(wgpu::DepthStencilState { -+ format: wgpu::TextureFormat::Depth32Float, -+ depth_write_enabled: Some(true), -+ depth_compare: Some(wgpu::CompareFunction::Less), -+ stencil: Default::default(), -+ bias: wgpu::DepthBiasState { -+ constant: 2, -+ slope_scale: 2.0, -+ clamp: 0.0, -+ }, -+ }); - let gbuffer_depth = Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, - depth_write_enabled: Some(true), -@@ -1366,18 +1395,48 @@ impl Renderer { - compilation_options: Default::default(), - buffers: &terrain_buffers, - }, -- primitive: wgpu::PrimitiveState { cull_mode: Some(wgpu::Face::Back), ..Default::default() }, -+ 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_gbuffer"), -- compilation_options: wgpu::PipelineCompilationOptions { constants: &sprite_constants, ..Default::default() }, -+ compilation_options: wgpu::PipelineCompilationOptions { -+ constants: &sprite_constants, -+ ..Default::default() -+ }, - targets: &gbuffer_targets, - }), - multiview_mask: None, - cache: None, - }); -+ let shadow_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { -+ label: Some("HD shadows terrain"), -+ layout: Some(&layout), -+ vertex: wgpu::VertexState { -+ module: &shader, -+ entry_point: Some("vs_terrain"), -+ compilation_options: Default::default(), -+ buffers: &terrain_buffers, -+ }, -+ primitive: Default::default(), -+ depth_stencil: shadow_depth.clone(), -+ multisample: Default::default(), -+ fragment: Some(wgpu::FragmentState { -+ module: &shader, -+ entry_point: Some("fs_shadow"), -+ compilation_options: wgpu::PipelineCompilationOptions { -+ constants: &sprite_constants, -+ ..Default::default() -+ }, -+ targets: &[], -+ }), -+ multiview_mask: None, -+ cache: None, -+ }); - let solid = pipeline( - "Terrain solid", - "vs_terrain", -@@ -1996,6 +2055,27 @@ impl Renderer { - multiview_mask: None, - cache: None, - }); -+ let shadow_entity = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { -+ label: Some("HD shadows entities"), -+ layout: Some(&entity_layout), -+ vertex: wgpu::VertexState { -+ module: &entity_shader, -+ entry_point: Some("vs_entity"), -+ compilation_options: Default::default(), -+ buffers: &entity_vertex, -+ }, -+ primitive: Default::default(), -+ depth_stencil: shadow_depth.clone(), -+ multisample: Default::default(), -+ fragment: Some(wgpu::FragmentState { -+ module: &entity_shader, -+ entry_point: Some("fs_entity_shadow"), -+ compilation_options: Default::default(), -+ targets: &[], -+ }), -+ multiview_mask: None, -+ cache: None, -+ }); - let entity_pipeline = |label, cull_mode, depth_write, compare, bias, blend, clouds| { - entity_pipeline_with( - label, -@@ -2729,6 +2809,9 @@ impl Renderer { - mod_views: None, - gbuffer_terrain, - gbuffer_entity, -+ shadow_terrain, -+ shadow_entity, -+ hd_shadow_visible: Vec::new(), - hd: None, - mod_params: (Vec::new(), 0.0), - pass_timing: pack_timing::PassTiming::logging(), -@@ -3112,14 +3195,17 @@ impl Renderer { - let mut at = range.start; - if let Some(views) = &self.mod_views { - for v in batch.views.iter().filter(|v| { -- v.at >= range.start && (v.at < range.end || (v.at == range.end && range.end == last)) -+ v.at >= range.start -+ && (v.at < range.end || (v.at == range.end && range.end == last)) - }) { -- self.hud.draw_range(encoder, target, &self.depth, batch, size, at..v.at); -+ self.hud -+ .draw_range(encoder, target, &self.depth, batch, size, at..v.at); - views.blit(encoder, target, size, v, batch.scale); - at = v.at; - } - } -- self.hud.draw_range(encoder, target, &self.depth, batch, size, at..range.end); -+ self.hud -+ .draw_range(encoder, target, &self.depth, batch, size, at..range.end); - } - /// The mod cameras due this frame, each into its picture: sky, solid - /// terrain and entities, through the world's own pipelines with the -@@ -3215,7 +3301,9 @@ impl Renderer { - pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); - } - for (k, v) in d.lods.iter().enumerate() { -- let Some(tile) = self.lods.get(&v.key) else { continue }; -+ let Some(tile) = self.lods.get(&v.key) else { -+ continue; -+ }; - let s = &tile.mesh; - if s.solid == 0 { - continue; -@@ -3520,7 +3608,11 @@ impl Renderer { - } - Some(source) => { - // Under the HD lighting it draws into the HD target, else onto the screen. -- let format = if self.mod_post_hdr { hd::HDR } else { self.config.format.remove_srgb_suffix() }; -+ let format = if self.mod_post_hdr { -+ hd::HDR -+ } else { -+ self.config.format.remove_srgb_suffix() -+ }; - self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); - self.mod_post_source = Some(source.to_owned()); - } -@@ -3530,7 +3622,9 @@ impl Renderer { - /// AirDef fork: the mod post pass's parameters for the next frames. - pub fn set_mod_params(&mut self, params: &[f32], partial: f32) { - self.mod_params.0.clear(); -- self.mod_params.0.extend_from_slice(¶ms[..params.len().min(mod_post::PARAMS)]); -+ self.mod_params -+ .0 -+ .extend_from_slice(¶ms[..params.len().min(mod_post::PARAMS)]); - self.mod_params.1 = partial; - } - pub fn drop_shader_packs(&mut self) { -@@ -4049,6 +4143,41 @@ 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 { -+ view_proj, -+ camera, -+ // 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 }, -+ fog_color, -+ fog, -+ 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_shadow_visible = std::mem::take(&mut self.hd_shadow_visible); -+ hd_shadow_visible.clear(); -+ if hd_wanted && shadow_strength > 0.0 { -+ let radius = -+ ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2); -+ let light = Frustum { -+ planes: frustum(hd_cascades[1]), -+ }; -+ self.sections.visible( -+ camera, -+ radius, -+ &light, -+ |_| true, -+ |_, _| false, -+ &mut hd_shadow_visible, -+ ); -+ } - // Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's. - let globals_size = std::mem::size_of::() as u64; - let mut views_due = Vec::new(); -@@ -4063,7 +4192,9 @@ impl Renderer { - for v in views.choose(device, frame.mod_views, globals_size) { - let (view_proj, view, aspect) = mod_views::matrices(v, camera, frame.depth_far); - let own = world_projection(v.fov, aspect, frame.depth_far) * view; -- let planes = Frustum { planes: frustum(own) }; -+ let planes = Frustum { -+ planes: frustum(own), -+ }; - let mut seen = Vec::new(); - // Where distant terrain covers a column, its tile draws instead, as in the world pass. - self.sections.visible( -@@ -4105,8 +4236,13 @@ impl Renderer { - } else if frame.mod_views.is_empty() { - self.mod_views = None; - } -- let view_sections: usize = views_due.iter().map(|d| d.visible.len() + d.lods.len()).sum(); -- let instances = self.drawn_sections + shadow_visible.len() + view_sections; -+ let view_sections: usize = views_due -+ .iter() -+ .map(|d| d.visible.len() + d.lods.len()) -+ .sum(); -+ let instances = -+ self.drawn_sections + shadow_visible.len() + view_sections + hd_shadow_visible.len(); -+ let hd_shadow_first = (self.drawn_sections + shadow_visible.len() + view_sections) as u32; - if instances as u64 > self.instance_capacity { - self.instance_capacity = (instances as u64).next_power_of_two(); - self.instances = self.device.create_buffer(&wgpu::BufferDescriptor { -@@ -4125,14 +4261,13 @@ impl Renderer { - .map(|v| section_instance(v.origin)) - .chain(visible_lods.iter().map(|v| lod::instance(v.origin))) - .chain(shadow_visible.iter().map(|v| section_instance(v.origin))) -- .chain( -- views_due.iter().flat_map(|d| { -- d.visible -- .iter() -- .map(|v| section_instance(v.origin)) -- .chain(d.lods.iter().map(|v| lod::instance(v.origin))) -- }), -- ), -+ .chain(views_due.iter().flat_map(|d| { -+ d.visible -+ .iter() -+ .map(|v| section_instance(v.origin)) -+ .chain(d.lods.iter().map(|v| lod::instance(v.origin))) -+ })) -+ .chain(hd_shadow_visible.iter().map(|v| section_instance(v.origin))), - ); - if !self.origins.is_empty() { - self.uploads.buffer( -@@ -4835,10 +4970,65 @@ impl Renderer { - if hd_on && let Some(size) = self.world_post.size() { - let format = self.config.format.remove_srgb_suffix(); - let device = &self.device; -- let hd = self.hd.get_or_insert_with(|| hd::Hd::new(device, size, format)); -+ let globals_size = std::mem::size_of::() as u64; -+ let hd = self -+ .hd -+ .get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size)); - hd.resize(device, size); - if let Some(settings) = frame.hd { -- hd.write(&self.queue, view_proj, frame.hd_lights, settings); -+ hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); -+ } -+ // The sun's (or moon's) shadow cascades: the world's globals swapped for the light's. -+ if shadow_strength > 0.0 { -+ self.queue -+ .write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals)); -+ for (c, cascade) in hd_cascades.iter().enumerate() { -+ let light = Globals { -+ view_proj: cascade.to_cols_array_2d(), -+ ..globals -+ }; -+ self.queue -+ .write_buffer(&hd.shadow_globals[c], 0, bytemuck::bytes_of(&light)); -+ encoder.copy_buffer_to_buffer( -+ &hd.shadow_globals[c], -+ 0, -+ &self.globals, -+ 0, -+ globals_size, -+ ); -+ let box_ = Frustum { -+ planes: frustum(*cascade), -+ }; -+ let mut pass = hd.shadow_pass(&mut encoder, c); -+ pass.set_bind_group(0, &self.group, &[]); -+ pass.set_pipeline(&self.shadow_terrain); -+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); -+ pass.set_vertex_buffer(1, self.instances.slice(..)); -+ let mut page = None; -+ for (k, v) in hd_shadow_visible.iter().enumerate() { -+ let s = self.sections.get(v.slot); -+ if s.solid == 0 { -+ continue; -+ } -+ let min = Vec3::from(v.origin); -+ if c == 0 && !box_.intersects(min, min + Vec3::splat(16.0)) { -+ continue; -+ } -+ if page != Some(s.page) { -+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); -+ 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); -+ } -+ if entity_quads > 0 { -+ 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); -+ } -+ } -+ encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size); - } - let mut pass = hd.gbuffer_pass(&mut encoder); - pass.set_bind_group(0, &self.group, &[]); -@@ -4846,7 +5036,10 @@ impl Renderer { - pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); - pass.set_vertex_buffer(1, self.instances.slice(..)); - let mut page = None; -- let sections = visible.iter().enumerate().map(|(k, v)| (k, self.sections.get(v.slot))); -+ let sections = visible -+ .iter() -+ .enumerate() -+ .map(|(k, v)| (k, self.sections.get(v.slot))); - let tiles = visible_lods - .iter() - .enumerate() -@@ -5397,11 +5590,22 @@ 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.light(&self.device, &mut encoder, (color, depth)); -+ hd.light( -+ &self.device, -+ &mut encoder, -+ (color, depth), -+ (&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]); -- post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); -+ post.write( -+ &self.queue, -+ frame, -+ size, -+ &self.mod_params.0, -+ 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); - } -@@ -5412,7 +5616,13 @@ impl Renderer { - && let Some(post) = &mut self.mod_post - { - let size = self.world_post.size().unwrap_or([1, 1]); -- post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); -+ post.write( -+ &self.queue, -+ frame, -+ size, -+ &self.mod_params.0, -+ self.mod_params.1, -+ ); - post.run(&self.device, &mut encoder, (color, depth), &view_target); - } else if scaled.is_some() { - self.world_post.finish(&mut encoder, &view_target); -@@ -5635,6 +5845,7 @@ impl Renderer { - self.visible = visible; - self.visible_lods = visible_lods; - self.shadow_visible = shadow_visible; -+ self.hd_shadow_visible = hd_shadow_visible; - Ok(true) - } - } -diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs -index b40484e5e..c36c1795d 100644 ---- a/crates/horizon-client/src/gfx/hd.rs -+++ b/crates/horizon-client/src/gfx/hd.rs -@@ -10,6 +10,8 @@ use glam::Mat4; - /// The G-buffer's formats. - pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; - pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; -+/// Sky and block light per pixel. -+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 most lights drawn a frame. -@@ -41,15 +43,54 @@ struct HdFrame { - inverse_view_proj: [[f32; 4]; 4], - /// width, height, 1/width, 1/height. - resolution: [f32; 4], -- /// lights, exposure, bloom, -. -+ /// lights, exposure, bloom, a frame counter. - params: [f32; 4], -+ /// The shadow cascades: camera-relative position to the light's clip space. -+ cascades: [[[f32; 4]; 4]; 2], -+ /// xyz: towards the sun (or the moon at night); w: how much it lights (0 set or hidden). -+ sun: [f32; 4], -+ sun_color: [f32; 4], -+ 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). -+ ambient: [f32; 4], -+ /// x: shadows on; y, z: the cascades' half sizes. -+ shadow: [f32; 4], - } - -+/// What the world around looks like this frame (from the renderer's globals). -+#[derive(Clone, Copy, Debug)] -+pub struct HdScene { -+ pub view_proj: Mat4, -+ /// The camera in the world, for steady shadows. -+ pub camera: glam::DVec3, -+ /// Towards the sun, and the sky's darkening (0 day, 1 night). -+ pub sun: glam::Vec3, -+ pub dark: f32, -+ pub rain: f32, -+ pub fog_color: [f32; 4], -+ pub fog: [f32; 4], -+ /// The sky has a sun at all (the Overworld). -+ pub sky: bool, -+} -+ -+/// The cascades' half sizes (blocks) and shadow map size. -+pub const CASCADES: [f32; 2] = [40.0, 176.0]; -+pub const SHADOW_SIZE: u32 = 2048; -+ - const COMMON: &str = r#" - struct HdFrame { - inverse_view_proj: mat4x4, - resolution: vec4, - params: vec4, -+ cascades: array, 2>, -+ sun: vec4, -+ sun_color: vec4, -+ fog_color: vec4, -+ fog: vec4, -+ ambient: vec4, -+ shadow: vec4, - } - struct FullOut { - @builtin(position) position: vec4, -@@ -77,38 +118,131 @@ struct Light { - @group(0) @binding(4) var normal_tex: texture_2d; - @group(0) @binding(5) var gbuffer_depth: texture_depth_2d; - @group(0) @binding(6) var lights: array; -+@group(0) @binding(7) var light_tex: texture_2d; -+@group(0) @binding(8) var shadow_map: texture_depth_2d_array; -+@group(0) @binding(9) var shadow_sampler: sampler_comparison; -+@group(0) @binding(10) var lightmap: texture_2d; -+@group(0) @binding(11) var linear: sampler; -+ -+// Interleaved gradient noise: a different offset each pixel and frame, for the ray march. -+fn ign(px: vec2) -> f32 { -+ let p = px + vec2(5.588238 * frame.params.w); -+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -+} -+ -+// Where a camera-relative point falls in cascade c: uv and depth. -+fn cascade_at(p: vec3, c: i32) -> vec3 { -+ let s = frame.cascades[c] * vec4(p, 1.0); -+ return vec3(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; soft-edged. -+fn shadow(p: vec3, n: vec3) -> 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; -+ let q = p + n * texel * 1.5; -+ let s = cascade_at(q, c); -+ if any(s.xy < vec2(0.0)) || any(s.xy > vec2(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(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); -+} -+ -+// One shadow sample, for the rays through the air. -+fn lit_at(p: vec3) -> f32 { -+ let s = cascade_at(p, 1); -+ if any(s.xy < vec2(0.0)) || any(s.xy > vec2(1.0)) || s.z > 1.0 { return 1.0; } -+ return textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy, 1, s.z - 0.001); -+} -+ -+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); -+} - - @fragment - fn fs_light(in: FullOut) -> @location(0) vec4 { - let px = vec2(in.position.xy); -- var c = textureLoad(scene_color, px, 0).rgb; -+ let scene = textureLoad(scene_color, px, 0).rgb; - let d = textureLoad(scene_depth, px, 0); - let g = textureLoad(gbuffer_depth, px, 0); -- // Only where the G-buffer holds what the world shows (not the sky, water, particles). -- if d <= 0.0 || abs(d - g) > max(d, g) * 0.002 { -- return vec4(c, 1.0); -- } -- let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, d, 1.0); -+ let ndc = vec4(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 n = normalize(textureLoad(normal_tex, px, 0).xyz); -- let albedo = textureLoad(albedo_tex, px, 0); -- var light = vec3(0.0); -+ let view = normalize(p); -+ let sun = frame.sun.xyz; -+ let sun_on = frame.sun.w; - let count = u32(frame.params.x); -+ var c = scene; -+ let surface = 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(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; -+ let block = textureSampleLevel(lightmap, linear, vec2(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(p, n); } 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; -+ 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]; -+ 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; -+ } -+ 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)); -+ c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a); -+ } -+ // 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); -+ let density = frame.ambient.z; -+ if 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); -+ } -+ // Henyey-Greenstein, forward scattering: brightest looking towards the sun. -+ let cos_t = dot(view, 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)); -+ } - for (var i = 0u; i < count; i++) { - let l = lights[i]; -- let to = l.pos.xyz - p; -- let dist = length(to); -- if dist >= l.pos.w { continue; } -- // Inverse square, softened near the light, faded to nothing at its radius. -- 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 ndl = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); -- light += l.color.rgb * falloff * ndl; -+ // 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; -+ 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); - } -- c += albedo.rgb * light; -- // Glowing parts (flames, flares) shine past white, for the bloom. -- c += albedo.rgb * albedo.a * 1.5; - return vec4(c, 1.0); - } - "#; -@@ -188,6 +322,10 @@ fn fs_tonemap(in: FullOut) -> @location(0) vec4 { - // Very bright light bleaches towards white, as film does. - c = mix(c, vec3(rolled), clamp((peak - 2.0) / 8.0, 0.0, 0.6)); - } -+ // A light grade: a touch more colour, and a gentle S round the middle grey. -+ let luma = dot(c, vec3(0.2126, 0.7152, 0.0722)); -+ c = max(mix(vec3(luma), c, 1.12), vec3(0.0)); -+ c = mix(c, c * c * (3.0 - 2.0 * c), 0.18); - return vec4(c, 1.0); - } - "#; -@@ -196,11 +334,20 @@ struct Target { - view: wgpu::TextureView, - } - --fn target(device: &wgpu::Device, label: &str, size: [u32; 2], format: wgpu::TextureFormat) -> Target { -+fn target( -+ device: &wgpu::Device, -+ label: &str, -+ size: [u32; 2], -+ format: wgpu::TextureFormat, -+) -> Target { - let view = device - .create_texture(&wgpu::TextureDescriptor { - label: Some(label), -- size: wgpu::Extent3d { width: size[0].max(1), height: size[1].max(1), depth_or_array_layers: 1 }, -+ size: wgpu::Extent3d { -+ width: size[0].max(1), -+ height: size[1].max(1), -+ depth_or_array_layers: 1, -+ }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, -@@ -216,6 +363,15 @@ pub struct Hd { - size: [u32; 2], - pub albedo: wgpu::TextureView, - pub normal: wgpu::TextureView, -+ pub levels: wgpu::TextureView, -+ /// The shadow cascades: the array to sample, one view per layer to draw. -+ shadow_array: wgpu::TextureView, -+ shadow_layers: Vec, -+ shadow_sampler: wgpu::Sampler, -+ /// The globals the shadow passes draw with (the light's view), and the camera's to put back. -+ pub shadow_globals: [wgpu::Buffer; 2], -+ pub main_globals: wgpu::Buffer, -+ frames: u32, - pub depth: wgpu::TextureView, - /// The lit world, and the post pass's output. - pub lit: wgpu::TextureView, -@@ -258,12 +414,21 @@ fn fullscreen( - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&pipeline_layout), -- vertex: wgpu::VertexState { module, entry_point: Some("vs_full"), compilation_options: Default::default(), buffers: &[] }, -+ vertex: wgpu::VertexState { -+ module, -+ entry_point: Some("vs_full"), -+ compilation_options: Default::default(), -+ buffers: &[], -+ }, - fragment: Some(wgpu::FragmentState { - module, - entry_point: Some(entry), - compilation_options: Default::default(), -- targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], -+ targets: &[Some(wgpu::ColorTargetState { -+ format, -+ blend, -+ write_mask: wgpu::ColorWrites::ALL, -+ })], - }), - primitive: Default::default(), - depth_stencil: None, -@@ -286,8 +451,11 @@ fn entries(kinds: &[wgpu::BindingType]) -> Vec { - .collect() - } - --const UNIFORM: wgpu::BindingType = -- wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }; -+const UNIFORM: wgpu::BindingType = wgpu::BindingType::Buffer { -+ ty: wgpu::BufferBindingType::Uniform, -+ has_dynamic_offset: false, -+ min_binding_size: None, -+}; - const FLOAT: wgpu::BindingType = wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, -@@ -306,7 +474,12 @@ const DEPTH: wgpu::BindingType = wgpu::BindingType::Texture { - const SAMPLER: wgpu::BindingType = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering); - - impl Hd { -- pub fn new(device: &wgpu::Device, size: [u32; 2], screen: wgpu::TextureFormat) -> Self { -+ pub fn new( -+ device: &wgpu::Device, -+ size: [u32; 2], -+ screen: wgpu::TextureFormat, -+ globals_size: u64, -+ ) -> Self { - let light_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("HD lighting"), - entries: &entries(&[ -@@ -321,6 +494,15 @@ impl Hd { - has_dynamic_offset: false, - min_binding_size: None, - }, -+ UNFILTERED, -+ wgpu::BindingType::Texture { -+ sample_type: wgpu::TextureSampleType::Depth, -+ view_dimension: wgpu::TextureViewDimension::D2Array, -+ multisampled: false, -+ }, -+ wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), -+ FLOAT, -+ SAMPLER, - ]), - }); - let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { -@@ -335,14 +517,58 @@ impl Hd { - let bloom = module(device, "HD bloom", BLOOM); - let tone = module(device, "HD tone mapping", TONEMAP); - let add = wgpu::BlendState { -- color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add }, -+ color: wgpu::BlendComponent { -+ src_factor: wgpu::BlendFactor::One, -+ dst_factor: wgpu::BlendFactor::One, -+ operation: wgpu::BlendOperation::Add, -+ }, - alpha: wgpu::BlendComponent::REPLACE, - }; -- let light_pipeline = fullscreen(device, "HD lighting", &light_layout, &light, "fs_light", HDR, None); -- let prefilter = fullscreen(device, "HD bloom prefilter", &bloom_layout, &bloom, "fs_prefilter", HDR, None); -- let down = fullscreen(device, "HD bloom down", &bloom_layout, &bloom, "fs_down", HDR, None); -- let up = fullscreen(device, "HD bloom up", &bloom_layout, &bloom, "fs_up", HDR, Some(add)); -- let tone = fullscreen(device, "HD tone mapping", &tone_layout, &tone, "fs_tonemap", screen, None); -+ let light_pipeline = fullscreen( -+ device, -+ "HD lighting", -+ &light_layout, -+ &light, -+ "fs_light", -+ HDR, -+ None, -+ ); -+ let prefilter = fullscreen( -+ device, -+ "HD bloom prefilter", -+ &bloom_layout, -+ &bloom, -+ "fs_prefilter", -+ HDR, -+ None, -+ ); -+ let down = fullscreen( -+ device, -+ "HD bloom down", -+ &bloom_layout, -+ &bloom, -+ "fs_down", -+ HDR, -+ None, -+ ); -+ let up = fullscreen( -+ device, -+ "HD bloom up", -+ &bloom_layout, -+ &bloom, -+ "fs_up", -+ HDR, -+ Some(add), -+ ); -+ let tone = fullscreen( -+ device, -+ "HD tone mapping", -+ &tone_layout, -+ &tone, -+ "fs_tonemap", -+ screen, -+ None, -+ ); - let frame = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("HD frame"), - size: std::mem::size_of::() as u64, -@@ -363,11 +589,70 @@ impl Hd { - address_mode_v: wgpu::AddressMode::ClampToEdge, - ..Default::default() - }); -+ let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { -+ label: Some("HD shadow cascades"), -+ size: wgpu::Extent3d { -+ width: SHADOW_SIZE, -+ height: SHADOW_SIZE, -+ depth_or_array_layers: 2, -+ }, -+ mip_level_count: 1, -+ sample_count: 1, -+ dimension: wgpu::TextureDimension::D2, -+ format: wgpu::TextureFormat::Depth32Float, -+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, -+ view_formats: &[], -+ }); -+ let shadow_array = shadow_texture.create_view(&wgpu::TextureViewDescriptor { -+ dimension: Some(wgpu::TextureViewDimension::D2Array), -+ ..Default::default() -+ }); -+ let shadow_layers = (0..2) -+ .map(|l| { -+ shadow_texture.create_view(&wgpu::TextureViewDescriptor { -+ dimension: Some(wgpu::TextureViewDimension::D2), -+ base_array_layer: l, -+ array_layer_count: Some(1), -+ ..Default::default() -+ }) -+ }) -+ .collect(); -+ let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { -+ label: Some("HD shadows"), -+ mag_filter: wgpu::FilterMode::Linear, -+ min_filter: wgpu::FilterMode::Linear, -+ compare: Some(wgpu::CompareFunction::LessEqual), -+ ..Default::default() -+ }); -+ let globals = |label| { -+ device.create_buffer(&wgpu::BufferDescriptor { -+ label: Some(label), -+ size: globals_size, -+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, -+ mapped_at_creation: false, -+ }) -+ }; - let mut hd = Hd { - size: [0, 0], - albedo: target(device, "HD albedo", [1, 1], ALBEDO).view, - normal: target(device, "HD normal", [1, 1], NORMAL).view, -- depth: target(device, "HD depth", [1, 1], wgpu::TextureFormat::Depth32Float).view, -+ levels: target(device, "HD light levels", [1, 1], LIGHT_LEVELS).view, -+ shadow_array, -+ shadow_layers, -+ shadow_sampler, -+ shadow_globals: [ -+ globals("HD shadow globals 0"), -+ globals("HD shadow globals 1"), -+ ], -+ main_globals: globals("HD camera globals"), -+ frames: 0, -+ depth: target( -+ device, -+ "HD depth", -+ [1, 1], -+ wgpu::TextureFormat::Depth32Float, -+ ) -+ .view, - lit: target(device, "HD lit", [1, 1], HDR).view, - fx: target(device, "HD post", [1, 1], HDR).view, - bloom: Vec::new(), -@@ -395,6 +680,7 @@ impl Hd { - self.size = size; - 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; -@@ -406,20 +692,77 @@ impl Hd { - .collect(); - } - -- /// This frame's camera, lights and settings. -- pub fn write(&mut self, queue: &wgpu::Queue, view_proj: Mat4, lights: &[HdLight], settings: HdSettings) { -+ /// This frame's camera, sun, fog, lights and settings. -+ pub fn write( -+ &mut self, -+ queue: &wgpu::Queue, -+ scene: &HdScene, -+ lights: &[HdLight], -+ settings: HdSettings, -+ ) { - let n = lights.len().min(MAX_LIGHTS); - if n > 0 { - queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n])); - } -+ 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); -+ let cascades = cascades(scene.camera, dir); -+ let night = scene.dark.clamp(0.0, 1.0); - let frame = HdFrame { -- inverse_view_proj: view_proj.inverse().to_cols_array_2d(), -- resolution: [self.size[0] as f32, self.size[1] as f32, 1.0 / self.size[0] as f32, 1.0 / self.size[1] as f32], -- params: [n as f32, settings.exposure, settings.bloom, 0.0], -+ inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(), -+ resolution: [ -+ self.size[0] as f32, -+ self.size[1] as f32, -+ 1.0 / self.size[0] as f32, -+ 1.0 / self.size[1] as f32, -+ ], -+ params: [ -+ n as f32, -+ settings.exposure, -+ settings.bloom, -+ (self.frames % 64) as f32, -+ ], -+ cascades: cascades.map(|m| m.to_cols_array_2d()), -+ sun: [dir.x, dir.y, dir.z, strength], -+ sun_color: [color.x, color.y, color.z, night], -+ 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], -+ shadow: [ -+ if strength > 0.0 { 1.0 } else { 0.0 }, -+ CASCADES[0], -+ CASCADES[1], -+ 0.0, -+ ], - }; - queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame)); - } - -+ /// The shadow pass of cascade `c`: its depth layer cleared, nothing else. -+ pub fn shadow_pass<'e>( -+ &self, -+ encoder: &'e mut wgpu::CommandEncoder, -+ c: usize, -+ ) -> wgpu::RenderPass<'e> { -+ encoder -+ .begin_render_pass(&wgpu::RenderPassDescriptor { -+ label: Some("HD shadow cascade"), -+ color_attachments: &[], -+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { -+ view: &self.shadow_layers[c], -+ depth_ops: Some(wgpu::Operations { -+ load: wgpu::LoadOp::Clear(1.0), -+ store: wgpu::StoreOp::Store, -+ }), -+ stencil_ops: None, -+ }), -+ ..Default::default() -+ }) -+ .forget_lifetime() -+ } -+ - /// 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> { - let clear = |view| { -@@ -427,23 +770,41 @@ impl Hd { - view, - resolve_target: None, - depth_slice: None, -- ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, -+ ops: wgpu::Operations { -+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), -+ store: wgpu::StoreOp::Store, -+ }, - }) - }; -- encoder.begin_render_pass(&wgpu::RenderPassDescriptor { -- label: Some("HD G-buffer"), -- color_attachments: &[clear(&self.albedo), clear(&self.normal)], -- depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { -- view: &self.depth, -- depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Store }), -- stencil_ops: None, -- }), -- ..Default::default() -- }) -- .forget_lifetime() -+ encoder -+ .begin_render_pass(&wgpu::RenderPassDescriptor { -+ label: Some("HD G-buffer"), -+ color_attachments: &[ -+ clear(&self.albedo), -+ clear(&self.normal), -+ clear(&self.levels), -+ ], -+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { -+ view: &self.depth, -+ depth_ops: Some(wgpu::Operations { -+ load: wgpu::LoadOp::Clear(0.0), -+ store: wgpu::StoreOp::Store, -+ }), -+ stencil_ops: None, -+ }), -+ ..Default::default() -+ }) -+ .forget_lifetime() - } - -- fn draw(encoder: &mut wgpu::CommandEncoder, label: &str, to: &wgpu::TextureView, load: bool, pipeline: &wgpu::RenderPipeline, group: &wgpu::BindGroup) { -+ fn draw( -+ encoder: &mut wgpu::CommandEncoder, -+ label: &str, -+ to: &wgpu::TextureView, -+ load: bool, -+ pipeline: &wgpu::RenderPipeline, -+ group: &wgpu::BindGroup, -+ ) { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some(label), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { -@@ -451,7 +812,11 @@ impl Hd { - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { -- load: if load { wgpu::LoadOp::Load } else { wgpu::LoadOp::Clear(wgpu::Color::BLACK) }, -+ load: if load { -+ wgpu::LoadOp::Load -+ } else { -+ wgpu::LoadOp::Clear(wgpu::Color::BLACK) -+ }, - store: wgpu::StoreOp::Store, - }, - })], -@@ -463,21 +828,75 @@ impl Hd { - } - - /// Lights the world (its colour and depth) into `lit`. -- pub fn light(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: (&wgpu::TextureView, &wgpu::TextureView)) { -+ pub fn light( -+ &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: 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::draw(encoder, "HD lighting", &self.lit, false, &self.light_pipeline, &group); -+ Self::draw( -+ encoder, -+ "HD lighting", -+ &self.lit, -+ false, -+ &self.light_pipeline, -+ &group, -+ ); - } - - fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup { -@@ -485,36 +904,140 @@ impl Hd { - label: Some("HD bloom"), - layout: &self.bloom_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::Sampler(&self.sampler) }, -+ 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::Sampler(&self.sampler), -+ }, - ], - }) - } - - /// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`. -- pub fn finish(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, screen: &wgpu::TextureView) { -+ pub fn finish( -+ &self, -+ device: &wgpu::Device, -+ encoder: &mut wgpu::CommandEncoder, -+ source: &wgpu::TextureView, -+ screen: &wgpu::TextureView, -+ ) { - // Down: the prefilter into the first level, then each level from the one above. -- Self::draw(encoder, "HD bloom prefilter", &self.bloom[0].view, false, &self.prefilter, &self.bloom_group(device, source)); -+ Self::draw( -+ encoder, -+ "HD bloom prefilter", -+ &self.bloom[0].view, -+ false, -+ &self.prefilter, -+ &self.bloom_group(device, source), -+ ); - 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::draw( -+ encoder, -+ "HD bloom down", -+ &self.bloom[k].view, -+ false, -+ &self.down, -+ &g, -+ ); - } - // 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::draw( -+ encoder, -+ "HD bloom up", -+ &self.bloom[k - 1].view, -+ true, -+ &self.up, -+ &g, -+ ); - } - 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) }, -+ 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, -+ &group, -+ ); -+ } -+} -+ -+/// The light that casts the shadows: towards the sun by day, the moon by night; how strong -+/// (0 below the horizon or without a sky) and its colour (warm low, white high, cold moon). -+pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) { -+ if !scene.sky { -+ return (glam::Vec3::Y, 0.0, glam::Vec3::ZERO); -+ } -+ let sun = scene.sun.normalize_or(glam::Vec3::Y); -+ 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) -+ } else { -+ let moon = -sun; -+ let up = smooth(-0.02, 0.18, moon.y); -+ (moon, 0.15 * up * clear, glam::Vec3::new(0.55, 0.66, 1.0)) - } - } -+ -+fn smooth(a: f32, b: f32, x: f32) -> f32 { -+ let t = ((x - a) / (b - a)).clamp(0.0, 1.0); -+ t * t * (3.0 - 2.0 * t) -+} -+ -+/// The cascades' light view-projections (camera-relative), each square snapped to its texels in -+/// the world so the shadows don't crawl as the camera moves. -+pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] { -+ let l = light.normalize_or(glam::Vec3::Y).as_dvec3(); -+ let forward = -l; -+ let reference = if l.y.abs() > 0.99 { -+ glam::DVec3::Z -+ } else { -+ glam::DVec3::Y -+ }; -+ let right = forward.cross(reference).normalize(); -+ let up = right.cross(forward); -+ CASCADES.map(|r| { -+ let texel = f64::from(r) * 2.0 / f64::from(SHADOW_SIZE); -+ let snap = |v: f64| (v / texel).floor() * texel - v; -+ let centre = right * snap(camera.dot(right)) + up * snap(camera.dot(up)); -+ let eye = centre + l * 400.0; -+ let view = glam::DMat4::look_at_rh(eye, centre, up); -+ let r = f64::from(r); -+ let projection = glam::DMat4::orthographic_rh(-r, r, -r, r, 1.0, 800.0); -+ (projection * view).as_mat4() -+ }) -+} -diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs -index 497cff854..d1f54a4bb 100644 ---- a/crates/horizon-client/src/gfx/mod_post.rs -+++ b/crates/horizon-client/src/gfx/mod_post.rs -@@ -121,7 +121,11 @@ fn compile(source: &str) -> Result { - let text = e.emit_to_string(&full); - match e.location(&full) { - Some(l) if l.line_number as usize > prelude_lines => { -- format!("line {}: {}", l.line_number as usize - prelude_lines, e.message()) -+ format!( -+ "line {}: {}", -+ l.line_number as usize - prelude_lines, -+ e.message() -+ ) - } - _ => text, - } -@@ -136,7 +140,11 @@ fn compile(source: &str) -> Result { - } - - impl ModPost { -- pub fn new(device: &wgpu::Device, source: &str, format: wgpu::TextureFormat) -> Result { -+ pub fn new( -+ device: &wgpu::Device, -+ source: &str, -+ format: wgpu::TextureFormat, -+ ) -> Result { - let module = compile(source)?; - let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { - binding, -@@ -232,11 +240,19 @@ impl ModPost { - } - - /// This frame's camera and the mod's `params`, for a world of `size`. -- pub fn write(&self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2], params: &[f32], partial: f32) { -+ pub fn write( -+ &self, -+ queue: &wgpu::Queue, -+ frame: &Frame, -+ size: [u32; 2], -+ params: &[f32], -+ partial: f32, -+ ) { - let aspect = size[0] as f32 / size[1].max(1) as f32; - let view = crate::camera::view_matrix(frame.yaw, frame.pitch); -- let projection = -- super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob * frame.spin; -+ let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far) -+ * frame.bob -+ * frame.spin; - let a = frame.sky.sun_angle.to_radians(); - let sun = [-a.sin(), a.cos(), 0.0, 0.0]; - let mut p = [[0.0f32; 4]; PARAMS / 4]; -@@ -248,14 +264,24 @@ 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: [ -+ frame.camera.x as f32, -+ frame.camera.y as f32, -+ frame.camera.z as f32, -+ 0.0, -+ ], - resolution: [ - size[0] as f32, - size[1] as f32, - 1.0 / size[0].max(1) as f32, - 1.0 / size[1].max(1) as f32, - ], -- time: [self.start.elapsed().as_secs_f32() % 3600.0, partial, sun[1], 0.0], -+ time: [ -+ self.start.elapsed().as_secs_f32() % 3600.0, -+ partial, -+ sun[1], -+ 0.0, -+ ], - sun, - params: p, - }; -@@ -279,15 +305,29 @@ impl ModPost { - label: Some("Mod post"), - layout: &self.layout, - entries: &[ -- wgpu::BindGroupEntry { binding: 0, resource: self.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) }, -+ wgpu::BindGroupEntry { -+ binding: 0, -+ resource: self.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), -+ }, - ], - }); - self.group = Some(((world.0.clone(), world.1.clone()), group)); - } -- let Some((_, group)) = &self.group else { return }; -+ let Some((_, group)) = &self.group else { -+ return; -+ }; - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Mod post"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { -diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs -index c0d886afd..aa2c5b971 100644 ---- a/crates/horizon-client/src/gfx/mod_views.rs -+++ b/crates/horizon-client/src/gfx/mod_views.rs -@@ -194,7 +194,8 @@ impl Views { - views: &'a [ModView], - globals_size: u64, - ) -> Vec<&'a ModView> { -- self.targets.retain(|id, _| views.iter().any(|v| v.id == *id)); -+ self.targets -+ .retain(|id, _| views.iter().any(|v| v.id == *id)); - for v in views { - if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) { - let t = self.target(device, v.size, globals_size); -@@ -318,5 +319,9 @@ pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) { - let view = crate::camera::view_matrix(v.yaw, v.pitch); - let projection = super::world_projection(v.fov, aspect, far); - let offset = (eye - v.camera).as_vec3(); -- (projection * view * Mat4::from_translation(offset), view, aspect) -+ ( -+ projection * view * Mat4::from_translation(offset), -+ view, -+ aspect, -+ ) - } -diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl -index 293729e33..bb5c70f63 100644 ---- a/crates/horizon-client/src/terrain.wgsl -+++ b/crates/horizon-client/src/terrain.wgsl -@@ -74,6 +74,8 @@ struct TerrainOut { - // AirDef fork, HD lighting's G-buffer: the camera-relative position and the unlit colour. - @location(6) pos: vec3, - @location(7) tint: vec4, -+ // Sky and block light, 0 to 1. -+ @location(8) lm: vec2, - } - // Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units - // (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one -@@ -132,6 +134,7 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4 f32 { -@@ -159,6 +162,7 @@ fn apply_fog(color: vec4, spherical: f32, cylindrical: f32) -> vec4 { - struct GBufferOut { - @location(0) albedo: vec4, - @location(1) normal: vec4, -+ @location(2) light: vec4, - } - fn face_normal(pos: vec3) -> vec3 { - var n = normalize(cross(dpdx(pos), dpdy(pos))); -@@ -171,8 +175,14 @@ fn face_normal(pos: vec3) -> vec3 { - var out: GBufferOut; - out.albedo = vec4(color.rgb, f32(in.emission) / 15.0); - out.normal = vec4(face_normal(in.pos), 1.0); -+ out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 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; -+ if a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } -+} - @fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4 { - let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; - if color.a < 0.01 { discard; } --- -2.54.0 (Apple Git-157) - diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch deleted file mode 100644 index 905a883..0000000 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ /dev/null @@ -1,439 +0,0 @@ -From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/42] 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 ---- - 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, -+ /// 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, -+ up_groups: Vec, - 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) - diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch deleted file mode 100644 index 3ca0b62..0000000 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ /dev/null @@ -1,362 +0,0 @@ -From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/42] 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 ---- - 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; - @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 tiles: array; - - // Interleaved gradient noise: a different offset each pixel and frame, for the ray march. - fn ign(px: vec2) -> f32 { -@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { - 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 { - 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 { - 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, - 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>, -+ tile_words: Vec, - 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::()) 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>, -+ words: &mut Vec, -+) { -+ 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 { -+ 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) - diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch deleted file mode 100644 index 3d31fc0..0000000 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ /dev/null @@ -1,712 +0,0 @@ -From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/42] 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 ---- - 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 { -+ 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 { -@@ -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 tiles: array; -+// 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; -+ -+// 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, dist: f32) -> f32 { -+ let size = vec2(textureDimensions(ao_tex)); -+ let f = vec2(px) * 0.5; -+ let base = vec2(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(i, j), vec2(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(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 { -@@ -214,7 +264,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { - // 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 { - 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 { - } - "#; - -+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 frame: HdFrame; -+@group(0) @binding(1) var depth_tex: texture_depth_2d; -+@group(0) @binding(2) var normal_tex: texture_2d; -+ -+// Interleaved gradient noise, shifted per frame (params.w) and per use. -+fn noise(px: vec2, shift: vec2) -> f32 { -+ let p = px + shift + vec2(5.588238 * frame.params.w); -+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -+} -+ -+// The camera-relative position of a (continuous) full-resolution pixel at depth z. -+fn position_at(px: vec2, z: f32) -> vec3 { -+ let uv = px * frame.resolution.zw; -+ let h = frame.inverse_view_proj * vec4(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 { -+ let size = vec2(textureDimensions(depth_tex)); -+ let ic = min(vec2(in.position.xy) * 2, size - 1); -+ let z = textureLoad(depth_tex, ic, 0); -+ if z <= 0.0 { return vec2(1.0, 0.0); } -+ let nrm = textureLoad(normal_tex, ic, 0).xyz; -+ let c = vec2(ic) + 0.5; -+ let p = position_at(c, z); -+ let dist = length(p); -+ if dot(nrm, nrm) < 0.01 { return vec2(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(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(1.0, dist); } -+ let radius = reach * per_px; -+ let rot = noise(in.position.xy, vec2(0.0)); -+ let jitter = noise(in.position.xy, vec2(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(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(floor(c + d2 * (r * sg))); -+ if any(ip < vec2(0)) || any(ip >= size) { continue; } -+ let sz = textureLoad(depth_tex, ip, 0); -+ if sz <= 0.0 { continue; } -+ let to = position_at(vec2(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(mix(1.0, ao, 1.0 - smoothstep(40.0, 96.0, dist)), dist); -+} -+"#; -+ -+const AO_BLUR: &str = r#" -+@group(0) @binding(0) var frame: HdFrame; -+@group(0) @binding(1) var raw_ao: texture_2d; -+ -+// 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 { -+ let size = vec2(textureDimensions(raw_ao)); -+ let ip = vec2(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(x, y); -+ if any(q < vec2(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(sum / max(total, 0.0001), centre.y); -+} -+"#; -+ - const BLOOM: &str = r#" - @group(0) @binding(0) var frame: HdFrame; - @group(0) @binding(1) var source: texture_2d; -@@ -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, -+ ao_blur_group: Option, -+ /// GPU time of the HD passes while diagnostics ask for it (`set_timing`). -+ timing: Option, -+ /// 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, -+ name: &str, -+) -> Option> { -+ 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>, - ) { - 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) - diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch deleted file mode 100644 index 071b86e..0000000 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ /dev/null @@ -1,678 +0,0 @@ -From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/42] 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 ---- - 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, -+ /// 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, - mod_params: (Vec, 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::() 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, 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 = 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, 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, - ambient: vec4, - shadow: vec4, -+ prev_view_proj: mat4x4, -+ taa: vec4, -+ taa_params: vec4, - } - struct FullOut { - @builtin(position) position: vec4, -@@ -507,7 +522,21 @@ const TONEMAP: &str = r#" - - @fragment - fn fs_tonemap(in: FullOut) -> @location(0) vec4 { -- 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(textureDimensions(hdr)); -+ let n0 = textureSampleLevel(hdr, linear, in.uv + vec2(t.x, 0.0), 0.0).rgb; -+ let n1 = textureSampleLevel(hdr, linear, in.uv - vec2(t.x, 0.0), 0.0).rgb; -+ let n2 = textureSampleLevel(hdr, linear, in.uv + vec2(0.0, t.y), 0.0).rgb; -+ let n3 = textureSampleLevel(hdr, linear, in.uv - vec2(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 { - } - "#; - -+const TAA: &str = r#" -+struct TaaOut { -+ @location(0) color: vec4, -+ @location(1) history: vec4, -+} -+@group(0) @binding(0) var frame: HdFrame; -+@group(0) @binding(1) var source: texture_2d; -+@group(0) @binding(2) var depth_tex: texture_depth_2d; -+@group(0) @binding(3) var history: texture_2d; -+@group(0) @binding(4) var linear: sampler; -+ -+fn to_ycocg(c: vec3) -> vec3 { -+ return vec3(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) -> vec3 { -+ let t = c.x - c.z; -+ return vec3(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) -> vec3 { -+ return c / (1.0 + max(c.r, max(c.g, c.b))); -+} -+fn unsquash(c: vec3) -> vec3 { -+ 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) -> vec3 { -+ let size = vec2(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(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y); -+ var total = w12.x * w0.y; -+ sum += textureSampleLevel(history, linear, vec2(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(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y); -+ total += w3.x * w12.y; -+ sum += textureSampleLevel(history, linear, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y); -+ total += w12.x * w3.y; -+ return max(sum / total, vec3(0.0)); -+} -+ -+// Pulls `p` towards the box's centre until it is inside. -+fn clip_to_box(lo: vec3, hi: vec3, p: vec3) -> vec3 { -+ let centre = (lo + hi) * 0.5; -+ let ext = max((hi - lo) * 0.5, vec3(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(textureDimensions(source)); -+ let px = vec2(in.position.xy); -+ let d = textureLoad(depth_tex, px, 0); -+ var sum = vec3(0.0); -+ var sum2 = vec3(0.0); -+ var lo = vec3(1.0e9); -+ var hi = vec3(-1.0e9); -+ var centre = vec3(0.0); -+ for (var y = -1; y <= 1; y++) { -+ for (var x = -1; x <= 1; x++) { -+ let q = clamp(px + vec2(x, y), vec2(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(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(select(p + frame.taa.xyz, p, sky), 1.0); -+ if prev.w > 0.0 { -+ let pn = prev.xy / prev.w; -+ let uv = vec2(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5); -+ if all(uv > vec2(0.0)) && all(uv < vec2(1.0)) { -+ let hp = clamp(vec2(uv * vec2(size)), vec2(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(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(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(color, 1.0); -+ out.history = vec4(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 = 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, - /// 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) - diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch deleted file mode 100644 index e9523c7..0000000 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ /dev/null @@ -1,643 +0,0 @@ -From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/42] 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 ---- - 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); -+ /// 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; -+ /// 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, -+ 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) -> 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>>>; - -+/// 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 Option + 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, -+} -+ -+type ModelSlot = Arc>; -+ -+/// 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, - /// Capability `render`'s world models (AirDef fork), drawn until replaced. -- models: Vec, -+ models: Vec, -+ /// 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, - out: Vec, - 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> { - 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 { -+ pub fn models(&self) -> impl Iterator { - 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 { -- 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) -> Vec { -+ 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) { -- 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 { -+ 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) -> 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) { - let particles: Vec = 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) { - 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` | None on the title screen. | - | `get-clock` | `func() -> option` | None on the title screen. | -+| `world-id` | `func() -> option` | Which world is open (AirDef fork): `world:` for a saved world, `server:
` on a server; none on the title screen. Mods keep their state apart by it. | - | `raycast` | `func(distance: f32) -> option` | 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` | 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` | | -+| `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` | Gaussian spread round the point, per axis, in blocks. | -+| `velocity` | `tuple` | 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` | 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)` | 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)` | 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)` | 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)` | 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` | 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) -> 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)` | 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`, `:`) at a point on this client. | -+| `query-ground` | `func(columns: list>)` | 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>` | 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, pixel: vec2) -> vec4`, 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)` | 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)` | 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)` | 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) - diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch deleted file mode 100644 index 31ea9b5..0000000 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ /dev/null @@ -1,527 +0,0 @@ -From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/42] 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 ---- - 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(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(size)); -@@ -679,6 +682,99 @@ fn fs_taa(in: FullOut) -> TaaOut { - out.history = vec4(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(textureDimensions(source)); -+ let lowi = vec2(low); -+ let high = vec2(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(floor(t + 0.5)); -+ var sum = vec3(0.0); -+ var sum2 = vec3(0.0); -+ var lo = vec3(1.0e9); -+ var hi = vec3(-1.0e9); -+ var weighted = vec3(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(x, y), vec2(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(base + vec2(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(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(x, y), vec2(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(jit.x * 2.0 / low.x, -jit.y * 2.0 / low.y); -+ let ndc = vec4(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(select(p + frame.taa.xyz, p, sky), 1.0); -+ if prev.w > 0.0 { -+ let pn = prev.xy / prev.w; -+ let uv = vec2(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5); -+ if all(uv > vec2(0.0)) && all(uv < vec2(1.0)) { -+ let hp = clamp(vec2(uv * high), vec2(0), vec2(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(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(color, 1.0); -+ out.history = vec4(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 { -+ static SCALE: std::sync::OnceLock> = 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, - /// 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, - pub antialias: bool, - target: Option, - } -@@ -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) -> vec3 { - 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) - diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch deleted file mode 100644 index efb492d..0000000 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ /dev/null @@ -1,982 +0,0 @@ -From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/42] 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 ---- - 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, - /// 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, - previous: Option<&Self>, - api: GraphicsApi, -+ water_layers: [u32; 2], - ) -> Result { - 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>]| { -+ 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, - taa: vec4, - taa_params: vec4, -+ view_proj: mat4x4, -+ water: vec4, -+ sky_color: vec4, - } - struct FullOut { - @builtin(position) position: vec4, -@@ -175,6 +193,8 @@ struct Light { - @group(0) @binding(12) var tiles: array; - // 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; -+// 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, c: i32) -> vec3 { - - // The sun's (or moon's) light reaching p: 1 lit, 0 in shadow; soft-edged. - fn shadow(p: vec3, n: vec3) -> 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, n: vec3, 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, n: vec3) -> f32 { - if any(s.xy < vec2(0.0)) || any(s.xy > vec2(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(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 { - 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(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(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 { - 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 { - 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 { - 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(c, 1.0); -+ return vec4(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 frame: HdFrame; -+@group(0) @binding(1) var lit_tex: texture_2d; -+@group(0) @binding(2) var gbuffer_depth: texture_depth_2d; -+@group(0) @binding(3) var water_tint: texture_2d; -+@group(0) @binding(4) var water_normal: texture_2d; -+@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; -+@group(0) @binding(9) var linear: sampler; -+ -+fn ign(px: vec2) -> f32 { -+ let p = px + vec2(5.588238 * frame.params.w); -+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -+} -+ -+// The camera-relative position of the screen point `uv` at depth z. -+fn position_at(uv: vec2, z: f32) -> vec3 { -+ let h = frame.inverse_view_proj * vec4(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) -> vec3 { -+ let c = frame.view_proj * vec4(p, 1.0); -+ return vec3(c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5, c.w); -+} -+ -+fn on_screen(uv: vec2) -> bool { -+ return all(uv > vec2(0.0)) && all(uv < vec2(1.0)); -+} -+ -+fn texel(uv: vec2) -> vec2 { -+ return clamp(vec2(uv * frame.resolution.xy), vec2(0), vec2(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, 10>( -+ vec4(3.0, 1.0, 0.040, 0.3), -+ vec4(-2.0, 5.0, 0.045, 2.1), -+ vec4(7.0, 2.0, 0.040, 4.4), -+ vec4(-5.0, -9.0, 0.040, 1.2), -+ vec4(11.0, -4.0, 0.035, 5.6), -+ vec4(13.0, 10.0, 0.035, 3.3), -+ vec4(-17.0, 7.0, 0.030, 0.9), -+ vec4(9.0, 23.0, 0.030, 2.7), -+ vec4(-27.0, 19.0, 0.025, 4.9), -+ vec4(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, footprint: f32) -> vec2 { -+ var grad = vec2(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, c: i32) -> vec3 { -+ let s = frame.cascades[c] * vec4(p, 1.0); -+ return vec3(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, n: vec3) -> 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(0.0)) || any(s.xy > vec2(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(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, vis: f32) -> vec3 { -+ 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, r: vec3, jitter: f32) -> vec4 { -+ 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(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(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(colour, smoothstep(0.0, 0.08, edge)); -+ } -+ } -+ } -+ near = s; -+ } -+ return vec4(0.0); -+} -+ -+// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing). -+fn glint(n: vec3, v: vec3, l: vec3) -> 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 { -+ let px = vec2(in.position.xy); -+ let base = textureLoad(lit_tex, px, 0); -+ if base.a > 0.75 { return vec4(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(-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(nt.a, tint.a); -+ let sky_reach = smoothstep(0.35, 0.95, levels.x); -+ let ambient = textureSampleLevel(lightmap, linear, clamp(vec2(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; -+ let block = textureSampleLevel(lightmap, linear, vec2(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(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(0.03), vec3(1.0)); -+ // The colours the water takes up fastest are the ones it has the least of. -+ let absorb = exp(-(vec3(1.0) - colour) * 0.8 * (thickness + 0.3)); -+ let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3(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(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(max(c, vec3(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 = 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) -> vec3 { - 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, -+ @location(1) normal: vec4, -+} -+@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) - diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch deleted file mode 100644 index 8f68f53..0000000 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ /dev/null @@ -1,54 +0,0 @@ -From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/42] HD water: no vanilla texture on the surface (the biome - colour only), a darker, deeper colour - -Co-Authored-By: Claude Opus 5.5 ---- - 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 { - } - 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(0.03), vec3(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(0.03), vec3(1.0)); -+ let colour = mix(vec3(dot(biome, vec3(0.333))), biome, 0.8) * vec3(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(1.0) - colour) * 0.8 * (thickness + 0.3)); -- let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3(1.0) - absorb); -+ let absorb = exp(-(vec3(1.0) - colour) * 1.1 * (thickness + 0.3)); -+ let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3(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) - diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch deleted file mode 100644 index 9993f59..0000000 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ /dev/null @@ -1,945 +0,0 @@ -From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/42] 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 ---- - 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, - water: vec4, - sky_color: vec4, -+ sky_sun: vec4, -+ sky_extra: vec4, -+ sunrise: vec4, - } - struct FullOut { - @builtin(position) position: vec4, -@@ -195,6 +208,9 @@ struct Light { - @group(0) @binding(13) var ao_tex: texture_2d; - // 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 frag_px: vec2; - - // 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, n: vec3) -> 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 { -+ let r = sqrt((f32(i) + 0.5) / f32(count)); -+ let a = f32(i) * 2.39996323 + angle; -+ return vec2(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, 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((s.xy + o) * 2048.0), vec2(0), vec2(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, n: vec3, r: i32) -> f32 { -@@ -249,13 +301,16 @@ fn shadow_taps(p: vec3, n: vec3, r: i32) -> f32 { - let s = cascade_at(q, c); - if any(s.xy < vec2(0.0)) || any(s.xy > vec2(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(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 { - 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(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(1.0), mix(frame.sun_color.rgb, vec3(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 { - 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(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(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a)); -+ sky_tint = mix(vec3(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 { - 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; - @group(0) @binding(9) var linear: sampler; -+//SKY10 - - fn ign(px: vec2) -> f32 { - let p = px + vec2(5.588238 * frame.params.w); -@@ -417,7 +512,7 @@ fn texel(uv: vec2) -> vec2 { - // 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, 10>( -+const WAVES = array, 26>( - vec4(3.0, 1.0, 0.040, 0.3), - vec4(-2.0, 5.0, 0.045, 2.1), - vec4(7.0, 2.0, 0.040, 4.4), -@@ -428,17 +523,36 @@ const WAVES = array, 10>( - vec4(9.0, 23.0, 0.030, 2.7), - vec4(-27.0, 19.0, 0.025, 4.9), - vec4(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(83.0, 41.0, 0.042, 0.4), -+ vec4(-97.0, 59.0, 0.042, 3.1), -+ vec4(71.0, -113.0, 0.042, 5.0), -+ vec4(131.0, 89.0, 0.040, 1.5), -+ vec4(-157.0, -103.0, 0.040, 4.2), -+ vec4(191.0, -67.0, 0.038, 2.4), -+ vec4(-223.0, 149.0, 0.038, 0.8), -+ vec4(101.0, 241.0, 0.036, 3.7), -+ vec4(-263.0, -181.0, 0.034, 5.5), -+ vec4(311.0, 127.0, 0.034, 2.0), -+ // Finer still (0.35 to 0.6 blocks): the small sparkles. -+ vec4(397.0, -263.0, 0.050, 1.1), -+ vec4(-467.0, 331.0, 0.050, 4.6), -+ vec4(521.0, 449.0, 0.045, 3.0), -+ vec4(-613.0, -199.0, 0.045, 5.9), -+ vec4(293.0, 577.0, 0.045, 0.2), -+ vec4(-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, footprint: f32) -> vec2 { - var grad = vec2(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, vis: f32) -> vec3 { - 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, r: vec3, jitter: f32) -> vec4 { - // The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing). - fn glint(n: vec3, v: vec3, l: vec3) -> 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 { - // Light on the water: vanilla's lightmap at the surface, and the sun where it reaches. - let levels = vec2(nt.a, tint.a); - let sky_reach = smoothstep(0.35, 0.95, levels.x); -- let ambient = textureSampleLevel(lightmap, linear, clamp(vec2(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; -+ var ambient = textureSampleLevel(lightmap, linear, clamp(vec2(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; -+ if frame.sky_sun.w > 0.5 { -+ ambient *= mix(vec3(1.0), sky_light_tint(vec3(0.0, 1.0, 0.0)), 0.7 * sky_reach); -+ } - let block = textureSampleLevel(lightmap, linear, vec2(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 { - // 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(0.03), vec3(1.0)); -- let colour = mix(vec3(dot(biome, vec3(0.333))), biome, 0.8) * vec3(0.55, 0.68, 0.8); -+ let colour = mix(vec3(dot(biome, vec3(0.333))), biome, 0.85) * vec3(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(1.0) - colour) * 1.1 * (thickness + 0.3)); -- let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3(1.0) - absorb); -+ let absorb = exp(-(vec3(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(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 { - 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(max(c, vec3(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::()) 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 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 = vec2(8400.0, 1250.0); -+const RAYLEIGH: vec3 = vec3(8.059375432e-06, 1.671209429e-05, 4.080133294e-05); -+const MIE: vec3 = vec3(1.666442358e-06, 1.812685127e-06, 1.958927896e-06); -+const OZONE: vec3 = vec3(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 = vec3(1.051, 0.985, 0.940) * 24.0; -+const MOON_I: vec3 = vec3(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 { -+ 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(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 { -+ if mu < 0.0 && r * r * (1.0 - mu * mu) < PLANET_R * PLANET_R { return vec3(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(0.0), vec3(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, sun: vec3, moon: vec3) -> vec3 { -+ 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(0.0); -+ var sum = vec3(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 { -+ let px = vec2(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(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(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(cos(l) * cos(phi), sin(l), cos(l) * sin(phi)); -+ return vec4(march(dir, sun, moon) + night, 1.0); -+ } -+ if px.y == 64 && px.x == 0 { -+ return vec4(march(vec3(0.0, 1.0, 0.0), sun, moon) + night, 1.0); -+ } -+ if px.y == 64 && px.x == 1 { -+ var sum = vec3(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(cos(l) * cos(phi), sin(l), cos(l) * sin(phi)), sun, moon); -+ } -+ return vec4(sum / 6.0 + night, 1.0); -+ } -+ return vec4(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; -+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) -> vec2 { -+ 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((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) -> vec3 { -+ // 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(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(dot(c, vec3(0.2126, 0.7152, 0.0722))), c, boost), vec3(0.0)); -+} -+ -+// The vanilla sky's own gradient (the overcast and the rain keep it). -+fn vanilla_gradient(dir: vec3) -> vec3 { -+ 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) -> vec3 { -+ 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) -> vec3 { -+ 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(dir.x, 0.0, dir.z) + vec3(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) -> vec3 { -+ let zenith = sky_show(textureLoad(sky_lut, vec2(0, 64), 0).rgb); -+ let horizon = sky_show(textureLoad(sky_lut, vec2(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(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, y: f32) -> vec2 { -+ if d.y * y <= 0.0 { return vec2(-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(-1.0); } -+ let s = h / edge; -+ let ay = abs(y); -+ let rim = sqrt(512.0 * 512.0 + y * y); -+ return vec2(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) -> vec3 { return vec3(v.x, -v.z, v.y); } -+fn vanilla_sunrise_weight(d: vec3) -> 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(-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(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha); -+ let l1 = vec3(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha); -+ let r0 = vanilla_rotate_x90(vec3(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z)); -+ let r1 = vanilla_rotate_x90(vec3(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) -> vec3 { -+ 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) - diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch deleted file mode 100644 index 2481ba2..0000000 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ /dev/null @@ -1,42 +0,0 @@ -From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/42] HD water: a darker blue, a little clearer, ripples a - little higher - -Co-Authored-By: Claude Opus 5.5 ---- - 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, footprint: f32) -> vec2 { - 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, c: i32) -> vec3 { -@@ -713,10 +714,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { - // 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(0.03), vec3(1.0)); -- let colour = mix(vec3(dot(biome, vec3(0.333))), biome, 0.85) * vec3(0.92, 1.0, 1.0); -+ let colour = mix(vec3(dot(biome, vec3(0.333))), biome, 0.85) * vec3(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(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(1.0) - absorb); -+ let absorb = exp(-(vec3(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(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) - diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch deleted file mode 100644 index 944ab63..0000000 --- a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch +++ /dev/null @@ -1,1914 +0,0 @@ -From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 -From: chexit -Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/42] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun - tint, blue hour; MIT), volumetric clouds with ground shadows instead of - vanilla's in HD, volumetric fog with sun shafts and morning mist - (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) - -Co-Authored-By: Claude Opus 5.5 ---- - crates/horizon-client/src/gfx.rs | 24 +- - .../src/gfx/THIRD_PARTY_PHOTON.md | 22 +- - crates/horizon-client/src/gfx/hd.rs | 345 ++++++- - crates/horizon-client/src/gfx/hd_noise.rs | 163 ++++ - crates/horizon-client/src/gfx/hd_sky.rs | 90 +- - crates/horizon-client/src/gfx/hd_volumes.rs | 857 ++++++++++++++++++ - 6 files changed, 1448 insertions(+), 53 deletions(-) - create mode 100644 crates/horizon-client/src/gfx/hd_noise.rs - create mode 100644 crates/horizon-client/src/gfx/hd_volumes.rs - -diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs -index d01131433..072090e33 100644 ---- a/crates/horizon-client/src/gfx.rs -+++ b/crates/horizon-client/src/gfx.rs -@@ -72,7 +72,9 @@ mod cull; - mod end_sky; - mod flat; - mod hd; -+mod hd_noise; - mod hd_sky; -+mod hd_volumes; - mod hdr; - pub mod lightning; - mod lod; -@@ -4278,6 +4280,17 @@ impl Renderer { - water: !frame.underwater, - sunrise: frame.sky.sunrise_color, - sky_fog_end: frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end), -+ // AirDef fork: volumetric clouds stand where vanilla's would (option on, Overworld). -+ clouds: frame.clouds.is_some() && hd::clouds_enabled(), -+ camera_world: [ -+ camera.x.rem_euclid(65536.0) as f32, -+ camera.z.rem_euclid(65536.0) as f32, -+ camera.y as f32, -+ ], -+ seconds: frame -+ .clouds -+ .as_ref() -+ .map_or(0.0, |c| ((c.time as f64 + c.partial_tick) / 20.0) as f32), - }; - let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene); - let mut hd_cascades = hd::cascades(camera, shadow_dir); -@@ -5071,6 +5084,7 @@ impl Renderer { - } - } - let pack_reads_world = pack_post || native_post || mod_post || hd_on; -+ let hd_volumetric_clouds = hd_on && hd::clouds_enabled(); - // The pack's program reads the vertices this frame's draws bind. - let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat; - // The shadow camera's lists, drawn by the pack's shadow programs. -@@ -5128,9 +5142,9 @@ impl Renderer { - let format = self.config.format.remove_srgb_suffix(); - let device = &self.device; - let globals_size = std::mem::size_of::() as u64; -- let hd = self -- .hd -- .get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size)); -+ let hd = self.hd.get_or_insert_with(|| { -+ hd::Hd::new(device, &self.queue, 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) { -@@ -5691,7 +5705,8 @@ impl Renderer { - 0..1, - ); - } -- if cloud_quads > 0 && !pack_draws_clouds { -+ // AirDef fork: HD draws the clouds volumetrically (in the lighting) instead. -+ if cloud_quads > 0 && !pack_draws_clouds && !hd_volumetric_clouds { - pass.set_pipeline(&self.cloud_pipeline); - pass.set_bind_group(0, &self.entity_group, &[]); - pass.set_bind_group(1, &self.cloud_group, &[]); -@@ -5821,6 +5836,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.volumes(&self.device, &mut encoder, depth); - 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 -index 03d945001..fd9f4d029 100644 ---- a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md -+++ b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md -@@ -1,14 +1,26 @@ - # 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 -+Photon shader pack by sixthsurge, `shaders/include/sky/atmosphere.glsl`, `shaders/include/lighting/colors/light_color.glsl`, `shaders/include/utility/phase_functions.glsl`, `shaders/include/sky/clouds/{common,cumulus}.glsl`, `shaders/include/lighting/cloud_shadows.glsl`, `shaders/include/fog/air_fog_vl.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. -+What is taken from `atmosphere.glsl`: the air's scattering and absorption coefficients (Rayleigh, Mie, -+ozone) moved to Rec. 2020 as Photon does (the march runs in that space, the result goes back to Rec. 709), -+the scale heights, the ozone density profile, the Mie albedo, the Klein-Nishina Mie phase and the -+depolarised Rayleigh phase (`phase_functions.glsl`), the sunlight colour outside the atmosphere, and the -+Chapman-function approximation of the sun's transmittance. From `light_color.glsl`: `get_sun_tint` and -+the blue-hour / sunset exposure lift (`sun_light_tint`, `sun_tint`), and `pulse`. The sky-view texture and -+its march are our own. -+ -+The volumetric clouds and fog (`hd_volumes.rs`, `hd_noise.rs` for the CPU-made noise textures) port -+from `include/sky/clouds/common.glsl` and `cumulus.glsl` (the density shaping with the egg shape and -+edge sharpening, Worley erosion, the single and multiple scattering phase functions, the powder effect, -+the eight-octave multiple-scattering loop, the light march with doubling steps, the transmittance remap, -+the aerial perspective), `include/lighting/cloud_shadows.glsl` (the cloud shadow map, here a lookup into a -+small map) and `include/fog/air_fog_vl.glsl` (the phase mix of two Henyey-Greenstein lobes, the evening -+glow). The 3D Worley and 2D noise textures are generated on our side; the ray march, the passes, the -+reprojection and the integration are our own. - - Photon is distributed under the MIT License: - -diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs -index f078d423c..3b9daabf4 100644 ---- a/crates/horizon-client/src/gfx/hd.rs -+++ b/crates/horizon-client/src/gfx/hd.rs -@@ -6,6 +6,8 @@ - //! 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; -+pub use super::hd_volumes::clouds_enabled; -+use super::hd_volumes::{self, HdVolumes}; - use super::pack_timing::PassTiming; - use glam::Mat4; - use std::sync::atomic::{AtomicU64, Ordering}; -@@ -108,6 +110,18 @@ struct HdFrame { - 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], -+ /// x: the cloud slab's base height relative to the camera; y: its thickness; z: 1 when the -+ /// volumetric clouds are drawn; w: seconds (the wind). -+ clouds: [f32; 4], -+ /// xy: the camera's x and z in the world modulo 65536; z: extra coverage (rain); w: 1 when the -+ /// clouds' history is usable. -+ cloud_cam: [f32; 4], -+ /// xyz: the direction the clouds are lit from (the sun, or the moon at night). -+ cloud_dir: [f32; 4], -+ /// rgb: the colour of that light at the clouds; w: its strength. -+ cloud_sun: [f32; 4], -+ /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist. -+ vfog: [f32; 4], - } - - /// What the world around looks like this frame (from the renderer's globals). -@@ -141,6 +155,11 @@ pub struct HdScene { - /// 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 volumetric clouds are drawn (vanilla's option is on, in the Overworld). -+ pub clouds: bool, -+ /// The camera's world position, x and z modulo 65536 (precision), and y; seconds of game time. -+ pub camera_world: [f32; 3], -+ pub seconds: f32, - } - - /// The cascades' half sizes (blocks) and shadow map size. -@@ -168,6 +187,11 @@ struct HdFrame { - sky_sun: vec4, - sky_extra: vec4, - sunrise: vec4, -+ clouds: vec4, -+ cloud_cam: vec4, -+ cloud_dir: vec4, -+ cloud_sun: vec4, -+ vfog: vec4, - } - struct FullOut { - @builtin(position) position: vec4, -@@ -209,6 +233,13 @@ struct Light { - // 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 volumetric fog (half resolution: light in rgb, transmittance in a), the distance it was marched -+// to, the volumetric clouds (half resolution: light, transmittance) and the clouds' shadow map. -+@group(0) @binding(16) var fog_tex: texture_2d; -+@group(0) @binding(17) var fog_dist_tex: texture_2d; -+@group(0) @binding(18) var cloud_tex: texture_2d; -+@group(0) @binding(19) var cloud_shadow_tex: texture_2d; -+const CLOUD_SHADOW_R: f32 = 1200.0; - // The pixel being lit, for the shadow filter's noise. - var frag_px: vec2; - -@@ -327,6 +358,51 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 { - return (d - start) / (end - start); - } - -+// The share of the sunlight the clouds let through at a camera-relative point: the shadow map -+// holds, for the sun's way down through the slab from a point of its base, the optical depth -+// (Photon's cloud shadow map, as a lookup rather than a pass). -+fn cloud_shadow(p: vec3) -> f32 { -+ if frame.clouds.z < 0.5 { return 1.0; } -+ let l = frame.cloud_dir.xyz; -+ if l.y < 0.1 { return 1.0; } -+ let t = (frame.clouds.x - p.y) / l.y; -+ if t <= 0.0 { return 1.0; } -+ let q = p.xz + l.xz * t; -+ let uv = q / (2.0 * CLOUD_SHADOW_R) + vec2(0.5); -+ if any(uv < vec2(0.0)) || any(uv > vec2(1.0)) { return 1.0; } -+ let od = textureSampleLevel(cloud_shadow_tex, linear, uv, 0.0).r; -+ // Fade out as the sun gets low (the shadow then falls far from the cloud). -+ let fade = smoothstep(0.1, 0.25, l.y); -+ return mix(1.0, mix(1.0, exp(-od * 0.9), 0.9), fade); -+} -+ -+// The volumetric fog at a full-resolution pixel `dist` away: the four nearest half-resolution -+// texels weighted by closeness and by how near their march's length is (so it does not bleed over edges). -+fn fog_at(px: vec2, dist: f32) -> vec4 { -+ let size = vec2(textureDimensions(fog_tex)); -+ let f = vec2(px) * 0.5 - vec2(0.25); -+ let base = vec2(floor(f)); -+ let t = f - floor(f); -+ var sum = vec4(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(i, j), vec2(0), size - 1); -+ let s = textureLoad(fog_tex, q, 0); -+ let sd = textureLoad(fog_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 * dist + 0.4); -+ let w = bw / (1.0 + dz * dz * dz * dz); -+ sum += s * w; -+ total += w; -+ } -+ } -+ if total < 0.0001 { -+ return textureLoad(fog_tex, clamp(px / 2, vec2(0), size - 1), 0); -+ } -+ return sum / total; -+} -+ - @fragment - fn fs_light(in: FullOut) -> @location(0) vec4 { - let px = vec2(in.position.xy); -@@ -373,10 +449,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { - 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; -+ // 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); -+ 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 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 { -+ 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(1.0), mix(frame.sun_color.rgb, vec3(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z)); - } -@@ -391,7 +472,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { - 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)); } 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)) * 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; -@@ -436,10 +517,27 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { - 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. -+ // 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); - let density = frame.ambient.z; -- if sun_on > 0.0 && density > 0.0 { -+ 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(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); -+ } -+ let fg = fog_at(px, fog_len); -+ c = c * fg.a + 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; -@@ -1185,11 +1283,11 @@ fn fs_taau(in: FullOut) -> TaaOut { - } - "#; - --struct Target { -- view: wgpu::TextureView, -+pub(super) struct Target { -+ pub(super) view: wgpu::TextureView, - } - --fn target( -+pub(super) fn target( - device: &wgpu::Device, - label: &str, - size: [u32; 2], -@@ -1324,6 +1422,9 @@ pub struct Hd { - /// The frame's translucent terrain exists (set by the renderer before `write`). - pub water_geometry: bool, - water_composited: bool, -+ /// The volumetric clouds and the fog run this frame. -+ clouds_active: bool, -+ fog_active: 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, -@@ -1346,7 +1447,9 @@ pub struct Hd { - bloom: Vec, - /// 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)>, -+ light_group: [Option<(LightKey, wgpu::BindGroup)>; 2], -+ /// The volumetric clouds and fog (see `hd_volumes`). -+ volumes: HdVolumes, - 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 -@@ -1537,25 +1640,76 @@ fn ao_strength_default() -> f32 { - - /// 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 { -+pub(super) fn assemble(body: &str) -> String { - let mut body = body.to_owned(); -- for binding in ["15", "10"] { -+ // Highest number first: `//SKY1` must not eat the start of `//SKY15`. -+ for binding in (0..32).rev() { - body = body.replace( - &format!("//SKY{binding}"), -- &hd_sky::SKY_FUNCS.replace("SKYBIND", binding), -+ &hd_sky::SKY_FUNCS.replace("SKYBIND", &binding.to_string()), - ); - } - format!("{COMMON}{body}") - } - --fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { -+pub(super) fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { -+ // Development: with `HORIZON_HD_SHADER_DIR` set, a shader's body is read from -+ // `/