From 3265eb63d2a86b4128f392f5e2da46caeb61c272 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:01:09 +0300 Subject: [PATCH] HD: coloured shadows, rain ripples, film motion blur and depth of field (fork patch 34) Co-Authored-By: Claude Opus 5.5 --- docs/HD-RENDERER.md | 16 +- ...ork-world-models-client-particles-so.patch | 2 +- ...izon-mod-report-particles-and-sounds.patch | 2 +- ...-fork-full-screen-post-pass-for-mods.patch | 2 +- ...far-models-as-scaled-impostors-past-.patch | 2 +- ...er.set-views-draws-the-world-from-ot.patch | 2 +- ...er-tick-and-per-event-AirDef-plans-r.patch | 2 +- ...-up-to-1920x1080-full-screen-replays.patch | 2 +- ...ant-terrain-tiles-fog-measured-from-.patch | 2 +- ...id-so-mods-keep-state-per-world-API-.patch | 2 +- ...d-trial-runs-behind-feature-full-so-.patch | 2 +- ...ffer-pass-per-pixel-mod-point-lights.patch | 2 +- ...e-2-sun-and-moon-shadow-cascades-the.patch | 2 +- ...ached-until-a-resize-or-a-new-source.patch | 2 +- ...-per-16x16-tile-surface-and-halo-lis.patch | 2 +- ...pass-in-the-F3-timing-list-half-reso.patch | 2 +- ...only-sun-facing-quads-and-skip-burie.patch | 2 +- ...es-render.model-id-model-generation-.patch | 2 +- ...aling-HORIZON_HD_SCALE-world-G-buffe.patch | 2 +- ...-pass-colour-normal-depth-the-floor-.patch | 2 +- ...lla-texture-on-the-surface-the-biome.patch | 2 +- ...-a-physical-atmosphere-Rayleigh-Mie-.patch | 2 +- ...r-blue-a-little-clearer-ripples-a-li.patch | 2 +- ...sunsets-Rec.-2020-atmosphere-sun-tin.patch | 2 +- ...es-for-simulations-render.query-rays.patch | 2 +- ...vel-from-the-world-not-from-rain-geo.patch | 2 +- ...dules-gfx-hd-one-Rust-file-and-one-W.patch | 2 +- ...scales-that-divide-the-camera-s-6553.patch | 2 +- ...oke-and-missile-trails-with-clear-tu.patch | 2 +- ...ited-by-depth-so-terrain-and-vehicle.patch | 2 +- ...rails-drawn-at-least-as-wide-as-a-gr.patch | 2 +- ...main-view-API-level-15-renders-the-s.patch | 2 +- ...mods-render.set-spot-lights-API-leve.patch | 2 +- ...g-polish-cloud-panorama-cirrus-terra.patch | 2 +- ...through-glass-and-leaves-rain-ripple.patch | 1089 +++++++++++++++++ 35 files changed, 1135 insertions(+), 36 deletions(-) create mode 100644 horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 4fbc24d..42deb96 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -233,6 +233,9 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home | #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка | | #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка | | Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено | +| Цветные тени (план 6): ближний каскад без листвы в глубине + карта цвета 1024² (`Rgba16Float`, `shadow_tint_pass`): стекло/лёд умножают свет на цвет, листва пропускает ×0.55 зелёного; в альфе расстояние до источника света (min-blend); `fs_light` берёт 8 отсчётов (`shadow_tint`); `HORIZON_HD_COLOR_SHADOW=0` — выкл. | готово; радуга от цветного стекла видна в игре, листва — слабым зеленоватым пятном; без A/B с выключенным | в рабочем дереве форка | +| Рябь от дождя на воде (остаток плана 7): кольца в сетке 1.6 блока (`ripple_gradient`, `water.wgsl`), уровень дождя в `sky_extra.w`; `HORIZON_HD_RAIN_RIPPLES=0` — выкл. | готово; в игре едва различимы дуги в бликах (частицы дождя закрывают), не настраивалось | в рабочем дереве форка | +| Motion blur и DoF кинокамеры (план 9): `gfx/hd/film.rs` + `film.wgsl`, проход после TAA, только при `film_quality()`; `HORIZON_HD_MOTION_BLUR=0`, `HORIZON_HD_DOF=0`, `HORIZON_HD_DOF_FOCUS`, `HORIZON_HD_DOF_STRENGTH`, `HORIZON_HD_FILM_QUALITY=1` (принудительно, для проб) | DoF виден в игре (до правки стоил 14–17 мс); motion blur в игре не подтверждён; правка стоимости DoF (планарная глубина, 24 отсчёта, радиус 0.7%) собрана и прошла тесты, но глазами не проверена | в рабочем дереве форка | ## План (по порядку) и ТЗ @@ -253,10 +256,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home 4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Перистые и облака в воде сделаны (см. «Облака, перистые, туман по рельефу»). 5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из мода), мокрые поверхности и лужи в дождь. -6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву. -7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя. +6. **PCSS** и цветные тени через стекло/листву — СДЕЛАНО (см. «Цветные тени, рябь, кино-пост»). Не сделано: цвет в лучах тумана и в тени под водой, цветная тень дальнего каскада. +7. **Ветер** и рябь от дождя — СДЕЛАНО. Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя. 8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней). -9. Опционально: motion blur, DoF (для кинокамеры повторов). +9. Motion blur, DoF для кинокамеры — СДЕЛАНО в коде (см. ниже), проверено частично. 10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и отражения, затем GI. Отдельный пресет, HD остаётся. @@ -364,3 +367,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home - **Захват** (`render.capture-start/stop/status`, `game/film.rs`, `Renderer::capture_*`): часы игры идут ровно `1/fps` секунды за кадр (тики, интерполяция, `tick` мода), кадр копируется с GPU через очередь из ≤3 буферов и уходит в поток-писатель → `ffmpeg` (libx264, иначе h264_videotoolbox; иначе PNG-последовательность). Звук: микшер OpenAL-подобный (`audio.rs`) работает офлайн (`film_begin/film_mix`), слышны только звуки мода, ориентация — по камере; дорожка вмуксивается AAC. После склейки рисуются `warmup` кадров без записи, чтобы сошлись TAA и объёмы. Размер: offscreen-игра ресайзится, окно просится (`request_inner_size`). - Мод-HUD (`set-hud`) теперь рисуется и при `set-main-view` (титры, леттербокс фильма); ванильный HUD по-прежнему скрыт. - Режиссура: `airdef-mod/src/film.rs` (план по записи, варианты ракурсов, проверка рельефа по высотам `Ground::land_state`, ускорение/замедление времени). + +## Цветные тени, рябь, кино-пост: что знать преемнику + +- **Цветные тени.** Ближний каскад (c = 0) рисуется `fs_shadow_near` (листва `MAT_LEAVES` отброшена), потом проход `hd shadow colour` (`Hd::shadow_tint_pass`, цель `Shadows::tint`, очистка в белое) рисует листву (`shadow_tint_leaves`, твёрдые квады, `SHADOW_SOLID`) и полупрозрачные квады секций (`shadow_tint_glass`, по отсортированным индексам; вода не рисуется) тем же `fs_shadow_tint`: rgb умножаются (blend `Dst*src`), альфа = `(расстояние от света − 320)/160` с blend Min — ближайший такой квад. Приёмник в `shadow_tint` (`light.wgsl`, привязка 22) окрашивается, если квад ближе к свету, чем он; 8 точек Vogel радиусом 0.4 блока размывают листву в «пятна». Дальний каскад по-прежнему рисует листву полностью (на границе 36 блоков шов: ближе листва светлее). Цвет не доходит до лучей тумана, воды и дальнего каскада. Цена: `hd shadow colour` 0.25–0.35 мс (Mac, малая сцена). +- **Рябь.** `ripple_gradient` берёт часы воды (`water.y`, часы фильма): частоты — целые циклы на 600 с, сетка 160 клеток на 256 блоков, поэтому рисунок не прыгает на обороте. Гаснет по размеру пикселя, только под открытым небом (`nt.a`). Амплитуда 0.012 не подбиралась. +- **Кино-пост.** `Hd::film` между `taa` и `finish` (`HdSource::Film`), только при `film_quality()`; цели создаются при первом фильме. Motion blur — только от движения камеры (глубина + `prev_view_proj`, затвор 180°, мёртвая зона 1–2.5 px из-за джиттера; движущиеся объекты не размываются; нет после склейки/телепорта: `taa.w`). DoF — диск 24 отсчёта, фокус авто (гармоническое среднее 9 глубин у центра) или `HORIZON_HD_DOF_FOCUS`. Детерминизм: единственное время в шейдере — `water.y` (часы фильма) для шума. Проверять на реальном фильме `/airdef replay film`; `HORIZON_HD_FILM_QUALITY=1` принудительно включает качество фильма для проб. DoF в игре был слишком сильным и дорогим; параметры уменьшены без повторного снимка. Motion blur на снимке (поворот 40° за 1.5 с) не разобран. +- Из Photon (MIT) ничего нового не переносилось: THIRD_PARTY_PHOTON.md не менялся. 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 5b276bf..84ba3f0 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/33] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/34] 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 8f53dab..ad93fe8 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/33] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/34] 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 77d5da7..1375f76 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/33] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/34] 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 1e4d5ca..d2192b8 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/33] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/34] 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 55a80df..8d5e970 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/33] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/34] 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 a77c4e9..0491340 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/33] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/34] 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 f2c2611..f420210 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/33] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/34] 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 e1f1715..47cc31a 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/33] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/34] 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 b2e050f..5cfc420 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/33] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/34] 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 400a78f..490f3dc 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/33] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/34] 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 61fbb5b..37e160d 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/33] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/34] 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 52cae11..72a85c9 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/33] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/34] 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 962bd6e..10c1f48 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/33] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/34] 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 0509ea3..b251e06 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/33] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/34] 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 c36b974..01d1836 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/33] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/34] 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 2448519..21da0d0 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/33] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/34] 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 5521ce7..aa62ccf 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/33] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/34] 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 index 4844f67..d5312ff 100644 --- 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 @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/33] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/34] 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 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 index e00f60d..84c6a24 100644 --- 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 @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/33] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/34] 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) 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 index 73711b0..105714c 100644 --- 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 @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/33] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/34] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 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 index 1d87f5e..ad35641 100644 --- 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 @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/33] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/34] 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 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 index db5c49c..088cebd 100644 --- 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 @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/33] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/34] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 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 index 86b01bd..34a5748 100644 --- 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 @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/33] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/34] 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) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index af1757d..6908824 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/33] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/34] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch index 49e71b2..3a0fde5 100644 --- a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/33] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/34] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index 615f5c4..0fcb8b5 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/33] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/34] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index 825c131..8cc052d 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/33] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/34] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch index 8c5788a..be52fab 100644 --- a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -1,7 +1,7 @@ From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:17:11 +0300 -Subject: [PATCH 28/33] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/34] HD: volumetric smoke and missile trails with clear tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun speculars by material, wet surfaces and puddles in rain, wind in foliage (HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders diff --git a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch index 1d504ea..75338e9 100644 --- a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch +++ b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch @@ -1,7 +1,7 @@ From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:26:09 +0300 -Subject: [PATCH 29/33] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/34] HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual diff --git a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch index 32d5d26..3a2345e 100644 --- a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch +++ b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch @@ -1,7 +1,7 @@ From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:44:45 +0300 -Subject: [PATCH 30/33] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/34] HD smoke: young trails drawn at least as wide as a grid cell (density scaled to keep the mass), gentler bulges, so a trail is a smooth ribbon and not a chain of puffs diff --git a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch index 8cc313b..875cb4e 100644 --- a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch +++ b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch @@ -1,7 +1,7 @@ From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:59:41 +0300 -Subject: [PATCH 31/33] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/34] Mods: render.set-main-view (API level 15) renders the screen's world from a mod camera with the full pipeline, so replays get HD and the post effects; small mod views get the post pass with their own camera diff --git a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch index 4131676..3cb7f9e 100644 --- a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch +++ b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch @@ -1,7 +1,7 @@ From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:05 +0300 -Subject: [PATCH 32/33] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/34] Spot lights for mods (render.set-spot-lights, API level 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); diff --git a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch index c220c10..a581d47 100644 --- a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch +++ b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch @@ -1,7 +1,7 @@ From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 14:30:39 +0300 -Subject: [PATCH 33/33] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/34] HD clouds and fog polish: cloud panorama, cirrus, terrain-aware fog, far smoke cascade; replay world log and film clock; material overlays by sprite name diff --git a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch new file mode 100644 index 0000000..74c77f9 --- /dev/null +++ b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch @@ -0,0 +1,1089 @@ +From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 15:01:09 +0300 +Subject: [PATCH 34/34] HD: coloured sun through glass and leaves, rain ripples + on water, film motion blur and depth of field + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/gfx.rs | 152 ++++++++++++- + crates/horizon-client/src/gfx/hd/film.rs | 204 ++++++++++++++++++ + crates/horizon-client/src/gfx/hd/film.wgsl | 139 ++++++++++++ + crates/horizon-client/src/gfx/hd/frame.rs | 6 +- + crates/horizon-client/src/gfx/hd/light.wgsl | 32 ++- + crates/horizon-client/src/gfx/hd/lighting.rs | 7 +- + crates/horizon-client/src/gfx/hd/mod.rs | 31 ++- + crates/horizon-client/src/gfx/hd/prelude.wgsl | 1 + + crates/horizon-client/src/gfx/hd/shadows.rs | 44 ++++ + crates/horizon-client/src/gfx/hd/taa.rs | 7 +- + crates/horizon-client/src/gfx/hd/tonemap.rs | 1 + + crates/horizon-client/src/gfx/hd/water.rs | 8 +- + crates/horizon-client/src/gfx/hd/water.wgsl | 47 +++- + crates/horizon-client/src/terrain.wgsl | 29 +++ + 14 files changed, 696 insertions(+), 12 deletions(-) + create mode 100644 crates/horizon-client/src/gfx/hd/film.rs + create mode 100644 crates/horizon-client/src/gfx/hd/film.wgsl + +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index 5b221fd7e..a2a6e78b6 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -595,6 +595,11 @@ pub struct Renderer { + gbuffer_entity: wgpu::RenderPipeline, + /// The HD sun shadows: depth only, from the light, and the sections in its farther cascade. + shadow_terrain: wgpu::RenderPipeline, ++ /// The near cascade's depth without leaves, and its colour map's leaves and glass ++ /// (coloured shadows, `HORIZON_HD_COLOR_SHADOW`). ++ shadow_terrain_near: wgpu::RenderPipeline, ++ shadow_tint_leaves: wgpu::RenderPipeline, ++ shadow_tint_glass: wgpu::RenderPipeline, + shadow_entity: wgpu::RenderPipeline, + hd_shadow_visible: Vec, + /// The far cascade as last drawn (matrix, camera, light) and the frames since. +@@ -1534,6 +1539,84 @@ impl Renderer { + multiview_mask: None, + cache: None, + }); ++ // Coloured shadows: the near cascade's depth leaves the leaves out, a second pass writes ++ // what leaves and glass do to the light (see `fs_shadow_tint`). ++ let shadow_terrain_near = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { ++ label: Some("HD shadows terrain near"), ++ 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_near"), ++ compilation_options: wgpu::PipelineCompilationOptions { ++ constants: &sprite_constants, ++ ..Default::default() ++ }, ++ targets: &[], ++ }), ++ multiview_mask: None, ++ cache: None, ++ }); ++ let tint_target = [Some(wgpu::ColorTargetState { ++ format: hd::SHADOW_TINT, ++ // Colours multiply; the alpha (distance from the light) keeps the nearest. ++ blend: Some(wgpu::BlendState { ++ color: wgpu::BlendComponent { ++ src_factor: wgpu::BlendFactor::Dst, ++ dst_factor: wgpu::BlendFactor::Zero, ++ operation: wgpu::BlendOperation::Add, ++ }, ++ alpha: wgpu::BlendComponent { ++ src_factor: wgpu::BlendFactor::One, ++ dst_factor: wgpu::BlendFactor::One, ++ operation: wgpu::BlendOperation::Min, ++ }, ++ }), ++ write_mask: wgpu::ColorWrites::ALL, ++ })]; ++ let tint_pipeline = |label: &str, leaves: bool| { ++ let 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])), ++ ("SHADOW_SOLID", if leaves { 1.0 } else { 0.0 }), ++ ]; ++ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { ++ label: Some(label), ++ 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: None, ++ multisample: Default::default(), ++ fragment: Some(wgpu::FragmentState { ++ module: &shader, ++ entry_point: Some("fs_shadow_tint"), ++ compilation_options: wgpu::PipelineCompilationOptions { ++ constants: &constants, ++ ..Default::default() ++ }, ++ targets: &tint_target, ++ }), ++ multiview_mask: None, ++ cache: None, ++ }) ++ }; ++ let shadow_tint_leaves = tint_pipeline("HD shadow colour leaves", true); ++ let shadow_tint_glass = tint_pipeline("HD shadow colour glass", false); + let solid = pipeline( + "Terrain solid", + "vs_terrain", +@@ -2911,6 +2994,9 @@ impl Renderer { + gbuffer_water, + gbuffer_entity, + shadow_terrain, ++ shadow_terrain_near, ++ shadow_tint_leaves, ++ shadow_tint_glass, + shadow_entity, + hd_shadow_visible: Vec::new(), + hd_far: None, +@@ -5342,7 +5428,13 @@ impl Renderer { + }; + let mut pass = hd.shadow_pass(&mut encoder, c); + pass.set_bind_group(0, &self.group, &[]); +- pass.set_pipeline(&self.shadow_terrain); ++ // The near cascade leaves the leaves to the colour map. ++ let tinted = c == 0 && hd::color_shadows_enabled(); ++ pass.set_pipeline(if tinted { ++ &self.shadow_terrain_near ++ } else { ++ &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; +@@ -5376,6 +5468,62 @@ impl Renderer { + pass.draw_indexed(first * 6..end * 6, 0, 0..1); + } + } ++ drop(pass); ++ if tinted { ++ // What leaves and glass do to the sunlight in the near box: colours ++ // multiplied, the nearest one's distance in alpha (cleared to white, far). ++ let mut pass = hd.shadow_tint_pass(&mut encoder); ++ pass.set_bind_group(0, &self.group, &[]); ++ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); ++ pass.set_vertex_buffer(1, self.instances.slice(..)); ++ pass.set_pipeline(&self.shadow_tint_leaves); ++ let mut page = None; ++ for (k, v) in hd_shadow_visible.iter().enumerate() { ++ let s = self.sections.get(v.slot); ++ let min = Vec3::from(v.origin); ++ if s.solid == 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, ++ ); ++ } ++ pass.set_pipeline(&self.shadow_tint_glass); ++ let mut page = None; ++ for (k, v) in hd_shadow_visible.iter().enumerate() { ++ let s = self.sections.get(v.slot); ++ let min = Vec3::from(v.origin); ++ let Some((index_page, index_start)) = s.sorted_indices else { ++ continue; ++ }; ++ if s.translucent == 0 || !box_.intersects(min, min + Vec3::splat(16.0)) ++ { ++ continue; ++ } ++ if page != Some((s.page, index_page)) { ++ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); ++ pass.set_index_buffer( ++ self.sorted_indices.buffer(index_page).slice(..), ++ wgpu::IndexFormat::Uint32, ++ ); ++ page = Some((s.page, index_page)); ++ } ++ let instance = hd_shadow_first + k as u32; ++ let first = index_start as u32; ++ pass.draw_indexed( ++ first..first + s.translucent * 6, ++ (s.start + u64::from(s.solid) * 4) as i32, ++ instance..instance + 1, ++ ); ++ } ++ } + } + encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size); + } +@@ -5994,10 +6142,12 @@ impl Renderer { + ); + post.run(&self.device, &mut encoder, (hd.lit_view(), depth), &hd.fx); + let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth); ++ let source = hd.film(&self.device, &mut encoder, source, depth); + hd.finish(&self.device, &mut encoder, source, &view_target); + } + _ => { + let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Lit, depth); ++ let source = hd.film(&self.device, &mut encoder, source, depth); + hd.finish(&self.device, &mut encoder, source, &view_target); + } + } +diff --git a/crates/horizon-client/src/gfx/hd/film.rs b/crates/horizon-client/src/gfx/hd/film.rs +new file mode 100644 +index 000000000..89413af41 +--- /dev/null ++++ b/crates/horizon-client/src/gfx/hd/film.rs +@@ -0,0 +1,204 @@ ++//! The film camera's motion blur and depth of field. Only while film quality is on (a film is ++//! playing or exported, `set_film_quality`); normal play never runs these passes. ++//! ++//! Contract: ++//! - Runs after `taa`, before bloom: reads the colour source (display space, may exceed 1, output ++//! size) and the world depth (reversed-Z; world size, equal to the output's in film quality), ++//! plus `HdFrame`: `film` (x: motion blur on; y: depth of field on; z: focus distance in blocks, ++//! 0 = automatic; w: depth of field strength), `inverse_view_proj`, `prev_view_proj` and `taa.xyz` ++//! (the camera's move), `taa.w` (the previous frame is usable: not after a cut or a teleport). ++//! - Writes `motion` (`fs_motion`: a blur along the camera's motion on screen, rebuilt from depth; ++//! things that move by themselves are not blurred) and then `dof` (`fs_dof`: a disc gather whose ++//! radius follows the circle of confusion of each pixel's distance from the focus). Either pass ++//! is skipped when its toggle is off; `film` returns the source the next pass reads. ++//! - Deterministic: the only time the shaders read is `HdFrame.water.y`, the film clock's seconds ++//! (`film_clock.rs`), for the sample noise; nothing reads wall time or keeps a history. ++//! - Toggles: `HORIZON_HD_MOTION_BLUR=0`, `HORIZON_HD_DOF=0`; `HORIZON_HD_DOF_FOCUS=` ++//! fixes the focus, `HORIZON_HD_DOF_STRENGTH=` scales the blur (default 0.8). ++use super::{ ++ DEPTH, FLOAT, HDR, Hd, HdSource, SAMPLER, UNIFORM, entries, film_quality, fullscreen, ++ make_group, module, stamp, target, ++}; ++ ++pub(super) const FILM: &str = include_str!("film.wgsl"); ++/// Group 0 of both passes: frame, the colour, the world depth, the linear sampler. ++pub(super) const FILM_BINDINGS: [wgpu::BindingType; 4] = [UNIFORM, FLOAT, DEPTH, SAMPLER]; ++ ++fn flag(name: &str) -> bool { ++ std::env::var(name).map_or(true, |v| v != "0") ++} ++ ++/// Motion blur is allowed (on, unless `HORIZON_HD_MOTION_BLUR=0`); it still needs film quality. ++pub fn motion_blur_enabled() -> bool { ++ static ON: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ON.get_or_init(|| flag("HORIZON_HD_MOTION_BLUR")) ++} ++ ++/// Depth of field is allowed (on, unless `HORIZON_HD_DOF=0`); it still needs film quality. ++pub fn dof_enabled() -> bool { ++ static ON: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ON.get_or_init(|| flag("HORIZON_HD_DOF")) ++} ++ ++/// The focus distance in blocks (0 = automatic) and the blur strength. ++pub(super) fn dof_settings() -> (f32, f32) { ++ static SETTINGS: std::sync::OnceLock<(f32, f32)> = std::sync::OnceLock::new(); ++ *SETTINGS.get_or_init(|| { ++ let read = |name: &str, default: f32| { ++ std::env::var(name) ++ .ok() ++ .and_then(|v| v.parse::().ok()) ++ .filter(|v| v.is_finite() && *v >= 0.0) ++ .unwrap_or(default) ++ }; ++ ( ++ read("HORIZON_HD_DOF_FOCUS", 0.0), ++ read("HORIZON_HD_DOF_STRENGTH", 0.8), ++ ) ++ }) ++} ++ ++type Key = (wgpu::TextureView, wgpu::TextureView); ++ ++pub(super) struct Film { ++ layout: wgpu::BindGroupLayout, ++ motion_pipeline: wgpu::RenderPipeline, ++ dof_pipeline: wgpu::RenderPipeline, ++ pub(super) motion: wgpu::TextureView, ++ pub(super) dof: wgpu::TextureView, ++ motion_group: Option<(Key, wgpu::BindGroup)>, ++ dof_group: Option<(Key, wgpu::BindGroup)>, ++ /// The target the last frame's passes ended in, for `HdSource::Film`. ++ pub(super) result: wgpu::TextureView, ++ /// The targets' size (0 until a film plays: normal play never pays for them). ++ size: [u32; 2], ++} ++ ++impl Film { ++ pub(super) fn new(device: &wgpu::Device) -> Self { ++ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("HD film"), ++ entries: &entries(&FILM_BINDINGS), ++ }); ++ let shader = module(device, "HD film", FILM); ++ let pipeline = ++ |entry: &str| fullscreen(device, "HD film", &layout, &shader, entry, HDR, None); ++ let motion_pipeline = pipeline("fs_motion"); ++ let dof_pipeline = pipeline("fs_dof"); ++ let motion = target(device, "HD film motion", [1, 1], HDR).view; ++ Self { ++ layout, ++ motion_pipeline, ++ dof_pipeline, ++ dof: target(device, "HD film depth of field", [1, 1], HDR).view, ++ result: motion.clone(), ++ motion, ++ motion_group: None, ++ dof_group: None, ++ size: [0, 0], ++ } ++ } ++ ++ /// The output size changed: the targets are made again when a film next needs them. ++ pub(super) fn resize(&mut self) { ++ self.size = [0, 0]; ++ } ++ ++ /// Output-size targets, made when a film first plays (or after a resize). ++ fn ensure(&mut self, device: &wgpu::Device, out: [u32; 2]) { ++ if self.size == out { ++ return; ++ } ++ self.size = out; ++ self.motion = target(device, "HD film motion", out, HDR).view; ++ self.dof = target(device, "HD film depth of field", out, HDR).view; ++ self.result = self.motion.clone(); ++ self.motion_group = None; ++ self.dof_group = None; ++ } ++} ++ ++impl Hd { ++ /// Whether the film passes run this frame, and which (motion blur, depth of field). ++ pub(super) fn film_passes(&self) -> (bool, bool) { ++ let on = film_quality(); ++ (on && motion_blur_enabled(), on && dof_enabled()) ++ } ++ ++ /// Motion blur and depth of field over `source` (after TAA) with the world's `depth`; returns ++ /// the source `finish` reads: the same one when film quality is off. ++ pub fn film( ++ &mut self, ++ device: &wgpu::Device, ++ encoder: &mut wgpu::CommandEncoder, ++ source: HdSource, ++ depth: &wgpu::TextureView, ++ ) -> HdSource { ++ let (blur, dof) = self.film_passes(); ++ if !blur && !dof { ++ return source; ++ } ++ self.film.ensure(device, self.out); ++ let mut input = match source { ++ HdSource::Lit => self.lit_view().clone(), ++ HdSource::Fx => self.fx.clone(), ++ HdSource::Taa => self.taa.target.clone(), ++ HdSource::Film => return source, ++ }; ++ let mut passes: Vec<(&str, bool)> = Vec::new(); ++ if blur { ++ passes.push(("hd film motion", true)); ++ } ++ if dof { ++ passes.push(("hd film dof", false)); ++ } ++ for (name, is_motion) in passes { ++ let key = (input.clone(), depth.clone()); ++ let slot = if is_motion { ++ &mut self.film.motion_group ++ } else { ++ &mut self.film.dof_group ++ }; ++ if slot.as_ref().is_none_or(|(k, _)| *k != key) { ++ let group = make_group( ++ &self.groups_made, ++ device, ++ "HD film", ++ &self.film.layout, ++ &[ ++ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()), ++ wgpu::BindingResource::TextureView(&key.0), ++ wgpu::BindingResource::TextureView(depth), ++ wgpu::BindingResource::Sampler(&self.sampler), ++ ], ++ ); ++ *slot = Some((key, group)); ++ } ++ let group = if is_motion { ++ &self.film.motion_group ++ } else { ++ &self.film.dof_group ++ }; ++ let Some((_, group)) = group else { ++ return source; ++ }; ++ let (to, pipeline) = if is_motion { ++ (&self.film.motion, &self.film.motion_pipeline) ++ } else { ++ (&self.film.dof, &self.film.dof_pipeline) ++ }; ++ Self::draw( ++ encoder, ++ name, ++ to, ++ false, ++ pipeline, ++ group, ++ stamp(&mut self.timing, name), ++ ); ++ input = to.clone(); ++ } ++ self.film.result = input; ++ HdSource::Film ++ } ++} +diff --git a/crates/horizon-client/src/gfx/hd/film.wgsl b/crates/horizon-client/src/gfx/hd/film.wgsl +new file mode 100644 +index 000000000..2759513f1 +--- /dev/null ++++ b/crates/horizon-client/src/gfx/hd/film.wgsl +@@ -0,0 +1,139 @@ ++// The film camera: motion blur from the camera's own move, then depth of field (see film.rs). ++@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 linear: sampler; ++ ++// Where the sky counts as being, in blocks. ++const FAR: f32 = 3000.0; ++// The shutter stays open for half the frame (a 180 degree shutter). ++const SHUTTER: f32 = 0.5; ++// The longest blur, and the widest circle of confusion at strength 1, as shares of the screen's height. ++const MAX_BLUR: f32 = 0.045; ++const MAX_COC: f32 = 0.007; ++const MOTION_TAPS: i32 = 14; ++const DOF_TAPS: i32 = 24; ++ ++// Noise for the taps: a function of the film clock's time only, so the same film frame gets the ++// same noise whenever and however fast it is rendered. ++fn film_noise(px: vec2) -> f32 { ++ let step = floor(fract(frame.water.y * 1.618034) * 64.0); ++ let p = px + vec2(5.588238 * step); ++ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); ++} ++ ++fn depth_load(uv: vec2) -> f32 { ++ let size = vec2(textureDimensions(depth_tex)); ++ return textureLoad(depth_tex, clamp(vec2(uv * vec2(size)), vec2(0), size - 1), 0); ++} ++ ++// The camera-relative point a depth value at uv stands for. ++fn world_at(uv: vec2, d: f32) -> vec3 { ++ let h = frame.inverse_view_proj * vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, max(d, 0.000001), 1.0); ++ return h.xyz / h.w; ++} ++ ++// The depth along the view axis in blocks (the inverse matrix's w row gives 1 / w of the clip ++// position: one dot product, not a whole reconstruction). ++fn planar(uv: vec2, d: f32) -> f32 { ++ let m = frame.inverse_view_proj; ++ let w = m[0][3] * (uv.x * 2.0 - 1.0) + m[1][3] * (1.0 - uv.y * 2.0) + m[2][3] * d + m[3][3]; ++ return select(FAR, clamp(1.0 / w, 0.05, FAR), d > 0.0 && w > 0.0); ++} ++ ++fn distance_at(uv: vec2) -> f32 { ++ return planar(uv, depth_load(uv)); ++} ++ ++// Motion blur: each pixel is smeared along where the camera moved it on screen during the shutter, ++// found by projecting its point with last frame's matrix. Samples much nearer than the pixel are ++// weighed down, so a close object does not smear over the far scene behind it. ++@fragment ++fn fs_motion(in: FullOut) -> @location(0) vec4 { ++ let uv = in.uv; ++ let base = textureSampleLevel(source, linear, uv, 0.0); ++ if frame.film.x < 0.5 || frame.taa.w < 0.5 { return vec4(base.rgb, 1.0); } ++ let d = depth_load(uv); ++ let sky = d <= 0.0; ++ let p = world_at(uv, d); ++ let prev = frame.prev_view_proj * vec4(select(p + frame.taa.xyz, p, sky), 1.0); ++ if prev.w <= 0.0 { return vec4(base.rgb, 1.0); } ++ let before = vec2(prev.x / prev.w * 0.5 + 0.5, 0.5 - prev.y / prev.w * 0.5); ++ let size = vec2(textureDimensions(source)); ++ let moved = (uv - before) * size; ++ let len = length(moved); ++ // Under about a pixel is the jitter's noise, not motion. ++ let amount = smoothstep(1.0, 2.5, len); ++ if amount <= 0.0 { return vec4(base.rgb, 1.0); } ++ let blur = moved * (SHUTTER * amount * min(1.0, MAX_BLUR * size.y / (len * SHUTTER))); ++ let here = planar(uv, d); ++ let n = film_noise(in.position.xy); ++ var sum = base.rgb; ++ var total = 1.0; ++ for (var i = 0; i < MOTION_TAPS; i++) { ++ let t = (f32(i) + n) / f32(MOTION_TAPS) - 0.5; ++ let suv = uv + blur * t / size; ++ let nearer = (here - distance_at(suv)) / (0.25 * here + 1.0); ++ let w = 1.0 - clamp(nearer, 0.0, 1.0); ++ sum += textureSampleLevel(source, linear, suv, 0.0).rgb * w; ++ total += w; ++ } ++ return vec4(sum / total, 1.0); ++} ++ ++// Where the lens is focused, in blocks: the setting, or what the middle of the picture shows (the ++// harmonic mean of nine distances, the middle counting thrice, so near things win). ++fn focus_distance() -> f32 { ++ if frame.film.z > 0.0 { return frame.film.z; } ++ var inverse = 0.0; ++ var weight = 0.0; ++ for (var j = -1; j <= 1; j++) { ++ for (var i = -1; i <= 1; i++) { ++ let uv = vec2(0.5) + vec2(f32(i) * 0.012, f32(j) * 0.02); ++ let d = depth_load(uv); ++ if d <= 0.0 { continue; } ++ let w = select(1.0, 3.0, i == 0 && j == 0); ++ inverse += w / planar(uv, d); ++ weight += w; ++ } ++ } ++ return select(400.0, weight / inverse, weight > 0.0); ++} ++ ++// The signed circle of confusion in pixels (far positive, near negative): nothing within a few ++// per cent of the focus, then growing as the reciprocal distances differ. ++fn circle_of_confusion(dist: f32, focus: f32, height: f32) -> f32 { ++ let a = 1.0 - focus / max(dist, 0.1); ++ return sign(a) * smoothstep(0.03, 0.5, abs(a)) * frame.film.w * MAX_COC * height; ++} ++ ++// Depth of field: a disc gather. Each tap counts if its own circle reaches this pixel; a tap behind ++// a sharper pixel is limited to that pixel's circle, so far blur does not creep over the subject. ++@fragment ++fn fs_dof(in: FullOut) -> @location(0) vec4 { ++ let uv = in.uv; ++ let base = textureSampleLevel(source, linear, uv, 0.0); ++ if frame.film.y < 0.5 { return vec4(base.rgb, 1.0); } ++ let size = vec2(textureDimensions(source)); ++ let focus = focus_distance(); ++ let here = distance_at(uv); ++ let coc = circle_of_confusion(here, focus, size.y); ++ let radius = MAX_COC * size.y * max(frame.film.w, 0.25); ++ let angle = film_noise(in.position.xy) * 6.2831853; ++ var sum = base.rgb / max(coc * coc, 1.0); ++ var total = 1.0 / max(coc * coc, 1.0); ++ for (var i = 0; i < DOF_TAPS; i++) { ++ let r = sqrt((f32(i) + 0.5) / f32(DOF_TAPS)) * radius; ++ let a = f32(i) * 2.39996323 + angle; ++ let suv = uv + vec2(cos(a), sin(a)) * r / size; ++ let there = distance_at(suv); ++ var reach = abs(circle_of_confusion(there, focus, size.y)); ++ if there > here * 1.05 + 0.5 { ++ reach = min(reach, max(abs(coc), 1.0)); ++ } ++ let w = clamp(reach - r + 1.0, 0.0, 1.0) / max(reach * reach, 1.0); ++ sum += textureSampleLevel(source, linear, suv, 0.0).rgb * w; ++ total += w; ++ } ++ return vec4(sum / total, 1.0); ++} +diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs +index a98bbd2b9..d61cbfc4f 100644 +--- a/crates/horizon-client/src/gfx/hd/frame.rs ++++ b/crates/horizon-client/src/gfx/hd/frame.rs +@@ -111,7 +111,8 @@ pub(super) struct HdFrame { + /// w: the number of spot lights. + pub(super) vfog: [f32; 4], + /// x: how wet the surfaces are (0 dry .. 1 soaked; follows the rain, dries slowly); y: 1 when +- /// the specular and the wet look are on. ++ /// the specular and the wet look are on; z: film quality; w: 1 when glass and leaves colour the ++ /// sunlight (`shadows.rs`). + pub(super) surface: [f32; 4], + /// Smoke (see `smoke.rs`). x: 1 when the smoke grid is drawn; y: the grid's cell size in blocks; + /// z: the smoke clock in seconds modulo 86400 (the noise rides the wind with it); w: 1 when the far grid has smoke (x: the near one has). +@@ -126,6 +127,9 @@ pub(super) struct HdFrame { + /// Up to `MAX_SPOTS` spot lights, four vec4 each (`HdSpot`: pos+range, dir+cos outer, + /// colour+intensity, cone); their number is `vfog.w`. + pub(super) spots: [[f32; 4]; 64], ++ /// The film camera (`film.rs`). x: motion blur on; y: depth of field on; z: focus distance in ++ /// blocks (0 = automatic); w: depth of field strength. ++ pub(super) film: [f32; 4], + } + + /// What the world around looks like this frame (from the renderer's globals). +diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl +index bbeb35b86..6505c5c76 100644 +--- a/crates/horizon-client/src/gfx/hd/light.wgsl ++++ b/crates/horizon-client/src/gfx/hd/light.wgsl +@@ -33,6 +33,8 @@ struct Light { + // How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky). + @group(0) @binding(20) var cloud_dist_tex: texture_2d; + //PANO21 ++// The near cascade's colour map: glass and leaves (`shadow_tint`). ++@group(0) @binding(22) var tint_map: texture_2d; + const SKY_RAY: f32 = 20000.0; + const CLOUD_SHADOW_R: f32 = 1200.0; + // The pixel being lit, for the shadow filter's noise. +@@ -140,6 +142,32 @@ fn shadow_taps(p: vec3, n: vec3, r: i32) -> f32 { + return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade); + } + ++// Coloured shadows: the colour the near cascade's glass and leaves give the sunlight that reaches ++// p (white where it is clear). The map (`shadows.rs`) holds, per light-space texel, the colour ++// multiplier of the quads there and the nearest one's distance from the light; a tap counts when ++// that quad is nearer the light than p. A ring of taps turned by the pixel's noise softens the ++// leaves into dappled light; the temporal pass averages it. ++fn shadow_tint(p: vec3, n: vec3) -> vec3 { ++ let dist = length(p); ++ let reach = frame.shadow.y * 0.92; ++ if frame.surface.w < 0.5 || dist >= reach { return vec3(1.0); } ++ let texel = frame.shadow.y * 2.0 / 2048.0; ++ let s = cascade_at(p + n * texel * 1.5, 0); ++ if any(s.xy < vec2(0.0)) || any(s.xy > vec2(1.0)) || s.z > 1.0 { return vec3(1.0); } ++ // The receiver's distance in the map's units (see fs_shadow_tint). ++ let local = ((s.z * 799.0 + 1.0) - 320.0) / 160.0; ++ let angle = ign(frag_px) * 6.2831853; ++ var sum = vec3(0.0); ++ for (var i = 0; i < 8; i++) { ++ let o = vogel(i, 8, angle) * (0.4 / (frame.shadow.y * 2.0)); ++ let t = textureSampleLevel(tint_map, linear, s.xy + o, 0.0); ++ // About half a block nearer the light than the receiver counts as in front of it. ++ let in_front = smoothstep(0.0, 0.004, local - t.a - 0.003); ++ sum += mix(vec3(1.0), t.rgb, in_front); ++ } ++ return mix(vec3(1.0), sum / 8.0, clamp((reach - dist) / 6.0, 0.0, 1.0)); ++} ++ + // One shadow sample, for the rays through the air. + fn lit_at(p: vec3) -> f32 { + let s = cascade_at(p, 1); +@@ -304,7 +332,9 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { + 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; ++ var sun_rgb = frame.sun_color.rgb; ++ if s > 0.0 && !water { sun_rgb *= shadow_tint(p, n); } ++ let direct = sun_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; +diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs +index 23e16abfd..8d0cf4f0d 100644 +--- a/crates/horizon-client/src/gfx/hd/lighting.rs ++++ b/crates/horizon-client/src/gfx/hd/lighting.rs +@@ -8,7 +8,8 @@ + //! 7 light levels; 8-9 shadow cascades and comparison sampler; 10-11 lightmap and sampler; + //! 12 the light tiles; 13 AO (half size); 14 the water depth; 15 the sky texture; 16-17 the + //! volumetric fog and its ray length (half size); 18-19 the volumetric clouds and the clouds' +-//! shadow map. ++//! shadow map; 21 the cloud panorama; 22 the near cascade's colour map (glass and leaves, ++//! `shadows.rs`). + //! - Writes two targets (`Rgba16Float`, display space, may exceed 1): + //! - `lit`: rgb the lit colour; **alpha is a marker**: 1 an ordinary pixel, **0.5 water** (rgb is + //! the *floor* under it, lit without any air), **0.6 something translucent in front of the +@@ -34,7 +35,7 @@ pub const MAX_LIGHTS: usize = 512; + + pub(super) const LIGHT: &str = include_str!("light.wgsl"); + /// The lighting's group 0, binding by binding (see the contract above). +-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 22] = [ ++pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 23] = [ + UNIFORM, + UNFILTERED, + DEPTH, +@@ -57,6 +58,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 22] = [ + FLOAT, + UNFILTERED, + FLOAT, ++ FLOAT, + ]; + + /// The outside views and sampler the lighting group reads. +@@ -225,6 +227,7 @@ impl Hd { + wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow), + wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()), + wgpu::BindingResource::TextureView(self.volumes.pano_view()), ++ wgpu::BindingResource::TextureView(&self.shadows.tint), + ]; + let entries: Vec<_> = resources + .into_iter() +diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs +index f7a64e7a4..d8c015a28 100644 +--- a/crates/horizon-client/src/gfx/hd/mod.rs ++++ b/crates/horizon-client/src/gfx/hd/mod.rs +@@ -32,6 +32,7 @@ + //! | 4a | `water_composite` | `lit`, `air`, G-buffer depth, water targets, shadows, sky | `lit_water` | + //! | 5 | mod post pass (caller) | `lit_view()` + depth | `fx` | + //! | 6 | `taa` | `lit_view()` or `fx`, world depth, history | `taa` + next history (out) | ++//! | 6a | `film` (film quality only) | the colour source, world depth | motion blur, then depth of field (out) | + //! | 7 | `finish` | colour source (`HdSource`) | bloom chain, then the screen | + //! + //! - **Colour.** Everything between the lighting and the tone curve is in vanilla's display space +@@ -61,6 +62,7 @@ + + mod ao; + mod bloom; ++mod film; + mod frame; + mod gbuffer; + mod ground; +@@ -79,6 +81,7 @@ mod water; + use crate::gfx::pack_timing::PassTiming; + use ao::Ao; + use bloom::Bloom; ++use film::Film; + use gbuffer::GBuffer; + use lighting::Lighting; + use shadows::Shadows; +@@ -93,7 +96,7 @@ use water::Water; + pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanilla_cloud_tint}; + pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL}; + pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds}; +-pub use shadows::{CASCADES, cascades}; ++pub use shadows::{CASCADES, SHADOW_TINT, cascades, color_shadows_enabled}; + pub use smoke::{HdSmoke, smoke_enabled}; + pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled}; + pub use volumes::clouds_enabled; +@@ -148,6 +151,8 @@ pub enum HdSource { + Fx, + /// The anti-aliased result of `taa`. + Taa, ++ /// The film camera's motion blur and depth of field over that (`film`). ++ Film, + } + + /// The timestamps of the pass `name` while HD passes are timed. +@@ -389,6 +394,7 @@ pub struct Hd { + /// The surfaces' wetness and when it was last updated (see `wetness`). + wet: (f32, std::time::Instant), + taa: Taa, ++ film: Film, + bloom: Bloom, + tonemap: Tonemap, + /// The volumetric clouds and fog (see `volumes`). +@@ -457,6 +463,7 @@ impl Hd { + wet: (0.0, std::time::Instant::now()), + started: std::time::Instant::now(), + taa: Taa::new(device), ++ film: Film::new(device), + bloom: Bloom::new(device), + tonemap: Tonemap::new(device, screen), + volumes: HdVolumes::new(device, queue), +@@ -486,6 +493,7 @@ impl Hd { + self.fx = target(device, "HD post", size, HDR).view; + self.water.resize(device, size); + self.taa.resize(device, out); ++ self.film.resize(); + self.ao + .resize(device, size, &self.frame, &self.gbuffer, &self.groups_made); + // Every group names a view that was just replaced. +@@ -598,7 +606,12 @@ impl Hd { + scene.sky_fog_end, + 0.0, + (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85, +- 0.0, ++ // Rain ripples on the water: the rain level, 0 when off or dry. ++ if scene.sky && water::ripples_enabled() { ++ (scene.rain / 0.7).clamp(0.0, 1.0) ++ } else { ++ 0.0 ++ }, + ], + sunrise: scene.sunrise, + clouds: [ +@@ -637,6 +650,16 @@ impl Hd { + smoke_min: self.smoke.frame.min, + smoke_far: self.smoke.frame.far, + smoke_lights: self.smoke.frame.lights, ++ film: { ++ let (blur, dof) = self.film_passes(); ++ let (focus, strength) = film::dof_settings(); ++ [ ++ if blur { 1.0 } else { 0.0 }, ++ if dof { 1.0 } else { 0.0 }, ++ focus, ++ strength, ++ ] ++ }, + spots: { + let mut all = [[0.0; 4]; 64]; + for (i, s) in self.spots.iter().enumerate() { +@@ -653,7 +676,8 @@ impl Hd { + }, + // Film quality: more steps for the clouds and the fog. + if film_quality() { 1.0 } else { 0.0 }, +- 0.0, ++ // Coloured shadows: the lighting reads the near cascade's colour map. ++ if color_shadows_enabled() { 1.0 } else { 0.0 }, + ], + taa_params: [ + if self.upscaling() { +@@ -836,6 +860,7 @@ mod tests { + ("ao blur", ao::AO_BLUR.to_owned(), &ao::AO_BLUR_BINDINGS), + ("water", water::WATER.to_owned(), &water::WATER_BINDINGS), + ("sky", sky::SKY_PASS.to_owned(), &sky::SKY_BINDINGS), ++ ("film", film::FILM.to_owned(), &film::FILM_BINDINGS), + ]; + all.extend(volumes::shader_sources()); + all.extend(smoke::shader_sources()); +diff --git a/crates/horizon-client/src/gfx/hd/prelude.wgsl b/crates/horizon-client/src/gfx/hd/prelude.wgsl +index 6075cbb0b..11e4bcb31 100644 +--- a/crates/horizon-client/src/gfx/hd/prelude.wgsl ++++ b/crates/horizon-client/src/gfx/hd/prelude.wgsl +@@ -30,6 +30,7 @@ struct HdFrame { + smoke_far: vec4, + smoke_lights: array, 32>, + spots: array, 64>, ++ film: vec4, + } + struct FullOut { + @builtin(position) position: vec4, +diff --git a/crates/horizon-client/src/gfx/hd/shadows.rs b/crates/horizon-client/src/gfx/hd/shadows.rs +index daa34b3c6..e3340c17c 100644 +--- a/crates/horizon-client/src/gfx/hd/shadows.rs ++++ b/crates/horizon-client/src/gfx/hd/shadows.rs +@@ -9,6 +9,12 @@ + //! (comparison sampler, `LessEqual`). + //! - Space: `cascades()` returns camera-relative position to the light's clip space; each + //! cascade's square is snapped to its texels in the world, so shadows do not crawl. ++//! - Coloured shadows (`HORIZON_HD_COLOR_SHADOW`, on by default): the near cascade's depth leaves the ++//! leaves out; `shadow_tint_pass` writes `Shadows::tint` (`SHADOW_TINT`, `TINT_SIZE` squared, the ++//! same light matrix): rgb the sunlight's multiplier after glass and leaves (cleared to 1), alpha ++//! the nearest such quad's distance from the light, `(distance - 320) / 160` (cleared to 1; the ++//! light sits 400 blocks from the cascade's middle). `fs_light` tints a surface lit by the sun ++//! where that distance is nearer to the light than the surface's own (`shadow_tint`). + //! - Readers: lighting (`fs_light`, bindings 8-9), water composite (6-7) and the volumetric fog. + //! - The light is the sun by day and the moon at night (`frame::light_of`). + use super::{Hd, stamp}; +@@ -17,11 +23,22 @@ use glam::Mat4; + /// The cascades' half sizes (blocks) and shadow map size. + pub const CASCADES: [f32; 2] = [40.0, 176.0]; + pub const SHADOW_SIZE: u32 = 2048; ++/// The near cascade's colour map: its format and size. ++pub const SHADOW_TINT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; ++pub const TINT_SIZE: u32 = 1024; ++ ++/// Glass and leaves colour the sunlight unless `HORIZON_HD_COLOR_SHADOW=0`. ++pub fn color_shadows_enabled() -> bool { ++ static ON: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ON.get_or_init(|| std::env::var("HORIZON_HD_COLOR_SHADOW").map_or(true, |v| v != "0")) ++} + + /// The cascade array, a view per layer to draw, and the comparison sampler. + pub(super) struct Shadows { + pub(super) array: wgpu::TextureView, + layers: Vec, ++ /// The near cascade's colour map (see the contract). ++ pub(super) tint: wgpu::TextureView, + pub(super) sampler: wgpu::Sampler, + } + +@@ -62,9 +79,11 @@ impl Shadows { + compare: Some(wgpu::CompareFunction::LessEqual), + ..Default::default() + }); ++ let tint = super::target(device, "HD shadow colour", [TINT_SIZE; 2], SHADOW_TINT).view; + Self { + array, + layers, ++ tint, + sampler, + } + } +@@ -96,6 +115,31 @@ impl Hd { + } + } + ++impl Hd { ++ /// The colour map's pass: cleared to white, far; the caller draws the leaves and the glass. ++ pub fn shadow_tint_pass<'e>( ++ &mut self, ++ encoder: &'e mut wgpu::CommandEncoder, ++ ) -> wgpu::RenderPass<'e> { ++ encoder ++ .begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("HD shadow colour"), ++ timestamp_writes: stamp(&mut self.timing, "hd shadow colour"), ++ color_attachments: &[Some(wgpu::RenderPassColorAttachment { ++ view: &self.shadows.tint, ++ resolve_target: None, ++ depth_slice: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), ++ store: wgpu::StoreOp::Store, ++ }, ++ })], ++ ..Default::default() ++ }) ++ .forget_lifetime() ++ } ++} ++ + /// 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] { +diff --git a/crates/horizon-client/src/gfx/hd/taa.rs b/crates/horizon-client/src/gfx/hd/taa.rs +index 2b758cec6..6083103c8 100644 +--- a/crates/horizon-client/src/gfx/hd/taa.rs ++++ b/crates/horizon-client/src/gfx/hd/taa.rs +@@ -68,7 +68,10 @@ pub fn set_film_quality(on: bool) { + + /// Whether film quality is on. + pub fn film_quality() -> bool { +- FILM.load(std::sync::atomic::Ordering::Relaxed) ++ // `HORIZON_HD_FILM_QUALITY=1` forces it on, to try the film passes without a film. ++ static FORCED: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *FORCED.get_or_init(|| std::env::var("HORIZON_HD_FILM_QUALITY").is_ok_and(|v| v == "1")) ++ || FILM.load(std::sync::atomic::Ordering::Relaxed) + } + + /// How many jitter positions the sequence cycles through for an output `ratio` times the world's +@@ -191,7 +194,7 @@ impl Hd { + let input = match source { + HdSource::Lit => self.lit_view().clone(), + HdSource::Fx => self.fx.clone(), +- HdSource::Taa => return source, ++ HdSource::Taa | HdSource::Film => return source, + }; + let key = (input, depth.clone()); + if self.taa.groups.as_ref().is_none_or(|(k, _)| *k != key) { +diff --git a/crates/horizon-client/src/gfx/hd/tonemap.rs b/crates/horizon-client/src/gfx/hd/tonemap.rs +index da79c43da..e20c04ac9 100644 +--- a/crates/horizon-client/src/gfx/hd/tonemap.rs ++++ b/crates/horizon-client/src/gfx/hd/tonemap.rs +@@ -63,6 +63,7 @@ impl Hd { + HdSource::Lit => self.lit_view().clone(), + HdSource::Fx => self.fx.clone(), + HdSource::Taa => self.taa.target.clone(), ++ HdSource::Film => self.film.result.clone(), + }; + if self + .bloom +diff --git a/crates/horizon-client/src/gfx/hd/water.rs b/crates/horizon-client/src/gfx/hd/water.rs +index 403892348..41ce7a195 100644 +--- a/crates/horizon-client/src/gfx/hd/water.rs ++++ b/crates/horizon-client/src/gfx/hd/water.rs +@@ -13,7 +13,7 @@ + //! front of the water: the lit floor rgb), the G-buffer depth, `tint`, `normal`, `depth`, the + //! shadow cascades, the lightmap and the sky texture. Writes `composite` (`Rgba16Float`); `lit_view` + //! is that target afterwards. +-//! - Time: the waves use `HdFrame.water.y`, wall-clock seconds since HD started modulo 600, and the ++//! - Time: the waves use `HdFrame.water.y`, film-clock seconds since HD started modulo 600, and the + //! camera's x/z modulo 256 (`water.zw`) so they stand still in the world (period 256 blocks, + //! whole cycles per 600 s). It is not game time: a replay paused in game time still has moving + //! water. +@@ -53,6 +53,12 @@ pub fn water_enabled() -> bool { + *ON.get_or_init(|| std::env::var("HORIZON_HD_WATER").map_or(true, |v| v != "0")) + } + ++/// Rain makes ripples on the water unless `HORIZON_HD_RAIN_RIPPLES=0`. ++pub fn ripples_enabled() -> bool { ++ static ON: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ON.get_or_init(|| std::env::var("HORIZON_HD_RAIN_RIPPLES").map_or(true, |v| v != "0")) ++} ++ + pub(super) struct Water { + tint: wgpu::TextureView, + normal: wgpu::TextureView, +diff --git a/crates/horizon-client/src/gfx/hd/water.wgsl b/crates/horizon-client/src/gfx/hd/water.wgsl +index 69e295980..4f3de9a4e 100644 +--- a/crates/horizon-client/src/gfx/hd/water.wgsl ++++ b/crates/horizon-client/src/gfx/hd/water.wgsl +@@ -97,6 +97,49 @@ fn wave_gradient(xz: vec2, footprint: f32) -> vec2 { + return grad * 1.25; + } + ++fn hash21(p: vec2) -> f32 { ++ var q = fract(p * vec2(0.1031, 0.1030)); ++ q += dot(q, q.yx + 33.33); ++ return fract((q.x + q.y) * q.x); ++} ++ ++// Rain ripples: a drop falls in some cells of a 1.6-block grid (160 per 256 blocks, so the pattern ++// repeats with the waves), and its ring widens and fades over about a second. The cell's drop ++// repeats every 600/n seconds with n a whole number of cycles per 600 s, so water time wrapping at ++// 600 does not pop. `rain` (0..1) sets how many cells have a drop; `footprint` fades rings the ++// pixel cannot resolve. Returns the surface's slope. ++fn ripple_gradient(xz: vec2, rain: f32, footprint: f32) -> vec2 { ++ let fade = clamp(1.0 - footprint * 6.0, 0.0, 1.0); ++ if rain <= 0.0 || fade <= 0.0 { return vec2(0.0); } ++ let cell_size = 1.6; ++ let cell = floor(xz / cell_size); ++ var grad = vec2(0.0); ++ for (var j = -1; j <= 1; j++) { ++ for (var i = -1; i <= 1; i++) { ++ let c = cell + vec2(f32(i), f32(j)); ++ let id = c - 160.0 * floor(c / 160.0); ++ let h0 = hash21(id); ++ let h1 = hash21(id + vec2(37.0, 91.0)); ++ let h2 = hash21(id + vec2(11.0, 53.0)); ++ let n = 600.0 + 150.0 * floor(h1 * 3.0); ++ let x = frame.water.y * (n / 600.0) + h0; ++ let t = fract(x); ++ let cycle = x - t - n * floor((x - t) / n); ++ if hash21(id + vec2(cycle * 0.37, cycle * 0.11)) >= rain * 0.8 { continue; } ++ let centre = (c + vec2(0.2 + 0.6 * h1, 0.2 + 0.6 * h2)) * cell_size; ++ let to = xz - centre; ++ let d = max(length(to), 0.0001); ++ let width = 0.1; ++ let u = (d - t * 0.9) / width; ++ // d/dd of cos(2.4 u) * exp(-u^2 / 2), scaled by how young the ring is. ++ let slope = -(2.4 * sin(2.4 * u) + u * cos(2.4 * u)) * exp(-0.5 * u * u) / width; ++ let young = (1.0 - t) * (1.0 - t); ++ grad += (to / d) * (slope * 0.012 * young); ++ } ++ } ++ return grad * fade; ++} ++ + 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); +@@ -213,7 +256,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { + 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); ++ // Rain: rings on the water that is open to the sky. ++ let ripples = ripple_gradient(p.xz + frame.water.zw, frame.sky_extra.w, footprint) * smoothstep(0.7, 0.95, nt.a); ++ var n = normalize(geometric + vec3(-(grad.x + ripples.x), 0.0, -(grad.y + ripples.y)) * flat_part); + n = normalize(n + v * max(0.03 - dot(n, v), 0.0)); + let ndv = max(dot(n, v), 0.02); + +diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl +index de96226e9..bfd0830ad 100644 +--- a/crates/horizon-client/src/terrain.wgsl ++++ b/crates/horizon-client/src/terrain.wgsl +@@ -273,6 +273,35 @@ struct WaterOut { + let a = sprite(in.uv, in.layer & 0x7fffu).a; + if a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } + } ++// AirDef fork, coloured shadows: the near cascade's depth map without the leaves (they are ++// filtered by `fs_shadow_tint` instead, so their shade is soft and green, not a hard cut-out). ++@fragment fn fs_shadow_near(in: TerrainOut) { ++ if material_class(in.layer) == MAT_LEAVES { discard; } ++ let a = sprite(in.uv, in.layer & 0x7fffu).a; ++ if a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } ++} ++// The colour map of the near cascade: what the sunlight is multiplied by after glass (translucent ++// quads) or a leaf (solid quads, SHADOW_SOLID), and in alpha the nearest such quad's distance from ++// the light, remapped to the cascade's middle +-80 blocks (0..1) so half floats hold it finely. ++// Blending multiplies the colours and keeps the smallest alpha. Water is left to the HD water. ++override SHADOW_SOLID: bool = false; ++@fragment fn fs_shadow_tint(in: TerrainOut) -> @location(0) vec4 { ++ let layer = in.layer & 0x7fffu; ++ let s = sprite(in.uv, layer); ++ var t: vec3; ++ if SHADOW_SOLID { ++ if material_class(in.layer) != MAT_LEAVES || s.a < 0.5 { discard; } ++ // A leaf lets a little of its own green through. ++ let c = max(s.rgb * in.tint.rgb, vec3(0.02)); ++ t = 0.55 * pow(c / max(max(c.r, c.g), c.b), vec3(0.8)); ++ } else { ++ if layer == WATER_STILL || layer == WATER_FLOW || s.a < 0.05 { discard; } ++ // Stained glass filters the light by its colour as much as it is opaque. ++ t = pow(max(s.rgb * in.tint.rgb, vec3(0.02)), vec3(s.a * 0.9)); ++ } ++ let dist = in.clip.z * 799.0 + 1.0; ++ return vec4(t, clamp((dist - 320.0) / 160.0, 0.0, 1.0)); ++} + @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) +