diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index fa53bf0..c09031b 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -39,7 +39,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H ## Конвейер кадра (HD включён) -Все цели в «масштабированном» разрешении мира (`world_post`), HDR — `Rgba16Float`. Глубина камеры — reversed-Z +Цели мира (G-буфер, AO, освещение, пост-проход мода) — в «масштабированном» разрешении мира (`world_post`; при `HORIZON_HD_SCALE` < 1 оно меньше окна), цели после TAA (история, bloom, tonemap) — в разрешении окна. HDR — `Rgba16Float`. Глубина камеры — reversed-Z (`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64` на CPU. @@ -62,6 +62,13 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H 6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории — дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к `view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат. + **TAAU**: если мир меньше окна (`HORIZON_HD_SCALE=0.67`), тот же проход (`fs_taau`, в таймингах `hd taau`) восстанавливает + полное разрешение. История, bloom и tonemap — в разрешении окна. Для каждого пикселя выхода берётся 3×3 выборки мира + вокруг (с учётом джиттера в `taa_params.zw`), веса — полином, аппроксимирующий Lanczos-2, по расстоянию от центра + пикселя выхода до выборки; variance clip в YCoCg по тем же 3×3; глубина — ближайшая из 2×2; история (Catmull-Rom, + полное разрешение) вес 0.92, но ближе к ней там, где ближайшая выборка далеко от центра пикселя. Джиттер — + Halton(2,3) на `max(16, ceil(8·ratio²))` кадров (0.67 → 18, 0.5 → 32). При масштабе 1 работает прежний `fs_taa` + без изменений. С `HORIZON_HD_TAA=0` и масштабом < 1 остаётся простой билинейный апскейл в tonemap. 7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид), выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая). @@ -113,8 +120,14 @@ $H key f1 tap; $H screenshot out.png; $H bench 4; $H quit Тайминги проходов на GPU: `HORIZON_SHADER_PACK_TIMING=1` (лог в `run-home/ctl.log`) или список F3. Ориентиры (M5, 2560×1440): тени 0.6 + 2.85 мс (дальний каскад через кадр, ≈1.4 в среднем), G-буфер 0.8, AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈ 15 мс GPU (замеры шумные). +TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс; 0.67 — 13.4 мс; 0.5 — 10.4 мс (освещение 6.1 → 2.7 → 1.6, AO 3.5 → 1.4 → 0.8, `hd taau` ≈ 3.5 мс на любом масштабе, дальний каскад теней 3.4 мс не зависит от масштаба). Следующие цели по скорости: освещение (лучи 14 шагов на полном разрешении → полразрешения + апскейл) и AO. +Проверка мерцания: два скриншота с неподвижной камеры подряд и разница по пикселям (Pillow `ImageChops.difference`) +— на мире `Hills` с травой и деревьями; после исправлений ≈2% пикселей с разницей > 20/255 (дрожание TAA на пиксельных +текстурах и анимация воды), а при ошибке теней было 8.5%. Сравни с `HORIZON_HD_TAA=0` / `HORIZON_HD_AO=0` / +`HORIZON_HD_SHADOW_FAST=0`, чтобы найти виновника. + Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей плоскости YZ. @@ -131,13 +144,15 @@ AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈ | Отсечение взрывов в `effects.wgsl` (#11), порядок дыма (#16), метрика перекрытия свечения (#14) | готово | 67a2a52, ed2db85 | | Тени: только грани к солнцу, без «закопанных» секций, дальний каскад через кадр (`HORIZON_HD_SHADOW_FAST=0` — по-старому) | готово | патч 16 | | TAA: джиттер Halton(2,3) ×16, перепроекция по глубине и сдвигу камеры, variance clip в YCoCg, история Catmull-Rom 0.9, лёгкий шарп в tonemap (`HORIZON_HD_TAA=0` — выкл) | готово | патч 16 | +| TAAU: мир в `HORIZON_HD_SCALE` (0.25–1, по умолчанию 1 = как раньше), восстановление в TAA-проходе `fs_taau`; без LOD-bias текстур (не доступен) | готово | патч 18 | +| Исправлено мерцание теней у игрока: ближний каскад терял секции в кадры, когда дальний держится; держимая матрица дальнего каскада сдвигается на шаг камеры; у растений (наклонные квады) нормаль «вверх», а не к камере; TAA держит 94% истории в покое | готово | патч 18 | | API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 | ## План (по порядку) и ТЗ Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком. -1. **TAAU — временной апскейлер** (поверх TAA). Мир и G-буфер рендерятся в `render_scale` (например 0.67), +1. **TAAU — временной апскейлер** (поверх TAA) — СДЕЛАНО (патч 18, см. статус; масштаб — `HORIZON_HD_SCALE`, переопределяет «масштаб разрешения» настроек Horizon только для HD; поля в `HdSettings` нет). Осталось: LOD-bias текстур террейна, MetalFX. Исходное ТЗ: Мир и G-буфер рендерятся в `render_scale` (например 0.67), TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно 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 index d027e2c..4b64670 100644 --- 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 @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/17] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/18] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; 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 index 03018fa..dfaf85b 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/17] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/18] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- 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 index 27185a4..8131e6c 100644 --- 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 @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/17] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/18] 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 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 index 9dcd1e9..cfd7b36 100644 --- 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 @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/17] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/18] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- 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 index 03786a5..2013ab6 100644 --- 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 @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/17] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/18] 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 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 index 35d3407..2483e12 100644 --- 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 @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/17] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/18] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 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 index 28af9b8..5cb0143 100644 --- 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 @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/17] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/18] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- 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 index b241c29..88c8c40 100644 --- 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 @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/17] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/18] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 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 index 8b085ad..5ba1a4b 100644 --- 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 @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/17] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/18] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 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 index 90b12d5..fc6429c 100644 --- 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 @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/17] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/18] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 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 index c11d8e5..bf2f5a1 100644 --- 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 @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/17] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/18] 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) 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 index 727ce41..2afa4e5 100644 --- 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 @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/17] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/18] 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 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 index 49f2b91..9d68036 100644 --- 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 @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/17] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/18] 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 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 index e4c9776..50618c8 100644 --- 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 @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/17] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/18] 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 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 index 248dc82..7209aea 100644 --- 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 @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/17] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/18] 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 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 index 91ad2fd..1cd4390 100644 --- 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 @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/17] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/18] 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 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 index 95b0910..4bf4c90 100644 --- 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 @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/17] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/18] 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 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 new file mode 100644 index 0000000..f3cc23c --- /dev/null +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -0,0 +1,527 @@ +From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Thu, 29 Oct 2026 17:19:57 +0300 +Subject: [PATCH 18/18] 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) +