Pin the standalone Horizon fork as a submodule (#104)

Replace patch-stack reconstruction with safe pinned initialization and legacy checkout migration; use an engine-local packer and write artifact identities.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-02 01:09:21 +03:00
parent afe320786d
commit 4761ae7d70
57 changed files with 194 additions and 47435 deletions
+6 -1
View File
@@ -20,6 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Rust
run: |
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target wasm32-unknown-unknown
@@ -46,7 +48,9 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
mkdir -p out && cp airdef-mod/target/horizon-mod/airdef.hmod out/airdef.hmod
mkdir -p out
cp airdef-mod/target/horizon-mod/airdef.hmod out/airdef.hmod
cp airdef-mod/target/horizon-mod/build-identity.json out/build-identity.json
BODY=$(printf 'Сборка %s (%s).\n\n- airdef.hmod — мод, для всех ОС.\n\nИгра-форк Horizon (нужна для мода): собрать у себя — python3 tools/airdef.py all, или скачать из большого релиза v* (Windows), или запустить вручную workflow game (Linux) / desktop (macOS) с этим тегом.\nКак запускать: docs/INSTALL.md.' "$TAG" "$SHA")
DATA=$(BODY="$BODY" python3 -c 'import json,os;print(json.dumps({"tag_name":os.environ["TAG"],"target_commitish":os.environ["SHA"],"name":os.environ["TAG"],"body":os.environ["BODY"]}))')
if [ -z "$TOKEN" ]; then echo "::error::нет токена: добавь секрет RELEASE_TOKEN"; exit 1; fi
@@ -57,3 +61,4 @@ jobs:
fi
ID=$(python3 -c 'import json;print(json.load(open("release.json"))["id"])')
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef.hmod" "$API/releases/$ID/assets?name=airdef.hmod" > /dev/null
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/build-identity.json" "$API/releases/$ID/assets?name=build-identity.json" > /dev/null
+1
View File
@@ -41,6 +41,7 @@ jobs:
PY
- uses: actions/checkout@v4
with:
submodules: recursive
ref: refs/tags/${{ inputs.tag }}
- name: Проверка коммита
env:
+1
View File
@@ -49,6 +49,7 @@ jobs:
PY
- uses: actions/checkout@v4
with:
submodules: recursive
ref: refs/tags/${{ inputs.tag }}
- name: Проверка коммита
env:
+1
View File
@@ -37,6 +37,7 @@ jobs:
if [ "$FREE" -lt 12 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 12"; exit 1; fi
- uses: actions/checkout@v4
with:
submodules: recursive
ref: refs/tags/${{ inputs.tag || github.ref_name }}
- name: Инструменты
run: |
-1
View File
@@ -1,4 +1,3 @@
/horizon/
run-home/
.horizon-cache/
airdef-mod/target/
+3
View File
@@ -0,0 +1,3 @@
[submodule "horizon"]
path = horizon
url = https://gitea.maoniao.team/chexit/horizon-airdef.git
-1
View File
@@ -1 +0,0 @@
b6c936f82b08798057161bdfa577e3aac53fb91e
+40
View File
@@ -0,0 +1,40 @@
# Закреплённый движок AirDef
Движок: https://gitea.maoniao.team/chexit/horizon-airdef, ветка `airdef`.
AirDef хранит точный SHA как gitlink `horizon/`; новый push движка не меняет старые сборки мода.
История upstream и всех 44 прежних патчей сохранена в форке. Старые patch-файлы и HORIZON_COMMIT
доступны в истории AirDef и больше не являются вторым источником движка. Baseline описан в AIRDEF-FORK.md.
## Windows: обновление и запуск
```powershell
git fetch origin
git switch kimi-work
git pull --ff-only
python tools/airdef.py all
python tools/airdef.py play
```
Первое клонирование: `git clone --recurse-submodules https://gitea.maoniao.team/chexit/airdef.git`.
Обычный clone тоже поддерживается: `python tools/airdef.py fork` или команда submodule выше.
`all` инициализирует зависимость сам, включая переход со старой папки `horizon/`.
Для чистого checkout можно отдельно выполнить `git submodule update --init --recursive`. Нужны Git, Python и Rust согласно INSTALL.md.
Сборка клиента и мод устанавливаются в обычное место; ресурсы Minecraft готовятся отдельно по INSTALL.md.
При первом переходе со старого отдельного clone `horizon/` скрипт сохраняет его Git-историю и ветки,
переводит в сабмодуль и получает закреплённый SHA. Если там есть изменения при другом SHA,
обновление останавливается с объяснением: сначала закоммитьте/сохраните свои изменения.
Скрипт не выполняет reset --hard или clean. Чистый checkout может остаться detached HEAD — это нормально для зависимости.
## Изменение движка
1. В `horizon/` создать рабочую ветку от закреплённого SHA. Проверить изменение и push в репозиторий движка.
2. В AirDef выполнить `git add horizon`, проверить мод/игру, коммитить обновление gitlink отдельно.
3. Push AirDef выполняется после движка. Не коммитить Git URL с токенами.
4. Для возврата выбрать прежний commit AirDef и выполнить `git submodule update --init --recursive`.
Упаковщик собирается repo-local в `.horizon-cache/packer/<engine-identity>/`, глобальный
`cargo-horizon-mod` не используется. Изменения tracked-исходников движка входят в identity.
Рядом с airdef.hmod записывается build-identity.json: SHA AirDef, pin/identity движка,
SHA256 WIT и артефакта. API-диагностика остаётся в `python tools/airdef.py check`.
Нативные тесты не заменяют отдельную игровую проверку графики, звука и экспорта.
+1 -1
View File
@@ -76,7 +76,7 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
Клон по HTTPS не требует ключа. Если у тебя есть SSH-ключ в Gitea, можно и так: `git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git`.
Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру),
Шаги по отдельности: `fork` (инициализировать закреплённый сабмодуль `horizon/`), `game` (собрать игру),
`mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.
`check` говорит прямо, совпадают ли форк, упаковщик мода, уровень API мода с игрой и ресурсы, и подсказывает,
что запустить, если нет (`python3 tools/airdef.py check`; Windows: `py tools\airdef.py check`). Версия Rust
+1 -1
View File
@@ -10,7 +10,7 @@
| `airdef-mod/Cargo.toml`, `horizon-mod.toml` | Сборка мода |
| `airdef-mod/tests/`, `airdef-mod/examples/` | Тесты и примеры |
| `airdef-mod/NOTES-terrain-guidance.md` | Рабочие заметки по рельефу |
| `horizon/`, `HORIZON_COMMIT`, `horizon-patches/` | Форк Horizon и патчи к нему |
| `horizon/`, `.gitmodules` | Закреплённый сабмодуль отдельного форка Horizon |
| `tools/airdef.py` | Сборка мода и установка в папку игры |
| `tools/gen_models.py`, `tools/gen_tex.py`, `tools/gen_sounds.py` | Генераторы контента (см. ниже) |
| `tools/acoustics_check.py`, `tools/hd_bench.py` | Проверочные скрипты |
Submodule
+1
Submodule horizon added at eae00eafc5
@@ -1,577 +0,0 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/42] Mod API (AirDef fork): world models, client particles,
sounds, ground heights
render.set-models draws block models with an interpolated pose per id;
render.spawn-particles and render.play-sound act on this client only;
render.query-ground / render.ground read the motion-blocking heightmap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 2 +
crates/horizon-client/src/game/mod_models.rs | 197 +++++++++++++++++++
crates/horizon-client/src/game/mods.rs | 24 +++
crates/horizon-mod-sdk/wit/horizon.wit | 58 ++++++
crates/horizon-mods/src/api_level.rs | 10 +-
crates/horizon-mods/src/runtime.rs | 116 ++++++++++-
6 files changed, 403 insertions(+), 4 deletions(-)
create mode 100644 crates/horizon-client/src/game/mod_models.rs
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 1d7bbbbd6..7c342cd86 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -115,6 +115,7 @@ mod mod_input;
#[cfg(feature = "mods")]
mod mod_recipes;
mod mod_render;
+mod mod_models;
mod mods;
mod mount_menu;
mod mounts;
@@ -8232,6 +8233,7 @@ impl Game<'_> {
}
}
self.mark_entity(out.len(), "");
+ self.mod_model_geometry(camera, alpha, &mut out);
self.moving_piston_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:tnt");
self.tnt_geometry(camera, alpha, &mut out);
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
new file mode 100644
index 000000000..3c7f9345c
--- /dev/null
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -0,0 +1,197 @@
+//! AirDef fork: capability `render`'s world models, client particles and
+//! sounds. A mod sets its models once a tick (`render.set-models`); each id
+//! keeps its last two poses and is drawn between them every frame, so models
+//! move smoothly at any frame rate.
+use super::Game;
+#[cfg(feature = "mods")]
+use {
+ glam::{DVec3, Mat4, Quat, Vec3},
+ horizon_client::{
+ mesher::Vertex,
+ particles::{ParticleOptions, ParticleType},
+ },
+ horizon_core::random::RandomSource,
+ horizon_mods::ParticleSpawn,
+ horizon_world::registry::BlockStateId,
+ std::collections::HashMap,
+};
+
+/// One model of a mod: where it was a tick ago and where it is now.
+#[cfg(feature = "mods")]
+struct Pose {
+ state: BlockStateId,
+ previous: DVec3,
+ position: DVec3,
+ old_rotation: Quat,
+ rotation: Quat,
+ old_scale: f32,
+ scale: f32,
+ glow: bool,
+}
+
+/// The models the running mods keep, by (mod, id).
+#[cfg(feature = "mods")]
+#[derive(Default)]
+pub(super) struct ModModels {
+ poses: HashMap<(usize, u32), Pose>,
+ /// Block states by the text mods name them, parsed once.
+ states: HashMap<String, Option<BlockStateId>>,
+}
+
+/// Full brightness: sky and block light 15, as the entity shader packs them.
+#[cfg(feature = "mods")]
+const GLOW: u32 = 240 | (240 << 8);
+
+/// A build without the `mods` feature draws no mod models.
+#[cfg(not(feature = "mods"))]
+impl Game<'_> {
+ pub(super) fn mod_model_geometry(
+ &self,
+ _camera: glam::DVec3,
+ _alpha: f64,
+ _out: &mut Vec<horizon_client::mesher::Vertex>,
+ ) {
+ }
+}
+
+#[cfg(feature = "mods")]
+impl Game<'_> {
+ /// After the mods' tick: each model's new pose, its last one kept to
+ /// draw from. Ids no longer set are dropped; new ones start where they
+ /// are.
+ pub(super) fn update_mod_models(&mut self) {
+ let mut seen: Vec<(usize, u32)> = Vec::new();
+ let registry = &self.pack.assets.registry;
+ let models = &mut self.mods.models;
+ for (owner, m) in self.mods.mods.models() {
+ let state = *models
+ .states
+ .entry(m.model.clone())
+ .or_insert_with(|| super::render_control::parse_state(registry, &m.model).ok());
+ let Some(state) = state else {
+ continue;
+ };
+ let key = (owner, m.id);
+ seen.push(key);
+ let position = DVec3::new(m.x, m.y, m.z);
+ let (x, y, z, w) = m.rotation;
+ let rotation = Quat::from_xyzw(x, y, z, w);
+ match models.poses.get_mut(&key) {
+ Some(p) => {
+ p.previous = p.position;
+ p.old_rotation = p.rotation;
+ p.old_scale = p.scale;
+ p.position = position;
+ p.rotation = rotation;
+ p.scale = m.scale;
+ p.state = state;
+ p.glow = m.glow;
+ }
+ None => {
+ models.poses.insert(
+ key,
+ Pose {
+ state,
+ previous: position,
+ position,
+ old_rotation: rotation,
+ rotation,
+ old_scale: m.scale,
+ scale: m.scale,
+ glow: m.glow,
+ },
+ );
+ }
+ }
+ }
+ if seen.len() != models.poses.len() {
+ let seen: std::collections::HashSet<_> = seen.into_iter().collect();
+ models.poses.retain(|k, _| seen.contains(k));
+ }
+ }
+
+ /// Draws every mod model between its last two poses, lit by the world
+ /// (or at full brightness), its faces shaded by its own rotation.
+ pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec<Vertex>) {
+ if self.mods.models.poses.is_empty() {
+ return;
+ }
+ let t = alpha.clamp(0.0, 1.0);
+ for p in self.mods.models.poses.values() {
+ let at = p.previous.lerp(p.position, t);
+ // Far models are culled by distance; the view frustum does the rest.
+ if at.distance_squared(camera) > 512.0 * 512.0 {
+ continue;
+ }
+ let rotation = p.old_rotation.slerp(p.rotation, t as f32);
+ let scale = p.old_scale + (p.scale - p.old_scale) * t as f32;
+ if scale <= 0.0 {
+ continue;
+ }
+ let light = if p.glow {
+ GLOW
+ } else {
+ let l = self.world.light(
+ at.x.floor() as i32,
+ at.y.floor() as i32,
+ at.z.floor() as i32,
+ );
+ (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
+ };
+ // The model's unit cube turned and scaled about its centre.
+ let m = Mat4::from_scale_rotation_translation(
+ Vec3::splat(scale),
+ rotation,
+ (at - camera).as_vec3(),
+ ) * Mat4::from_translation(Vec3::splat(-0.5));
+ self.block_model_vertices(p.state, 0, m, light, true, out);
+ }
+ }
+
+ /// `render.spawn-particles`: made on this client at once.
+ pub(super) fn mod_particles(&mut self, particles: Vec<ParticleSpawn>) {
+ for s in particles {
+ let Some(ty) = ParticleType::from_name(&s.particle) else {
+ continue;
+ };
+ let rgb = [
+ ((s.color >> 16) & 255) as f32 / 255.0,
+ ((s.color >> 8) & 255) as f32 / 255.0,
+ (s.color & 255) as f32 / 255.0,
+ ];
+ let rgb = if s.color == 0 { [1.0; 3] } else { rgb };
+ let options = match ty {
+ ParticleType::Dust => ParticleOptions::dust(rgb, s.scale),
+ ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => {
+ ParticleOptions::colored(ty, [rgb[0], rgb[1], rgb[2], 1.0])
+ }
+ ParticleType::Block
+ | ParticleType::FallingDust
+ | ParticleType::DustPillar
+ | ParticleType::BlockMarker
+ | ParticleType::Item
+ | ParticleType::ItemSlime
+ | ParticleType::ItemSnowball
+ | ParticleType::ItemCobweb => continue,
+ _ => ParticleOptions::simple(ty),
+ };
+ let (sx, sy, sz) = s.spread;
+ let (vx, vy, vz) = s.velocity;
+ let speed = f64::from(s.speed);
+ for _ in 0..s.count {
+ let r = self.particles.random();
+ let p = [
+ s.x + r.next_gaussian() * f64::from(sx),
+ s.y + r.next_gaussian() * f64::from(sy),
+ s.z + r.next_gaussian() * f64::from(sz),
+ ];
+ let v = [
+ f64::from(vx) + r.next_gaussian() * speed,
+ f64::from(vy) + r.next_gaussian() * speed,
+ f64::from(vz) + r.next_gaussian() * speed,
+ ];
+ self.particles.create(&options, p, v);
+ }
+ }
+ }
+}
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index 52dc46fe0..3eef96aa3 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -46,6 +46,9 @@ pub(super) struct ModsState {
/// Capability `render`'s camera shakes.
#[cfg(feature = "mods")]
pub(super) shake: super::mod_render::Shake,
+ /// Capability `render`'s world models (AirDef fork), with their last poses.
+ #[cfg(feature = "mods")]
+ pub(super) models: super::mod_models::ModModels,
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
pub(super) dev: Vec<PathBuf>,
/// When dev mods were last checked for a rebuild.
@@ -596,9 +599,17 @@ impl Game<'_> {
return;
}
self.tick_mod_use();
+ for (i, columns) in self.mods.mods.take_ground_queries() {
+ let ground = columns
+ .into_iter()
+ .map(|(x, z)| (x, z, self.rain_height(x, z)))
+ .collect();
+ self.mods.mods.answer_ground(i, ground);
+ }
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
self.mods.mods.tick(self.ticks);
+ self.update_mod_models();
self.show_mod_output();
}
/// Restarts the dev mods rebuilt since the last check, once a second.
@@ -716,6 +727,19 @@ impl Game<'_> {
self.mods.shake.add(strength, seconds);
continue;
}
+ Output::Particles { particles } => {
+ self.mod_particles(particles);
+ continue;
+ }
+ Output::Sound {
+ event,
+ at,
+ volume,
+ pitch,
+ } => {
+ self.audio.play(&event, Some(at), volume, pitch);
+ continue;
+ }
Output::PlaceRecipe { book_id, all } => {
self.mod_place_recipe(book_id, all);
continue;
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 0680cce92..89d1906a9 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -429,6 +429,64 @@ interface render {
/// Distance setting, when on, wins. One far terrain shows at a time: the
/// first loaded mod's.
set-far-terrain: func(far-terrain: option<far-terrain>);
+ /// A block model drawn in the world (AirDef fork).
+ record model-instance {
+ /// Stays the same while the object lives: the game moves an id
+ /// smoothly from where the last `set-models` put it.
+ id: u32,
+ /// A block state as commands write it (`airdef:shahed`,
+ /// `minecraft:stone`, `minecraft:furnace[facing=east]`): its block
+ /// model is drawn. An unknown one draws nothing.
+ model: string,
+ /// Where the model's centre is, in blocks.
+ x: f64,
+ y: f64,
+ z: f64,
+ /// Rotation about the model's centre: a quaternion (x, y, z, w).
+ rotation: tuple<f32, f32, f32, f32>,
+ /// Uniform scale, 1.0 a block (0 to 64).
+ scale: f32,
+ /// Drawn at full brightness, whatever the light where it is.
+ glow: bool,
+ }
+ /// Replaces the mod's models: up to 4096, drawn with the world's
+ /// entities until replaced. Call it once a tick: the game interpolates
+ /// each id between its last two positions and rotations every frame.
+ /// An empty list removes them.
+ set-models: func(models: list<model-instance>);
+ /// Particles to spawn on this client (AirDef fork).
+ record particle-spawn {
+ /// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`.
+ particle: string,
+ x: f64,
+ y: f64,
+ z: f64,
+ /// Gaussian spread round the point, per axis, in blocks.
+ spread: tuple<f32, f32, f32>,
+ /// Every particle's starting velocity, blocks a tick.
+ velocity: tuple<f32, f32, f32>,
+ /// Gaussian speed added to it per axis.
+ speed: f32,
+ /// 1 to 256.
+ count: u32,
+ /// 0xRRGGBB for dust and coloured types (0: white).
+ color: u32,
+ /// Dust size (0.01 to 4).
+ scale: f32,
+ }
+ /// Spawns particles on this client only: no command, no server.
+ /// At most 512 spawns a call.
+ spawn-particles: func(particles: list<particle-spawn>);
+ /// Plays a sound event (`minecraft:entity.generic.explode`,
+ /// `<mod>:<name>`) at a point on this client.
+ play-sound: func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32);
+ /// Asks for the ground height of these columns (x, z): at most 512 a
+ /// tick, answered by `ground` from the next tick on.
+ query-ground: func(columns: list<tuple<s32, s32>>);
+ /// The answers to the last tick's `query-ground`: (x, z, y), y one above
+ /// the highest block that blocks motion or holds a fluid, as the motion
+ /// blocking heightmap; -64 where the chunk isn't loaded.
+ ground: func() -> list<tuple<s32, s32, s32>>;
}
/// What the game calls. Only the hooks `init` asks for are ever called.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 045a9346b..b64792eaa 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 7;
+pub const LEVEL: u32 = 8;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 46] = [
+pub const FUNCTIONS: [(&str, u32); 51] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -60,6 +60,12 @@ pub const FUNCTIONS: [(&str, u32); 46] = [
("recipes.place", 6),
("recipes.give", 6),
("render.set-far-terrain", 7),
+ // AirDef fork: world models, client particles and sounds.
+ ("render.set-models", 8),
+ ("render.spawn-particles", 8),
+ ("render.play-sound", 8),
+ ("render.query-ground", 8),
+ ("render.ground", 8),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index cff44722b..317cd4f06 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -71,6 +71,7 @@ mod render_bindings {
wasmtime::component::bindgen!({
path: "../horizon-mod-sdk/wit",
world: "render-host",
+ additional_derives: [PartialEq],
});
}
use render_bindings::horizon::mods::input;
@@ -79,8 +80,8 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I
/// What capability `render` asks the game to draw (WIT `interface render`).
pub use render_bindings::horizon::mods::render::{
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
- Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape,
- TextShape, Zoom as ModZoom,
+ Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
+ Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -238,6 +239,15 @@ pub enum Output {
CloseScreen { owner: usize },
/// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10).
Shake { strength: f32, seconds: f32 },
+ /// `render.spawn-particles` (AirDef fork): particles to make on this client.
+ Particles { particles: Vec<ParticleSpawn> },
+ /// `render.play-sound` (AirDef fork): a sound event at a point on this client.
+ Sound {
+ event: String,
+ at: [f64; 3],
+ volume: f32,
+ pitch: f32,
+ },
/// `recipes.place`: the recipe book's click on this display id, all of
/// it when `all`.
PlaceRecipe { book_id: i32, all: bool },
@@ -416,6 +426,12 @@ struct State {
minimap: Option<ModMinimap>,
/// Capability `render`'s far terrain, when the mod draws one.
far_terrain: Option<ModFarTerrain>,
+ /// Capability `render`'s world models (AirDef fork), drawn until replaced.
+ models: Vec<ModelInstance>,
+ /// `render.query-ground`'s columns, answered before the next tick.
+ ground_queries: Vec<(i32, i32)>,
+ /// The game's answers to them.
+ ground: Vec<(i32, i32, i32)>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -619,6 +635,9 @@ impl Mods {
zoom: None,
minimap: None,
far_terrain: None,
+ models: Vec::new(),
+ ground_queries: Vec::new(),
+ ground: Vec::new(),
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1173,6 +1192,32 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().minimap)
}
+ /// Every running mod's world models (AirDef fork), with the mod's index
+ /// so ids of different mods don't meet.
+ pub fn models(&self) -> impl Iterator<Item = (usize, &ModelInstance)> {
+ self.entries.iter().enumerate().flat_map(|(i, e)| {
+ e.run
+ .iter()
+ .flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m)))
+ })
+ }
+ /// The columns each running mod asked the ground height of, taken.
+ pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
+ self.entries
+ .iter_mut()
+ .enumerate()
+ .filter_map(|(i, e)| {
+ let q = std::mem::take(&mut e.run.as_mut()?.store.data_mut().ground_queries);
+ (!q.is_empty()).then_some((i, q))
+ })
+ .collect()
+ }
+ /// Mod `i`'s answers to its ground queries, for its next calls.
+ pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) {
+ if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) {
+ run.store.data_mut().ground = ground;
+ }
+ }
/// The far terrain the first running mod draws (capability `render`).
pub fn far_terrain(&self) -> Option<ModFarTerrain> {
self.entries
@@ -1734,6 +1779,12 @@ const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0);
const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0);
/// `set-far-terrain`'s distance in chunks, as the LOD Distance setting's.
const FAR_TERRAIN_DISTANCE: (u32, u32) = (64, 1024);
+/// `set-models`' most models a mod keeps, and their largest scale.
+const MODELS: usize = 4096;
+const MODEL_SCALE: f32 = 64.0;
+/// `spawn-particles`' most spawns a call and particles a spawn.
+const PARTICLE_SPAWNS: usize = 512;
+const PARTICLES_PER_SPAWN: u32 = 256;
/// A mod's corner panel: shapes in a `width` × `height` box the game
/// places in `corner`, stacked with the other overlays.
@@ -1851,6 +1902,67 @@ impl render::Host for State {
self.out.push(Output::Shake { strength, seconds });
}
}
+ fn set_models(&mut self, models: Vec<ModelInstance>) {
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
+ self.models = models
+ .into_iter()
+ .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
+ .take(MODELS)
+ .map(|mut m| {
+ let (x, y, z, w) = m.rotation;
+ let len = (x * x + y * y + z * z + w * w).sqrt();
+ m.rotation = if len.is_finite() && len > 1e-6 {
+ (x / len, y / len, z / len, w / len)
+ } else {
+ (0.0, 0.0, 0.0, 1.0)
+ };
+ m.scale = if m.scale.is_finite() {
+ m.scale.clamp(0.0, MODEL_SCALE)
+ } else {
+ 1.0
+ };
+ m.model = clip(m.model);
+ m
+ })
+ .collect();
+ }
+ fn spawn_particles(&mut self, particles: Vec<ParticleSpawn>) {
+ let particles: Vec<ParticleSpawn> = particles
+ .into_iter()
+ .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
+ .take(PARTICLE_SPAWNS)
+ .map(|mut p| {
+ p.count = p.count.clamp(1, PARTICLES_PER_SPAWN);
+ p.particle = clip(p.particle);
+ p
+ })
+ .collect();
+ if !particles.is_empty() {
+ self.out.push(Output::Particles { particles });
+ }
+ }
+ fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
+ let room = 512usize.saturating_sub(self.ground_queries.len());
+ self.ground_queries.extend(columns.into_iter().take(room));
+ }
+ fn ground(&mut self) -> Vec<(i32, i32, i32)> {
+ self.ground.clone()
+ }
+ fn play_sound(&mut self, event: String, x: f64, y: f64, z: f64, volume: f32, pitch: f32) {
+ if !(x.is_finite() && y.is_finite() && z.is_finite()) {
+ return;
+ }
+ let ok = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max) } else { 1.0 };
+ let sounds = self.out.iter().filter(|o| matches!(o, Output::Sound { .. })).count();
+ if sounds < 64 {
+ self.out.push(Output::Sound {
+ event: clip(event),
+ at: [x, y, z],
+ volume: ok(volume, 16.0),
+ pitch: ok(pitch, 2.0),
+ });
+ }
+ }
}
impl command::Host for State {
--
2.54.0 (Apple Git-157)
@@ -1,28 +0,0 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:39:45 +0300
Subject: [PATCH 02/42] cargo-horizon-mod: report particles and sounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/cargo-horizon-mod/src/check.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs
index 988ba826d..6a91fda28 100644
--- a/crates/cargo-horizon-mod/src/check.rs
+++ b/crates/cargo-horizon-mod/src/check.rs
@@ -280,6 +280,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
+ Output::Particles { particles } => {
+ format!("spawned {} particle group(s)", particles.len())
+ }
+ Output::Sound { event, .. } => format!("played {event}"),
});
}
let shapes = mods.hud_shapes().count();
--
2.54.0 (Apple Git-157)
@@ -1,603 +0,0 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 13:33:10 +0300
Subject: [PATCH 03/42] Mod API (AirDef fork): full-screen post pass for mods
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
time, 256 vec4 of params, the world's colour and depth) and runs it after
the world, before the hand and the HUD; render.set-params feeds it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 1 +
crates/horizon-client/src/game/mod_models.rs | 28 ++
crates/horizon-client/src/gfx.rs | 35 +-
crates/horizon-client/src/gfx/mod_post.rs | 320 +++++++++++++++++++
crates/horizon-mod-sdk/wit/horizon.wit | 13 +
crates/horizon-mods/src/api_level.rs | 5 +-
crates/horizon-mods/src/runtime.rs | 28 ++
7 files changed, 428 insertions(+), 2 deletions(-)
create mode 100644 crates/horizon-client/src/gfx/mod_post.rs
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 7c342cd86..ca6a8c266 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11527,6 +11527,7 @@ impl Game<'_> {
return Ok(false);
};
let alpha = self.clock.fraction();
+ self.apply_mod_post(alpha as f32);
// Camera.getCameraEntityPartialTicks: the view, the hand and the
// player's own model stay smooth while the level is frozen.
let camera_alpha = self.clock.camera_fraction();
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 3c7f9345c..be00ea4c7 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -36,6 +36,8 @@ pub(super) struct ModModels {
poses: HashMap<(usize, u32), Pose>,
/// Block states by the text mods name them, parsed once.
states: HashMap<String, Option<BlockStateId>>,
+ /// The version of the mod post pass the renderer has (0: none).
+ post: u64,
}
/// Full brightness: sky and block light 15, as the entity shader packs them.
@@ -52,6 +54,7 @@ impl Game<'_> {
_out: &mut Vec<horizon_client::mesher::Vertex>,
) {
}
+ pub(super) fn apply_mod_post(&mut self, _partial: f32) {}
}
#[cfg(feature = "mods")]
@@ -148,6 +151,31 @@ impl Game<'_> {
}
}
+ /// Before each frame: the renderer gets the mod's post pass when it changed, and its
+ /// parameters. A pass that doesn't compile is reported once; the last good one runs on.
+ pub(super) fn apply_mod_post(&mut self, partial: f32) {
+ let (version, source, params) = match self.mods.mods.post() {
+ Some((v, s, p)) => (v, Some(s.to_owned()), p.to_vec()),
+ None => (0, None, Vec::new()),
+ };
+ let mut error = None;
+ let Some(renderer) = self.renderer.as_mut() else { return };
+ if version != self.mods.models.post {
+ self.mods.models.post = version;
+ error = renderer.set_mod_post(source.as_deref()).err();
+ }
+ if version != 0 {
+ renderer.set_mod_params(&params, partial);
+ }
+ if let Some(e) = error {
+ log::warn!("Horizon: mod post pass refused: {e}");
+ self.system_message(
+ horizon_client::text::Text::literal(format!("Mod shader error: {e}"))
+ .with_color(horizon_client::text::RED),
+ );
+ }
+ }
+
/// `render.spawn-particles`: made on this client at once.
pub(super) fn mod_particles(&mut self, particles: Vec<ParticleSpawn>) {
for s in particles {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index beb8b9060..4c5005615 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -77,6 +77,7 @@ mod lod;
mod native_material;
mod native_pack;
mod native_post;
+mod mod_post;
mod native_sky;
mod occlusion;
mod pack_atlas;
@@ -543,6 +544,9 @@ pub struct Renderer {
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
native: Option<native_pack::NativePack>,
+ /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for.
+ mod_post: Option<mod_post::ModPost>,
+ mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
@@ -2646,6 +2650,8 @@ impl Renderer {
hdr_output,
pack_post: None,
native: None,
+ mod_post: None,
+ mod_params: (Vec::new(), 0.0),
pass_timing: pack_timing::PassTiming::logging(),
pack_entity_rest: Vec::new(),
cloud_bindings,
@@ -3287,6 +3293,24 @@ impl Renderer {
report
}
/// Drops the running shader pack, of either kind.
+ /// AirDef fork: runs a mod's post pass from now on (`None`: none). On an error the pass
+ /// that ran keeps running.
+ pub fn set_mod_post(&mut self, source: Option<&str>) -> std::result::Result<(), String> {
+ match source {
+ None => self.mod_post = None,
+ Some(source) => {
+ let format = self.config.format.remove_srgb_suffix();
+ self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?);
+ }
+ }
+ Ok(())
+ }
+ /// AirDef fork: the mod post pass's parameters for the next frames.
+ pub fn set_mod_params(&mut self, params: &[f32], partial: f32) {
+ self.mod_params.0.clear();
+ self.mod_params.0.extend_from_slice(&params[..params.len().min(mod_post::PARAMS)]);
+ self.mod_params.1 = partial;
+ }
pub fn drop_shader_packs(&mut self) {
self.pack_post = None;
self.native = None;
@@ -4456,7 +4480,9 @@ impl Renderer {
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
- let pack_reads_world = pack_post || native_post;
+ // AirDef fork: a mod's post pass reads the world target too.
+ let mod_post = !frame.title && !pack_post && !native_post && self.mod_post.is_some();
+ let pack_reads_world = pack_post || native_post || mod_post;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
@@ -5026,6 +5052,13 @@ impl Renderer {
size,
native.timing.as_mut(),
);
+ } else if let Some((color, depth)) = &scaled
+ && mod_post
+ && let Some(post) = &mut self.mod_post
+ {
+ let size = self.world_post.size().unwrap_or([1, 1]);
+ post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1);
+ post.run(&self.device, &mut encoder, (color, depth), &view_target);
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
}
diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs
new file mode 100644
index 000000000..497cff854
--- /dev/null
+++ b/crates/horizon-client/src/gfx/mod_post.rs
@@ -0,0 +1,320 @@
+//! AirDef fork: a mod's full-screen post pass (`render.set-post`). The mod's WGSL is put after
+//! a prelude that declares the frame (camera matrices, time, the mod's parameters) and the
+//! world's colour and depth; it defines `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`.
+//! The pass runs after the world and before the hand and the HUD, reading the scaled world
+//! target and drawing onto the screen.
+
+use glam::Mat4;
+
+use super::Frame;
+
+/// Floats of mod parameters (256 vec4).
+pub const PARAMS: usize = 1024;
+
+/// The uniform the prelude's `frame` reads.
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct ModFrame {
+ view_proj: [[f32; 4]; 4],
+ view_inverse: [[f32; 4]; 4],
+ projection_inverse: [[f32; 4]; 4],
+ /// xyz: the camera; w: unused.
+ camera: [f32; 4],
+ /// width, height, 1/width, 1/height.
+ resolution: [f32; 4],
+ /// seconds (wraps hourly), the partial tick, the sun's height, unused.
+ time: [f32; 4],
+ /// The sun's direction.
+ sun: [f32; 4],
+ params: [[f32; 4]; PARAMS / 4],
+}
+
+pub const PRELUDE: &str = r#"
+struct ModFrame {
+ view_proj: mat4x4<f32>,
+ view_inverse: mat4x4<f32>,
+ projection_inverse: mat4x4<f32>,
+ camera: vec4<f32>,
+ resolution: vec4<f32>,
+ time: vec4<f32>,
+ sun: vec4<f32>,
+ params: array<vec4<f32>, 256>,
+}
+@group(0) @binding(0) var<uniform> frame: ModFrame;
+@group(0) @binding(1) var scene_color: texture_2d<f32>;
+@group(0) @binding(2) var scene_depth: texture_depth_2d;
+@group(0) @binding(3) var linear_sampler: sampler;
+
+struct HorizonModPostOut {
+ @builtin(position) position: vec4<f32>,
+ @location(0) uv: vec2<f32>,
+}
+
+@vertex
+fn horizon_mod_post_vs(@builtin(vertex_index) i: u32) -> HorizonModPostOut {
+ let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
+ var out: HorizonModPostOut;
+ out.position = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
+ out.uv = uv;
+ return out;
+}
+
+/// The world's depth at `uv` (reversed: 1 near, 0 the sky).
+fn depth_at(uv: vec2<f32>) -> f32 {
+ let size = vec2<f32>(textureDimensions(scene_depth));
+ let p = vec2<i32>(clamp(uv * size, vec2<f32>(0.0), size - 1.0));
+ return textureLoad(scene_depth, p, 0);
+}
+
+/// The point the pixel at `uv` sees at `depth`, relative to the camera (world axes).
+fn view_ray_point(uv: vec2<f32>, depth: f32) -> vec3<f32> {
+ let ndc = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
+ let v = frame.projection_inverse * ndc;
+ return (frame.view_inverse * vec4<f32>(v.xyz / v.w, 0.0)).xyz;
+}
+
+/// The unit direction of the view ray through `uv`.
+fn view_dir(uv: vec2<f32>) -> vec3<f32> {
+ return normalize(view_ray_point(uv, 0.5));
+}
+
+/// How far along the view ray the world is at `uv` (a large number for the sky).
+fn scene_distance(uv: vec2<f32>) -> f32 {
+ let d = depth_at(uv);
+ if d <= 0.0 { return 1.0e6; }
+ return length(view_ray_point(uv, d));
+}
+
+/// A world point on the screen: xy in uv, z its distance (negative behind the camera).
+fn project(world: vec3<f32>) -> vec3<f32> {
+ let c = frame.view_proj * vec4<f32>(world - frame.camera.xyz, 1.0);
+ let ndc = c.xyz / max(abs(c.w), 1e-5);
+ return vec3<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, c.w);
+}
+
+fn scene(uv: vec2<f32>) -> vec3<f32> {
+ return textureSampleLevel(scene_color, linear_sampler, uv, 0.0).rgb;
+}
+"#;
+
+const ENTRY: &str = r#"
+@fragment
+fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4<f32> {
+ return post(in.uv, in.position.xy);
+}
+"#;
+
+pub struct ModPost {
+ pipeline: wgpu::RenderPipeline,
+ layout: wgpu::BindGroupLayout,
+ buffer: wgpu::Buffer,
+ sampler: wgpu::Sampler,
+ group: Option<((wgpu::TextureView, wgpu::TextureView), wgpu::BindGroup)>,
+ start: std::time::Instant,
+}
+
+/// The mod's source with the prelude, checked by naga: errors name the mod's own lines.
+fn compile(source: &str) -> Result<naga::Module, String> {
+ let full = format!("{PRELUDE}\n{source}\n{ENTRY}");
+ let module = naga::front::wgsl::parse_str(&full).map_err(|e| {
+ let prelude_lines = PRELUDE.lines().count() + 1;
+ let text = e.emit_to_string(&full);
+ match e.location(&full) {
+ Some(l) if l.line_number as usize > prelude_lines => {
+ format!("line {}: {}", l.line_number as usize - prelude_lines, e.message())
+ }
+ _ => text,
+ }
+ })?;
+ naga::valid::Validator::new(
+ naga::valid::ValidationFlags::all(),
+ naga::valid::Capabilities::default(),
+ )
+ .validate(&module)
+ .map_err(|e| format!("{}", e.into_inner()))?;
+ Ok(module)
+}
+
+impl ModPost {
+ pub fn new(device: &wgpu::Device, source: &str, format: wgpu::TextureFormat) -> Result<Self, String> {
+ let module = compile(source)?;
+ let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
+ binding,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type,
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ };
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("Mod post"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: wgpu::BufferSize::new(size_of::<ModFrame>() as u64),
+ },
+ count: None,
+ },
+ texture(1, wgpu::TextureSampleType::Float { filterable: true }),
+ texture(2, wgpu::TextureSampleType::Depth),
+ wgpu::BindGroupLayoutEntry {
+ binding: 3,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ ],
+ });
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("Mod post"),
+ bind_group_layouts: &[Some(&layout)],
+ ..Default::default()
+ });
+ let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("Mod post"),
+ source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
+ });
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("Mod post"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("horizon_mod_post_vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("horizon_mod_post_fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ multiview_mask: None,
+ cache: None,
+ });
+ if let Some(e) = pollster::block_on(scope.pop()) {
+ return Err(e.to_string());
+ }
+ let buffer = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Mod post frame"),
+ size: size_of::<ModFrame>() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("Mod post linear"),
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+ Ok(Self {
+ pipeline,
+ layout,
+ buffer,
+ sampler,
+ group: None,
+ start: std::time::Instant::now(),
+ })
+ }
+
+ /// This frame's camera and the mod's `params`, for a world of `size`.
+ pub fn write(&self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2], params: &[f32], partial: f32) {
+ let aspect = size[0] as f32 / size[1].max(1) as f32;
+ let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
+ let projection =
+ super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob * frame.spin;
+ let a = frame.sky.sun_angle.to_radians();
+ let sun = [-a.sin(), a.cos(), 0.0, 0.0];
+ let mut p = [[0.0f32; 4]; PARAMS / 4];
+ for (i, v) in params.iter().take(PARAMS).enumerate() {
+ p[i / 4][i % 4] = *v;
+ }
+ let m = |m: Mat4| m.to_cols_array_2d();
+ let inputs = ModFrame {
+ view_proj: m(projection * view),
+ view_inverse: m(view.inverse()),
+ projection_inverse: m(projection.inverse()),
+ camera: [frame.camera.x as f32, frame.camera.y as f32, frame.camera.z as f32, 0.0],
+ resolution: [
+ size[0] as f32,
+ size[1] as f32,
+ 1.0 / size[0].max(1) as f32,
+ 1.0 / size[1].max(1) as f32,
+ ],
+ time: [self.start.elapsed().as_secs_f32() % 3600.0, partial, sun[1], 0.0],
+ sun,
+ params: p,
+ };
+ queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
+ }
+
+ /// Draws the pass over the world target `world` (colour, depth) onto `screen`.
+ pub fn run(
+ &mut self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ world: (&wgpu::TextureView, &wgpu::TextureView),
+ screen: &wgpu::TextureView,
+ ) {
+ if self
+ .group
+ .as_ref()
+ .is_none_or(|((c, d), _)| c != world.0 || d != world.1)
+ {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod post"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry { binding: 0, resource: self.buffer.as_entire_binding() },
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(world.0) },
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(world.1) },
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler) },
+ ],
+ });
+ self.group = Some(((world.0.clone(), world.1.clone()), group));
+ }
+ let Some((_, group)) = &self.group else { return };
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod post"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: screen,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.pipeline);
+ pass.set_bind_group(0, group, &[]);
+ pass.draw(0..3, 0..1);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn a_shader_compiles_and_errors_name_its_line() {
+ let ok = "fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> { return vec4<f32>(scene(uv), 1.0); }";
+ assert!(super::compile(ok).is_ok());
+ let bad = "fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {\n return nope;\n}";
+ let e = super::compile(bad).unwrap_err();
+ assert!(e.starts_with("line 2"), "{e}");
+ }
+}
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 89d1906a9..1b167c7f7 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -487,6 +487,19 @@ interface render {
/// the highest block that blocks motion or holds a fluid, as the motion
/// blocking heightmap; -64 where the chunk isn't loaded.
ground: func() -> list<tuple<s32, s32, s32>>;
+ /// A full-screen post pass (AirDef fork): WGSL defining
+ /// `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, after a prelude
+ /// with `frame` (camera matrices, time, `params`), the world's colour and
+ /// depth and helpers (`scene`, `depth-at`, `view-dir`, `scene-distance`,
+ /// `project`). It runs after the world, before the hand and the HUD.
+ /// Compile errors come back in the chat and the log; the last good pass
+ /// keeps running.
+ set-post: func(wgsl: string);
+ clear-post: func();
+ /// The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until
+ /// replaced; call it once a tick (the pass gets the partial tick in
+ /// `frame.time.y`).
+ set-params: func(params: list<f32>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index b64792eaa..68487e600 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -13,7 +13,7 @@ pub const LEVEL: u32 = 8;
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 51] = [
+pub const FUNCTIONS: [(&str, u32); 54] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -66,6 +66,9 @@ pub const FUNCTIONS: [(&str, u32); 51] = [
("render.play-sound", 8),
("render.query-ground", 8),
("render.ground", 8),
+ ("render.set-post", 8),
+ ("render.clear-post", 8),
+ ("render.set-params", 8),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 317cd4f06..55c1fd13f 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -432,6 +432,9 @@ struct State {
ground_queries: Vec<(i32, i32)>,
/// The game's answers to them.
ground: Vec<(i32, i32, i32)>,
+ /// `render.set-post`'s source, with a number that changes with it.
+ post: Option<(u64, String)>,
+ post_params: Vec<f32>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -638,6 +641,8 @@ impl Mods {
models: Vec::new(),
ground_queries: Vec::new(),
ground: Vec::new(),
+ post: None,
+ post_params: Vec::new(),
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1212,6 +1217,14 @@ impl Mods {
})
.collect()
}
+ /// The first running mod's post pass (version, WGSL) and its parameters.
+ pub fn post(&self) -> Option<(u64, &str, &[f32])> {
+ self.entries.iter().find_map(|e| {
+ let d = e.run.as_ref()?.store.data();
+ let (v, src) = d.post.as_ref()?;
+ Some((*v, src.as_str(), d.post_params.as_slice()))
+ })
+ }
/// Mod `i`'s answers to its ground queries, for its next calls.
pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) {
if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) {
@@ -1941,6 +1954,21 @@ impl render::Host for State {
self.out.push(Output::Particles { particles });
}
}
+ fn set_post(&mut self, wgsl: String) {
+ static VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
+ let v = VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ self.post = Some((v, clip_to(wgsl, 256 * 1024)));
+ }
+ fn clear_post(&mut self) {
+ self.post = None;
+ }
+ fn set_params(&mut self, params: Vec<f32>) {
+ self.post_params = params
+ .into_iter()
+ .take(1024)
+ .map(|v| if v.is_finite() { v } else { 0.0 })
+ .collect();
+ }
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
let room = 512usize.saturating_sub(self.ground_queries.len());
self.ground_queries.extend(columns.into_iter().take(room));
--
2.54.0 (Apple Git-157)
@@ -1,48 +0,0 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:09:30 +0300
Subject: [PATCH 04/42] Mod models: draw far models as scaled impostors past
the far plane, cull at 4096
---
crates/horizon-client/src/game/mod_models.rs | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index be00ea4c7..6cd55afc6 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -120,14 +120,26 @@ impl Game<'_> {
return;
}
let t = alpha.clamp(0.0, 1.0);
+ // Missiles are seen from far off: past the far plane a model is drawn nearer and
+ // smaller, the same size on screen.
+ let depth_far = f64::from(horizon_client::camera::depth_far(
+ self.terrain_distance(),
+ self.settings.cloud_range,
+ ));
+ let reach = depth_far * 0.85;
for p in self.mods.models.poses.values() {
- let at = p.previous.lerp(p.position, t);
- // Far models are culled by distance; the view frustum does the rest.
- if at.distance_squared(camera) > 512.0 * 512.0 {
+ let mut at = p.previous.lerp(p.position, t);
+ let d = at.distance(camera);
+ if d > 4096.0 {
continue;
}
+ let mut shrink = 1.0f32;
+ if d > reach {
+ shrink = (reach / d) as f32;
+ at = camera + (at - camera) * (reach / d);
+ }
let rotation = p.old_rotation.slerp(p.rotation, t as f32);
- let scale = p.old_scale + (p.scale - p.old_scale) * t as f32;
+ let scale = (p.old_scale + (p.scale - p.old_scale) * t as f32) * shrink;
if scale <= 0.0 {
continue;
}
--
2.54.0 (Apple Git-157)
@@ -1,927 +0,0 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:31:00 +0300
Subject: [PATCH 05/42] Mod cameras: render.set-views draws the world from
other points into pictures that HUD view shapes show (API level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 3 +
crates/horizon-client/src/game/mod_models.rs | 39 ++-
crates/horizon-client/src/game/mod_render.rs | 9 +
crates/horizon-client/src/gfx.rs | 192 ++++++++++-
crates/horizon-client/src/gfx/mod_views.rs | 318 +++++++++++++++++++
crates/horizon-client/src/hud.rs | 15 +
crates/horizon-mod-sdk/wit/horizon.wit | 30 ++
crates/horizon-mods/src/api_level.rs | 5 +-
crates/horizon-mods/src/runtime.rs | 30 +-
9 files changed, 624 insertions(+), 17 deletions(-)
create mode 100644 crates/horizon-client/src/gfx/mod_views.rs
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index ca6a8c266..e5ffb30c4 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11806,7 +11806,9 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
+ let mod_views = self.mod_views(alpha);
let frame = Frame {
+ mod_views: &mod_views,
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
@@ -14725,6 +14727,7 @@ fn loading_renderer(
/// A title-shaped frame: black clear and panorama, only the HUD is drawn.
fn loading_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
+ mod_views: &[],
weather: &[],
weather_time: horizon_client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 6cd55afc6..7ae58f664 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -11,7 +11,7 @@ use {
particles::{ParticleOptions, ParticleType},
},
horizon_core::random::RandomSource,
- horizon_mods::ParticleSpawn,
+ horizon_mods::{CameraView, ParticleSpawn},
horizon_world::registry::BlockStateId,
std::collections::HashMap,
};
@@ -38,6 +38,8 @@ pub(super) struct ModModels {
states: HashMap<String, Option<BlockStateId>>,
/// The version of the mod post pass the renderer has (0: none).
post: u64,
+ /// The mods' cameras by id: a tick ago and now.
+ views: Vec<(CameraView, CameraView)>,
}
/// Full brightness: sky and block light 15, as the entity shader packs them.
@@ -55,6 +57,9 @@ impl Game<'_> {
) {
}
pub(super) fn apply_mod_post(&mut self, _partial: f32) {}
+ pub(super) fn mod_views(&self, _alpha: f64) -> Vec<horizon_client::gfx::ModView> {
+ Vec::new()
+ }
}
#[cfg(feature = "mods")]
@@ -111,6 +116,38 @@ impl Game<'_> {
let seen: std::collections::HashSet<_> = seen.into_iter().collect();
models.poses.retain(|k, _| seen.contains(k));
}
+ let views: Vec<CameraView> = self.mods.mods.views().map(|(_, v)| *v).collect();
+ let old = std::mem::take(&mut self.mods.models.views);
+ self.mods.models.views = views
+ .into_iter()
+ .map(|v| {
+ let before = old.iter().find(|(_, o)| o.id == v.id).map_or(v, |(_, o)| *o);
+ (before, v)
+ })
+ .collect();
+ }
+
+ /// The mods' cameras between their last two settings, for this frame.
+ pub(super) fn mod_views(&self, alpha: f64) -> Vec<horizon_client::gfx::ModView> {
+ let t = alpha.clamp(0.0, 1.0);
+ let lerp = |a: f32, b: f32| a + (b - a) * t as f32;
+ self.mods
+ .models
+ .views
+ .iter()
+ .map(|(a, b)| {
+ // The short way round.
+ let turn = (b.yaw - a.yaw + 540.0).rem_euclid(360.0) - 180.0;
+ horizon_client::gfx::ModView {
+ id: b.id,
+ camera: DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t),
+ yaw: a.yaw + turn * t as f32,
+ pitch: lerp(a.pitch, b.pitch),
+ fov: lerp(a.fov, b.fov),
+ size: [b.width, b.height],
+ }
+ })
+ .collect()
}
/// Draws every mod model between its last two poses, lit by the world
diff --git a/crates/horizon-client/src/game/mod_render.rs b/crates/horizon-client/src/game/mod_render.rs
index 931aec641..85aae67e7 100644
--- a/crates/horizon-client/src/game/mod_render.rs
+++ b/crates/horizon-client/src/game/mod_render.rs
@@ -120,6 +120,15 @@ impl Game<'_> {
self.draw_item_model(batch, &stack, x, y);
batch.scale_since(start, x, y, i.scale, i.scale);
}
+ Shape::View(v) => {
+ let r = v.rect;
+ batch.views.push(horizon_client::hud::HudView {
+ at: batch.vertices.len() as u32,
+ id: v.view,
+ rect: [ox + r.x, oy + r.y, r.w, r.h],
+ mode: v.mode,
+ });
+ }
Shape::Text(t) => {
let text = Text::literal(t.text.clone());
let centre = if t.centered {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 4c5005615..3ad257728 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -78,6 +78,8 @@ mod native_material;
mod native_pack;
mod native_post;
mod mod_post;
+mod mod_views;
+pub use mod_views::ModView;
mod native_sky;
mod occlusion;
mod pack_atlas;
@@ -214,6 +216,8 @@ const SECTION_UPLOAD_CHUNK: u64 = 4 << 20;
const SECTION_COPY_LIMIT: u64 = 64 << 20;
pub struct Frame<'a> {
pub camera: DVec3,
+ /// Mods' cameras (AirDef fork), drawn into the pictures the HUD shows.
+ pub mod_views: &'a [ModView],
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
@@ -546,6 +550,7 @@ pub struct Renderer {
native: Option<native_pack::NativePack>,
/// AirDef fork: a mod's post pass, its parameters and the partial tick they are for.
mod_post: Option<mod_post::ModPost>,
+ mod_views: Option<mod_views::Views>,
mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
@@ -2651,6 +2656,7 @@ impl Renderer {
pack_post: None,
native: None,
mod_post: None,
+ mod_views: None,
mod_params: (Vec::new(), 0.0),
pass_timing: pack_timing::PassTiming::logging(),
pack_entity_rest: Vec::new(),
@@ -3019,6 +3025,128 @@ impl Renderer {
self.section_uploads.finish(&encoder);
Some(encoder.finish())
}
+ /// The HUD's vertices in `range`, with the mod cameras' pictures between
+ /// them where their shapes were.
+ fn draw_hud(
+ &self,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ batch: &crate::hud::HudBatch,
+ range: std::ops::Range<u32>,
+ ) {
+ let size = [self.config.width, self.config.height];
+ let last = batch.vertices.len() as u32;
+ let mut at = range.start;
+ if let Some(views) = &self.mod_views {
+ for v in batch.views.iter().filter(|v| {
+ v.at >= range.start && (v.at < range.end || (v.at == range.end && range.end == last))
+ }) {
+ self.hud.draw_range(encoder, target, &self.depth, batch, size, at..v.at);
+ views.blit(encoder, target, size, v, batch.scale);
+ at = v.at;
+ }
+ }
+ self.hud.draw_range(encoder, target, &self.depth, batch, size, at..range.end);
+ }
+ /// The mod cameras due this frame, each into its picture: sky, solid
+ /// terrain and entities, through the world's own pipelines with the
+ /// camera's globals in the world's buffer (the player's put back after).
+ fn draw_mod_views(
+ &mut self,
+ encoder: &mut wgpu::CommandEncoder,
+ frame: &Frame,
+ globals: &Globals,
+ due: &[mod_views::Due],
+ entity_quads: u32,
+ ) {
+ let Some(views) = &mut self.mod_views else {
+ return;
+ };
+ let size = std::mem::size_of::<Globals>() as u64;
+ self.uploads
+ .buffer(encoder, &views.main, 0, bytemuck::bytes_of(globals));
+ for d in due {
+ let Some(t) = views.targets.get(&d.id) else {
+ continue;
+ };
+ let own = Globals {
+ view_proj: d.view_proj.to_cols_array_2d(),
+ inverse_view: d.view.inverse().to_cols_array_2d(),
+ misc: [
+ globals.misc[0],
+ globals.misc[1],
+ d.aspect,
+ (d.fov.to_radians() / 2.0).tan(),
+ ],
+ ..*globals
+ };
+ self.uploads
+ .buffer(encoder, &t.globals, 0, bytemuck::bytes_of(&own));
+ }
+ let fog = globals.fog_color;
+ let clear = wgpu::Color {
+ r: f64::from(fog[0]),
+ g: f64::from(fog[1]),
+ b: f64::from(fog[2]),
+ a: 1.0,
+ };
+ for d in due {
+ let Some(t) = views.targets.get_mut(&d.id) else {
+ continue;
+ };
+ t.drawn = true;
+ encoder.copy_buffer_to_buffer(&t.globals, 0, &self.globals, 0, size);
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod view"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &t.color,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(clear),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: &t.depth,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(0.0),
+ store: wgpu::StoreOp::Discard,
+ }),
+ stencil_ops: None,
+ }),
+ ..Default::default()
+ });
+ pass.set_bind_group(0, &self.group, &[]);
+ if frame.skybox {
+ pass.set_pipeline(&self.sky);
+ pass.draw(0..3, 0..1);
+ }
+ pass.set_pipeline(&self.solid);
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
+ pass.set_vertex_buffer(1, self.instances.slice(..));
+ let mut page = None;
+ for (k, v) in d.visible.iter().enumerate() {
+ let s = self.sections.get(v.slot);
+ if s.solid == 0 {
+ continue;
+ }
+ if page != Some(s.page) {
+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..));
+ page = Some(s.page);
+ }
+ let instance = d.first + k as u32;
+ pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
+ }
+ if entity_quads > 0 {
+ pass.set_pipeline(&self.entity_pipeline);
+ pass.set_bind_group(0, &self.entity_group, &[]);
+ pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
+ pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
+ }
+ }
+ encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size);
+ }
/// Draws `count` section draws from `first` with the arena and section
/// origins bound: one indirect multi-draw, or a loop without it.
/// Consecutive draws in one arena page share a vertex buffer binding,
@@ -3827,7 +3955,46 @@ impl Renderer {
});
}
self.drawn_sections = visible.len() + visible_lods.len();
- let instances = self.drawn_sections + shadow_visible.len();
+ // Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's.
+ let globals_size = std::mem::size_of::<Globals>() as u64;
+ let mut views_due = Vec::new();
+ if !frame.title && !frame.mod_views.is_empty() {
+ let format = self.config.format.remove_srgb_suffix();
+ let device = &self.device;
+ let views = self
+ .mod_views
+ .get_or_insert_with(|| mod_views::Views::new(device, format, globals_size));
+ let mut first = (self.drawn_sections + shadow_visible.len()) as u32;
+ let radius = ((frame.detail_distance / 16.0) as i32).min(mod_views::RADIUS);
+ for v in views.choose(device, frame.mod_views, globals_size) {
+ let (view_proj, view, aspect) = mod_views::matrices(v, camera, frame.depth_far);
+ let own = world_projection(v.fov, aspect, frame.depth_far) * view;
+ let planes = Frustum { planes: frustum(own) };
+ let mut seen = Vec::new();
+ self.sections
+ .visible(v.camera, radius, &planes, |_| true, |_, _| false, &mut seen);
+ // Relative to the player's camera, as the rest of the frame.
+ let shift = (v.camera - camera).as_vec3();
+ for s in &mut seen {
+ s.origin = [s.origin[0] + shift.x, s.origin[1] + shift.y, s.origin[2] + shift.z];
+ }
+ let n = seen.len() as u32;
+ views_due.push(mod_views::Due {
+ id: v.id,
+ view_proj,
+ view,
+ aspect,
+ fov: v.fov,
+ visible: seen,
+ first,
+ });
+ first += n;
+ }
+ } else if frame.mod_views.is_empty() {
+ self.mod_views = None;
+ }
+ let view_sections: usize = views_due.iter().map(|d| d.visible.len()).sum();
+ let instances = self.drawn_sections + shadow_visible.len() + view_sections;
if instances as u64 > self.instance_capacity {
self.instance_capacity = (instances as u64).next_power_of_two();
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -3845,7 +4012,12 @@ impl Renderer {
.iter()
.map(|v| section_instance(v.origin))
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
- .chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
+ .chain(shadow_visible.iter().map(|v| section_instance(v.origin)))
+ .chain(
+ views_due
+ .iter()
+ .flat_map(|d| d.visible.iter().map(|v| section_instance(v.origin))),
+ ),
);
if !self.origins.is_empty() {
self.uploads.buffer(
@@ -4461,6 +4633,9 @@ impl Renderer {
bytemuck::cast_slice(&self.draws),
);
}
+ if !views_due.is_empty() {
+ self.draw_mod_views(&mut encoder, frame, &globals, &views_due, entity_quads);
+ }
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.config.format.remove_srgb_suffix()),
..Default::default()
@@ -5167,14 +5342,7 @@ impl Renderer {
.menu_blur
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
- self.hud.draw_range(
- &mut encoder,
- &view_target,
- &self.depth,
- frame.hud,
- [self.config.width, self.config.height],
- 0..blur_at,
- );
+ self.draw_hud(&mut encoder, &view_target, frame.hud, 0..blur_at);
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
@@ -5190,12 +5358,10 @@ impl Renderer {
radius,
);
}
- self.hud.draw_range(
+ self.draw_hud(
&mut encoder,
&view_target,
- &self.depth,
frame.hud,
- [self.config.width, self.config.height],
blur_at..frame.hud.vertices.len() as u32,
);
if culling && let Some(cull) = &mut self.cull {
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
new file mode 100644
index 000000000..91ae103a8
--- /dev/null
+++ b/crates/horizon-client/src/gfx/mod_views.rs
@@ -0,0 +1,318 @@
+//! AirDef fork: mods' cameras (`render.set-views`). The world is drawn from
+//! another point into a small picture (sky, solid terrain, entities), which
+//! the HUD's `view` shapes show. The picture's passes reuse the world's
+//! pipelines and bind groups: its globals are copied into the world's
+//! buffer before them and the camera's copied back after.
+use glam::{DVec3, Mat4};
+use std::collections::HashMap;
+
+/// One camera of a mod, as the game interpolated it for this frame.
+#[derive(Clone, Copy, Debug)]
+pub struct ModView {
+ pub id: u32,
+ pub camera: DVec3,
+ /// Degrees, as the player's.
+ pub yaw: f32,
+ pub pitch: f32,
+ pub fov: f32,
+ pub size: [u32; 2],
+}
+
+pub use crate::hud::HudView;
+
+/// The most cameras drawn a frame; the others wait their turn.
+pub(super) const PER_FRAME: usize = 3;
+/// Sections round a camera it draws, in chunks.
+pub(super) const RADIUS: i32 = 12;
+
+pub(super) struct Target {
+ size: [u32; 2],
+ pub color: wgpu::TextureView,
+ pub depth: wgpu::TextureView,
+ pub globals: wgpu::Buffer,
+ blit: wgpu::BindGroup,
+ /// Drawn at least once: the HUD shows nothing before.
+ pub drawn: bool,
+}
+
+/// A camera drawn this frame.
+pub(super) struct Due {
+ pub id: u32,
+ /// Camera-relative to the player's camera, as the entities and the
+ /// world's other geometry are.
+ pub view_proj: Mat4,
+ pub view: Mat4,
+ pub aspect: f32,
+ pub fov: f32,
+ pub visible: Vec<super::resident::Visible>,
+ /// The instance of `visible[0]` in the section origins.
+ pub first: u32,
+}
+
+pub(super) struct Views {
+ pub targets: HashMap<u32, Target>,
+ /// The player's camera's globals, copied back after the pictures.
+ pub main: wgpu::Buffer,
+ cursor: usize,
+ format: wgpu::TextureFormat,
+ layout: wgpu::BindGroupLayout,
+ sampler: wgpu::Sampler,
+ pipeline: wgpu::RenderPipeline,
+}
+
+const BLIT: &str = r#"
+@group(0) @binding(0) var picture: texture_2d<f32>;
+@group(0) @binding(1) var picture_sampler: sampler;
+struct Out {
+ @builtin(position) position: vec4<f32>,
+ @location(0) uv: vec2<f32>,
+ @location(1) @interpolate(flat) mode: u32,
+}
+@vertex
+fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mode: u32) -> Out {
+ let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
+ var out: Out;
+ out.position = vec4<f32>(uv * vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0), 0.0, 1.0);
+ out.uv = uv;
+ out.mode = mode;
+ return out;
+}
+fn hash(p: vec2<f32>) -> f32 {
+ return fract(sin(dot(p, vec2<f32>(12.9898, 78.233))) * 43758.547);
+}
+@fragment
+fn fs(in: Out) -> @location(0) vec4<f32> {
+ let c = textureSample(picture, picture_sampler, in.uv).rgb;
+ let l = dot(c, vec3<f32>(0.299, 0.587, 0.114));
+ let grain = (hash(in.position.xy + vec2<f32>(c.r * 91.0, c.g * 37.0)) - 0.5) * 0.06;
+ if in.mode == 1u {
+ // White hot: the cold sky dark, the land grey, flames and engines burning white.
+ let warm = max(c.r - c.b, 0.0) * 2.2;
+ let cold = max(c.b - c.r, 0.0) * 2.5;
+ let glow = smoothstep(0.8, 1.0, max(c.r, c.g));
+ // High gain: a thermal camera sees as well by night.
+ let v = clamp(0.06 + pow(l, 0.4) * 0.55 + warm + glow * 0.9 - cold + grain, 0.0, 1.0);
+ return vec4<f32>(v, v * 0.98, v * 0.94, 1.0);
+ }
+ if in.mode == 2u {
+ let v = clamp(pow(l, 0.45) * 1.5 + grain, 0.0, 1.0);
+ return vec4<f32>(v * 0.3, v, v * 0.35, 1.0);
+ }
+ return vec4<f32>(c, 1.0);
+}
+"#;
+
+impl Views {
+ pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, globals_size: u64) -> Self {
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("Mod view picture"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ ],
+ });
+ let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("Mod view blit"),
+ source: wgpu::ShaderSource::Wgsl(BLIT.into()),
+ });
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("Mod view blit"),
+ bind_group_layouts: &[Some(&layout)],
+ ..Default::default()
+ });
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("Mod view blit"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &module,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &module,
+ entry_point: Some("fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ });
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("Mod view picture"),
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+ let main = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Camera globals"),
+ size: globals_size,
+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ Self {
+ targets: HashMap::new(),
+ main,
+ cursor: 0,
+ format,
+ layout,
+ sampler,
+ pipeline,
+ }
+ }
+
+ /// The cameras to draw this frame, in turn, at most `PER_FRAME`; their
+ /// targets made or remade to size, the gone ones' dropped.
+ pub fn choose<'a>(
+ &mut self,
+ device: &wgpu::Device,
+ views: &'a [ModView],
+ globals_size: u64,
+ ) -> Vec<&'a ModView> {
+ self.targets.retain(|id, _| views.iter().any(|v| v.id == *id));
+ for v in views {
+ if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) {
+ let t = self.target(device, v.size, globals_size);
+ self.targets.insert(v.id, t);
+ }
+ }
+ if views.is_empty() {
+ return Vec::new();
+ }
+ let n = views.len().min(PER_FRAME);
+ let start = self.cursor % views.len();
+ self.cursor = self.cursor.wrapping_add(n);
+ (0..n).map(|k| &views[(start + k) % views.len()]).collect()
+ }
+
+ fn target(&self, device: &wgpu::Device, size: [u32; 2], globals_size: u64) -> Target {
+ let extent = wgpu::Extent3d {
+ width: size[0],
+ height: size[1],
+ depth_or_array_layers: 1,
+ };
+ let texture = |label, format, usage| {
+ device
+ .create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format,
+ usage,
+ view_formats: &[],
+ })
+ .create_view(&Default::default())
+ };
+ let color = texture(
+ "Mod view colour",
+ self.format,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+ );
+ let depth = texture(
+ "Mod view depth",
+ wgpu::TextureFormat::Depth32Float,
+ wgpu::TextureUsages::RENDER_ATTACHMENT,
+ );
+ let blit = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod view picture"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(&color),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ });
+ let globals = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Mod view globals"),
+ size: globals_size,
+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ Target {
+ size,
+ color,
+ depth,
+ globals,
+ blit,
+ drawn: false,
+ }
+ }
+
+ /// Shows a camera's picture in `rect` (framebuffer pixels) of `screen`.
+ pub fn blit(
+ &self,
+ encoder: &mut wgpu::CommandEncoder,
+ screen: &wgpu::TextureView,
+ screen_size: [u32; 2],
+ view: &HudView,
+ scale: f32,
+ ) {
+ let Some(t) = self.targets.get(&view.id).filter(|t| t.drawn) else {
+ return;
+ };
+ let [x, y, w, h] = view.rect.map(|v| v * scale);
+ let (x0, y0) = (x.max(0.0), y.max(0.0));
+ let x1 = (x + w).min(screen_size[0] as f32);
+ let y1 = (y + h).min(screen_size[1] as f32);
+ if x1 - x0 < 1.0 || y1 - y0 < 1.0 {
+ return;
+ }
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod view picture"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: screen,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_viewport(x, y, w, h, 0.0, 1.0);
+ pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
+ pass.set_pipeline(&self.pipeline);
+ pass.set_bind_group(0, &t.blit, &[]);
+ let mode = view.mode.min(2);
+ pass.draw(0..3, mode..mode + 1);
+ }
+}
+
+/// A camera's view and projection: the view from `camera`, moved so that
+/// geometry relative to `eye` (the player's camera) lands right.
+pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) {
+ let aspect = v.size[0] as f32 / v.size[1].max(1) as f32;
+ let view = crate::camera::view_matrix(v.yaw, v.pitch);
+ let projection = super::world_projection(v.fov, aspect, far);
+ let offset = (eye - v.camera).as_vec3();
+ (projection * view * Mat4::from_translation(offset), view, aspect)
+}
diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs
index fda0094d8..41af0650e 100644
--- a/crates/horizon-client/src/hud.rs
+++ b/crates/horizon-client/src/hud.rs
@@ -625,6 +625,17 @@ pub struct HudVertex {
}
/// The GUI Scale option (0 = auto).
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+/// Where the HUD shows a mod camera's picture (AirDef fork): before its
+/// vertex `at`, over what was drawn before.
+#[derive(Clone, Copy, Debug)]
+pub struct HudView {
+ pub at: u32,
+ pub id: u32,
+ /// GUI pixels: x, y, width, height.
+ pub rect: [f32; 4],
+ /// 0 as seen, 1 thermal, 2 night vision.
+ pub mode: u32,
+}
/// One frame of overlay geometry in GUI pixels.
pub struct HudBatch {
pub vertices: Vec<HudVertex>,
@@ -641,6 +652,8 @@ pub struct HudBatch {
pub glint_alpha: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
+ /// Mod cameras' pictures, in vertex order.
+ pub views: Vec<HudView>,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -692,6 +705,7 @@ impl HudBatch {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
+ views: Vec::new(),
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -2008,6 +2022,7 @@ mod tests {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
+ views: Vec::new(),
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 1b167c7f7..436455a19 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -338,12 +338,21 @@ interface render {
centered: bool,
shadow: bool,
}
+ /// A picture of one of the mod's `set-views` cameras (AirDef fork).
+ record view-shape {
+ /// The camera's id; an unknown one draws nothing.
+ view: u32,
+ rect: rect,
+ /// 0 as seen, 1 thermal (white hot), 2 night vision (green).
+ mode: u32,
+ }
variant shape {
fill(fill-shape),
quad(quad-shape),
sprite(sprite-shape),
item(item-shape),
text(text-shape),
+ view(view-shape),
}
/// Replaces the mod's HUD shapes: up to 1024, drawn every frame in
/// order over the HUD (after the hotbar, under chat and screens), hidden
@@ -500,6 +509,27 @@ interface render {
/// replaced; call it once a tick (the pass gets the partial tick in
/// `frame.time.y`).
set-params: func(params: list<f32>);
+ /// A camera the game renders the world from besides the player's eye
+ /// (AirDef fork): the land, the sky and the entities near the player.
+ record camera-view {
+ /// Stays the same while the camera lives: the game turns it smoothly
+ /// between the last two `set-views`.
+ id: u32,
+ x: f64,
+ y: f64,
+ z: f64,
+ /// Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down.
+ yaw: f32,
+ pitch: f32,
+ /// Vertical field of view, degrees (1 to 110).
+ fov: f32,
+ /// The picture's size in pixels (16 to 640 × 16 to 480).
+ width: u32,
+ height: u32,
+ }
+ /// Replaces the mod's cameras: up to 8. A `view` shape shows one; the
+ /// game renders up to three a frame, in turn.
+ set-views: func(views: list<camera-view>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 68487e600..9d15851b4 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 8;
+pub const LEVEL: u32 = 9;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 54] = [
+pub const FUNCTIONS: [(&str, u32); 55] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -69,6 +69,7 @@ pub const FUNCTIONS: [(&str, u32); 54] = [
("render.set-post", 8),
("render.clear-post", 8),
("render.set-params", 8),
+ ("render.set-views", 9),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 55c1fd13f..01000dfeb 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -81,7 +81,7 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I
pub use render_bindings::horizon::mods::render::{
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
- Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom,
+ CameraView, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -435,6 +435,8 @@ struct State {
/// `render.set-post`'s source, with a number that changes with it.
post: Option<(u64, String)>,
post_params: Vec<f32>,
+ /// `render.set-views`' cameras (AirDef fork).
+ views: Vec<CameraView>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -643,6 +645,7 @@ impl Mods {
ground: Vec::new(),
post: None,
post_params: Vec::new(),
+ views: Vec::new(),
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1206,6 +1209,14 @@ impl Mods {
.flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m)))
})
}
+ /// Every running mod's cameras (AirDef fork), with the mod's index.
+ pub fn views(&self) -> impl Iterator<Item = (usize, &CameraView)> {
+ self.entries.iter().enumerate().flat_map(|(i, e)| {
+ e.run
+ .iter()
+ .flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v)))
+ })
+ }
/// The columns each running mod asked the ground height of, taken.
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
self.entries
@@ -1969,6 +1980,23 @@ impl render::Host for State {
.map(|v| if v.is_finite() { v } else { 0.0 })
.collect();
}
+ fn set_views(&mut self, views: Vec<CameraView>) {
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
+ let angle = |v: f32| if v.is_finite() { v } else { 0.0 };
+ self.views = views
+ .into_iter()
+ .filter(|v| finite(v.x) && finite(v.y) && finite(v.z))
+ .take(8)
+ .map(|mut v| {
+ v.yaw = angle(v.yaw);
+ v.pitch = angle(v.pitch).clamp(-90.0, 90.0);
+ v.fov = if v.fov.is_finite() { v.fov.clamp(1.0, 110.0) } else { 70.0 };
+ v.width = v.width.clamp(16, 640);
+ v.height = v.height.clamp(16, 480);
+ v
+ })
+ .collect();
+ }
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
let room = 512usize.saturating_sub(self.ground_queries.len());
self.ground_queries.extend(columns.into_iter().take(room));
--
2.54.0 (Apple Git-157)
@@ -1,30 +0,0 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:08:21 +0300
Subject: [PATCH 06/42] Mods: more fuel per tick and per event (AirDef plans
raids)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-mods/src/runtime.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 01000dfeb..a551b7376 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -108,9 +108,9 @@ const MEMORY: usize = 64 << 20;
/// Fuel (about one unit per WebAssembly instruction) per call: a bound on
/// runaway loops, far above the hooks' time budgets.
const FUEL_INIT: u64 = 2_000_000_000;
-const FUEL_TICK: u64 = 50_000_000;
+const FUEL_TICK: u64 = 300_000_000;
const FUEL_FRAME: u64 = 10_000_000;
-const FUEL_EVENT: u64 = 100_000_000;
+const FUEL_EVENT: u64 = 1_000_000_000;
/// Blocks a mod may set per `world.set-blocks` call.
pub const BLOCKS_PER_CALL: usize = 4096;
/// Chat lines a mod may show per call, HUD lines and their length.
--
2.54.0 (Apple Git-157)
@@ -1,42 +0,0 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:25:29 +0300
Subject: [PATCH 07/42] Mod cameras up to 1920x1080 (full-screen replays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-mod-sdk/wit/horizon.wit | 2 +-
crates/horizon-mods/src/runtime.rs | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 436455a19..e30f3a30e 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -523,7 +523,7 @@ interface render {
pitch: f32,
/// Vertical field of view, degrees (1 to 110).
fov: f32,
- /// The picture's size in pixels (16 to 640 × 16 to 480).
+ /// The picture's size in pixels (16 to 1920 × 16 to 1080).
width: u32,
height: u32,
}
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index a551b7376..1389407be 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -1991,8 +1991,8 @@ impl render::Host for State {
v.yaw = angle(v.yaw);
v.pitch = angle(v.pitch).clamp(-90.0, 90.0);
v.fov = if v.fov.is_finite() { v.fov.clamp(1.0, 110.0) } else { 70.0 };
- v.width = v.width.clamp(16, 640);
- v.height = v.height.clamp(16, 480);
+ v.width = v.width.clamp(16, 1920);
+ v.height = v.height.clamp(16, 1080);
v
})
.collect();
--
2.54.0 (Apple Git-157)
@@ -1,158 +0,0 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:15:11 +0300
Subject: [PATCH 08/42] Mod cameras: distant terrain tiles, fog measured from
the camera, no impostors while one is shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game/mod_models.rs | 4 +-
crates/horizon-client/src/gfx.rs | 53 +++++++++++++++++---
crates/horizon-client/src/gfx/mod_views.rs | 4 ++
3 files changed, 52 insertions(+), 9 deletions(-)
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 7ae58f664..87d46c843 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -164,6 +164,8 @@ impl Game<'_> {
self.settings.cloud_range,
));
let reach = depth_far * 0.85;
+ // A mod camera (a replay's, a system's) must see them where they are: no impostors then.
+ let impostors = self.mods.models.views.is_empty();
for p in self.mods.models.poses.values() {
let mut at = p.previous.lerp(p.position, t);
let d = at.distance(camera);
@@ -171,7 +173,7 @@ impl Game<'_> {
continue;
}
let mut shrink = 1.0f32;
- if d > reach {
+ if d > reach && impostors {
shrink = (reach / d) as f32;
at = camera + (at - camera) * (reach / d);
}
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 3ad257728..9772e3a83 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -3078,6 +3078,9 @@ impl Renderer {
d.aspect,
(d.fov.to_radians() / 2.0).tan(),
],
+ // Fog goes by the distance from the player's camera: pushed out by how far this
+ // camera is from it, so what is near this one stays clear.
+ fog: globals.fog.map(|f| f + d.away),
..*globals
};
self.uploads
@@ -3138,6 +3141,19 @@ impl Renderer {
let instance = d.first + k as u32;
pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
}
+ for (k, v) in d.lods.iter().enumerate() {
+ let Some(tile) = self.lods.get(&v.key) else { continue };
+ let s = &tile.mesh;
+ if s.solid == 0 {
+ continue;
+ }
+ if page != Some(s.page) {
+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..));
+ page = Some(s.page);
+ }
+ let instance = d.first + (d.visible.len() + k) as u32;
+ pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
+ }
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -3971,14 +3987,30 @@ impl Renderer {
let own = world_projection(v.fov, aspect, frame.depth_far) * view;
let planes = Frustum { planes: frustum(own) };
let mut seen = Vec::new();
- self.sections
- .visible(v.camera, radius, &planes, |_| true, |_, _| false, &mut seen);
+ // Where distant terrain covers a column, its tile draws instead, as in the world pass.
+ self.sections.visible(
+ v.camera,
+ radius,
+ &planes,
+ |_| true,
+ |x, z| !cutout.is_empty() && cutout.contains(&(x, z)),
+ &mut seen,
+ );
+ let mut tiles: Vec<lod::Visible> = self
+ .lods
+ .iter()
+ .filter_map(|(&key, tile)| tile.visible(key, v.camera, frame.distance, &planes))
+ .collect();
// Relative to the player's camera, as the rest of the frame.
let shift = (v.camera - camera).as_vec3();
+ let moved = |o: [f32; 3]| [o[0] + shift.x, o[1] + shift.y, o[2] + shift.z];
for s in &mut seen {
- s.origin = [s.origin[0] + shift.x, s.origin[1] + shift.y, s.origin[2] + shift.z];
+ s.origin = moved(s.origin);
+ }
+ for t in &mut tiles {
+ t.origin = moved(t.origin);
}
- let n = seen.len() as u32;
+ let n = (seen.len() + tiles.len()) as u32;
views_due.push(mod_views::Due {
id: v.id,
view_proj,
@@ -3986,14 +4018,16 @@ impl Renderer {
aspect,
fov: v.fov,
visible: seen,
+ lods: tiles,
first,
+ away: shift.length(),
});
first += n;
}
} else if frame.mod_views.is_empty() {
self.mod_views = None;
}
- let view_sections: usize = views_due.iter().map(|d| d.visible.len()).sum();
+ let view_sections: usize = views_due.iter().map(|d| d.visible.len() + d.lods.len()).sum();
let instances = self.drawn_sections + shadow_visible.len() + view_sections;
if instances as u64 > self.instance_capacity {
self.instance_capacity = (instances as u64).next_power_of_two();
@@ -4014,9 +4048,12 @@ impl Renderer {
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
.chain(shadow_visible.iter().map(|v| section_instance(v.origin)))
.chain(
- views_due
- .iter()
- .flat_map(|d| d.visible.iter().map(|v| section_instance(v.origin))),
+ views_due.iter().flat_map(|d| {
+ d.visible
+ .iter()
+ .map(|v| section_instance(v.origin))
+ .chain(d.lods.iter().map(|v| lod::instance(v.origin)))
+ }),
),
);
if !self.origins.is_empty() {
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
index 91ae103a8..c0d886afd 100644
--- a/crates/horizon-client/src/gfx/mod_views.rs
+++ b/crates/horizon-client/src/gfx/mod_views.rs
@@ -45,8 +45,12 @@ pub(super) struct Due {
pub aspect: f32,
pub fov: f32,
pub visible: Vec<super::resident::Visible>,
+ /// Distant terrain tiles in its view, after the sections.
+ pub lods: Vec<super::lod::Visible>,
/// The instance of `visible[0]` in the section origins.
pub first: u32,
+ /// How far it is from the player's camera: fog is measured from there.
+ pub away: f32,
}
pub(super) struct Views {
--
2.54.0 (Apple Git-157)
@@ -1,126 +0,0 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:25:45 +0300
Subject: [PATCH 09/42] Mods: game.world-id, so mods keep state per world (API
level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 1 +
crates/horizon-client/src/game/mods.rs | 3 +++
crates/horizon-client/src/game/remote.rs | 4 ++++
crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++
crates/horizon-mods/src/api_level.rs | 3 ++-
crates/horizon-mods/src/runtime.rs | 5 +++++
6 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index e5ffb30c4..c6b642345 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -8918,6 +8918,7 @@ impl Game<'_> {
create: Option<&horizon_client::title::WorldOptions>,
) -> Result<()> {
world_loading::world_data_used();
+ self.mods.world_id = Some(format!("world:{folder}"));
if self.defer_world_entry(PendingWorldEntry::Local {
folder: folder.to_owned(),
name: name.map(str::to_owned),
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index 3eef96aa3..ebdd18a24 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -38,6 +38,8 @@ use {
#[derive(Default)]
pub(super) struct ModsState {
+ /// The open world, for `game.world-id` (AirDef fork): its folder, or the server's address.
+ pub(super) world_id: Option<String>,
#[cfg(feature = "mods")]
pub(super) mods: Mods,
/// Capability `input`: a claimed item's use while the button is down.
@@ -482,6 +484,7 @@ impl Game<'_> {
known_recipes,
placing: self.mod_recipe_screen(),
creative: self.creative,
+ world: self.player.is_some().then(|| self.mods.world_id.clone()).flatten(),
}
}
/// The first block along the player's view within
diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs
index 22b7c13df..89df0616b 100644
--- a/crates/horizon-client/src/game/remote.rs
+++ b/crates/horizon-client/src/game/remote.rs
@@ -342,6 +342,10 @@ impl Game<'_> {
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
self.perf.world_opening();
+ // A server's own world; joining the world this window hosts keeps its folder.
+ if local.is_none() {
+ self.mods.world_id = Some(format!("server:{address}"));
+ }
let root = self.options.resources.clone();
let builtin = Builtin::find(&root)
.and_then(|p| Builtin::load(&p).ok())
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index e30f3a30e..b6758d9fb 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -47,6 +47,10 @@ interface game {
get-player: func() -> option<player>;
/// None on the title screen.
get-clock: func() -> option<clock>;
+ /// Which world is open (AirDef fork): `world:<folder>` for a saved
+ /// world, `server:<address>` on a server; none on the title screen.
+ /// Mods keep their state apart by it.
+ world-id: func() -> option<string>;
/// A block along the player's view.
record hit {
/// The block hit.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 9d15851b4..cf2993186 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -13,7 +13,7 @@ pub const LEVEL: u32 = 9;
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 55] = [
+pub const FUNCTIONS: [(&str, u32); 56] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -70,6 +70,7 @@ pub const FUNCTIONS: [(&str, u32); 55] = [
("render.clear-post", 8),
("render.set-params", 8),
("render.set-views", 9),
+ ("game.world-id", 9),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 1389407be..abed5b31f 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -168,6 +168,8 @@ pub struct Snapshot {
pub placing: bool,
/// The player is in creative mode (`recipes.give`).
pub creative: bool,
+ /// The open world (`game.world-id`, AirDef fork).
+ pub world: Option<String>,
}
/// Capability `recipes`'s tables: `items` in the registry's order, which
@@ -1475,6 +1477,9 @@ impl game::Host for State {
fn get_clock(&mut self) -> Option<Clock> {
self.snapshot.clock
}
+ fn world_id(&mut self) -> Option<String> {
+ self.snapshot.world.clone()
+ }
fn raycast(&mut self, distance: f32) -> Option<Hit> {
let (at, hit) = self.snapshot.view_hit.as_ref()?;
// NaN compares false: no hit.
--
2.54.0 (Apple Git-157)
@@ -1,98 +0,0 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 12:01:08 +0300
Subject: [PATCH 10/42] cargo-horizon-mod: trial runs behind feature full, so a
packing-only tool skips wasmtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/cargo-horizon-mod/Cargo.toml | 8 +++++++-
crates/cargo-horizon-mod/src/main.rs | 16 ++++++++++++++--
2 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/crates/cargo-horizon-mod/Cargo.toml b/crates/cargo-horizon-mod/Cargo.toml
index 34eefe227..21b8a9221 100644
--- a/crates/cargo-horizon-mod/Cargo.toml
+++ b/crates/cargo-horizon-mod/Cargo.toml
@@ -6,8 +6,14 @@ license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
+[features]
+default = ["full"]
+# The trial runs (`check`, `test`) and the registry's CI need wasmtime. Without this feature the
+# tool is new, build and run only, and builds in a fraction of the time (CI that only packs mods).
+full = ["horizon-mods/runtime", "horizon-mods/index"]
+
[dependencies]
-horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
+horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
serde_json = "1.0.151"
[lints]
diff --git a/crates/cargo-horizon-mod/src/main.rs b/crates/cargo-horizon-mod/src/main.rs
index 95d0a9eb3..ff4ab663b 100644
--- a/crates/cargo-horizon-mod/src/main.rs
+++ b/crates/cargo-horizon-mod/src/main.rs
@@ -11,12 +11,15 @@
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
+#[cfg(feature = "full")]
+use horizon_mods::testing::{Script, TestGame};
use horizon_mods::{
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
- testing::{Script, TestGame},
};
+#[cfg(feature = "full")]
mod check;
+#[cfg(feature = "full")]
mod registry;
use std::{
@@ -39,9 +42,16 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
+ #[cfg(feature = "full")]
Some("test") => test(&args[1..]),
+ #[cfg(feature = "full")]
Some("check") => check(&args[1..]),
+ #[cfg(feature = "full")]
Some("registry") => registry::main(&args[1..]),
+ #[cfg(not(feature = "full"))]
+ Some("test" | "check" | "registry") => {
+ Err("this cargo-horizon-mod was built without trial runs (feature `full`)".into())
+ }
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
@@ -249,6 +259,7 @@ fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
/// `check <file>`: the registry's checks of a submission, printed; fails
/// when one does.
+#[cfg(feature = "full")]
fn check(args: &[String]) -> Result<(), String> {
let file = args
.first()
@@ -280,6 +291,7 @@ fn check(args: &[String]) -> Result<(), String> {
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
/// against the built mod, or the `.hmod` given with `--hmod`.
+#[cfg(feature = "full")]
fn test(args: &[String]) -> Result<(), String> {
let hmod = match flag(args, "--hmod") {
Some(p) => PathBuf::from(p),
@@ -360,7 +372,7 @@ fn run(args: &[String]) -> Result<(), String> {
.ok_or_else(|| format!("the game exited with {status}"))
}
-#[cfg(test)]
+#[cfg(all(test, feature = "full"))]
mod tests {
use super::*;
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,439 +0,0 @@
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:19:53 +0300
Subject: [PATCH 13/42] HD: bind groups cached until a resize or a new source,
shadow passes draw only the entities that cast shadows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 39 ++++-
crates/horizon-client/src/gfx/hd.rs | 247 +++++++++++++++++-----------
2 files changed, 188 insertions(+), 98 deletions(-)
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index e351763fd..f6bbe038c 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -459,6 +459,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
+ /// AirDef fork: the runs of entity quads (first, end) that cast shadows this frame.
+ shadow_casters: Vec<(u32, u32)>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
lightmap: wgpu::Texture,
@@ -2744,6 +2746,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
+ shadow_casters: Vec::new(),
group,
globals,
lightmap,
@@ -4980,6 +4983,30 @@ impl Renderer {
}
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
if shadow_strength > 0.0 {
+ // The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
+ // glowing mod geometry), as runs, so those are not vertex-processed twice.
+ self.shadow_casters.clear();
+ let mut start = None;
+ let quads = self.entity_batch.combined.as_chunks::<4>().0;
+ for (i, quad) in quads.iter().take(entity_quads as usize).enumerate() {
+ let caster = !matches!(quad[0].layer, 4 | 9 | 10 | 40);
+ match (caster, start) {
+ (true, None) => start = Some(i as u32),
+ (false, Some(first)) => {
+ self.shadow_casters.push((first, i as u32));
+ start = None;
+ }
+ _ => {}
+ }
+ }
+ if let Some(first) = start {
+ self.shadow_casters.push((first, entity_quads));
+ }
+ // Many tiny runs cost more in draw calls than they save: draw everything.
+ if self.shadow_casters.len() > 64 {
+ self.shadow_casters.clear();
+ self.shadow_casters.push((0, entity_quads));
+ }
self.queue
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
for (c, cascade) in hd_cascades.iter().enumerate() {
@@ -5021,11 +5048,13 @@ impl Renderer {
let instance = hd_shadow_first + k as u32;
pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
}
- if entity_quads > 0 {
+ if !self.shadow_casters.is_empty() {
pass.set_pipeline(&self.shadow_entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
- pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
+ for &(first, end) in &self.shadow_casters {
+ pass.draw_indexed(first * 6..end * 6, 0, 0..1);
+ }
}
}
encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size);
@@ -5586,7 +5615,7 @@ impl Renderer {
);
} else if let Some((color, depth)) = &scaled
&& hd_on
- && let Some(hd) = &self.hd
+ && let Some(hd) = &mut self.hd
{
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
// that, then bloom and the tone curve onto the screen.
@@ -5607,9 +5636,9 @@ impl Renderer {
self.mod_params.1,
);
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
- hd.finish(&self.device, &mut encoder, &hd.fx, &view_target);
+ hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
}
- _ => hd.finish(&self.device, &mut encoder, &hd.lit, &view_target),
+ _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
}
} else if let Some((color, depth)) = &scaled
&& mod_post
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index c36c1795d..fe2634f2c 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -359,6 +359,21 @@ fn target(
Target { view }
}
+/// The outside views and sampler the lighting group reads.
+type LightKey = (
+ wgpu::TextureView,
+ wgpu::TextureView,
+ wgpu::TextureView,
+ wgpu::Sampler,
+);
+
+/// What bloom and the tone curve read: the lit world, or the post pass's output.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum HdSource {
+ Lit,
+ Fx,
+}
+
pub struct Hd {
size: [u32; 2],
pub albedo: wgpu::TextureView,
@@ -377,6 +392,15 @@ pub struct Hd {
pub lit: wgpu::TextureView,
pub fx: wgpu::TextureView,
bloom: Vec<Target>,
+ /// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
+ /// lighting and the source-reading ones are keyed by the views they read.
+ light_group: Option<(LightKey, wgpu::BindGroup)>,
+ prefilter_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
+ tone_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
+ /// Reading bloom level k - 1 (for the down pass into level k, index k - 1) and level k (for
+ /// the up pass onto level k - 1, index k - 1).
+ down_groups: Vec<wgpu::BindGroup>,
+ up_groups: Vec<wgpu::BindGroup>,
frame: wgpu::Buffer,
lights: wgpu::Buffer,
sampler: wgpu::Sampler,
@@ -656,6 +680,11 @@ impl Hd {
lit: target(device, "HD lit", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
bloom: Vec::new(),
+ light_group: None,
+ prefilter_group: None,
+ tone_group: None,
+ down_groups: Vec::new(),
+ up_groups: Vec::new(),
frame,
lights,
sampler,
@@ -690,6 +719,16 @@ impl Hd {
target(device, "HD bloom", s, HDR)
})
.collect();
+ // Every group names a view that was just replaced.
+ self.light_group = None;
+ self.prefilter_group = None;
+ self.tone_group = None;
+ self.down_groups = (1..self.bloom.len())
+ .map(|k| self.bloom_group(device, &self.bloom[k - 1].view))
+ .collect();
+ self.up_groups = (1..self.bloom.len())
+ .map(|k| self.bloom_group(device, &self.bloom[k].view))
+ .collect();
}
/// This frame's camera, sun, fog, lights and settings.
@@ -829,73 +868,85 @@ impl Hd {
/// Lights the world (its colour and depth) into `lit`.
pub fn light(
- &self,
+ &mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
) {
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("HD lighting"),
- layout: &self.light_layout,
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: self.frame.as_entire_binding(),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::TextureView(world.0),
- },
- wgpu::BindGroupEntry {
- binding: 2,
- resource: wgpu::BindingResource::TextureView(world.1),
- },
- wgpu::BindGroupEntry {
- binding: 3,
- resource: wgpu::BindingResource::TextureView(&self.albedo),
- },
- wgpu::BindGroupEntry {
- binding: 4,
- resource: wgpu::BindingResource::TextureView(&self.normal),
- },
- wgpu::BindGroupEntry {
- binding: 5,
- resource: wgpu::BindingResource::TextureView(&self.depth),
- },
- wgpu::BindGroupEntry {
- binding: 6,
- resource: self.lights.as_entire_binding(),
- },
- wgpu::BindGroupEntry {
- binding: 7,
- resource: wgpu::BindingResource::TextureView(&self.levels),
- },
- wgpu::BindGroupEntry {
- binding: 8,
- resource: wgpu::BindingResource::TextureView(&self.shadow_array),
- },
- wgpu::BindGroupEntry {
- binding: 9,
- resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
- },
- wgpu::BindGroupEntry {
- binding: 10,
- resource: wgpu::BindingResource::TextureView(lightmap.0),
- },
- wgpu::BindGroupEntry {
- binding: 11,
- resource: wgpu::BindingResource::Sampler(lightmap.1),
- },
- ],
- });
+ let key = (
+ world.0.clone(),
+ world.1.clone(),
+ lightmap.0.clone(),
+ lightmap.1.clone(),
+ );
+ if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("HD lighting"),
+ layout: &self.light_layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: self.frame.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(world.0),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(world.1),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::TextureView(&self.albedo),
+ },
+ wgpu::BindGroupEntry {
+ binding: 4,
+ resource: wgpu::BindingResource::TextureView(&self.normal),
+ },
+ wgpu::BindGroupEntry {
+ binding: 5,
+ resource: wgpu::BindingResource::TextureView(&self.depth),
+ },
+ wgpu::BindGroupEntry {
+ binding: 6,
+ resource: self.lights.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 7,
+ resource: wgpu::BindingResource::TextureView(&self.levels),
+ },
+ wgpu::BindGroupEntry {
+ binding: 8,
+ resource: wgpu::BindingResource::TextureView(&self.shadow_array),
+ },
+ wgpu::BindGroupEntry {
+ binding: 9,
+ resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
+ },
+ wgpu::BindGroupEntry {
+ binding: 10,
+ resource: wgpu::BindingResource::TextureView(lightmap.0),
+ },
+ wgpu::BindGroupEntry {
+ binding: 11,
+ resource: wgpu::BindingResource::Sampler(lightmap.1),
+ },
+ ],
+ });
+ self.light_group = Some((key, group));
+ }
+ let Some((_, group)) = &self.light_group else {
+ return;
+ };
Self::draw(
encoder,
"HD lighting",
&self.lit,
false,
&self.light_pipeline,
- &group,
+ group,
);
}
@@ -922,12 +973,53 @@ impl Hd {
/// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`.
pub fn finish(
- &self,
+ &mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
- source: &wgpu::TextureView,
+ source: HdSource,
screen: &wgpu::TextureView,
) {
+ let source = match source {
+ HdSource::Lit => self.lit.clone(),
+ HdSource::Fx => self.fx.clone(),
+ };
+ if self
+ .prefilter_group
+ .as_ref()
+ .is_none_or(|(v, _)| *v != source)
+ {
+ let group = self.bloom_group(device, &source);
+ self.prefilter_group = Some((source.clone(), group));
+ }
+ if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("HD tone mapping"),
+ layout: &self.tone_layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: self.frame.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(&source),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ });
+ self.tone_group = Some((source, group));
+ }
+ let (Some((_, prefilter)), Some((_, tone))) = (&self.prefilter_group, &self.tone_group)
+ else {
+ return;
+ };
// Down: the prefilter into the first level, then each level from the one above.
Self::draw(
encoder,
@@ -935,61 +1027,30 @@ impl Hd {
&self.bloom[0].view,
false,
&self.prefilter,
- &self.bloom_group(device, source),
+ prefilter,
);
for k in 1..self.bloom.len() {
- let g = self.bloom_group(device, &self.bloom[k - 1].view);
Self::draw(
encoder,
"HD bloom down",
&self.bloom[k].view,
false,
&self.down,
- &g,
+ &self.down_groups[k - 1],
);
}
// Up: each level added onto the bigger one.
for k in (1..self.bloom.len()).rev() {
- let g = self.bloom_group(device, &self.bloom[k].view);
Self::draw(
encoder,
"HD bloom up",
&self.bloom[k - 1].view,
true,
&self.up,
- &g,
+ &self.up_groups[k - 1],
);
}
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("HD tone mapping"),
- layout: &self.tone_layout,
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: self.frame.as_entire_binding(),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::TextureView(source),
- },
- wgpu::BindGroupEntry {
- binding: 2,
- resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
- },
- wgpu::BindGroupEntry {
- binding: 3,
- resource: wgpu::BindingResource::Sampler(&self.sampler),
- },
- ],
- });
- Self::draw(
- encoder,
- "HD tone mapping",
- screen,
- false,
- &self.tone,
- &group,
- );
+ Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
}
}
--
2.54.0 (Apple Git-157)
@@ -1,362 +0,0 @@
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:25:44 +0300
Subject: [PATCH 14/42] HD: lights culled per 16x16 tile (surface and halo
lists built on the CPU), a naga test for the lighting shaders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd.rs | 242 +++++++++++++++++++++++++++-
1 file changed, 236 insertions(+), 6 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index fe2634f2c..2776219fc 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -16,6 +16,12 @@ pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The most lights drawn a frame.
pub const MAX_LIGHTS: usize = 512;
+/// The side (pixels) of a tile of the screen with its own short list of lights.
+const TILE: u32 = 16;
+/// The indices all the tile lists may hold together; a tile past it loops over every light.
+const TILE_INDICES: usize = 1 << 19;
+/// A list length that means "every light": the list overflowed.
+const EVERY_LIGHT: u32 = u32::MAX;
/// Bloom's mip levels.
const BLOOM_LEVELS: usize = 6;
@@ -55,7 +61,7 @@ struct HdFrame {
fog: [f32; 4],
/// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
ambient: [f32; 4],
- /// x: shadows on; y, z: the cascades' half sizes.
+ /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
shadow: [f32; 4],
}
@@ -123,6 +129,10 @@ struct Light {
@group(0) @binding(9) var shadow_sampler: sampler_comparison;
@group(0) @binding(10) var lightmap: texture_2d<f32>;
@group(0) @binding(11) var linear: sampler;
+// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
+// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
+// every light.
+@group(0) @binding(12) var<storage, read> tiles: array<u32>;
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
fn ign(px: vec2<f32>) -> f32 {
@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let sun = frame.sun.xyz;
let sun_on = frame.sun.w;
let count = u32(frame.params.x);
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
+ let surface_start = tiles[tile];
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
+ let halo_start = tiles[tile + 2u];
+ let halo_all = tiles[tile + 3u] == 0xffffffffu;
+ let halo_count = select(tiles[tile + 3u], count, halo_all);
var c = scene;
let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
if surface {
@@ -199,8 +216,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
// The mods' lights.
- for (var i = 0u; i < count; i++) {
- let l = lights[i];
+ for (var k = 0u; k < surface_count; k++) {
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
let to = l.pos.xyz - p;
let dist = length(to);
if dist >= l.pos.w { continue; }
@@ -233,8 +250,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
}
- for (var i = 0u; i < count; i++) {
- let l = lights[i];
+ for (var k = 0u; k < halo_count; k++) {
+ let l = lights[select(tiles[halo_start + k], k, halo_all)];
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
let t0 = dot(l.pos.xyz, view);
let hh = length(l.pos.xyz - view * t0) + 0.5;
@@ -403,6 +420,11 @@ pub struct Hd {
up_groups: Vec<wgpu::BindGroup>,
frame: wgpu::Buffer,
lights: wgpu::Buffer,
+ /// The tiles' light lists (see `tile_lists`), sized for the screen, and the tiles per row.
+ tiles: wgpu::Buffer,
+ tiles_x: u32,
+ tile_scratch: Vec<Vec<u16>>,
+ tile_words: Vec<u32>,
sampler: wgpu::Sampler,
light_layout: wgpu::BindGroupLayout,
light_pipeline: wgpu::RenderPipeline,
@@ -414,6 +436,137 @@ pub struct Hd {
tone: wgpu::RenderPipeline,
}
+/// The tiles' header (4 words each) and index lists for a screen of `size`.
+fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffer {
+ let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("HD light tiles"),
+ size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ })
+}
+
+/// Where a camera-relative sphere can show on the screen.
+enum Cover {
+ /// Nowhere: it is wholly behind the camera.
+ Nothing,
+ /// Anywhere: it crosses the camera's plane, or holds the camera.
+ Everywhere,
+ /// Inside this pixel rectangle (min x, min y, max x, max y).
+ Rect([f32; 4]),
+}
+
+/// The screen cover of the sphere at `c` of radius `r`: the projection of the 8 corners of the
+/// box round it. A box is convex and so is its projection while every corner is in front of the
+/// camera, so the rectangle holds the whole sphere. With some corners behind, the projection
+/// is not bounded and the cover is the whole screen; with all of them behind, no pixel ray
+/// (they all point forward) can meet the sphere.
+fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
+ if !c.is_finite() || !r.is_finite() {
+ return Cover::Everywhere;
+ }
+ // A little more than the shader's radius, against rounding.
+ let r = r * 1.01 + 0.01;
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
+ let mut front = 0;
+ for corner in 0..8 {
+ let o = glam::Vec3::new(
+ if corner & 1 == 0 { -r } else { r },
+ if corner & 2 == 0 { -r } else { r },
+ if corner & 4 == 0 { -r } else { r },
+ );
+ let h = *view_proj * (c + o).extend(1.0);
+ if h.w > 0.0 {
+ front += 1;
+ let ndc = glam::Vec2::new(h.x, h.y) / h.w;
+ let px = glam::Vec2::new(
+ (ndc.x * 0.5 + 0.5) * size[0] as f32,
+ (0.5 - ndc.y * 0.5) * size[1] as f32,
+ );
+ lo = lo.min(px);
+ hi = hi.max(px);
+ }
+ }
+ match front {
+ 0 => Cover::Nothing,
+ 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
+ _ => Cover::Everywhere,
+ }
+}
+
+/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
+/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
+/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
+/// surface list holds the lights whose sphere can touch what the tile sees. The halo list
+/// holds the lights whose halo can reach a ray of the tile: the shader adds a halo for the
+/// view *line* passing within half the radius of the light, so a ray pointing away from a light
+/// behind it is lit too, and the mirrored sphere is covered as well. Lights stay in order, so
+/// the sums are the ones of the full loop.
+fn tile_lists(
+ view_proj: &Mat4,
+ lights: &[HdLight],
+ size: [u32; 2],
+ scratch: &mut Vec<Vec<u16>>,
+ words: &mut Vec<u32>,
+) {
+ let tx = size[0].div_ceil(TILE).max(1) as usize;
+ let ty = size[1].div_ceil(TILE).max(1) as usize;
+ let tiles = tx * ty;
+ scratch.resize_with(tiles * 2, Vec::new);
+ scratch.truncate(tiles * 2);
+ scratch.iter_mut().for_each(Vec::clear);
+ let mut add = |list: usize, area: Cover, light: u16| match area {
+ Cover::Nothing => {}
+ Cover::Everywhere => {
+ for t in 0..tiles {
+ scratch[t * 2 + list].push(light);
+ }
+ }
+ Cover::Rect([x0, y0, x1, y1]) => {
+ let tile = TILE as f32;
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
+ return;
+ }
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
+ for y in from(y0, ty)..=from(y1, ty) {
+ for x in from(x0, tx)..=from(x1, tx) {
+ scratch[(y * tx + x) * 2 + list].push(light);
+ }
+ }
+ }
+ };
+ for (i, l) in lights.iter().enumerate() {
+ let c = glam::Vec3::new(l.pos[0], l.pos[1], l.pos[2]);
+ add(0, cover(view_proj, c, l.pos[3], size), i as u16);
+ // The shader's halo reach: the line within `radius / 2 - 0.5` of the light.
+ let reach = l.pos[3] * 0.5 - 0.5;
+ if reach > 0.0 || !reach.is_finite() {
+ add(1, cover(view_proj, c, reach, size), i as u16);
+ add(1, cover(view_proj, -c, reach, size), i as u16);
+ }
+ }
+ words.clear();
+ words.resize(tiles * 4, 0);
+ let mut room = TILE_INDICES;
+ for t in 0..tiles {
+ for list in 0..2 {
+ let ids = &mut scratch[t * 2 + list];
+ // Both the sphere and its mirror can name a light: once is enough.
+ ids.dedup();
+ let at = t * 4 + list * 2;
+ if ids.len() > room {
+ words[at + 1] = EVERY_LIGHT;
+ } else {
+ room -= ids.len();
+ words[at] = words.len() as u32;
+ words[at + 1] = ids.len() as u32;
+ words.extend(ids.iter().map(|&i| u32::from(i)));
+ }
+ }
+ }
+}
+
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
@@ -527,6 +680,11 @@ impl Hd {
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
FLOAT,
SAMPLER,
+ wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Storage { read_only: true },
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
]),
});
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -687,6 +845,10 @@ impl Hd {
up_groups: Vec::new(),
frame,
lights,
+ tiles: tiles_buffer(device, [1, 1]),
+ tiles_x: 1,
+ tile_scratch: Vec::new(),
+ tile_words: Vec::new(),
sampler,
light_layout,
light_pipeline,
@@ -713,6 +875,8 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ self.tiles = tiles_buffer(device, size);
+ self.tiles_x = size[0].div_ceil(TILE).max(1);
self.bloom = (0..BLOOM_LEVELS)
.map(|k| {
let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
@@ -743,6 +907,15 @@ impl Hd {
if n > 0 {
queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n]));
}
+ // Written every frame, empty too: the shader reads the counts, not `params.x`.
+ tile_lists(
+ &scene.view_proj,
+ &lights[..n],
+ self.size,
+ &mut self.tile_scratch,
+ &mut self.tile_words,
+ );
+ queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
self.frames = self.frames.wrapping_add(1);
// The sun by day, the moon (opposite) by night, faded out round the horizon.
let (dir, strength, color) = light_of(scene);
@@ -773,7 +946,7 @@ impl Hd {
if strength > 0.0 { 1.0 } else { 0.0 },
CASCADES[0],
CASCADES[1],
- 0.0,
+ self.tiles_x as f32,
],
};
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
@@ -933,6 +1106,10 @@ impl Hd {
binding: 11,
resource: wgpu::BindingResource::Sampler(lightmap.1),
},
+ wgpu::BindGroupEntry {
+ binding: 12,
+ resource: self.tiles.as_entire_binding(),
+ },
],
});
self.light_group = Some((key, group));
@@ -1102,3 +1279,56 @@ pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
(projection * view).as_mat4()
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn lighting_shader_parses() {
+ for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
+ let source = format!("{COMMON}{body}");
+ let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
+ panic!("{name}: {}", e.emit_to_string(&source));
+ });
+ naga::valid::Validator::new(
+ naga::valid::ValidationFlags::all(),
+ naga::valid::Capabilities::all(),
+ )
+ .validate(&module)
+ .unwrap_or_else(|e| panic!("{name}: {e:?}"));
+ }
+ }
+
+ #[test]
+ fn tile_lists_keep_lights_that_reach_a_tile() {
+ let size = [320, 160];
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
+ let light = |x: f32, y: f32, z: f32, r: f32| HdLight {
+ pos: [x, y, z, r],
+ color: [1.0; 4],
+ };
+ let lights = [
+ // Dead ahead and small; far to the side; behind the camera; holding the camera.
+ light(0.0, 0.0, -20.0, 4.0),
+ light(30.0, 0.0, -5.0, 4.0),
+ light(0.0, 0.0, 10.0, 8.0),
+ light(1.0, 0.0, 0.0, 6.0),
+ ];
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
+ tile_lists(&proj, &lights, size, &mut scratch, &mut words);
+ let list = |tile: usize, which: usize| -> Vec<u32> {
+ let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
+ words[start as usize..(start + len) as usize].to_vec()
+ };
+ // The centre tile sees the first light and the one at the camera, in both lists.
+ let centre = 5 * 20 + 10;
+ assert_eq!(list(centre, 0), vec![0, 3]);
+ assert!(list(centre, 1).contains(&0) && list(centre, 1).contains(&3));
+ // A far corner sees only the one at the camera.
+ assert_eq!(list(0, 0), vec![3]);
+ // The light behind the camera shows in the halo list where the view points away.
+ assert!(list(centre, 1).contains(&2));
+ assert!(!list(0, 0).contains(&2));
+ }
+}
--
2.54.0 (Apple Git-157)
@@ -1,712 +0,0 @@
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:43:36 +0300
Subject: [PATCH 15/42] HD: GPU time per pass in the F3 timing list,
half-resolution GTAO with a depth-aware blur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 23 +-
crates/horizon-client/src/gfx/hd.rs | 430 +++++++++++++++++++++++++++-
2 files changed, 443 insertions(+), 10 deletions(-)
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index f6bbe038c..e14429b03 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -3725,9 +3725,20 @@ impl Renderer {
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
- timer
+ let mut times = timer
.map(pack_timing::PassTiming::medians)
- .unwrap_or_default()
+ .unwrap_or_default();
+ // AirDef fork: the HD lighting's passes (shadows, G-buffer, occlusion, lighting, bloom,
+ // tone curve) while it is on.
+ if let Some(hd) = &self.hd {
+ times.extend(hd.pass_times());
+ }
+ times
+ }
+
+ /// AirDef fork: bind groups the HD lighting made in the last frame (0 in a steady state).
+ pub fn hd_bind_groups_last_frame(&self) -> Option<u64> {
+ self.hd.as_ref().map(|hd| hd.groups_last_frame)
}
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
@@ -4981,6 +4992,8 @@ impl Renderer {
if let Some(settings) = frame.hd {
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
}
+ hd.set_timing(device, &self.queue, self.pass_timing);
+ hd.timing_begin(device, &self.queue);
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
if shadow_strength > 0.0 {
// The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
@@ -5090,6 +5103,9 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
+ drop(pass);
+ // Contact shadows from the G-buffer's depth and normals, for the lighting.
+ hd.ao(&mut encoder);
} else if !hd_on {
self.hd = None;
}
@@ -5831,6 +5847,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
+ if hd_on && let Some(hd) = &mut self.hd {
+ hd.timing_submitted(&self.queue);
+ }
if let Some((path, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 2776219fc..d3944e97b 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -5,7 +5,9 @@
//! into a high-dynamic-range target; the mod's post pass runs on that; then
//! bright parts bloom and a tone curve brings it to the screen — vanilla's
//! range (up to about 0.8) left as it is, only what is brighter rolled off.
+use super::pack_timing::PassTiming;
use glam::Mat4;
+use std::sync::atomic::{AtomicU64, Ordering};
/// The G-buffer's formats.
pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
@@ -24,6 +26,25 @@ const TILE_INDICES: usize = 1 << 19;
const EVERY_LIGHT: u32 = u32::MAX;
/// Bloom's mip levels.
const BLOOM_LEVELS: usize = 6;
+/// The ambient occlusion's targets (half resolution): occlusion and the distance it was taken at.
+pub const AO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;
+/// The occlusion's default strength (`Hd::ao_strength`; 0 turns it off).
+pub const AO_STRENGTH: f32 = 1.0;
+/// The timed passes of bloom, by level (each is timed on its own; the report sums them).
+const BLOOM_NAMES: [&str; 2 * BLOOM_LEVELS] = [
+ "hd bloom#0",
+ "hd bloom#1",
+ "hd bloom#2",
+ "hd bloom#3",
+ "hd bloom#4",
+ "hd bloom#5",
+ "hd bloom#6",
+ "hd bloom#7",
+ "hd bloom#8",
+ "hd bloom#9",
+ "hd bloom#10",
+ "hd bloom#11",
+];
/// A light for this frame, relative to the camera.
#[repr(C)]
@@ -59,7 +80,8 @@ struct HdFrame {
fog_color: [f32; 4],
/// Environmental start, end; render-distance start, end.
fog: [f32; 4],
- /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
+ /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos);
+ /// w: the ambient occlusion's strength (0 off).
ambient: [f32; 4],
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
shadow: [f32; 4],
@@ -133,6 +155,34 @@ struct Light {
// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
// every light.
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
+// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
+@group(0) @binding(13) var ao_tex: texture_2d<f32>;
+
+// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
+// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
+fn ao_at(px: vec2<i32>, dist: f32) -> f32 {
+ let size = vec2<i32>(textureDimensions(ao_tex));
+ let f = vec2<f32>(px) * 0.5;
+ let base = vec2<i32>(floor(f));
+ let t = f - floor(f);
+ var sum = 0.0;
+ var total = 0.0;
+ for (var j = 0; j < 2; j++) {
+ for (var i = 0; i < 2; i++) {
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
+ let s = textureLoad(ao_tex, q, 0).xy;
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
+ let dz = (s.y - dist) / (0.03 * dist + 0.1);
+ let w = select(0.0, bw / (1.0 + dz * dz * dz * dz), s.y > 0.0);
+ sum += s.x * w;
+ total += w;
+ }
+ }
+ if total < 0.0001 {
+ return textureLoad(ao_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0).x;
+ }
+ return sum / total;
+}
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
fn ign(px: vec2<f32>) -> f32 {
@@ -214,7 +264,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
// direct light by the face's angle.
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
- var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
+ // the mods' lights by half, not on the sun or on emission.
+ var occlusion = 1.0;
+ if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); }
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
+ var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
// The mods' lights.
for (var k = 0u; k < surface_count; k++) {
let l = lights[select(tiles[surface_start + k], k, surface_all)];
@@ -224,7 +280,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
let falloff = l.color.w / (dist * dist + 1.0) * window;
let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
- light += l.color.rgb * falloff * k;
+ light += l.color.rgb * falloff * k * ao_lights;
}
c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
// Vanilla's fog over it again.
@@ -264,6 +320,131 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
}
"#;
+const AO: &str = r#"
+const AO_RADIUS: f32 = 1.8;
+const AO_SLICES: i32 = 3;
+const AO_STEPS: i32 = 5;
+const PI: f32 = 3.14159265;
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var depth_tex: texture_depth_2d;
+@group(0) @binding(2) var normal_tex: texture_2d<f32>;
+
+// Interleaved gradient noise, shifted per frame (params.w) and per use.
+fn noise(px: vec2<f32>, shift: vec2<f32>) -> f32 {
+ let p = px + shift + vec2<f32>(5.588238 * frame.params.w);
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
+}
+
+// The camera-relative position of a (continuous) full-resolution pixel at depth z.
+fn position_at(px: vec2<f32>, z: f32) -> vec3<f32> {
+ let uv = px * frame.resolution.zw;
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
+ return h.xyz / h.w;
+}
+
+// Ground-truth ambient occlusion (horizon based): per slice through the view ray, the highest
+// horizon on either side, integrated against the normal's cosine lobe. Half resolution: one
+// pixel of each 2x2.
+@fragment
+fn fs_ao(in: FullOut) -> @location(0) vec2<f32> {
+ let size = vec2<i32>(textureDimensions(depth_tex));
+ let ic = min(vec2<i32>(in.position.xy) * 2, size - 1);
+ let z = textureLoad(depth_tex, ic, 0);
+ if z <= 0.0 { return vec2<f32>(1.0, 0.0); }
+ let nrm = textureLoad(normal_tex, ic, 0).xyz;
+ let c = vec2<f32>(ic) + 0.5;
+ let p = position_at(c, z);
+ let dist = length(p);
+ if dot(nrm, nrm) < 0.01 { return vec2<f32>(1.0, dist); }
+ let n = normalize(nrm);
+ let v = -p / dist;
+ // Blocks per pixel here (the plane of equal depth is square to the view), so how far in
+ // pixels the radius reaches.
+ let per_px = max(length(position_at(c + vec2<f32>(4.0, 0.0), z) - p) / 4.0, 0.00001);
+ let reach = min(AO_RADIUS / per_px, 56.0);
+ if reach < 2.0 { return vec2<f32>(1.0, dist); }
+ let radius = reach * per_px;
+ let rot = noise(in.position.xy, vec2<f32>(0.0));
+ let jitter = noise(in.position.xy, vec2<f32>(37.0, 17.0));
+ var visibility = 0.0;
+ for (var s = 0; s < AO_SLICES; s++) {
+ let phi = (f32(s) + rot) / f32(AO_SLICES) * PI;
+ let d2 = vec2<f32>(cos(phi), sin(phi));
+ // The slice's direction in space, square to the view ray, and its plane's axis.
+ let q = position_at(c + d2 * 4.0, z) - p;
+ let dir = normalize(q - v * dot(q, v));
+ let axis = cross(dir, v);
+ let projected = n - axis * dot(n, axis);
+ let plen = length(projected);
+ if plen < 0.0001 { visibility += 1.0; continue; }
+ let cos_n = clamp(dot(projected, v) / plen, -1.0, 1.0);
+ let nang = select(-1.0, 1.0, dot(projected, dir) >= 0.0) * acos(cos_n);
+ var c_neg = -1.0;
+ var c_pos = -1.0;
+ for (var i = 0; i < AO_STEPS; i++) {
+ let t = (f32(i) + jitter) / f32(AO_STEPS);
+ let r = 1.5 + (reach - 1.5) * t * t;
+ for (var side = 0; side < 2; side++) {
+ let sg = select(-1.0, 1.0, side == 1);
+ let ip = vec2<i32>(floor(c + d2 * (r * sg)));
+ if any(ip < vec2<i32>(0)) || any(ip >= size) { continue; }
+ let sz = textureLoad(depth_tex, ip, 0);
+ if sz <= 0.0 { continue; }
+ let to = position_at(vec2<f32>(ip) + 0.5, sz) - p;
+ let len = length(to);
+ // Points on (or under) the surface's own plane don't occlude it.
+ if dot(to, n) < 0.1 * len { continue; }
+ // Far points count less, to nothing at the radius.
+ let fall = clamp((len - radius * 0.6) / (radius * 0.4), 0.0, 1.0);
+ let ch = mix(dot(to / len, v), -1.0, fall);
+ if side == 1 { c_pos = max(c_pos, ch); } else { c_neg = max(c_neg, ch); }
+ }
+ }
+ var h_neg = -acos(clamp(c_neg, -1.0, 1.0));
+ var h_pos = acos(clamp(c_pos, -1.0, 1.0));
+ h_neg = nang + max(h_neg - nang, -0.5 * PI);
+ h_pos = nang + min(h_pos - nang, 0.5 * PI);
+ let sn = sin(nang);
+ let arc_neg = cos_n + 2.0 * h_neg * sn - cos(2.0 * h_neg - nang);
+ let arc_pos = cos_n + 2.0 * h_pos * sn - cos(2.0 * h_pos - nang);
+ visibility += plen * 0.25 * (arc_neg + arc_pos);
+ }
+ let ao = clamp(visibility / f32(AO_SLICES), 0.0, 1.0);
+ // Far away it is too coarse to show: fade out.
+ return vec2<f32>(mix(1.0, ao, 1.0 - smoothstep(40.0, 96.0, dist)), dist);
+}
+"#;
+
+const AO_BLUR: &str = r#"
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var raw_ao: texture_2d<f32>;
+
+// A 5x5 blur that only takes texels at about the same distance (so not across an edge).
+@fragment
+fn fs_ao_blur(in: FullOut) -> @location(0) vec2<f32> {
+ let size = vec2<i32>(textureDimensions(raw_ao));
+ let ip = vec2<i32>(in.position.xy);
+ let centre = textureLoad(raw_ao, ip, 0).xy;
+ if centre.y <= 0.0 { return centre; }
+ let tolerance = 0.03 * centre.y + 0.1;
+ var sum = 0.0;
+ var total = 0.0;
+ for (var y = -2; y <= 2; y++) {
+ for (var x = -2; x <= 2; x++) {
+ let q = ip + vec2<i32>(x, y);
+ if any(q < vec2<i32>(0)) || any(q >= size) { continue; }
+ let s = textureLoad(raw_ao, q, 0).xy;
+ if s.y <= 0.0 { continue; }
+ let dz = (s.y - centre.y) / tolerance;
+ let w = 1.0 / (1.0 + dz * dz * dz * dz);
+ sum += s.x * w;
+ total += w;
+ }
+ }
+ return vec2<f32>(sum / max(total, 0.0001), centre.y);
+}
+"#;
+
const BLOOM: &str = r#"
@group(0) @binding(0) var<uniform> frame: HdFrame;
@group(0) @binding(1) var source: texture_2d<f32>;
@@ -434,6 +615,23 @@ pub struct Hd {
up: wgpu::RenderPipeline,
tone_layout: wgpu::BindGroupLayout,
tone: wgpu::RenderPipeline,
+ /// The ambient occlusion: its strength in the lighting, the half-resolution targets (raw, then
+ /// blurred), their passes and bind groups (made at `resize`).
+ pub ao_strength: f32,
+ ao_raw: wgpu::TextureView,
+ ao_blurred: wgpu::TextureView,
+ ao_layout: wgpu::BindGroupLayout,
+ ao_blur_layout: wgpu::BindGroupLayout,
+ ao_pipeline: wgpu::RenderPipeline,
+ ao_blur_pipeline: wgpu::RenderPipeline,
+ ao_group: Option<wgpu::BindGroup>,
+ ao_blur_group: Option<wgpu::BindGroup>,
+ /// GPU time of the HD passes while diagnostics ask for it (`set_timing`).
+ timing: Option<PassTiming>,
+ /// Bind groups made so far, and in the last frame (a steady state makes none).
+ groups_made: AtomicU64,
+ groups_seen: u64,
+ pub groups_last_frame: u64,
}
/// The tiles' header (4 words each) and index lists for a screen of `size`.
@@ -567,6 +765,22 @@ fn tile_lists(
}
}
+/// The timestamps of the pass `name` while HD passes are timed.
+fn stamp<'a>(
+ timing: &'a mut Option<PassTiming>,
+ name: &str,
+) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
+ timing.as_mut()?.writes(name)
+}
+
+/// `HORIZON_HD_AO=0` turns the ambient occlusion off, another number sets its strength.
+fn ao_strength_default() -> f32 {
+ std::env::var("HORIZON_HD_AO")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(AO_STRENGTH)
+}
+
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
@@ -685,8 +899,17 @@ impl Hd {
has_dynamic_offset: false,
min_binding_size: None,
},
+ UNFILTERED,
]),
});
+ let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD ambient occlusion"),
+ entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
+ });
+ let ao_blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD ambient occlusion blur"),
+ entries: &entries(&[UNIFORM, UNFILTERED]),
+ });
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("HD bloom"),
entries: &entries(&[UNIFORM, FLOAT, SAMPLER]),
@@ -698,6 +921,26 @@ impl Hd {
let light = module(device, "HD lighting", LIGHT);
let bloom = module(device, "HD bloom", BLOOM);
let tone = module(device, "HD tone mapping", TONEMAP);
+ let ao = module(device, "HD ambient occlusion", AO);
+ let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
+ let ao_pipeline = fullscreen(
+ device,
+ "HD ambient occlusion",
+ &ao_layout,
+ &ao,
+ "fs_ao",
+ AO_FORMAT,
+ None,
+ );
+ let ao_blur_pipeline = fullscreen(
+ device,
+ "HD ambient occlusion blur",
+ &ao_blur_layout,
+ &ao_blur,
+ "fs_ao_blur",
+ AO_FORMAT,
+ None,
+ );
let add = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
@@ -858,6 +1101,19 @@ impl Hd {
up,
tone_layout,
tone,
+ ao_strength: ao_strength_default(),
+ ao_raw: target(device, "HD AO", [1, 1], AO_FORMAT).view,
+ ao_blurred: target(device, "HD AO blurred", [1, 1], AO_FORMAT).view,
+ ao_layout,
+ ao_blur_layout,
+ ao_pipeline,
+ ao_blur_pipeline,
+ ao_group: None,
+ ao_blur_group: None,
+ timing: None,
+ groups_made: AtomicU64::new(0),
+ groups_seen: 0,
+ groups_last_frame: 0,
};
hd.resize(device, size);
hd
@@ -875,6 +1131,28 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
+ self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
+ self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
+ self.ao_group = Some(self.make_group(
+ device,
+ "HD ambient occlusion",
+ &self.ao_layout,
+ &[
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
+ wgpu::BindingResource::TextureView(&self.depth),
+ wgpu::BindingResource::TextureView(&self.normal),
+ ],
+ ));
+ self.ao_blur_group = Some(self.make_group(
+ device,
+ "HD ambient occlusion blur",
+ &self.ao_blur_layout,
+ &[
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
+ wgpu::BindingResource::TextureView(&self.ao_raw),
+ ],
+ ));
self.tiles = tiles_buffer(device, size);
self.tiles_x = size[0].div_ceil(TILE).max(1);
self.bloom = (0..BLOOM_LEVELS)
@@ -917,6 +1195,15 @@ impl Hd {
);
queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
self.frames = self.frames.wrapping_add(1);
+ let made = self.groups_made.load(Ordering::Relaxed);
+ self.groups_last_frame = made - self.groups_seen;
+ self.groups_seen = made;
+ if PassTiming::logging() && self.frames.is_multiple_of(240) {
+ log::info!(
+ "HD bind groups made in the last frame: {} ({made} in all)",
+ self.groups_last_frame
+ );
+ }
// The sun by day, the moon (opposite) by night, faded out round the horizon.
let (dir, strength, color) = light_of(scene);
let cascades = cascades(scene.camera, dir);
@@ -941,7 +1228,12 @@ impl Hd {
fog_color: scene.fog_color,
fog: scene.fog,
// In the shade by day a surface keeps half its sky light; at night almost all.
- ambient: [0.5 + 0.45 * night, 1.0, 0.0018 + scene.rain * 0.006, 0.0],
+ ambient: [
+ 0.5 + 0.45 * night,
+ 1.0,
+ 0.0018 + scene.rain * 0.006,
+ self.ao_strength.max(0.0),
+ ],
shadow: [
if strength > 0.0 { 1.0 } else { 0.0 },
CASCADES[0],
@@ -954,13 +1246,14 @@ impl Hd {
/// The shadow pass of cascade `c`: its depth layer cleared, nothing else.
pub fn shadow_pass<'e>(
- &self,
+ &mut self,
encoder: &'e mut wgpu::CommandEncoder,
c: usize,
) -> wgpu::RenderPass<'e> {
encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HD shadow cascade"),
+ timestamp_writes: stamp(&mut self.timing, ["hd shadow 0", "hd shadow 1"][c.min(1)]),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.shadow_layers[c],
@@ -976,7 +1269,10 @@ impl Hd {
}
/// The G-buffer pass's attachments: albedo and normal cleared, its own depth.
- pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> {
+ pub fn gbuffer_pass<'e>(
+ &mut self,
+ encoder: &'e mut wgpu::CommandEncoder,
+ ) -> wgpu::RenderPass<'e> {
let clear = |view| {
Some(wgpu::RenderPassColorAttachment {
view,
@@ -991,6 +1287,7 @@ impl Hd {
encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HD G-buffer"),
+ timestamp_writes: stamp(&mut self.timing, "hd g-buffer"),
color_attachments: &[
clear(&self.albedo),
clear(&self.normal),
@@ -1016,9 +1313,11 @@ impl Hd {
load: bool,
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
+ timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
+ timestamp_writes,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: to,
resolve_target: None,
@@ -1054,6 +1353,7 @@ impl Hd {
lightmap.1.clone(),
);
if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("HD lighting"),
layout: &self.light_layout,
@@ -1110,6 +1410,10 @@ impl Hd {
binding: 12,
resource: self.tiles.as_entire_binding(),
},
+ wgpu::BindGroupEntry {
+ binding: 13,
+ resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
+ },
],
});
self.light_group = Some((key, group));
@@ -1124,10 +1428,36 @@ impl Hd {
false,
&self.light_pipeline,
group,
+ stamp(&mut self.timing, "hd lighting"),
);
}
+ /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
+ fn make_group(
+ &self,
+ device: &wgpu::Device,
+ label: &str,
+ layout: &wgpu::BindGroupLayout,
+ resources: &[wgpu::BindingResource<'_>],
+ ) -> wgpu::BindGroup {
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
+ let entries: Vec<_> = resources
+ .iter()
+ .enumerate()
+ .map(|(i, r)| wgpu::BindGroupEntry {
+ binding: i as u32,
+ resource: r.clone(),
+ })
+ .collect();
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some(label),
+ layout,
+ entries: &entries,
+ })
+ }
+
fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup {
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("HD bloom"),
layout: &self.bloom_layout,
@@ -1169,6 +1499,7 @@ impl Hd {
self.prefilter_group = Some((source.clone(), group));
}
if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("HD tone mapping"),
layout: &self.tone_layout,
@@ -1205,6 +1536,7 @@ impl Hd {
false,
&self.prefilter,
prefilter,
+ stamp(&mut self.timing, BLOOM_NAMES[0]),
);
for k in 1..self.bloom.len() {
Self::draw(
@@ -1214,6 +1546,7 @@ impl Hd {
false,
&self.down,
&self.down_groups[k - 1],
+ stamp(&mut self.timing, BLOOM_NAMES[k]),
);
}
// Up: each level added onto the bigger one.
@@ -1225,9 +1558,84 @@ impl Hd {
true,
&self.up,
&self.up_groups[k - 1],
+ stamp(&mut self.timing, BLOOM_NAMES[BLOOM_LEVELS + k - 1]),
);
}
- Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
+ Self::draw(
+ encoder,
+ "HD tone mapping",
+ screen,
+ false,
+ &self.tone,
+ tone,
+ stamp(&mut self.timing, "hd tonemap"),
+ );
+ }
+
+ /// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
+ /// its blur. Run after the G-buffer pass; nothing when the strength is 0.
+ pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
+ if self.ao_strength <= 0.0 {
+ return;
+ }
+ let (Some(raw), Some(blur)) = (&self.ao_group, &self.ao_blur_group) else {
+ return;
+ };
+ Self::draw(
+ encoder,
+ "HD ambient occlusion",
+ &self.ao_raw,
+ false,
+ &self.ao_pipeline,
+ raw,
+ stamp(&mut self.timing, "hd ao"),
+ );
+ Self::draw(
+ encoder,
+ "HD ambient occlusion blur",
+ &self.ao_blurred,
+ false,
+ &self.ao_blur_pipeline,
+ blur,
+ stamp(&mut self.timing, "hd ao blur"),
+ );
+ }
+
+ /// Times the HD passes on the GPU while `on` (and the device can), else not.
+ pub fn set_timing(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, on: bool) {
+ if on != self.timing.is_some() {
+ self.timing = on.then(|| PassTiming::new(device, queue)).flatten();
+ }
+ }
+
+ /// At the start of the frame's passes, before the first of them.
+ pub fn timing_begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
+ if let Some(t) = &mut self.timing {
+ t.begin(device, queue);
+ }
+ }
+
+ /// After the frame is submitted.
+ pub fn timing_submitted(&mut self, queue: &wgpu::Queue) {
+ if let Some(t) = &mut self.timing {
+ t.submitted(queue);
+ }
+ }
+
+ /// Each HD pass's median GPU time in milliseconds (bloom's passes summed into one).
+ pub fn pass_times(&self) -> Vec<(String, f32)> {
+ let Some(t) = &self.timing else {
+ return Vec::new();
+ };
+ let mut out: Vec<(String, f32)> = Vec::new();
+ for (name, ms) in t.medians() {
+ let name = name.split('#').next().unwrap_or(&name).to_owned();
+ match out.iter_mut().find(|o| o.0 == name) {
+ Some(o) => o.1 += ms,
+ None => out.push((name, ms)),
+ }
+ }
+ out
}
}
@@ -1286,7 +1694,13 @@ mod tests {
#[test]
fn lighting_shader_parses() {
- for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
+ for (name, body) in [
+ ("light", LIGHT),
+ ("bloom", BLOOM),
+ ("tonemap", TONEMAP),
+ ("ao", AO),
+ ("ao blur", AO_BLUR),
+ ] {
let source = format!("{COMMON}{body}");
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
panic!("{name}: {}", e.emit_to_string(&source));
--
2.54.0 (Apple Git-157)
@@ -1,678 +0,0 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 16/42] HD: shadows draw only sun-facing quads and skip buried
sections, the far cascade every second frame; TAA (Halton jitter,
reprojection by depth, YCoCg variance clipping) before bloom
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 126 +++++++++-
crates/horizon-client/src/gfx/hd.rs | 347 +++++++++++++++++++++++++++-
2 files changed, 457 insertions(+), 16 deletions(-)
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index e14429b03..eb152667c 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -569,6 +569,11 @@ pub struct Renderer {
shadow_terrain: wgpu::RenderPipeline,
shadow_entity: wgpu::RenderPipeline,
hd_shadow_visible: Vec<resident::Visible>,
+ /// The far cascade as last drawn (matrix, camera, light) and the frames since.
+ hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
+ /// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
+ taa_frame: u32,
+ taa_prev: Option<(Mat4, glam::DVec3)>,
hd: Option<hd::Hd>,
mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
@@ -2815,6 +2820,9 @@ impl Renderer {
shadow_terrain,
shadow_entity,
hd_shadow_visible: Vec::new(),
+ hd_far: None,
+ taa_frame: 0,
+ taa_prev: None,
hd: None,
mod_params: (Vec::new(), 0.0),
pass_timing: pack_timing::PassTiming::logging(),
@@ -3954,7 +3962,29 @@ impl Renderer {
frame.block_flicker,
frame.boss_darken,
);
- let view_proj = self.view_projection(frame);
+ let hd_wanted = !frame.title
+ && frame.hd.is_some()
+ && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
+ && !self.native.as_ref().is_some_and(|n| n.post.is_some());
+ // AirDef fork: HD's temporal anti-aliasing shifts the whole projection by a sub-pixel
+ // offset each frame; `unjittered` is what the next frame reprojects with.
+ let unjittered = self.view_projection(frame);
+ let taa = hd_wanted && hd::taa_enabled();
+ let view_proj = if taa {
+ let size = self
+ .world_post
+ .size()
+ .unwrap_or([self.config.width, self.config.height]);
+ let j = hd::jitter(self.taa_frame, size);
+ self.taa_frame = self.taa_frame.wrapping_add(1);
+ Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
+ } else {
+ unjittered
+ };
+ let taa_prev = std::mem::replace(
+ &mut self.taa_prev,
+ taa.then_some((unjittered, frame.camera)),
+ );
if frame.title {
self.panorama.prepare(
&self.queue,
@@ -4158,13 +4188,12 @@ impl Renderer {
}
self.drawn_sections = visible.len() + visible_lods.len();
// AirDef fork: the HD sun shadows draw every section in the farther cascade's box.
- let hd_wanted = !frame.title
- && frame.hd.is_some()
- && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
- && !self.native.as_ref().is_some_and(|n| n.post.is_some());
- let hd_scene = hd::HdScene {
+ let mut hd_scene = hd::HdScene {
+ cascades: [Mat4::IDENTITY; 2],
view_proj,
- camera,
+ taa: taa && taa_prev.is_some(),
+ prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
+ camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
dark: 1.0 - frame.sky.sky_light_factor,
@@ -4174,10 +4203,34 @@ impl Renderer {
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
};
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
- let hd_cascades = hd::cascades(camera, shadow_dir);
+ let mut hd_cascades = hd::cascades(camera, shadow_dir);
+ // The far cascade is redrawn every second frame (it is snapped to texels and the sun is
+ // slow); between, the lighting pass gets the matrix its map was drawn with.
+ let hd_far_fresh = if hd_wanted && shadow_strength > 0.0 {
+ let fresh = match self.hd_far {
+ Some((_, at, dir, age)) if hd_shadow_fast() => {
+ age >= 1
+ || (camera - at).length_squared() > 48.0 * 48.0
+ || dir.dot(shadow_dir) < 0.99995
+ || self.hd.is_none()
+ }
+ _ => true,
+ };
+ if fresh {
+ self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
+ } else if let Some((held, _, _, age)) = &mut self.hd_far {
+ hd_cascades[1] = *held;
+ *age += 1;
+ }
+ fresh
+ } else {
+ self.hd_far = None;
+ true
+ };
+ hd_scene.cascades = hd_cascades;
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
hd_shadow_visible.clear();
- if hd_wanted && shadow_strength > 0.0 {
+ if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
let radius =
((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
let light = Frustum {
@@ -4191,6 +4244,11 @@ impl Renderer {
|_, _| false,
&mut hd_shadow_visible,
);
+ if hd_shadow_fast() {
+ drop_buried(&mut hd_shadow_visible, |slot| {
+ self.sections.get(slot).solid > 0
+ });
+ }
}
// Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's.
let globals_size = std::mem::size_of::<Globals>() as u64;
@@ -5022,7 +5080,12 @@ impl Renderer {
}
self.queue
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
+ // Facing the light only: the nearest surface to it is always one that does.
+ let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
for (c, cascade) in hd_cascades.iter().enumerate() {
+ if c == 1 && !hd_far_fresh {
+ continue;
+ }
let light = Globals {
view_proj: cascade.to_cols_array_2d(),
..globals
@@ -5059,7 +5122,13 @@ impl Renderer {
page = Some(s.page);
}
let instance = hd_shadow_first + k as u32;
- pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
+ facing_runs(&s.sides, toward, v.origin, |first, quads| {
+ pass.draw_indexed(
+ 0..quads * 6,
+ s.start as i32 + first as i32 * 4,
+ instance..instance + 1,
+ );
+ });
}
if !self.shadow_casters.is_empty() {
pass.set_pipeline(&self.shadow_entity);
@@ -5652,9 +5721,13 @@ impl Renderer {
self.mod_params.1,
);
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
- hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
+ hd.finish(&self.device, &mut encoder, source, &view_target);
+ }
+ _ => {
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Lit, depth);
+ hd.finish(&self.device, &mut encoder, source, &view_target);
}
- _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
}
} else if let Some((color, depth)) = &scaled
&& mod_post
@@ -6055,6 +6128,35 @@ fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: imp
None => {}
}
}
+/// `HORIZON_HD_SHADOW_FAST=0` draws the HD shadow cascades the slow way (every section, both
+/// faces, the far one every frame), for comparison.
+fn hd_shadow_fast() -> bool {
+ static FAST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *FAST.get_or_init(|| !std::env::var("HORIZON_HD_SHADOW_FAST").is_ok_and(|v| v == "0"))
+}
+/// Drops the sections that sit well under the ground of their own and all eight neighbouring
+/// columns: shut in by rock, what they hold neither lights nor shades anything the sun reaches.
+fn drop_buried(list: &mut Vec<resident::Visible>, has_solid: impl Fn(u32) -> bool) {
+ let mut top: std::collections::HashMap<(i32, i32), i32> = std::collections::HashMap::new();
+ for v in list.iter() {
+ if has_solid(v.slot) {
+ let e = top.entry((v.key.0, v.key.2)).or_insert(i32::MIN);
+ *e = (*e).max(v.key.1);
+ }
+ }
+ list.retain(|v| {
+ let mut lowest = i32::MAX;
+ for dx in -1..=1 {
+ for dz in -1..=1 {
+ match top.get(&(v.key.0 + dx, v.key.2 + dz)) {
+ Some(&t) => lowest = lowest.min(t),
+ None => return true,
+ }
+ }
+ }
+ v.key.1 > lowest - 3
+ });
+}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index d3944e97b..16610cb02 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -85,14 +85,26 @@ struct HdFrame {
ambient: [f32; 4],
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
shadow: [f32; 4],
+ /// The previous frame's camera-relative view-projection without jitter (for TAA).
+ prev_view_proj: [[f32; 4]; 4],
+ /// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
+ taa: [f32; 4],
+ /// x: the history's weight; y: the sharpening (0 without TAA).
+ taa_params: [f32; 4],
}
/// What the world around looks like this frame (from the renderer's globals).
#[derive(Clone, Copy, Debug)]
pub struct HdScene {
+ /// The shadow cascades' matrices (`cascades`; the far one may be a frame or two old).
+ pub cascades: [Mat4; 2],
+ /// The (jittered) projection the world was drawn with.
pub view_proj: Mat4,
- /// The camera in the world, for steady shadows.
- pub camera: glam::DVec3,
+ /// Last frame's view-projection without jitter, and the camera's move since (TAA).
+ pub prev_view_proj: Mat4,
+ pub camera_delta: glam::Vec3,
+ /// This frame is anti-aliased over time (the projection is jittered).
+ pub taa: bool,
/// Towards the sun, and the sky's darkening (0 day, 1 night).
pub sun: glam::Vec3,
pub dark: f32,
@@ -119,6 +131,9 @@ struct HdFrame {
fog: vec4<f32>,
ambient: vec4<f32>,
shadow: vec4<f32>,
+ prev_view_proj: mat4x4<f32>,
+ taa: vec4<f32>,
+ taa_params: vec4<f32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
@@ -507,7 +522,21 @@ const TONEMAP: &str = r#"
@fragment
fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
- var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb * frame.params.y;
+ var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb;
+ // A slight sharpen after TAA (its history resampling softens): the centre against its four
+ // neighbours, kept within their range so it can't ring.
+ let k = frame.taa_params.y;
+ if k > 0.0 {
+ let t = 1.0 / vec2<f32>(textureDimensions(hdr));
+ let n0 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(t.x, 0.0), 0.0).rgb;
+ let n1 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(t.x, 0.0), 0.0).rgb;
+ let n2 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(0.0, t.y), 0.0).rgb;
+ let n3 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(0.0, t.y), 0.0).rgb;
+ let lo = min(min(n0, n1), min(min(n2, n3), c));
+ let hi = max(max(n0, n1), max(max(n2, n3), c));
+ c = clamp(c * (1.0 + 4.0 * k) - (n0 + n1 + n2 + n3) * k, lo, hi);
+ }
+ c = c * frame.params.y;
c += textureSampleLevel(bloom, linear, in.uv, 0.0).rgb * frame.params.z;
// Up to 0.8 as it is (vanilla's look); above it, rolled off towards white by the
// brightest channel, so hues hold instead of clipping.
@@ -528,6 +557,130 @@ fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
}
"#;
+const TAA: &str = r#"
+struct TaaOut {
+ @location(0) color: vec4<f32>,
+ @location(1) history: vec4<f32>,
+}
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var source: texture_2d<f32>;
+@group(0) @binding(2) var depth_tex: texture_depth_2d;
+@group(0) @binding(3) var history: texture_2d<f32>;
+@group(0) @binding(4) var linear: sampler;
+
+fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
+ return vec3<f32>(c.r * 0.25 + c.g * 0.5 + c.b * 0.25, c.r * 0.5 - c.b * 0.5, c.g * 0.5 - c.r * 0.25 - c.b * 0.25);
+}
+fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
+ let t = c.x - c.z;
+ return vec3<f32>(t + c.y, c.x + c.z, t - c.y);
+}
+// Compress the range so a very bright pixel does not dominate the blend or the clamp.
+fn squash(c: vec3<f32>) -> vec3<f32> {
+ return c / (1.0 + max(c.r, max(c.g, c.b)));
+}
+fn unsquash(c: vec3<f32>) -> vec3<f32> {
+ return c / max(1.0 - max(c.r, max(c.g, c.b)), 0.001);
+}
+
+// The history at a uv, filtered with a Catmull-Rom spline (five bilinear taps) so that
+// resampling it every frame doesn't blur.
+fn history_color(uv: vec2<f32>) -> vec3<f32> {
+ let size = vec2<f32>(textureDimensions(history));
+ let pos = uv * size;
+ let c1 = floor(pos - 0.5) + 0.5;
+ let f = pos - c1;
+ let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
+ let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
+ let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
+ let w3 = f * f * (-0.5 + 0.5 * f);
+ let w12 = w1 + w2;
+ let t0 = (c1 - 1.0) / size;
+ let t3 = (c1 + 2.0) / size;
+ let t12 = (c1 + w2 / w12) / size;
+ var sum = textureSampleLevel(history, linear, vec2<f32>(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y);
+ var total = w12.x * w0.y;
+ sum += textureSampleLevel(history, linear, vec2<f32>(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y);
+ total += w0.x * w12.y;
+ sum += textureSampleLevel(history, linear, t12, 0.0).rgb * (w12.x * w12.y);
+ total += w12.x * w12.y;
+ sum += textureSampleLevel(history, linear, vec2<f32>(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y);
+ total += w3.x * w12.y;
+ sum += textureSampleLevel(history, linear, vec2<f32>(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
+ total += w12.x * w3.y;
+ return max(sum / total, vec3<f32>(0.0));
+}
+
+// Pulls `p` towards the box's centre until it is inside.
+fn clip_to_box(lo: vec3<f32>, hi: vec3<f32>, p: vec3<f32>) -> vec3<f32> {
+ let centre = (lo + hi) * 0.5;
+ let ext = max((hi - lo) * 0.5, vec3<f32>(0.00001));
+ let off = (p - centre) / ext;
+ let m = max(abs(off.x), max(abs(off.y), abs(off.z)));
+ return select(p, centre + (p - centre) / m, m > 1.0);
+}
+
+// Temporal anti-aliasing: the history, reprojected by depth and the camera's move, clamped to
+// the colours round this pixel (so what moved or was uncovered can't ghost), blended in.
+@fragment
+fn fs_taa(in: FullOut) -> TaaOut {
+ let size = vec2<i32>(textureDimensions(source));
+ let px = vec2<i32>(in.position.xy);
+ let d = textureLoad(depth_tex, px, 0);
+ var sum = vec3<f32>(0.0);
+ var sum2 = vec3<f32>(0.0);
+ var lo = vec3<f32>(1.0e9);
+ var hi = vec3<f32>(-1.0e9);
+ var centre = vec3<f32>(0.0);
+ for (var y = -1; y <= 1; y++) {
+ for (var x = -1; x <= 1; x++) {
+ let q = clamp(px + vec2<i32>(x, y), vec2<i32>(0), size - 1);
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
+ sum += s;
+ sum2 += s * s;
+ lo = min(lo, s);
+ hi = max(hi, s);
+ if x == 0 && y == 0 { centre = s; }
+ }
+ }
+ let sky = d <= 0.0;
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
+ let h = frame.inverse_view_proj * ndc;
+ let p = h.xyz / h.w;
+ let dist = select(length(p), 0.0, sky);
+ var resolved = centre;
+ if frame.taa.w > 0.5 {
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
+ if prev.w > 0.0 {
+ let pn = prev.xy / prev.w;
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
+ let hp = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - 1);
+ let seen = textureLoad(history, hp, 0).a;
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
+ // The same surface as last frame (not uncovered), by its distance.
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
+ let mean = sum / 9.0;
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
+ let box_lo = min(max(mean - sigma * 1.25, lo), centre);
+ let box_hi = max(min(mean + sigma * 1.25, hi), centre);
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
+ // Fast motion trusts the history less.
+ let moved = length((uv - in.uv) * vec2<f32>(size));
+ let keep = mix(frame.taa_params.x, frame.taa_params.x * 0.75, clamp(moved / 4.0, 0.0, 1.0));
+ resolved = mix(centre, past, keep);
+ }
+ }
+ }
+ }
+ let color = unsquash(from_ycocg(resolved));
+ var out: TaaOut;
+ out.color = vec4<f32>(color, 1.0);
+ out.history = vec4<f32>(color, dist);
+ return out;
+}
+"#;
+
struct Target {
view: wgpu::TextureView,
}
@@ -570,6 +723,35 @@ type LightKey = (
pub enum HdSource {
Lit,
Fx,
+ /// The anti-aliased result of `taa`.
+ Taa,
+}
+
+/// TAA is on unless `HORIZON_HD_TAA=0`.
+pub fn taa_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
+}
+
+/// The Halton sequence's `index`-th number in `base`, in (0, 1).
+fn halton(mut index: u32, base: u32) -> f32 {
+ let (mut f, mut r) = (1.0, 0.0);
+ while index > 0 {
+ f /= base as f32;
+ r += f * (index % base) as f32;
+ index /= base;
+ }
+ r
+}
+
+/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
+/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
+pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
+ let i = frame % 16 + 1;
+ [
+ (halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
+ (halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
+ ]
}
pub struct Hd {
@@ -589,6 +771,15 @@ pub struct Hd {
/// The lit world, and the post pass's output.
pub lit: wgpu::TextureView,
pub fx: wgpu::TextureView,
+ /// TAA: the resolved frame, and the two histories (one read, one written, alternating).
+ taa: wgpu::TextureView,
+ history: [wgpu::TextureView; 2],
+ history_ready: bool,
+ history_flip: usize,
+ taa_active: bool,
+ taa_layout: wgpu::BindGroupLayout,
+ taa_pipeline: wgpu::RenderPipeline,
+ taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
bloom: Vec<Target>,
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
/// lighting and the source-reading ones are keyed by the views they read.
@@ -902,6 +1093,44 @@ impl Hd {
UNFILTERED,
]),
});
+ let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD TAA"),
+ entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
+ });
+ let taa_module = module(device, "HD TAA", TAA);
+ let taa_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD TAA"),
+ bind_group_layouts: &[Some(&taa_layout)],
+ ..Default::default()
+ });
+ let target = Some(wgpu::ColorTargetState {
+ format: HDR,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD TAA"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &taa_module,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &taa_module,
+ entry_point: Some("fs_taa"),
+ compilation_options: Default::default(),
+ targets: &[target.clone(), target],
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("HD ambient occlusion"),
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
@@ -1080,6 +1309,17 @@ impl Hd {
.view,
lit: target(device, "HD lit", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
+ taa: target(device, "HD TAA", [1, 1], HDR).view,
+ history: [
+ target(device, "HD TAA history", [1, 1], HDR).view,
+ target(device, "HD TAA history", [1, 1], HDR).view,
+ ],
+ history_ready: false,
+ history_flip: 0,
+ taa_active: false,
+ taa_layout,
+ taa_pipeline,
+ taa_groups: None,
bloom: Vec::new(),
light_group: None,
prefilter_group: None,
@@ -1131,6 +1371,13 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ self.taa = target(device, "HD TAA", size, HDR).view;
+ self.history = [
+ target(device, "HD TAA history", size, HDR).view,
+ target(device, "HD TAA history", size, HDR).view,
+ ];
+ self.history_ready = false;
+ self.taa_groups = None;
let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
@@ -1206,9 +1453,22 @@ impl Hd {
}
// The sun by day, the moon (opposite) by night, faded out round the horizon.
let (dir, strength, color) = light_of(scene);
- let cascades = cascades(scene.camera, dir);
+ let cascades = scene.cascades;
let night = scene.dark.clamp(0.0, 1.0);
+ self.taa_active = scene.taa && taa_enabled();
+ // A long jump of the camera (a teleport) starts the history over.
+ if !self.taa_active || scene.camera_delta.length() > 64.0 {
+ self.history_ready = false;
+ }
let frame = HdFrame {
+ prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
+ taa: [
+ scene.camera_delta.x,
+ scene.camera_delta.y,
+ scene.camera_delta.z,
+ if self.history_ready { 1.0 } else { 0.0 },
+ ],
+ taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
resolution: [
self.size[0] as f32,
@@ -1489,6 +1749,7 @@ impl Hd {
let source = match source {
HdSource::Lit => self.lit.clone(),
HdSource::Fx => self.fx.clone(),
+ HdSource::Taa => self.taa.clone(),
};
if self
.prefilter_group
@@ -1572,6 +1833,70 @@ impl Hd {
);
}
+ /// Temporal anti-aliasing of `source` (with the world's `depth`): the source it left, to hand
+ /// to `finish` — the same one when TAA is off this frame.
+ pub fn taa(
+ &mut self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ source: HdSource,
+ depth: &wgpu::TextureView,
+ ) -> HdSource {
+ if !self.taa_active {
+ return source;
+ }
+ let input = match source {
+ HdSource::Lit => self.lit.clone(),
+ HdSource::Fx => self.fx.clone(),
+ HdSource::Taa => return source,
+ };
+ let key = (input, depth.clone());
+ if self.taa_groups.as_ref().is_none_or(|(k, _)| *k != key) {
+ let groups = [0, 1].map(|read| {
+ self.make_group(
+ device,
+ "HD TAA",
+ &self.taa_layout,
+ &[
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
+ wgpu::BindingResource::TextureView(&key.0),
+ wgpu::BindingResource::TextureView(depth),
+ wgpu::BindingResource::TextureView(&self.history[read]),
+ wgpu::BindingResource::Sampler(&self.sampler),
+ ],
+ )
+ });
+ self.taa_groups = Some((key, groups));
+ }
+ let Some((_, groups)) = &self.taa_groups else {
+ return source;
+ };
+ let (read, write) = (self.history_flip, 1 - self.history_flip);
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("HD TAA"),
+ timestamp_writes: stamp(&mut self.timing, "hd taa"),
+ color_attachments: &[&self.taa, &self.history[write]].map(|view| {
+ Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })
+ }),
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.taa_pipeline);
+ pass.set_bind_group(0, &groups[read], &[]);
+ pass.draw(0..3, 0..1);
+ drop(pass);
+ self.history_flip = write;
+ self.history_ready = true;
+ HdSource::Taa
+ }
+
/// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
/// its blur. Run after the G-buffer pass; nothing when the strength is 0.
pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
@@ -1698,6 +2023,7 @@ mod tests {
("light", LIGHT),
("bloom", BLOOM),
("tonemap", TONEMAP),
+ ("taa", TAA),
("ao", AO),
("ao blur", AO_BLUR),
] {
@@ -1714,6 +2040,19 @@ mod tests {
}
}
+ #[test]
+ fn jitter_stays_within_a_pixel() {
+ let size = [1920u32, 1080];
+ let mut seen = std::collections::HashSet::new();
+ for f in 0..32 {
+ let j = jitter(f, size);
+ assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
+ assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
+ seen.insert((j[0].to_bits(), j[1].to_bits()));
+ }
+ assert_eq!(seen.len(), 16);
+ }
+
#[test]
fn tile_lists_keep_lights_that_reach_a_tile() {
let size = [320, 160];
--
2.54.0 (Apple Git-157)
@@ -1,643 +0,0 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 17/42] Mods: model handles (render.model-id, model-generation,
set-models-by-id; API level 11) so mods send no model names each tick;
clear-post and set-lighting documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game/mod_models.rs | 21 +-
crates/horizon-client/src/game/mods.rs | 10 +
crates/horizon-mod-sdk/wit/horizon.wit | 31 +++
crates/horizon-mods/src/api_level.rs | 7 +-
crates/horizon-mods/src/runtime.rs | 212 +++++++++++++++----
docs/api/mods-reference.md | 113 ++++++++++
6 files changed, 351 insertions(+), 43 deletions(-)
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 2d1b4780a..f2329a123 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -11,7 +11,7 @@ use {
particles::{ParticleOptions, ParticleType},
},
horizon_core::random::RandomSource,
- horizon_mods::{CameraView, ParticleSpawn, PointLight},
+ horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight},
horizon_world::registry::BlockStateId,
std::collections::HashMap,
};
@@ -84,10 +84,21 @@ impl Game<'_> {
let registry = &self.pack.assets.registry;
let models = &mut self.mods.models;
for (owner, m) in self.mods.mods.models() {
- let state = *models
- .states
- .entry(m.model.clone())
- .or_insert_with(|| super::render_control::parse_state(registry, &m.model).ok());
+ let state = match &m.model {
+ ModelRef::Handle(h) => {
+ let id = BlockStateId(*h);
+ registry.state(id).map(|_| id)
+ }
+ ModelRef::Name(name) => {
+ if let Some(s) = models.states.get(name.as_str()) {
+ *s
+ } else {
+ let s = super::render_control::parse_state(registry, name).ok();
+ models.states.insert(name.clone(), s);
+ s
+ }
+ }
+ };
let Some(state) = state else {
continue;
};
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index a760c7763..db468c7cb 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -615,6 +615,16 @@ impl Game<'_> {
}
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
+ let registry = self.pack.assets.registry;
+ self.mods
+ .mods
+ .set_model_resolver(registry as *const _ as usize, || {
+ Box::new(move |name| {
+ super::render_control::parse_state(registry, name)
+ .ok()
+ .map(|s| s.0)
+ })
+ });
self.mods.mods.tick(self.ticks);
self.update_mod_models();
self.show_mod_output();
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 6696fa134..8019aab95 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -467,6 +467,34 @@ interface render {
/// each id between its last two positions and rotations every frame.
/// An empty list removes them.
set-models: func(models: list<model-instance>);
+ /// A handle for a block state name, for `set-models-by-id` (AirDef
+ /// fork, API level 11): the name crosses once, not every tick. None for
+ /// a name that is no block state of the game's registry (nothing is
+ /// remembered for it). Handles are valid until `model-generation`
+ /// changes (the registry changed: a resource reload, another world's
+ /// data); ask again for every name then.
+ model-id: func(model: string) -> option<u32>;
+ /// Moves on whenever the handles `model-id` gave stop being valid. A
+ /// mod reads it before `set-models-by-id` and re-asks when it changed.
+ model-generation: func() -> u32;
+ /// `model-instance` with the model as a handle.
+ record model-instance-id {
+ id: u32,
+ /// From `model-id`.
+ model: u32,
+ x: f64,
+ y: f64,
+ z: f64,
+ rotation: tuple<f32, f32, f32, f32>,
+ scale: f32,
+ glow: bool,
+ }
+ /// `set-models` with handles instead of names: the same models, limits
+ /// and interpolation, fewer bytes across the boundary. `generation` is
+ /// the `model-generation` the handles were asked under; if it is not
+ /// the current one the call is ignored and returns false (re-ask the
+ /// handles and call again). Handles the game doesn't know draw nothing.
+ set-models-by-id: func(generation: u32, models: list<model-instance-id>) -> bool;
/// Particles to spawn on this client (AirDef fork).
record particle-spawn {
/// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`.
@@ -508,6 +536,7 @@ interface render {
/// Compile errors come back in the chat and the log; the last good pass
/// keeps running.
set-post: func(wgsl: string);
+ /// Removes this mod's post pass: the world is shown as the game draws it.
clear-post: func();
/// The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until
/// replaced; call it once a tick (the pass gets the partial tick in
@@ -561,6 +590,8 @@ interface render {
/// How much bright things glow (0 none, 1 strong).
bloom: f32,
}
+ /// Switches the HD lighting on or off for this mod's world, with its
+ /// exposure and bloom; off, the world is drawn as the game draws it.
set-lighting: func(lighting: lighting);
}
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 80ac74b6f..d71396f59 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 10;
+pub const LEVEL: u32 = 11;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 58] = [
+pub const FUNCTIONS: [(&str, u32); 61] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -73,6 +73,9 @@ pub const FUNCTIONS: [(&str, u32); 58] = [
("game.world-id", 9),
("render.set-lights", 10),
("render.set-lighting", 10),
+ ("render.model-id", 11),
+ ("render.model-generation", 11),
+ ("render.set-models-by-id", 11),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 57713e228..951ff2e24 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -79,9 +79,10 @@ use render_bindings::horizon::mods::input;
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
/// What capability `render` asks the game to draw (WIT `interface render`).
pub use render_bindings::horizon::mods::render::{
- Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
- Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
- CameraView, Lighting as ModLighting, PointLight, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
+ CameraView, Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate,
+ ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId,
+ ParticleSpawn, Point as RenderPoint, PointLight, QuadShape, Rect as RenderRect, Shape,
+ SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -278,6 +279,42 @@ pub trait WorldView: std::any::Any + Send {
/// The view lent for the current calls, shared by every mod's store.
type WorldSlot = Arc<Mutex<Option<Box<dyn WorldView>>>>;
+/// How the game turns a block state name into a handle (`render.model-id`):
+/// None for a name that is no state of its registry.
+pub type ModelResolver = Box<dyn Fn(&str) -> Option<u32> + Send>;
+
+/// The handles of `render.model-id`: the game's registry decides which names
+/// have one, so nothing grows with what mods ask; `generation` moves on
+/// when the registry changes and the handles with it.
+#[derive(Default)]
+pub struct ModelTable {
+ key: usize,
+ generation: u32,
+ resolve: Option<ModelResolver>,
+}
+
+type ModelSlot = Arc<Mutex<ModelTable>>;
+
+/// A model as a mod set it: by name, or by a handle of the [`ModelTable`].
+#[derive(Clone, Debug)]
+pub enum ModelRef {
+ Name(String),
+ Handle(u32),
+}
+
+/// One world model (`render.set-models` or `set-models-by-id`).
+#[derive(Clone, Debug)]
+pub struct HostModel {
+ pub id: u32,
+ pub model: ModelRef,
+ pub x: f64,
+ pub y: f64,
+ pub z: f64,
+ pub rotation: (f32, f32, f32, f32),
+ pub scale: f32,
+ pub glow: bool,
+}
+
/// A mod's text panel, drawn every frame until it changes.
#[derive(Clone, Debug)]
pub struct Panel {
@@ -429,7 +466,9 @@ struct State {
/// Capability `render`'s far terrain, when the mod draws one.
far_terrain: Option<ModFarTerrain>,
/// Capability `render`'s world models (AirDef fork), drawn until replaced.
- models: Vec<ModelInstance>,
+ models: Vec<HostModel>,
+ /// The game's block state names -> handles, shared by every mod's store.
+ model_table: ModelSlot,
/// `render.query-ground`'s columns, answered before the next tick.
ground_queries: Vec<(i32, i32)>,
/// The game's answers to them.
@@ -464,6 +503,7 @@ pub struct Mods {
snapshot: Arc<Snapshot>,
out: Vec<Output>,
world: WorldSlot,
+ model_table: ModelSlot,
/// Moves on whenever a mod starts or stops: the game resends the server
/// mods' commands to players when it changes.
generation: u64,
@@ -492,6 +532,26 @@ impl Mods {
*slot = Some(view);
}
}
+ /// The game's registry for `render.model-id`. `key` names the registry
+ /// (its address): the same key keeps the handles, another one makes
+ /// `model-generation` move on and drops every mod's models. `make` runs
+ /// only then.
+ pub fn set_model_resolver(&mut self, key: usize, make: impl FnOnce() -> ModelResolver) {
+ let Ok(mut t) = self.model_table.lock() else {
+ return;
+ };
+ if t.key != key || t.resolve.is_none() {
+ t.key = key;
+ t.generation = t.generation.wrapping_add(1);
+ t.resolve = Some(make());
+ drop(t);
+ for e in &mut self.entries {
+ if let Some(r) = e.run.as_mut() {
+ r.store.data_mut().models.clear();
+ }
+ }
+ }
+ }
/// The view [`Mods::lend_world`] lent, back to the game.
pub fn take_world(&mut self) -> Option<Box<dyn WorldView>> {
self.world.lock().ok()?.take()
@@ -613,6 +673,7 @@ impl Mods {
.collect();
let snapshot = self.snapshot.clone();
let world = self.world.clone();
+ let model_table = self.model_table.clone();
let (engine, linker) = self.engine()?;
let component =
Component::new(engine, wasm).map_err(|e| format!("not a component: {e}"))?;
@@ -646,6 +707,7 @@ impl Mods {
minimap: None,
far_terrain: None,
models: Vec::new(),
+ model_table: model_table.clone(),
ground_queries: Vec::new(),
ground: Vec::new(),
post: None,
@@ -1209,7 +1271,7 @@ impl Mods {
}
/// Every running mod's world models (AirDef fork), with the mod's index
/// so ids of different mods don't meet.
- pub fn models(&self) -> impl Iterator<Item = (usize, &ModelInstance)> {
+ pub fn models(&self) -> impl Iterator<Item = (usize, &HostModel)> {
self.entries.iter().enumerate().flat_map(|(i, e)| {
e.run
.iter()
@@ -1234,7 +1296,9 @@ impl Mods {
}
/// The first running mod's lighting, if it set one.
pub fn lighting(&self) -> Option<ModLighting> {
- self.entries.iter().find_map(|e| e.run.as_ref()?.store.data().lighting)
+ self.entries
+ .iter()
+ .find_map(|e| e.run.as_ref()?.store.data().lighting)
}
/// The columns each running mod asked the ground height of, taken.
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
@@ -1435,6 +1499,29 @@ fn reason(e: &wasmtime::Error) -> String {
}
}
+/// The models a mod set, limited: finite, normalised, at most [`MODELS`].
+fn clean_models(models: impl Iterator<Item = HostModel>) -> Vec<HostModel> {
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
+ models
+ .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
+ .take(MODELS)
+ .map(|mut m| {
+ let (x, y, z, w) = m.rotation;
+ let len = (x * x + y * y + z * z + w * w).sqrt();
+ m.rotation = if len.is_finite() && len > 1e-6 {
+ (x / len, y / len, z / len, w / len)
+ } else {
+ (0.0, 0.0, 0.0, 1.0)
+ };
+ m.scale = if m.scale.is_finite() {
+ m.scale.clamp(0.0, MODEL_SCALE)
+ } else {
+ 1.0
+ };
+ m
+ })
+ .collect()
+}
fn clip(text: String) -> String {
clip_to(text, LINE_CHARS)
}
@@ -1949,28 +2036,45 @@ impl render::Host for State {
}
}
fn set_models(&mut self, models: Vec<ModelInstance>) {
- let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
- self.models = models
- .into_iter()
- .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
- .take(MODELS)
- .map(|mut m| {
- let (x, y, z, w) = m.rotation;
- let len = (x * x + y * y + z * z + w * w).sqrt();
- m.rotation = if len.is_finite() && len > 1e-6 {
- (x / len, y / len, z / len, w / len)
- } else {
- (0.0, 0.0, 0.0, 1.0)
- };
- m.scale = if m.scale.is_finite() {
- m.scale.clamp(0.0, MODEL_SCALE)
- } else {
- 1.0
- };
- m.model = clip(m.model);
- m
- })
- .collect();
+ let models = models.into_iter().map(|m| HostModel {
+ id: m.id,
+ model: ModelRef::Name(clip(m.model)),
+ x: m.x,
+ y: m.y,
+ z: m.z,
+ rotation: m.rotation,
+ scale: m.scale,
+ glow: m.glow,
+ });
+ self.models = clean_models(models);
+ }
+ fn model_id(&mut self, model: String) -> Option<u32> {
+ let t = self.model_table.lock().ok()?;
+ (t.resolve.as_ref()?)(&model)
+ }
+ fn model_generation(&mut self) -> u32 {
+ self.model_table.lock().map_or(0, |t| t.generation)
+ }
+ fn set_models_by_id(&mut self, generation: u32, models: Vec<ModelInstanceId>) -> bool {
+ if self
+ .model_table
+ .lock()
+ .map_or(true, |t| t.generation != generation)
+ {
+ return false;
+ }
+ let models = models.into_iter().map(|m| HostModel {
+ id: m.id,
+ model: ModelRef::Handle(m.model),
+ x: m.x,
+ y: m.y,
+ z: m.z,
+ rotation: m.rotation,
+ scale: m.scale,
+ glow: m.glow,
+ });
+ self.models = clean_models(models);
+ true
}
fn spawn_particles(&mut self, particles: Vec<ParticleSpawn>) {
let particles: Vec<ParticleSpawn> = particles
@@ -2012,7 +2116,11 @@ impl render::Host for State {
.map(|mut v| {
v.yaw = angle(v.yaw);
v.pitch = angle(v.pitch).clamp(-90.0, 90.0);
- v.fov = if v.fov.is_finite() { v.fov.clamp(1.0, 110.0) } else { 70.0 };
+ v.fov = if v.fov.is_finite() {
+ v.fov.clamp(1.0, 110.0)
+ } else {
+ 70.0
+ };
v.width = v.width.clamp(16, 1920);
v.height = v.height.clamp(16, 1080);
v
@@ -2021,22 +2129,44 @@ impl render::Host for State {
}
fn set_lights(&mut self, lights: Vec<PointLight>) {
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
- let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
+ let unit = |v: f32| {
+ if v.is_finite() {
+ v.clamp(0.0, 1.0)
+ } else {
+ 0.0
+ }
+ };
self.lights = lights
.into_iter()
.filter(|l| finite(l.x) && finite(l.y) && finite(l.z))
.take(512)
.map(|mut l| {
l.color = (unit(l.color.0), unit(l.color.1), unit(l.color.2));
- l.intensity = if l.intensity.is_finite() { l.intensity.clamp(0.0, 100_000.0) } else { 0.0 };
- l.radius = if l.radius.is_finite() { l.radius.clamp(1.0, 256.0) } else { 1.0 };
+ l.intensity = if l.intensity.is_finite() {
+ l.intensity.clamp(0.0, 100_000.0)
+ } else {
+ 0.0
+ };
+ l.radius = if l.radius.is_finite() {
+ l.radius.clamp(1.0, 256.0)
+ } else {
+ 1.0
+ };
l
})
.collect();
}
fn set_lighting(&mut self, mut lighting: ModLighting) {
- lighting.exposure = if lighting.exposure.is_finite() { lighting.exposure.clamp(0.05, 8.0) } else { 1.0 };
- lighting.bloom = if lighting.bloom.is_finite() { lighting.bloom.clamp(0.0, 1.0) } else { 0.0 };
+ lighting.exposure = if lighting.exposure.is_finite() {
+ lighting.exposure.clamp(0.05, 8.0)
+ } else {
+ 1.0
+ };
+ lighting.bloom = if lighting.bloom.is_finite() {
+ lighting.bloom.clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
self.lighting = Some(lighting);
}
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
@@ -2050,8 +2180,18 @@ impl render::Host for State {
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
return;
}
- let ok = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max) } else { 1.0 };
- let sounds = self.out.iter().filter(|o| matches!(o, Output::Sound { .. })).count();
+ let ok = |v: f32, max: f32| {
+ if v.is_finite() {
+ v.clamp(0.0, max)
+ } else {
+ 1.0
+ }
+ };
+ let sounds = self
+ .out
+ .iter()
+ .filter(|o| matches!(o, Output::Sound { .. }))
+ .count();
if sounds < 64 {
self.out.push(Output::Sound {
event: clip(event),
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index b25ef5b9e..150453ed5 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -125,6 +125,7 @@ A block along the player's view.
| --- | --- | --- |
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
+| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
## `chat`
@@ -399,6 +400,16 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `centered` | `bool` | Centred on `at.x` instead of starting there. |
| `shadow` | `bool` | |
+### record `view-shape`
+
+A picture of one of the mod's `set-views` cameras (AirDef fork).
+
+| Field | Type | |
+| --- | --- | --- |
+| `view` | `u32` | The camera's id; an unknown one draws nothing. |
+| `rect` | `rect` | |
+| `mode` | `u32` | 0 as seen, 1 thermal (white hot), 2 night vision (green). |
+
### variant `shape`
| Case | Payload | |
@@ -408,6 +419,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `sprite` | `sprite-shape` | |
| `item` | `item-shape` | |
| `text` | `text-shape` | |
+| `view` | `view-shape` | |
### record `frame-rate`
@@ -442,6 +454,93 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
+### record `model-instance`
+
+A block model drawn in the world (AirDef fork).
+
+| Field | Type | |
+| --- | --- | --- |
+| `id` | `u32` | Stays the same while the object lives: the game moves an id smoothly from where the last `set-models` put it. |
+| `model` | `string` | A block state as commands write it (`airdef:shahed`, `minecraft:stone`, `minecraft:furnace[facing=east]`): its block model is drawn. An unknown one draws nothing. |
+| `x` | `f64` | Where the model's centre is, in blocks. |
+| `y` | `f64` | |
+| `z` | `f64` | |
+| `rotation` | `tuple<f32, f32, f32, f32>` | Rotation about the model's centre: a quaternion (x, y, z, w). |
+| `scale` | `f32` | Uniform scale, 1.0 a block (0 to 64). |
+| `glow` | `bool` | Drawn at full brightness, whatever the light where it is. |
+
+### record `model-instance-id`
+
+`model-instance` with the model as a handle.
+
+| Field | Type | |
+| --- | --- | --- |
+| `id` | `u32` | |
+| `model` | `u32` | From `model-id`. |
+| `x` | `f64` | |
+| `y` | `f64` | |
+| `z` | `f64` | |
+| `rotation` | `tuple<f32, f32, f32, f32>` | |
+| `scale` | `f32` | |
+| `glow` | `bool` | |
+
+### record `particle-spawn`
+
+Particles to spawn on this client (AirDef fork).
+
+| Field | Type | |
+| --- | --- | --- |
+| `particle` | `string` | As `/particle` names the type: `minecraft:flame`, `minecraft:dust`. |
+| `x` | `f64` | |
+| `y` | `f64` | |
+| `z` | `f64` | |
+| `spread` | `tuple<f32, f32, f32>` | Gaussian spread round the point, per axis, in blocks. |
+| `velocity` | `tuple<f32, f32, f32>` | Every particle's starting velocity, blocks a tick. |
+| `speed` | `f32` | Gaussian speed added to it per axis. |
+| `count` | `u32` | 1 to 256. |
+| `color` | `u32` | 0xRRGGBB for dust and coloured types (0: white). |
+| `scale` | `f32` | Dust size (0.01 to 4). |
+
+### record `camera-view`
+
+A camera the game renders the world from besides the player's eye (AirDef fork): the land, the sky and the entities near the player.
+
+| Field | Type | |
+| --- | --- | --- |
+| `id` | `u32` | Stays the same while the camera lives: the game turns it smoothly between the last two `set-views`. |
+| `x` | `f64` | |
+| `y` | `f64` | |
+| `z` | `f64` | |
+| `yaw` | `f32` | Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down. |
+| `pitch` | `f32` | |
+| `fov` | `f32` | Vertical field of view, degrees (1 to 110). |
+| `width` | `u32` | The picture's size in pixels (16 to 1920 × 16 to 1080). |
+| `height` | `u32` | |
+
+### record `point-light`
+
+A light the mod's things give off (AirDef fork): rocket flames, flares, blasts. Drawn by the HD lighting (`set-lighting`).
+
+| Field | Type | |
+| --- | --- | --- |
+| `id` | `u32` | Stays the same while the light lives: the game moves it smoothly. |
+| `x` | `f64` | |
+| `y` | `f64` | |
+| `z` | `f64` | |
+| `color` | `tuple<f32, f32, f32>` | Linear RGB, 0 to 1. |
+| `intensity` | `f32` | Brightness: about what it lights one block away (0 to 100000). |
+| `radius` | `f32` | Nothing past this many blocks (1 to 256). |
+
+### record `lighting`
+
+The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in high dynamic range, with bloom and tone mapping.
+
+| Field | Type | |
+| --- | --- | --- |
+| `hd` | `bool` | |
+| `exposure` | `f32` | 1 as is; more brightens. |
+| `bloom` | `f32` | How much bright things glow (0 none, 1 strong). |
+
| Function | Signature | What it does |
| --- | --- | --- |
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
@@ -454,6 +553,20 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
+| `set-models` | `func(models: list<model-instance>)` | Replaces the mod's models: up to 4096, drawn with the world's entities until replaced. Call it once a tick: the game interpolates each id between its last two positions and rotations every frame. An empty list removes them. |
+| `model-id` | `func(model: string) -> option<u32>` | A handle for a block state name, for `set-models-by-id` (AirDef fork, API level 11): the name crosses once, not every tick. None for a name that is no block state of the game's registry (nothing is remembered for it). Handles are valid until `model-generation` changes (the registry changed: a resource reload, another world's data); ask again for every name then. |
+| `model-generation` | `func() -> u32` | Moves on whenever the handles `model-id` gave stop being valid. A mod reads it before `set-models-by-id` and re-asks when it changed. |
+| `set-models-by-id` | `func(generation: u32, models: list<model-instance-id>) -> bool` | `set-models` with handles instead of names: the same models, limits and interpolation, fewer bytes across the boundary. `generation` is the `model-generation` the handles were asked under; if it is not the current one the call is ignored and returns false (re-ask the handles and call again). Handles the game doesn't know draw nothing. |
+| `spawn-particles` | `func(particles: list<particle-spawn>)` | Spawns particles on this client only: no command, no server. At most 512 spawns a call. |
+| `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `<mod>:<name>`) at a point on this client. |
+| `query-ground` | `func(columns: list<tuple<s32, s32>>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. |
+| `ground` | `func() -> list<tuple<s32, s32, s32>>` | The answers to the last tick's `query-ground`: (x, z, y), y one above the highest block that blocks motion or holds a fluid, as the motion blocking heightmap; -64 where the chunk isn't loaded. |
+| `set-post` | `func(wgsl: string)` | A full-screen post pass (AirDef fork): WGSL defining `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, after a prelude with `frame` (camera matrices, time, `params`), the world's colour and depth and helpers (`scene`, `depth-at`, `view-dir`, `scene-distance`, `project`). It runs after the world, before the hand and the HUD. Compile errors come back in the chat and the log; the last good pass keeps running. |
+| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
+| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
+| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
+| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
+| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
## `events`
--
2.54.0 (Apple Git-157)
@@ -1,527 +0,0 @@
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:19:57 +0300
Subject: [PATCH 18/42] HD: temporal upscaling (HORIZON_HD_SCALE: world,
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
pass); fix flickering shadows near the player (the near cascade lost its
sections on frames the far one was held, the held far matrix ignored the
camera's step, plants' normals turned with the camera); TAA keeps more
history when still
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 33 ++-
crates/horizon-client/src/gfx/hd.rs | 211 ++++++++++++++++++--
crates/horizon-client/src/gfx/world_post.rs | 8 +-
crates/horizon-client/src/terrain.wgsl | 4 +
4 files changed, 223 insertions(+), 33 deletions(-)
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index eb152667c..b85c6e269 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -3970,13 +3970,15 @@ impl Renderer {
// offset each frame; `unjittered` is what the next frame reprojects with.
let unjittered = self.view_projection(frame);
let taa = hd_wanted && hd::taa_enabled();
+ let mut jitter_px = [0.0; 2];
let view_proj = if taa {
- let size = self
- .world_post
- .size()
- .unwrap_or([self.config.width, self.config.height]);
- let j = hd::jitter(self.taa_frame, size);
+ let window = [self.config.width, self.config.height];
+ let size = self.world_post.size().unwrap_or(window);
+ // Upscaling (the world smaller than the window) cycles through more positions.
+ let ratio = window[0] as f32 / size[0].max(1) as f32;
+ let j = hd::jitter(self.taa_frame, size, hd::jitter_count(ratio));
self.taa_frame = self.taa_frame.wrapping_add(1);
+ jitter_px = [j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5];
Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
} else {
unjittered
@@ -4192,6 +4194,7 @@ impl Renderer {
cascades: [Mat4::IDENTITY; 2],
view_proj,
taa: taa && taa_prev.is_some(),
+ jitter_px,
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
@@ -4218,8 +4221,11 @@ impl Renderer {
};
if fresh {
self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
- } else if let Some((held, _, _, age)) = &mut self.hd_far {
- hd_cascades[1] = *held;
+ } else if let Some((held, at, _, age)) = &mut self.hd_far {
+ // The held matrix is relative to the camera it was drawn from: shifted by the
+ // camera's step since, so the map stays put in the world as the player walks.
+ let step = (camera - *at).as_vec3();
+ hd_cascades[1] = *held * Mat4::from_translation(step);
*age += 1;
}
fresh
@@ -4230,11 +4236,14 @@ impl Renderer {
hd_scene.cascades = hd_cascades;
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
hd_shadow_visible.clear();
- if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
+ if hd_wanted && shadow_strength > 0.0 {
+ // The near cascade is drawn every frame: between the far one's redraws the list holds
+ // only the sections in the near box.
+ let c = if hd_far_fresh { 1 } else { 0 };
let radius =
- ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
+ ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[c] / 16.0) as i32 + 2);
let light = Frustum {
- planes: frustum(hd_cascades[1]),
+ planes: frustum(hd_cascades[c]),
};
self.sections.visible(
camera,
@@ -5028,6 +5037,8 @@ impl Renderer {
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_weather());
+ // AirDef fork: HD renders the world at its own scale and upscales it in the TAA pass.
+ self.world_post.hd_scale = hd_on.then(hd::scale_override).flatten();
let scaled = self.world_post.prepare(
&self.device,
[self.config.width, self.config.height],
@@ -5046,7 +5057,7 @@ impl Renderer {
let hd = self
.hd
.get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size));
- hd.resize(device, size);
+ hd.resize(device, size, [self.config.width, self.config.height]);
if let Some(settings) = frame.hd {
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
}
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 16610cb02..c54b7b961 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -89,7 +89,7 @@ struct HdFrame {
prev_view_proj: [[f32; 4]; 4],
/// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
taa: [f32; 4],
- /// x: the history's weight; y: the sharpening (0 without TAA).
+ /// x: the history's weight; y: the sharpening (0 without TAA); zw: the jitter in world pixels.
taa_params: [f32; 4],
}
@@ -105,6 +105,9 @@ pub struct HdScene {
pub camera_delta: glam::Vec3,
/// This frame is anti-aliased over time (the projection is jittered).
pub taa: bool,
+ /// The jitter in world-target pixels, screen orientation (x right, y down): the world's sample
+ /// of a pixel lies this far from its centre (the upscaler weighs samples by it).
+ pub jitter_px: [f32; 2],
/// Towards the sun, and the sky's darkening (0 day, 1 night).
pub sun: glam::Vec3,
pub dark: f32,
@@ -662,8 +665,8 @@ fn fs_taa(in: FullOut) -> TaaOut {
if abs(seen - expected) <= expected * 0.03 + 0.25 {
let mean = sum / 9.0;
let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
- let box_lo = min(max(mean - sigma * 1.25, lo), centre);
- let box_hi = max(min(mean + sigma * 1.25, hi), centre);
+ let box_lo = min(max(mean - sigma * 1.5, lo), centre);
+ let box_hi = max(min(mean + sigma * 1.5, hi), centre);
let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
// Fast motion trusts the history less.
let moved = length((uv - in.uv) * vec2<f32>(size));
@@ -679,6 +682,99 @@ fn fs_taa(in: FullOut) -> TaaOut {
out.history = vec4<f32>(color, dist);
return out;
}
+
+// The jittered samples' reconstruction filter (a polynomial fit of Lanczos-2, radius 2 in
+// world-target pixels): 1 at 0, 0 at 1 and 2, a little negative between.
+fn lanczos2(x: f32) -> f32 {
+ let x2 = min(x * x, 4.0);
+ let a = 0.4 * x2 - 1.0;
+ let b = 0.25 * x2 - 1.0;
+ return (1.5625 * a * a - 0.5625) * b * b;
+}
+
+// Temporal upscaling: the world (and its depth) is smaller than the output. Each output pixel
+// gathers the 3x3 world samples round it, each weighed by its distance from the pixel's centre
+// (the samples lie off the pixel centres by the frame's jitter), clamps the reprojected full-size
+// history to their colour box and blends: more history where the nearest sample is far away.
+@fragment
+fn fs_taau(in: FullOut) -> TaaOut {
+ let low = vec2<f32>(textureDimensions(source));
+ let lowi = vec2<i32>(low);
+ let high = vec2<f32>(textureDimensions(history));
+ let jit = frame.taa_params.zw;
+ // The output pixel's place among the world's samples: sample i lies at index i - jit, and
+ // the nearest to this pixel is `base`.
+ let t = in.uv * low - 0.5 + jit;
+ let base = vec2<i32>(floor(t + 0.5));
+ var sum = vec3<f32>(0.0);
+ var sum2 = vec3<f32>(0.0);
+ var lo = vec3<f32>(1.0e9);
+ var hi = vec3<f32>(-1.0e9);
+ var weighted = vec3<f32>(0.0);
+ var total = 0.0;
+ for (var y = -1; y <= 1; y++) {
+ for (var x = -1; x <= 1; x++) {
+ let q = clamp(base + vec2<i32>(x, y), vec2<i32>(0), lowi - 1);
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
+ sum += s;
+ sum2 += s * s;
+ lo = min(lo, s);
+ hi = max(hi, s);
+ let off = vec2<f32>(base + vec2<i32>(x, y)) - t;
+ let w = lanczos2(off.x) * lanczos2(off.y);
+ weighted += s * w;
+ total += w;
+ }
+ }
+ let current = weighted / max(total, 0.001);
+ // Depth: the nearest of the 2x2 samples round the pixel (steady between frames at an edge).
+ let corner = vec2<i32>(floor(in.uv * low - 0.5 + jit));
+ var d = 0.0;
+ for (var y = 0; y <= 1; y++) {
+ for (var x = 0; x <= 1; x++) {
+ d = max(d, textureLoad(depth_tex, clamp(corner + vec2<i32>(x, y), vec2<i32>(0), lowi - 1), 0));
+ }
+ }
+ let sky = d <= 0.0;
+ // The world was drawn shifted by the jitter in clip space; this pixel's unshifted point.
+ let jc = vec2<f32>(jit.x * 2.0 / low.x, -jit.y * 2.0 / low.y);
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0 + jc.x, 1.0 - in.uv.y * 2.0 + jc.y, max(d, 0.000001), 1.0);
+ let h = frame.inverse_view_proj * ndc;
+ let p = h.xyz / h.w;
+ let dist = select(length(p), 0.0, sky);
+ var resolved = current;
+ if frame.taa.w > 0.5 {
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
+ if prev.w > 0.0 {
+ let pn = prev.xy / prev.w;
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
+ let hp = clamp(vec2<i32>(uv * high), vec2<i32>(0), vec2<i32>(high) - 1);
+ let seen = textureLoad(history, hp, 0).a;
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
+ let mean = sum / 9.0;
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
+ let box_lo = min(max(mean - sigma * 1.25, lo), current);
+ let box_hi = max(min(mean + sigma * 1.25, hi), current);
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
+ // How far the nearest sample is from the pixel's centre (0 on it, 1 at the
+ // corner of the world's pixel): the farther, the less the frame says.
+ let near = clamp(length(t - floor(t + 0.5)) / 0.7071, 0.0, 1.0);
+ let fresh = (1.0 - frame.taa_params.x) * mix(1.8, 0.5, near);
+ let moved = length((uv - in.uv) * high);
+ let keep = (1.0 - fresh) * mix(1.0, 0.8, clamp(moved / 4.0, 0.0, 1.0));
+ resolved = mix(current, past, clamp(keep, 0.0, 0.98));
+ }
+ }
+ }
+ }
+ let color = unsquash(from_ycocg(resolved));
+ var out: TaaOut;
+ out.color = vec4<f32>(color, 1.0);
+ out.history = vec4<f32>(color, dist);
+ return out;
+}
"#;
struct Target {
@@ -744,10 +840,35 @@ fn halton(mut index: u32, base: u32) -> f32 {
r
}
+/// The history's weight when upscaling (the frames each pixel gathers are sparse, so more of them
+/// are kept than without; `fs_taau` lowers it where the frame's sample is near).
+const UPSCALE_HISTORY: f32 = 0.92;
+
+/// The world's size relative to the output (`HORIZON_HD_SCALE`, per axis, 0.25..=1) while HD is on, if
+/// set: it replaces the resolution scale of Horizon's settings for HD. 1 renders at full size.
+pub fn scale_override() -> Option<f32> {
+ static SCALE: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
+ *SCALE.get_or_init(|| {
+ let v: f32 = std::env::var("HORIZON_HD_SCALE").ok()?.parse().ok()?;
+ v.is_finite().then(|| v.clamp(0.25, 1.0))
+ })
+}
+
+/// How many jitter positions the sequence cycles through for an output `ratio` times the world's
+/// size per axis: 16 without upscaling, else 8 per output pixel's share of a world pixel
+/// (ratio squared), so every output pixel gets samples all over the world pixel it lies in.
+pub fn jitter_count(ratio: f32) -> u32 {
+ if ratio <= 1.001 {
+ 16
+ } else {
+ ((8.0 * ratio * ratio).ceil() as u32).clamp(16, 64)
+ }
+}
+
/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
-/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
-pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
- let i = frame % 16 + 1;
+/// screen's size in x and y), from the Halton(2, 3) sequence over `count` frames.
+pub fn jitter(frame: u32, size: [u32; 2], count: u32) -> [f32; 2] {
+ let i = frame % count.max(1) + 1;
[
(halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
(halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
@@ -755,7 +876,10 @@ pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
}
pub struct Hd {
+ /// The world's size (the G-buffer, lighting, AO, post pass), and the output's (TAA, bloom,
+ /// tone curve): bigger when the world is upscaled.
size: [u32; 2],
+ out: [u32; 2],
pub albedo: wgpu::TextureView,
pub normal: wgpu::TextureView,
pub levels: wgpu::TextureView,
@@ -779,6 +903,7 @@ pub struct Hd {
taa_active: bool,
taa_layout: wgpu::BindGroupLayout,
taa_pipeline: wgpu::RenderPipeline,
+ taau_pipeline: wgpu::RenderPipeline,
taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
bloom: Vec<Target>,
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
@@ -1098,7 +1223,7 @@ impl Hd {
entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
});
let taa_module = module(device, "HD TAA", TAA);
- let taa_pipeline = {
+ let taa_pipeline = |entry: &str| {
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("HD TAA"),
bind_group_layouts: &[Some(&taa_layout)],
@@ -1120,7 +1245,7 @@ impl Hd {
},
fragment: Some(wgpu::FragmentState {
module: &taa_module,
- entry_point: Some("fs_taa"),
+ entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[target.clone(), target],
}),
@@ -1131,6 +1256,7 @@ impl Hd {
cache: None,
})
};
+ let (taa_pipeline, taau_pipeline) = (taa_pipeline("fs_taa"), taa_pipeline("fs_taau"));
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("HD ambient occlusion"),
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
@@ -1288,6 +1414,7 @@ impl Hd {
};
let mut hd = Hd {
size: [0, 0],
+ out: [0, 0],
albedo: target(device, "HD albedo", [1, 1], ALBEDO).view,
normal: target(device, "HD normal", [1, 1], NORMAL).view,
levels: target(device, "HD light levels", [1, 1], LIGHT_LEVELS).view,
@@ -1319,6 +1446,7 @@ impl Hd {
taa_active: false,
taa_layout,
taa_pipeline,
+ taau_pipeline,
taa_groups: None,
bloom: Vec::new(),
light_group: None,
@@ -1355,26 +1483,29 @@ impl Hd {
groups_seen: 0,
groups_last_frame: 0,
};
- hd.resize(device, size);
+ hd.resize(device, size, size);
hd
}
- /// The targets, at the world target's size.
- pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
- if self.size == size {
+ /// The targets: the world's at `size` (the world target's), the output's (TAA history, bloom)
+ /// at `out`, which is bigger when the world is upscaled.
+ pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2], out: [u32; 2]) {
+ let out = [out[0].max(size[0]), out[1].max(size[1])];
+ if self.size == size && self.out == out {
return;
}
self.size = size;
+ self.out = out;
self.albedo = target(device, "HD albedo", size, ALBEDO).view;
self.normal = target(device, "HD normal", size, NORMAL).view;
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
- self.taa = target(device, "HD TAA", size, HDR).view;
+ self.taa = target(device, "HD TAA", out, HDR).view;
self.history = [
- target(device, "HD TAA history", size, HDR).view,
- target(device, "HD TAA history", size, HDR).view,
+ target(device, "HD TAA history", out, HDR).view,
+ target(device, "HD TAA history", out, HDR).view,
];
self.history_ready = false;
self.taa_groups = None;
@@ -1404,7 +1535,7 @@ impl Hd {
self.tiles_x = size[0].div_ceil(TILE).max(1);
self.bloom = (0..BLOOM_LEVELS)
.map(|k| {
- let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
+ let s = [(out[0] >> (k + 1)).max(1), (out[1] >> (k + 1)).max(1)];
target(device, "HD bloom", s, HDR)
})
.collect();
@@ -1420,6 +1551,11 @@ impl Hd {
.collect();
}
+ /// The world is smaller than the output, so TAA upsamples it.
+ fn upscaling(&self) -> bool {
+ self.out != self.size
+ }
+
/// This frame's camera, sun, fog, lights and settings.
pub fn write(
&mut self,
@@ -1468,7 +1604,18 @@ impl Hd {
scene.camera_delta.z,
if self.history_ready { 1.0 } else { 0.0 },
],
- taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
+ taa_params: [
+ if self.upscaling() {
+ UPSCALE_HISTORY
+ } else {
+ // A still picture keeps 94% of the past: the jitter's shimmer on pixel-art
+ // textures averages out instead of flickering.
+ 0.94
+ },
+ if self.taa_active { 0.12 } else { 0.0 },
+ scene.jitter_px[0],
+ scene.jitter_px[1],
+ ],
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
resolution: [
self.size[0] as f32,
@@ -1872,9 +2019,13 @@ impl Hd {
return source;
};
let (read, write) = (self.history_flip, 1 - self.history_flip);
+ let upscaling = self.upscaling();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HD TAA"),
- timestamp_writes: stamp(&mut self.timing, "hd taa"),
+ timestamp_writes: stamp(
+ &mut self.timing,
+ if upscaling { "hd taau" } else { "hd taa" },
+ ),
color_attachments: &[&self.taa, &self.history[write]].map(|view| {
Some(wgpu::RenderPassColorAttachment {
view,
@@ -1888,7 +2039,11 @@ impl Hd {
}),
..Default::default()
});
- pass.set_pipeline(&self.taa_pipeline);
+ pass.set_pipeline(if upscaling {
+ &self.taau_pipeline
+ } else {
+ &self.taa_pipeline
+ });
pass.set_bind_group(0, &groups[read], &[]);
pass.draw(0..3, 0..1);
drop(pass);
@@ -2045,7 +2200,7 @@ mod tests {
let size = [1920u32, 1080];
let mut seen = std::collections::HashSet::new();
for f in 0..32 {
- let j = jitter(f, size);
+ let j = jitter(f, size, 16);
assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
seen.insert((j[0].to_bits(), j[1].to_bits()));
@@ -2053,6 +2208,22 @@ mod tests {
assert_eq!(seen.len(), 16);
}
+ #[test]
+ fn jitter_covers_the_upscale_ratio() {
+ assert_eq!(jitter_count(1.0), 16);
+ assert_eq!(jitter_count(1.0 / 0.67), 18);
+ assert_eq!(jitter_count(2.0), 32);
+ let size = [1280u32, 720];
+ let count = jitter_count(2.0);
+ let seen: std::collections::HashSet<_> = (0..count * 2)
+ .map(|f| {
+ let j = jitter(f, size, count);
+ (j[0].to_bits(), j[1].to_bits())
+ })
+ .collect();
+ assert_eq!(seen.len(), count as usize);
+ }
+
#[test]
fn tile_lists_keep_lights_that_reach_a_tile() {
let size = [320, 160];
diff --git a/crates/horizon-client/src/gfx/world_post.rs b/crates/horizon-client/src/gfx/world_post.rs
index 17609d27d..a32bdc964 100644
--- a/crates/horizon-client/src/gfx/world_post.rs
+++ b/crates/horizon-client/src/gfx/world_post.rs
@@ -18,6 +18,8 @@ pub struct Post {
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
+ /// HD's own scale for this frame (`HORIZON_HD_SCALE`), instead of `scale`.
+ pub hd_scale: Option<f32>,
pub antialias: bool,
target: Option<WorldTarget>,
}
@@ -94,6 +96,7 @@ impl Post {
sampler,
format,
scale: 1.0,
+ hd_scale: None,
antialias: false,
target: None,
}
@@ -108,11 +111,12 @@ impl Post {
window: [u32; 2],
always: bool,
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
- if self.scale == 1.0 && !self.antialias && !always {
+ let scale = self.hd_scale.unwrap_or(self.scale);
+ if scale == 1.0 && !self.antialias && !always {
self.target = None;
return None;
}
- let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
+ let size = window.map(|v| ((v as f32 * scale).round() as u32).clamp(1, 8192));
if self.target.as_ref().is_none_or(|t| t.size != size) {
let extent = wgpu::Extent3d {
width: size[0],
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index bb5c70f63..c44449cc7 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -166,6 +166,10 @@ struct GBufferOut {
}
fn face_normal(pos: vec3<f32>) -> vec3<f32> {
var n = normalize(cross(dpdx(pos), dpdy(pos)));
+ // Block faces are axis-aligned; only plants' crossed quads lean. Those have two
+ // sides, so a normal turned to the camera would flip as the player walks by and
+ // the sun and the shadow lookup with it: plants take the up normal instead.
+ if max(abs(n.x), max(abs(n.y), abs(n.z))) < 0.9 { return vec3(0.0, 1.0, 0.0); }
if dot(n, pos) > 0.0 { n = -n; }
return n;
}
--
2.54.0 (Apple Git-157)
@@ -1,982 +0,0 @@
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:47:52 +0300
Subject: [PATCH 19/42] HD water: its own pass (colour, normal, depth), the
floor under it lit, a composite with world-space waves, refraction with depth
absorption in the biome colour, screen-space reflections, Fresnel and a
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 9 +
crates/horizon-client/src/gfx.rs | 109 +++++-
crates/horizon-client/src/gfx/flat.rs | 4 +
crates/horizon-client/src/gfx/hd.rs | 497 ++++++++++++++++++++++++-
crates/horizon-client/src/terrain.wgsl | 19 +
5 files changed, 627 insertions(+), 11 deletions(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 3a11ee283..5a173977f 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -14658,6 +14658,14 @@ fn build_renderer(
prepared,
previous,
api,
+ // AirDef fork, HD water: the layers of the water sprites.
+ ["water_still", "water_flow"].map(|name| {
+ assets
+ .sprite_layers
+ .get(&format!("minecraft:block/{name}"))
+ .copied()
+ .unwrap_or(0xffff)
+ }),
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -14717,6 +14725,7 @@ fn loading_renderer(
None,
previous,
api,
+ [0xffff; 2],
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index b85c6e269..f9dab8abf 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -564,6 +564,8 @@ pub struct Renderer {
mod_views: Option<mod_views::Views>,
/// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets.
gbuffer_terrain: wgpu::RenderPipeline,
+ /// The water quads alone into the HD water targets (section draws; `flat.water` for flat ones).
+ gbuffer_water: wgpu::RenderPipeline,
gbuffer_entity: wgpu::RenderPipeline,
/// The HD sun shadows: depth only, from the light, and the sections in its farther cascade.
shadow_terrain: wgpu::RenderPipeline,
@@ -1083,6 +1085,7 @@ impl Renderer {
prepared: Option<PreparedTextures>,
previous: Option<&Self>,
api: GraphicsApi,
+ water_layers: [u32; 2],
) -> Result<Self> {
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
@@ -1205,6 +1208,13 @@ impl Renderer {
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
("SPRITE_SPLIT", f64::from(split)),
];
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
+ let water_constants = [
+ ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
+ ("SPRITE_SPLIT", f64::from(split)),
+ ("WATER_STILL", f64::from(water_layers[0])),
+ ("WATER_FLOW", f64::from(water_layers[1])),
+ ];
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon terrain bindings"),
entries: &[
@@ -1420,6 +1430,55 @@ impl Renderer {
multiview_mask: None,
cache: None,
});
+ // AirDef fork, HD water: only the water quads (`fs_water`), into the colour and normal
+ // targets and the water's own depth; `vertex` is the section or the flat vertex stage.
+ let water_targets = [
+ Some(wgpu::ColorTargetState {
+ format: hd::WATER_TINT,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ }),
+ Some(wgpu::ColorTargetState {
+ format: hd::WATER_NORMAL,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ }),
+ ];
+ let water_pipeline =
+ |layout: &wgpu::PipelineLayout,
+ label: &str,
+ vertex: &str,
+ buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some(label),
+ layout: Some(layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some(vertex),
+ compilation_options: Default::default(),
+ buffers,
+ },
+ primitive: wgpu::PrimitiveState {
+ cull_mode: Some(wgpu::Face::Back),
+ ..Default::default()
+ },
+ depth_stencil: gbuffer_depth.clone(),
+ multisample: Default::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs_water"),
+ compilation_options: wgpu::PipelineCompilationOptions {
+ constants: &water_constants,
+ ..Default::default()
+ },
+ targets: &water_targets,
+ }),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
+ let gbuffer_water =
+ water_pipeline(&layout, "HD water terrain", "vs_terrain", &terrain_buffers);
let shadow_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("HD shadows terrain"),
layout: Some(&layout),
@@ -2597,6 +2656,7 @@ impl Renderer {
&device,
&bindings,
arena.page_vertices(),
+ |layout, label| water_pipeline(layout, label, "vs_terrain_flat", &buffers),
|layout, label, fragment, blend| {
pipeline_in(
layout,
@@ -2816,6 +2876,7 @@ impl Renderer {
mod_post_hdr: false,
mod_views: None,
gbuffer_terrain,
+ gbuffer_water,
gbuffer_entity,
shadow_terrain,
shadow_entity,
@@ -4204,6 +4265,16 @@ impl Renderer {
fog_color,
fog,
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
+ sky_color: [
+ frame.sky.sky_color[0],
+ frame.sky.sky_color[1],
+ frame.sky.sky_color[2],
+ ],
+ camera_mod: [
+ camera.x.rem_euclid(256.0) as f32,
+ camera.z.rem_euclid(256.0) as f32,
+ ],
+ water: !frame.underwater,
};
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
let mut hd_cascades = hd::cascades(camera, shadow_dir);
@@ -5058,6 +5129,11 @@ impl Renderer {
.hd
.get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size));
hd.resize(device, size, [self.config.width, self.config.height]);
+ // Translucent terrain on screen: the water pass has something to draw.
+ hd.water_geometry = match self.flat.as_ref().filter(|_| flat_frame) {
+ Some(flat) => !flat.translucent_calls().is_empty(),
+ None => translucent_draws > 0,
+ };
if let Some(settings) = frame.hd {
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
}
@@ -5186,6 +5262,32 @@ impl Renderer {
drop(pass);
// Contact shadows from the G-buffer's depth and normals, for the lighting.
hd.ao(&mut encoder);
+ // The water quads alone, with their own depth: the lighting finds the water by it and
+ // the composite after the lighting lays the surface over the lit floor.
+ if hd.water_active() {
+ let mut pass = hd.water_pass(&mut encoder);
+ pass.set_bind_group(0, &self.group, &[]);
+ match self.flat.as_ref().filter(|_| flat_frame) {
+ Some(flat) => {
+ pass.set_pipeline(&flat.water);
+ flat.draw(&mut pass, flat.translucent_calls(), |p| {
+ self.arena.buffer(p)
+ });
+ }
+ None => {
+ pass.set_pipeline(&self.gbuffer_water);
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
+ pass.set_vertex_buffer(1, self.instances.slice(..));
+ self.draw_sections(
+ &mut pass,
+ &self.indirect,
+ 0,
+ solid_draws,
+ translucent_draws,
+ );
+ }
+ }
+ }
} else if !hd_on {
self.hd = None;
}
@@ -5721,6 +5823,11 @@ impl Renderer {
(color, depth),
(&self.lightmap_view, &self.lightmap_sampler),
);
+ hd.water_composite(
+ &self.device,
+ &mut encoder,
+ (&self.lightmap_view, &self.lightmap_sampler),
+ );
match &mut self.mod_post {
Some(post) if mod_post => {
let size = self.world_post.size().unwrap_or([1, 1]);
@@ -5731,7 +5838,7 @@ impl Renderer {
&self.mod_params.0,
self.mod_params.1,
);
- post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
+ post.run(&self.device, &mut encoder, (hd.lit_view(), depth), &hd.fx);
let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
hd.finish(&self.device, &mut encoder, source, &view_target);
}
diff --git a/crates/horizon-client/src/gfx/flat.rs b/crates/horizon-client/src/gfx/flat.rs
index 5d86888da..7e2e20cad 100644
--- a/crates/horizon-client/src/gfx/flat.rs
+++ b/crates/horizon-client/src/gfx/flat.rs
@@ -64,6 +64,8 @@ pub struct Call {
pub struct Flat {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
+ /// The water quads alone, into HD's water targets.
+ pub water: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
flatten_seen: wgpu::ComputePipeline,
@@ -254,6 +256,7 @@ impl Flat {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
+ water: impl Fn(&wgpu::PipelineLayout, &str) -> wgpu::RenderPipeline,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -320,6 +323,7 @@ impl Flat {
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
),
+ water: water(&layout, "HD water terrain (flat)"),
flatten,
compute_layout,
flatten_seen,
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index c54b7b961..f5caa2d0b 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -16,6 +16,9 @@ pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The lit world and the post pass's output.
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
+/// The water pass's targets: the surface colour with the block light, and the normal with the sky light.
+pub const WATER_TINT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
+pub const WATER_NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The most lights drawn a frame.
pub const MAX_LIGHTS: usize = 512;
/// The side (pixels) of a tile of the screen with its own short list of lights.
@@ -91,6 +94,13 @@ struct HdFrame {
taa: [f32; 4],
/// x: the history's weight; y: the sharpening (0 without TAA); zw: the jitter in world pixels.
taa_params: [f32; 4],
+ /// The (jittered) world-to-clip matrix the world was drawn with (rays in the water pass).
+ view_proj: [[f32; 4]; 4],
+ /// x: the water effect runs this frame; y: seconds for the waves (wraps at 600); zw: the
+ /// camera's x and z modulo 256, the waves' period.
+ water: [f32; 4],
+ /// rgb: the sky's colour; w: 1 when there is a sky.
+ sky_color: [f32; 4],
}
/// What the world around looks like this frame (from the renderer's globals).
@@ -116,6 +126,11 @@ pub struct HdScene {
pub fog: [f32; 4],
/// The sky has a sun at all (the Overworld).
pub sky: bool,
+ pub sky_color: [f32; 3],
+ /// The camera's x and z modulo 256 (the waves' period), so the waves hold still in the world.
+ pub camera_mod: [f32; 2],
+ /// The water effect may run (not underwater, not disabled).
+ pub water: bool,
}
/// The cascades' half sizes (blocks) and shadow map size.
@@ -137,6 +152,9 @@ struct HdFrame {
prev_view_proj: mat4x4<f32>,
taa: vec4<f32>,
taa_params: vec4<f32>,
+ view_proj: mat4x4<f32>,
+ water: vec4<f32>,
+ sky_color: vec4<f32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
@@ -175,6 +193,8 @@ struct Light {
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
@group(0) @binding(13) var ao_tex: texture_2d<f32>;
+// The water pass's depth: the nearest water surface (0 where there is none).
+@group(0) @binding(14) var water_depth: texture_depth_2d;
// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
@@ -216,6 +236,12 @@ fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow; soft-edged.
fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
+ return shadow_taps(p, n, 1);
+}
+
+// `shadow` with a (2 * r + 1) square of taps: the floor under water takes one (the water dims and
+// blurs it anyway).
+fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
let dist = length(p);
let c = select(1, 0, dist < frame.shadow.y * 0.92);
let texel = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0;
@@ -224,13 +250,13 @@ fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
var lit = 0.0;
let step = 1.0 / 2048.0;
- for (var y = -1; y <= 1; y++) {
- for (var x = -1; x <= 1; x++) {
+ for (var y = -r; y <= r; y++) {
+ for (var x = -r; x <= r; x++) {
lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
}
}
let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
- return mix(1.0, lit / 9.0, fade);
+ return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
}
// One shadow sample, for the rays through the air.
@@ -252,7 +278,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let scene = textureLoad(scene_color, px, 0).rgb;
let d = textureLoad(scene_depth, px, 0);
let g = textureLoad(gbuffer_depth, px, 0);
- let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
+ // Water in front of the opaque world with nothing translucent over it: here the floor is lit
+ // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
+ var water = false;
+ if frame.water.x > 0.5 && g > 0.0 {
+ let wd = textureLoad(water_depth, px, 0);
+ water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
+ }
+ let dd = select(d, g, water);
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
let h = frame.inverse_view_proj * ndc;
let p = h.xyz / h.w;
let view = normalize(p);
@@ -267,7 +301,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let halo_all = tiles[tile + 3u] == 0xffffffffu;
let halo_count = select(tiles[tile + 3u], count, halo_all);
var c = scene;
- let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
+ let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
if surface {
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
let albedo = textureLoad(albedo_tex, px, 0);
@@ -278,14 +312,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let sky_reach = smoothstep(0.35, 0.95, lm.x);
var s = 1.0;
let ndl = dot(n, sun);
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow(p, n); } else { s = 0.0; }
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)); } else { s = 0.0; }
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
// direct light by the face's angle.
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
// Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
// the mods' lights by half, not on the sun or on emission.
var occlusion = 1.0;
- if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); }
+ // (The water dims and blurs the floor under it: no occlusion there.)
+ if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
@@ -334,7 +369,253 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let a1 = atan((far - t0) / hh);
c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
}
- return vec4<f32>(c, 1.0);
+ return vec4<f32>(c, select(1.0, 0.5, water));
+}
+"#;
+
+const WATER: &str = r#"
+const PI: f32 = 3.14159265;
+const REFLECT_STEPS: i32 = 24;
+const REFLECT_RANGE: f32 = 96.0;
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var lit_tex: texture_2d<f32>;
+@group(0) @binding(2) var gbuffer_depth: texture_depth_2d;
+@group(0) @binding(3) var water_tint: texture_2d<f32>;
+@group(0) @binding(4) var water_normal: texture_2d<f32>;
+@group(0) @binding(5) var water_depth: texture_depth_2d;
+@group(0) @binding(6) var shadow_map: texture_depth_2d_array;
+@group(0) @binding(7) var shadow_sampler: sampler_comparison;
+@group(0) @binding(8) var lightmap: texture_2d<f32>;
+@group(0) @binding(9) var linear: sampler;
+
+fn ign(px: vec2<f32>) -> f32 {
+ let p = px + vec2<f32>(5.588238 * frame.params.w);
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
+}
+
+// The camera-relative position of the screen point `uv` at depth z.
+fn position_at(uv: vec2<f32>, z: f32) -> vec3<f32> {
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
+ return h.xyz / h.w;
+}
+
+// The screen point (uv, clip w) of a camera-relative position.
+fn project(p: vec3<f32>) -> vec3<f32> {
+ let c = frame.view_proj * vec4<f32>(p, 1.0);
+ return vec3<f32>(c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5, c.w);
+}
+
+fn on_screen(uv: vec2<f32>) -> bool {
+ return all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0));
+}
+
+fn texel(uv: vec2<f32>) -> vec2<i32> {
+ return clamp(vec2<i32>(uv * frame.resolution.xy), vec2<i32>(0), vec2<i32>(frame.resolution.xy) - 1);
+}
+
+// Sine waves in world space: wave vectors that are whole multiples of 2 pi / 256 and speeds that
+// are whole cycles per 600 s, so the sum repeats every 256 blocks and 10 minutes and holds still
+// in the world as the camera moves. x, y: the vector in units of 2 pi / 256; z: the slope the
+// wave adds; w: its phase.
+const WAVES = array<vec4<f32>, 10>(
+ vec4<f32>(3.0, 1.0, 0.040, 0.3),
+ vec4<f32>(-2.0, 5.0, 0.045, 2.1),
+ vec4<f32>(7.0, 2.0, 0.040, 4.4),
+ vec4<f32>(-5.0, -9.0, 0.040, 1.2),
+ vec4<f32>(11.0, -4.0, 0.035, 5.6),
+ vec4<f32>(13.0, 10.0, 0.035, 3.3),
+ vec4<f32>(-17.0, 7.0, 0.030, 0.9),
+ vec4<f32>(9.0, 23.0, 0.030, 2.7),
+ vec4<f32>(-27.0, 19.0, 0.025, 4.9),
+ vec4<f32>(31.0, 30.0, 0.025, 1.8),
+);
+
+// The gradient of the water's height at world x, z: the waves that a pixel `footprint` blocks
+// wide can still resolve (finer ones fade out, or they would shimmer).
+fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
+ var grad = vec2<f32>(0.0);
+ for (var i = 0; i < 10; i++) {
+ let w = WAVES[i];
+ let k = w.xy * (2.0 * PI / 256.0);
+ let len = length(k);
+ let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.25 - 0.5, 0.0, 1.0);
+ let cycles = round(sqrt(9.8 * len) * 0.5 * 600.0 / (2.0 * PI));
+ let omega = cycles * 2.0 * PI / 600.0;
+ let phase = dot(k, xz) - omega * frame.water.y + w.w;
+ grad += (k / len) * (w.z * fade * cos(phase));
+ }
+ return grad;
+}
+
+fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
+ let s = frame.cascades[c] * vec4<f32>(p, 1.0);
+ return vec3<f32>(s.x * 0.5 + 0.5, 0.5 - s.y * 0.5, s.z);
+}
+
+// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow (as the lighting pass has it).
+fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
+ let dist = length(p);
+ let c = select(1, 0, dist < frame.shadow.y * 0.92);
+ let texel_size = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0;
+ let q = p + n * texel_size * 1.5;
+ let s = cascade_at(q, c);
+ if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
+ var lit = 0.0;
+ let step = 1.0 / 2048.0;
+ for (var y = -1; y <= 1; y++) {
+ for (var x = -1; x <= 1; x++) {
+ lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
+ }
+ }
+ let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
+ return mix(1.0, lit / 9.0, fade);
+}
+
+fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
+ if d <= start { return 0.0; }
+ if d >= end { return 1.0; }
+ return (d - start) / (end - start);
+}
+
+// What the sky shows along r when the ray leaves the screen: the horizon's colour rising to
+// the zenith's, darker where the water lies under cover (`vis` is the sky light there).
+fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
+ var c = frame.fog_color.rgb;
+ if frame.sky_color.w > 0.5 {
+ c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
+ }
+ return c * mix(0.12, 1.0, vis);
+}
+
+// Marches the ray from p along r over the depth buffer (28 steps, growing, jittered so TAA
+// averages the placement; then a binary search for the crossing). xyz: the colour found,
+// w: how much to trust it (0 for a miss, less near the screen's edge).
+fn reflection(p: vec3<f32>, r: vec3<f32>, jitter: f32) -> vec4<f32> {
+ var near = 0.0;
+ for (var i = 0; i < REFLECT_STEPS; i++) {
+ let u = (f32(i) + jitter) / f32(REFLECT_STEPS);
+ let s = 0.12 + REFLECT_RANGE * u * u;
+ let q = p + r * s;
+ let sp = project(q);
+ if sp.z <= 0.0 || !on_screen(sp.xy) { break; }
+ let z = textureLoad(gbuffer_depth, texel(sp.xy), 0);
+ if z > 0.0 {
+ let c = frame.view_proj * vec4<f32>(q, 1.0);
+ // Reversed Z: a larger depth is nearer; the ray is behind the surface when the
+ // surface is nearer than it.
+ if z > c.z / c.w {
+ let behind = length(q) - length(position_at(sp.xy, z));
+ if behind < 0.4 + 0.08 * s {
+ var lo = near;
+ var hi = s;
+ for (var k = 0; k < 5; k++) {
+ let m = 0.5 * (lo + hi);
+ let mq = p + r * m;
+ let mp = project(mq);
+ let mc = frame.view_proj * vec4<f32>(mq, 1.0);
+ let mz = textureLoad(gbuffer_depth, texel(mp.xy), 0);
+ if mz > mc.z / mc.w { hi = m; } else { lo = m; }
+ }
+ let hp = project(p + r * hi);
+ let edge = min(min(hp.x, 1.0 - hp.x), min(hp.y, 1.0 - hp.y));
+ let colour = textureSampleLevel(lit_tex, linear, hp.xy, 0.0).rgb;
+ return vec4<f32>(colour, smoothstep(0.0, 0.08, edge));
+ }
+ }
+ }
+ near = s;
+ }
+ return vec4<f32>(0.0);
+}
+
+// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing).
+fn glint(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
+ let h = normalize(l + v);
+ let a = 0.05;
+ let nh = max(dot(n, h), 0.0);
+ let d = a * a / (PI * pow(nh * nh * (a * a - 1.0) + 1.0, 2.0));
+ let f = 0.02 + 0.98 * pow(1.0 - max(dot(v, h), 0.0), 5.0);
+ return min(d * f * 0.25 * max(dot(n, l), 0.0) / max(dot(n, v), 0.1), 12.0);
+}
+
+// The water over the lit world: the world behind it (refracted by the waves, dimmed and tinted
+// by the water's depth), the sky and the world above it (a screen-space reflection) by Fresnel,
+// and the sun's glint. Pixels without water pass through.
+@fragment
+fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
+ let px = vec2<i32>(in.position.xy);
+ let base = textureLoad(lit_tex, px, 0);
+ if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
+ let wd = textureLoad(water_depth, px, 0);
+ let g = textureLoad(gbuffer_depth, px, 0);
+ let tint = textureLoad(water_tint, px, 0);
+ let nt = textureLoad(water_normal, px, 0);
+ let p = position_at(in.uv, wd);
+ let dist = length(p);
+ let v = -p / dist;
+ let geometric = normalize(nt.xyz * 2.0 - 1.0);
+ // The waves tilt the flat parts of the surface only.
+ let footprint = dist * frame.resolution.w * 1.4;
+ let grad = wave_gradient(p.xz + frame.water.zw, footprint);
+ let flat_part = smoothstep(0.6, 0.95, geometric.y);
+ var n = normalize(geometric + vec3<f32>(-grad.x, 0.0, -grad.y) * flat_part);
+ n = normalize(n + v * max(0.03 - dot(n, v), 0.0));
+ let ndv = max(dot(n, v), 0.02);
+
+ // Light on the water: vanilla's lightmap at the surface, and the sun where it reaches.
+ let levels = vec2<f32>(nt.a, tint.a);
+ let sky_reach = smoothstep(0.35, 0.95, levels.x);
+ let ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(levels.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
+ let sun = frame.sun.xyz;
+ let sun_on = frame.sun.w;
+ var sunlit = 0.0;
+ if sun_on > 0.0 && sun.y > 0.0 && sky_reach > 0.0 { sunlit = shadow(p, vec3<f32>(0.0, 1.0, 0.0)) * sky_reach; }
+ let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
+
+ // Refraction: where the ray through the waves meets the world, against where the flat surface
+ // would send it (so still water looks as vanilla's and only the waves distort).
+ let floor0 = position_at(in.uv, g);
+ let depth0 = max(length(floor0) - dist, 0.0);
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
+ var uv_floor = in.uv;
+ var floor_z = g;
+ let moved = project(floor0 + bent * min(depth0, 24.0));
+ if moved.z > 0.0 && on_screen(moved.xy) {
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
+ // Only what lies behind the water: not the bank or an entity in front of it.
+ if z > 0.0 && z <= wd {
+ uv_floor = moved.xy;
+ floor_z = z;
+ }
+ }
+ let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
+ let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
+ let colour = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
+ // The colours the water takes up fastest are the ones it has the least of.
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
+ let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3<f32>(1.0) - absorb);
+
+ // Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
+ var r = reflect(-v, n);
+ r.y = abs(r.y);
+ // Looking down onto it the reflection is a few percent of the picture: the sky alone does.
+ let fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
+ var hit = vec4<f32>(0.0);
+ if fresnel > 0.04 { hit = reflection(p, r, ign(in.position.xy)); }
+ let sky_vis = mix(0.15, 1.0, sky_reach);
+ let reflected = mix(sky_along(r, sky_vis), hit.rgb, hit.w);
+
+ var c = refracted * (1.0 - fresnel) + reflected * fresnel;
+ if sun_on > 0.0 && sunlit > 0.0 {
+ c += frame.sun_color.rgb * sun_on * sunlit * glint(n, v, sun);
+ }
+ // Vanilla's fog over it, at the surface.
+ let sph = dist;
+ let cyl = max(length(p.xz), abs(p.y));
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
+ c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
+ return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
}
"#;
@@ -829,6 +1110,12 @@ pub fn taa_enabled() -> bool {
*ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
}
+/// The water effect is on unless `HORIZON_HD_WATER=0`.
+pub fn water_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_WATER").map_or(true, |v| v != "0"))
+}
+
/// The Halton sequence's `index`-th number in `base`, in (0, 1).
fn halton(mut index: u32, base: u32) -> f32 {
let (mut f, mut r) = (1.0, 0.0);
@@ -895,6 +1182,22 @@ pub struct Hd {
/// The lit world, and the post pass's output.
pub lit: wgpu::TextureView,
pub fx: wgpu::TextureView,
+ /// Water: its surface colour and normal, its depth (the nearest water surface; 0 elsewhere),
+ /// the lit world with the water over it, and what draws and composites them.
+ water_tint: wgpu::TextureView,
+ water_normal: wgpu::TextureView,
+ water_depth: wgpu::TextureView,
+ lit_water: wgpu::TextureView,
+ water_layout: wgpu::BindGroupLayout,
+ water_pipeline: wgpu::RenderPipeline,
+ water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
+ /// The water effect runs this frame (the scene allows it and there is translucent terrain on
+ /// screen), and `lit_view` is the composite.
+ water_active: bool,
+ /// The frame's translucent terrain exists (set by the renderer before `write`).
+ pub water_geometry: bool,
+ water_composited: bool,
+ started: std::time::Instant,
/// TAA: the resolved frame, and the two histories (one read, one written, alternating).
taa: wgpu::TextureView,
history: [wgpu::TextureView; 2],
@@ -1216,6 +1519,26 @@ impl Hd {
min_binding_size: None,
},
UNFILTERED,
+ DEPTH,
+ ]),
+ });
+ let water_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD water"),
+ entries: &entries(&[
+ UNIFORM,
+ FLOAT,
+ DEPTH,
+ UNFILTERED,
+ UNFILTERED,
+ DEPTH,
+ wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Depth,
+ view_dimension: wgpu::TextureViewDimension::D2Array,
+ multisampled: false,
+ },
+ wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
+ FLOAT,
+ SAMPLER,
]),
});
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1278,6 +1601,16 @@ impl Hd {
let tone = module(device, "HD tone mapping", TONEMAP);
let ao = module(device, "HD ambient occlusion", AO);
let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
+ let water = module(device, "HD water", WATER);
+ let water_pipeline = fullscreen(
+ device,
+ "HD water",
+ &water_layout,
+ &water,
+ "fs_water",
+ HDR,
+ None,
+ );
let ao_pipeline = fullscreen(
device,
"HD ambient occlusion",
@@ -1436,6 +1769,23 @@ impl Hd {
.view,
lit: target(device, "HD lit", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
+ water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
+ water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
+ water_depth: target(
+ device,
+ "HD water depth",
+ [1, 1],
+ wgpu::TextureFormat::Depth32Float,
+ )
+ .view,
+ lit_water: target(device, "HD lit with water", [1, 1], HDR).view,
+ water_layout,
+ water_pipeline,
+ water_group: None,
+ water_active: false,
+ water_geometry: false,
+ water_composited: false,
+ started: std::time::Instant::now(),
taa: target(device, "HD TAA", [1, 1], HDR).view,
history: [
target(device, "HD TAA history", [1, 1], HDR).view,
@@ -1502,6 +1852,17 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
+ self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
+ self.water_depth = target(
+ device,
+ "HD water depth",
+ size,
+ wgpu::TextureFormat::Depth32Float,
+ )
+ .view;
+ self.lit_water = target(device, "HD lit with water", size, HDR).view;
+ self.water_group = None;
self.taa = target(device, "HD TAA", out, HDR).view;
self.history = [
target(device, "HD TAA history", out, HDR).view,
@@ -1591,6 +1952,7 @@ impl Hd {
let (dir, strength, color) = light_of(scene);
let cascades = scene.cascades;
let night = scene.dark.clamp(0.0, 1.0);
+ self.water_active = scene.water && water_enabled() && self.water_geometry;
self.taa_active = scene.taa && taa_enabled();
// A long jump of the camera (a teleport) starts the history over.
if !self.taa_active || scene.camera_delta.length() > 64.0 {
@@ -1604,6 +1966,19 @@ impl Hd {
scene.camera_delta.z,
if self.history_ready { 1.0 } else { 0.0 },
],
+ view_proj: scene.view_proj.to_cols_array_2d(),
+ water: [
+ if self.water_active { 1.0 } else { 0.0 },
+ (self.started.elapsed().as_secs_f64() % 600.0) as f32,
+ scene.camera_mod[0],
+ scene.camera_mod[1],
+ ],
+ sky_color: [
+ scene.sky_color[0],
+ scene.sky_color[1],
+ scene.sky_color[2],
+ if scene.sky { 1.0 } else { 0.0 },
+ ],
taa_params: [
if self.upscaling() {
UPSCALE_HISTORY
@@ -1821,6 +2196,10 @@ impl Hd {
binding: 13,
resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
},
+ wgpu::BindGroupEntry {
+ binding: 14,
+ resource: wgpu::BindingResource::TextureView(&self.water_depth),
+ },
],
});
self.light_group = Some((key, group));
@@ -1828,6 +2207,7 @@ impl Hd {
let Some((_, group)) = &self.light_group else {
return;
};
+ self.water_composited = false;
Self::draw(
encoder,
"HD lighting",
@@ -1894,7 +2274,7 @@ impl Hd {
screen: &wgpu::TextureView,
) {
let source = match source {
- HdSource::Lit => self.lit.clone(),
+ HdSource::Lit => self.lit_view().clone(),
HdSource::Fx => self.fx.clone(),
HdSource::Taa => self.taa.clone(),
};
@@ -1993,7 +2373,7 @@ impl Hd {
return source;
}
let input = match source {
- HdSource::Lit => self.lit.clone(),
+ HdSource::Lit => self.lit_view().clone(),
HdSource::Fx => self.fx.clone(),
HdSource::Taa => return source,
};
@@ -2052,6 +2432,102 @@ impl Hd {
HdSource::Taa
}
+ /// The lit world as later passes see it: with the water over it when the effect ran.
+ pub fn lit_view(&self) -> &wgpu::TextureView {
+ if self.water_composited {
+ &self.lit_water
+ } else {
+ &self.lit
+ }
+ }
+
+ /// The water effect runs this frame (the renderer draws the water quads with `water_pass`).
+ pub fn water_active(&self) -> bool {
+ self.water_active
+ }
+
+ /// The water pass's attachments: the surface colour and normal, and the water's own depth,
+ /// all cleared. The caller draws the translucent terrain with `fs_water`.
+ pub fn water_pass<'e>(
+ &mut self,
+ encoder: &'e mut wgpu::CommandEncoder,
+ ) -> wgpu::RenderPass<'e> {
+ let clear = |view| {
+ Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ store: wgpu::StoreOp::Store,
+ },
+ })
+ };
+ encoder
+ .begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("HD water"),
+ timestamp_writes: stamp(&mut self.timing, "hd water"),
+ color_attachments: &[clear(&self.water_tint), clear(&self.water_normal)],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: &self.water_depth,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(0.0),
+ store: wgpu::StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ ..Default::default()
+ })
+ .forget_lifetime()
+ }
+
+ /// Lays the water over the lit world (refraction, reflection, glint) into a target of its
+ /// own; `lit_view` is that target after this.
+ pub fn water_composite(
+ &mut self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ lightmap: (&wgpu::TextureView, &wgpu::Sampler),
+ ) {
+ if !self.water_active {
+ return;
+ }
+ let key = (lightmap.0.clone(), lightmap.1.clone());
+ if self.water_group.as_ref().is_none_or(|(k, _)| *k != key) {
+ let group = self.make_group(
+ device,
+ "HD water",
+ &self.water_layout,
+ &[
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
+ wgpu::BindingResource::TextureView(&self.lit),
+ wgpu::BindingResource::TextureView(&self.depth),
+ wgpu::BindingResource::TextureView(&self.water_tint),
+ wgpu::BindingResource::TextureView(&self.water_normal),
+ wgpu::BindingResource::TextureView(&self.water_depth),
+ wgpu::BindingResource::TextureView(&self.shadow_array),
+ wgpu::BindingResource::Sampler(&self.shadow_sampler),
+ wgpu::BindingResource::TextureView(lightmap.0),
+ wgpu::BindingResource::Sampler(&self.sampler),
+ ],
+ );
+ self.water_group = Some((key, group));
+ }
+ let Some((_, group)) = &self.water_group else {
+ return;
+ };
+ Self::draw(
+ encoder,
+ "HD water composite",
+ &self.lit_water,
+ false,
+ &self.water_pipeline,
+ group,
+ stamp(&mut self.timing, "hd water composite"),
+ );
+ self.water_composited = true;
+ }
+
/// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
/// its blur. Run after the G-buffer pass; nothing when the strength is 0.
pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
@@ -2181,6 +2657,7 @@ mod tests {
("taa", TAA),
("ao", AO),
("ao blur", AO_BLUR),
+ ("water", WATER),
] {
let source = format!("{COMMON}{body}");
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index c44449cc7..0d5d2fc89 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -182,6 +182,25 @@ fn face_normal(pos: vec3<f32>) -> vec3<f32> {
out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0);
return out;
}
+// AirDef fork, HD water: the water quads once more, after the G-buffer, into small targets of
+// their own: the surface colour (the water sprite times the biome tint) with the block light, and
+// the face normal with the sky light. The layers are the water sprites' (gfx.rs fills them in;
+// nothing else is water: glass, ice and the rest stay as vanilla draws them).
+override WATER_STILL: u32 = 0xffffu;
+override WATER_FLOW: u32 = 0xffffu;
+struct WaterOut {
+ @location(0) tint: vec4<f32>,
+ @location(1) normal: vec4<f32>,
+}
+@fragment fn fs_water(in: TerrainOut) -> WaterOut {
+ let layer = in.layer & 0x7fffu;
+ if layer != WATER_STILL && layer != WATER_FLOW { discard; }
+ let color = sprite(in.uv, layer) * in.tint;
+ var out: WaterOut;
+ out.tint = vec4(color.rgb, clamp(in.lm.y, 0.0, 1.0));
+ out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
+ return out;
+}
// AirDef fork: the sun's shadow map — depth only, cut-out leaves leave holes.
@fragment fn fs_shadow(in: TerrainOut) {
let a = sprite(in.uv, in.layer & 0x7fffu).a;
--
2.54.0 (Apple Git-157)
@@ -1,54 +0,0 @@
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:56:04 +0300
Subject: [PATCH 20/42] HD water: no vanilla texture on the surface (the biome
colour only), a darker, deeper colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd.rs | 9 ++++++---
crates/horizon-client/src/terrain.wgsl | 6 ++++--
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index f5caa2d0b..8070aab3c 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -591,10 +591,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
}
let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
- let colour = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
+ // A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
+ // depth rather than as bright paint.
+ let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.8) * vec3<f32>(0.55, 0.68, 0.8);
// The colours the water takes up fastest are the ones it has the least of.
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
- let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3<f32>(1.0) - absorb);
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 1.1 * (thickness + 0.3));
+ let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3<f32>(1.0) - absorb);
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
var r = reflect(-v, n);
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index 0d5d2fc89..e892d5a0f 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -195,9 +195,11 @@ struct WaterOut {
@fragment fn fs_water(in: TerrainOut) -> WaterOut {
let layer = in.layer & 0x7fffu;
if layer != WATER_STILL && layer != WATER_FLOW { discard; }
- let color = sprite(in.uv, layer) * in.tint;
+ // The biome's colour at the texture's mean brightness, not the texture: the HD water draws
+ // its own surface (waves, reflections), and vanilla's ripples on top would look painted on.
+ let color = vec3(0.62) * in.tint.rgb;
var out: WaterOut;
- out.tint = vec4(color.rgb, clamp(in.lm.y, 0.0, 1.0));
+ out.tint = vec4(color, clamp(in.lm.y, 0.0, 1.0));
out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
return out;
}
--
2.54.0 (Apple Git-157)
@@ -1,945 +0,0 @@
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 18:38:19 +0300
Subject: [PATCH 21/42] HD sky and light: a physical atmosphere (Rayleigh, Mie,
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
and water reflections; warmer sunlight from the atmosphere's transmittance, a
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
round the sun; lighter water with fine ripples
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 4 +
.../src/gfx/THIRD_PARTY_PHOTON.md | 34 ++
crates/horizon-client/src/gfx/hd.rs | 259 +++++++++++++-
crates/horizon-client/src/gfx/hd_sky.rs | 324 ++++++++++++++++++
crates/horizon-client/src/terrain.wgsl | 2 +-
5 files changed, 604 insertions(+), 19 deletions(-)
create mode 100644 crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
create mode 100644 crates/horizon-client/src/gfx/hd_sky.rs
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index f9dab8abf..d01131433 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -72,6 +72,7 @@ mod cull;
mod end_sky;
mod flat;
mod hd;
+mod hd_sky;
mod hdr;
pub mod lightning;
mod lod;
@@ -4275,6 +4276,8 @@ impl Renderer {
camera.z.rem_euclid(256.0) as f32,
],
water: !frame.underwater,
+ sunrise: frame.sky.sunrise_color,
+ sky_fog_end: frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end),
};
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
let mut hd_cascades = hd::cascades(camera, shadow_dir);
@@ -5817,6 +5820,7 @@ impl Renderer {
{
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
// that, then bloom and the tone curve onto the screen.
+ hd.sky(&mut encoder);
hd.light(
&self.device,
&mut encoder,
diff --git a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
new file mode 100644
index 000000000..03d945001
--- /dev/null
+++ b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
@@ -0,0 +1,34 @@
+# Third-party code: Photon
+
+Parts of the HD renderer's atmosphere (`hd_sky.rs`) are ported from, or follow closely, the
+Photon shader pack by sixthsurge, `shaders/include/sky/atmosphere.glsl` (and the idea of the water
+waves in `shaders/include/misc/water_normal.glsl`) at commit
+26c2906ee02cc484359b835fba6993d443d45966, https://github.com/sixthsurge/photon
+
+What is taken: the air's scattering and absorption coefficients (Rayleigh, Mie, ozone), the scale
+heights, the ozone density profile, the Mie albedo and Henyey-Greenstein anisotropy, the sunlight
+colour outside the atmosphere, and the Chapman-function approximation of the sun's transmittance.
+The sky-view texture, its march, the sun's colour and everything else are our own.
+
+Photon is distributed under the MIT License:
+
+ The MIT License
+
+ Copyright © 2025 Benjamin Stott "sixthsurge"
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software
+ and associated documentation files (the "Software"), to deal in the Software without
+ restriction, including without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all copies or
+ substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
+ BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+No other shader pack's code (BSL, Complementary and so on) may be used: their licences forbid it.
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 8070aab3c..031e9f2de 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -5,6 +5,7 @@
//! into a high-dynamic-range target; the mod's post pass runs on that; then
//! bright parts bloom and a tone curve brings it to the screen — vanilla's
//! range (up to about 0.8) left as it is, only what is brighter rolled off.
+use super::hd_sky;
use super::pack_timing::PassTiming;
use glam::Mat4;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -101,6 +102,12 @@ struct HdFrame {
water: [f32; 4],
/// rgb: the sky's colour; w: 1 when there is a sky.
sky_color: [f32; 4],
+ /// xyz: towards the sun itself (not the moon); w: 1 when the atmosphere draws the sky.
+ sky_sun: [f32; 4],
+ /// x: vanilla's sky fog end; z: how overcast the sky is (rain: 0 clear, 1 vanilla's colours).
+ sky_extra: [f32; 4],
+ /// Vanilla's sunrise colour fan (rgb, alpha), taken out of the sky before the atmosphere is put in.
+ sunrise: [f32; 4],
}
/// What the world around looks like this frame (from the renderer's globals).
@@ -131,6 +138,9 @@ pub struct HdScene {
pub camera_mod: [f32; 2],
/// The water effect may run (not underwater, not disabled).
pub water: bool,
+ /// Vanilla's sunrise fan colour and its sky fog end (to subtract vanilla's sky from the world).
+ pub sunrise: [f32; 4],
+ pub sky_fog_end: f32,
}
/// The cascades' half sizes (blocks) and shadow map size.
@@ -155,6 +165,9 @@ struct HdFrame {
view_proj: mat4x4<f32>,
water: vec4<f32>,
sky_color: vec4<f32>,
+ sky_sun: vec4<f32>,
+ sky_extra: vec4<f32>,
+ sunrise: vec4<f32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
@@ -195,6 +208,9 @@ struct Light {
@group(0) @binding(13) var ao_tex: texture_2d<f32>;
// The water pass's depth: the nearest water surface (0 where there is none).
@group(0) @binding(14) var water_depth: texture_depth_2d;
+//SKY15
+// The pixel being lit, for the shadow filter's noise.
+var<private> frag_px: vec2<f32>;
// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
@@ -239,6 +255,42 @@ fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
return shadow_taps(p, n, 1);
}
+// A point of a disc of radius 1 (Vogel's spiral), turned by `angle`.
+fn vogel(i: i32, count: i32, angle: f32) -> vec2<f32> {
+ let r = sqrt((f32(i) + 0.5) / f32(count));
+ let a = f32(i) * 2.39996323 + angle;
+ return vec2<f32>(cos(a), sin(a)) * r;
+}
+
+// The near cascade's soft shadow: a blocker search sets the penumbra's width (wider the farther
+// the shadow's caster is above the receiver), then a ring of taps turned by a per-pixel noise
+// filters it; the temporal pass averages the noise. `s`: the point in the cascade (uv, depth),
+// `texel`: the cascade's texel in blocks.
+fn soft_shadow(s: vec3<f32>, texel: f32) -> f32 {
+ let angle = ign(frag_px) * 6.2831853;
+ var blocker = 0.0;
+ var found = 0.0;
+ for (var i = 0; i < 8; i++) {
+ let o = vogel(i, 8, angle) * (6.0 / 2048.0);
+ let q = clamp(vec2<i32>((s.xy + o) * 2048.0), vec2<i32>(0), vec2<i32>(2047));
+ let z = textureLoad(shadow_map, q, 0, 0);
+ if z < s.z - 0.0006 {
+ blocker += z;
+ found += 1.0;
+ }
+ }
+ if found < 0.5 { return 1.0; }
+ // The caster's height above the receiver, in blocks (the cascade's depth spans 799 blocks).
+ let height = (s.z - blocker / found) * 799.0;
+ let radius = clamp(height * 0.18 / texel, 1.6, 8.0);
+ var lit = 0.0;
+ for (var i = 0; i < 12; i++) {
+ let o = vogel(i, 12, angle + 1.7) * (radius / 2048.0);
+ lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + o, 0, s.z - 0.0005 - 0.00005 * radius);
+ }
+ return lit / 12.0;
+}
+
// `shadow` with a (2 * r + 1) square of taps: the floor under water takes one (the water dims and
// blurs it anyway).
fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
@@ -249,13 +301,16 @@ fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
let s = cascade_at(q, c);
if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
var lit = 0.0;
+ let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
+ if r == 1 && c == 0 {
+ return mix(1.0, soft_shadow(s, texel), fade);
+ }
let step = 1.0 / 2048.0;
for (var y = -r; y <= r; y++) {
for (var x = -r; x <= r; x++) {
lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
}
}
- let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
}
@@ -300,7 +355,31 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let halo_start = tiles[tile + 2u];
let halo_all = tiles[tile + 3u] == 0xffffffffu;
let halo_count = select(tiles[tile + 3u], count, halo_all);
+ frag_px = in.position.xy;
+ let atmosphere = frame.sky_sun.w > 0.5;
var c = scene;
+ // The sky: the atmosphere's, with what the world drew over vanilla's plain sky (sun, moon,
+ // stars, clouds) kept on top.
+ if d <= 0.0 && atmosphere {
+ var kept = max(scene - vanilla_sky(view), vec3<f32>(0.0));
+ var sky = atmosphere_sky(view);
+ // The sun: vanilla's square sprite is taken out and a round disc with a soft haze round
+ // it (warm, strongest low) is drawn in its place.
+ let toward = dot(view, frame.sky_sun.xyz);
+ kept *= 1.0 - smoothstep(0.965, 0.975, toward);
+ let clear = 1.0 - frame.sky_extra.z;
+ let shine = smoothstep(-0.04, 0.02, frame.sky_sun.y) * clear;
+ let glow = max(toward, 0.0);
+ let angle = acos(clamp(toward, -1.0, 1.0));
+ sky += frame.sun_color.rgb * shine * (pow(glow, 24.0) * 0.07 + pow(glow, 400.0) * 0.4 + 6.0 * (1.0 - smoothstep(0.016, 0.022, angle)));
+ c = sky + kept;
+ }
+ // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
+ // sun's colour at dusk and dawn instead of vanilla's flat white.
+ if atmosphere && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
+ let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
+ c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
+ }
let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
if surface {
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
@@ -323,7 +402,17 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
- var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
+ // The sky's light is bluish from above and carries the horizon's colour from the sides;
+ // faces turned up take more of it than the ones turned down.
+ var sky_tint = vec3<f32>(1.0);
+ if atmosphere {
+ let reach = smoothstep(0.25, 0.9, lm.x);
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
+ }
+ var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
// The mods' lights.
for (var k = 0u; k < surface_count; k++) {
let l = lights[select(tiles[surface_start + k], k, surface_all)];
@@ -340,7 +429,12 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let sph = length(p);
let cyl = max(length(p.xz), abs(p.y));
let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
- c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
+ let haze = haze_colour(view);
+ c = mix(c, haze, f * frame.fog_color.a);
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
+ if atmosphere && frame.fog.x > 1.0e6 {
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
+ }
}
// Light in the air: the sun's rays between the shadows, and halos round the mods' lights.
let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
@@ -387,6 +481,7 @@ const REFLECT_RANGE: f32 = 96.0;
@group(0) @binding(7) var shadow_sampler: sampler_comparison;
@group(0) @binding(8) var lightmap: texture_2d<f32>;
@group(0) @binding(9) var linear: sampler;
+//SKY10
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -417,7 +512,7 @@ fn texel(uv: vec2<f32>) -> vec2<i32> {
// are whole cycles per 600 s, so the sum repeats every 256 blocks and 10 minutes and holds still
// in the world as the camera moves. x, y: the vector in units of 2 pi / 256; z: the slope the
// wave adds; w: its phase.
-const WAVES = array<vec4<f32>, 10>(
+const WAVES = array<vec4<f32>, 26>(
vec4<f32>(3.0, 1.0, 0.040, 0.3),
vec4<f32>(-2.0, 5.0, 0.045, 2.1),
vec4<f32>(7.0, 2.0, 0.040, 4.4),
@@ -428,17 +523,36 @@ const WAVES = array<vec4<f32>, 10>(
vec4<f32>(9.0, 23.0, 0.030, 2.7),
vec4<f32>(-27.0, 19.0, 0.025, 4.9),
vec4<f32>(31.0, 30.0, 0.025, 1.8),
+ // Fine ripples (wavelengths of 0.7 to 3 blocks) for the many small sun sparkles: slopes small,
+ // faded out by distance and pixel size like the rest.
+ vec4<f32>(83.0, 41.0, 0.042, 0.4),
+ vec4<f32>(-97.0, 59.0, 0.042, 3.1),
+ vec4<f32>(71.0, -113.0, 0.042, 5.0),
+ vec4<f32>(131.0, 89.0, 0.040, 1.5),
+ vec4<f32>(-157.0, -103.0, 0.040, 4.2),
+ vec4<f32>(191.0, -67.0, 0.038, 2.4),
+ vec4<f32>(-223.0, 149.0, 0.038, 0.8),
+ vec4<f32>(101.0, 241.0, 0.036, 3.7),
+ vec4<f32>(-263.0, -181.0, 0.034, 5.5),
+ vec4<f32>(311.0, 127.0, 0.034, 2.0),
+ // Finer still (0.35 to 0.6 blocks): the small sparkles.
+ vec4<f32>(397.0, -263.0, 0.050, 1.1),
+ vec4<f32>(-467.0, 331.0, 0.050, 4.6),
+ vec4<f32>(521.0, 449.0, 0.045, 3.0),
+ vec4<f32>(-613.0, -199.0, 0.045, 5.9),
+ vec4<f32>(293.0, 577.0, 0.045, 0.2),
+ vec4<f32>(-701.0, 263.0, 0.040, 2.8),
);
// The gradient of the water's height at world x, z: the waves that a pixel `footprint` blocks
// wide can still resolve (finer ones fade out, or they would shimmer).
fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
var grad = vec2<f32>(0.0);
- for (var i = 0; i < 10; i++) {
+ for (var i = 0; i < 26; i++) {
let w = WAVES[i];
let k = w.xy * (2.0 * PI / 256.0);
let len = length(k);
- let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.25 - 0.5, 0.0, 1.0);
+ let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.2 - 0.6, 0.0, 1.0);
let cycles = round(sqrt(9.8 * len) * 0.5 * 600.0 / (2.0 * PI));
let omega = cycles * 2.0 * PI / 600.0;
let phase = dot(k, xz) - omega * frame.water.y + w.w;
@@ -481,7 +595,9 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
// the zenith's, darker where the water lies under cover (`vis` is the sky light there).
fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
var c = frame.fog_color.rgb;
- if frame.sky_color.w > 0.5 {
+ if frame.sky_sun.w > 0.5 {
+ c = atmosphere_sky(r);
+ } else if frame.sky_color.w > 0.5 {
c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
}
return c * mix(0.12, 1.0, vis);
@@ -531,7 +647,7 @@ fn reflection(p: vec3<f32>, r: vec3<f32>, jitter: f32) -> vec4<f32> {
// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing).
fn glint(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
let h = normalize(l + v);
- let a = 0.05;
+ let a = 0.07;
let nh = max(dot(n, h), 0.0);
let d = a * a / (PI * pow(nh * nh * (a * a - 1.0) + 1.0, 2.0));
let f = 0.02 + 0.98 * pow(1.0 - max(dot(v, h), 0.0), 5.0);
@@ -565,7 +681,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
// Light on the water: vanilla's lightmap at the surface, and the sun where it reaches.
let levels = vec2<f32>(nt.a, tint.a);
let sky_reach = smoothstep(0.35, 0.95, levels.x);
- let ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
+ var ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
+ if frame.sky_sun.w > 0.5 {
+ ambient *= mix(vec3<f32>(1.0), sky_light_tint(vec3<f32>(0.0, 1.0, 0.0)), 0.7 * sky_reach);
+ }
let block = textureSampleLevel(lightmap, linear, vec2<f32>(levels.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
let sun = frame.sun.xyz;
let sun_on = frame.sun.w;
@@ -594,10 +713,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
// depth rather than as bright paint.
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.8) * vec3<f32>(0.55, 0.68, 0.8);
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
// The colours the water takes up fastest are the ones it has the least of.
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 1.1 * (thickness + 0.3));
- let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3<f32>(1.0) - absorb);
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
+ let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
var r = reflect(-v, n);
@@ -617,7 +736,11 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
let sph = dist;
let cyl = max(length(p.xz), abs(p.y));
let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
- c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
+ let haze = haze_colour(-v);
+ c = mix(c, haze, f * frame.fog_color.a);
+ if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
+ c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
+ }
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
}
"#;
@@ -1200,6 +1323,14 @@ pub struct Hd {
/// The frame's translucent terrain exists (set by the renderer before `write`).
pub water_geometry: bool,
water_composited: bool,
+ /// The sky-view texture (see `hd_sky`), the pass that marches it and its bind group.
+ sky_lut: wgpu::TextureView,
+ sky_pipeline: wgpu::RenderPipeline,
+ sky_group: wgpu::BindGroup,
+ /// The sun's height the sky-view texture was marched for (it depends on nothing else), and
+ /// whether this frame's sun has moved enough to march it again.
+ sky_drawn_for: f32,
+ sky_stale: bool,
started: std::time::Instant,
/// TAA: the resolved frame, and the two histories (one read, one written, alternating).
taa: wgpu::TextureView,
@@ -1403,10 +1534,23 @@ fn ao_strength_default() -> f32 {
.unwrap_or(AO_STRENGTH)
}
+/// The shader's source: the common part, then the body with the sky-reading functions where it
+/// has a marker (`//SKY15`: the sky-view texture is binding 15, `//SKY10`: binding 10).
+fn assemble(body: &str) -> String {
+ let mut body = body.to_owned();
+ for binding in ["15", "10"] {
+ body = body.replace(
+ &format!("//SKY{binding}"),
+ &hd_sky::SKY_FUNCS.replace("SKYBIND", binding),
+ );
+ }
+ format!("{COMMON}{body}")
+}
+
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
- source: wgpu::ShaderSource::Wgsl(format!("{COMMON}{body}").into()),
+ source: wgpu::ShaderSource::Wgsl(assemble(body).into()),
})
}
@@ -1523,6 +1667,7 @@ impl Hd {
},
UNFILTERED,
DEPTH,
+ FLOAT,
]),
});
let water_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1542,8 +1687,13 @@ impl Hd {
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
FLOAT,
SAMPLER,
+ FLOAT,
]),
});
+ let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD sky"),
+ entries: &entries(&[UNIFORM]),
+ });
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("HD TAA"),
entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
@@ -1605,6 +1755,16 @@ impl Hd {
let ao = module(device, "HD ambient occlusion", AO);
let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
let water = module(device, "HD water", WATER);
+ let sky_module = module(device, "HD sky", hd_sky::SKY_PASS);
+ let sky_pipeline = fullscreen(
+ device,
+ "HD sky",
+ &sky_layout,
+ &sky_module,
+ "fs_sky_lut",
+ hd_sky::SKY_LUT_FORMAT,
+ None,
+ );
let water_pipeline = fullscreen(
device,
"HD water",
@@ -1691,6 +1851,21 @@ impl Hd {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
+ let sky_lut = target(
+ device,
+ "HD sky",
+ hd_sky::SKY_LUT_SIZE,
+ hd_sky::SKY_LUT_FORMAT,
+ )
+ .view;
+ let sky_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("HD sky"),
+ layout: &sky_layout,
+ entries: &[wgpu::BindGroupEntry {
+ binding: 0,
+ resource: frame.as_entire_binding(),
+ }],
+ });
let lights = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("HD lights"),
size: (MAX_LIGHTS * std::mem::size_of::<HdLight>()) as u64,
@@ -1788,6 +1963,11 @@ impl Hd {
water_active: false,
water_geometry: false,
water_composited: false,
+ sky_lut,
+ sky_pipeline,
+ sky_group,
+ sky_drawn_for: 2.0,
+ sky_stale: true,
started: std::time::Instant::now(),
taa: target(device, "HD TAA", [1, 1], HDR).view,
history: [
@@ -1961,6 +2141,11 @@ impl Hd {
if !self.taa_active || scene.camera_delta.length() > 64.0 {
self.history_ready = false;
}
+ let sun_height = scene.sun.normalize_or(glam::Vec3::Y).y;
+ self.sky_stale = (sun_height - self.sky_drawn_for).abs() > 0.001;
+ if self.sky_stale {
+ self.sky_drawn_for = sun_height;
+ }
let frame = HdFrame {
prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
taa: [
@@ -1982,6 +2167,17 @@ impl Hd {
scene.sky_color[2],
if scene.sky { 1.0 } else { 0.0 },
],
+ sky_sun: {
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
+ [sun.x, sun.y, sun.z, if scene.sky { 1.0 } else { 0.0 }]
+ },
+ sky_extra: [
+ scene.sky_fog_end,
+ 0.0,
+ (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85,
+ 0.0,
+ ],
+ sunrise: scene.sunrise,
taa_params: [
if self.upscaling() {
UPSCALE_HISTORY
@@ -2123,6 +2319,23 @@ impl Hd {
pass.draw(0..3, 0..1);
}
+ /// Marches the sky-view texture for this frame's sun (before the lighting and the water); skipped
+ /// while the sun's height has moved less than a thousandth since the last march.
+ pub fn sky(&mut self, encoder: &mut wgpu::CommandEncoder) {
+ if !self.sky_stale {
+ return;
+ }
+ Self::draw(
+ encoder,
+ "HD sky",
+ &self.sky_lut,
+ false,
+ &self.sky_pipeline,
+ &self.sky_group,
+ stamp(&mut self.timing, "hd sky"),
+ );
+ }
+
/// Lights the world (its colour and depth) into `lit`.
pub fn light(
&mut self,
@@ -2203,6 +2416,10 @@ impl Hd {
binding: 14,
resource: wgpu::BindingResource::TextureView(&self.water_depth),
},
+ wgpu::BindGroupEntry {
+ binding: 15,
+ resource: wgpu::BindingResource::TextureView(&self.sky_lut),
+ },
],
});
self.light_group = Some((key, group));
@@ -2512,6 +2729,7 @@ impl Hd {
wgpu::BindingResource::Sampler(&self.shadow_sampler),
wgpu::BindingResource::TextureView(lightmap.0),
wgpu::BindingResource::Sampler(&self.sampler),
+ wgpu::BindingResource::TextureView(&self.sky_lut),
],
);
self.water_group = Some((key, group));
@@ -2608,9 +2826,13 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
let clear = 1.0 - scene.rain * 0.85;
if sun.y > -0.02 {
let up = smooth(-0.02, 0.18, sun.y);
- let warm = smooth(0.0, 0.45, sun.y);
- let color = glam::Vec3::new(1.0, 0.62, 0.38).lerp(glam::Vec3::new(1.0, 0.95, 0.86), warm);
- (sun, 0.6 * up * clear, color)
+ // The atmosphere's transmittance along the sun's ray (orange when low, a clean warm white
+ // high), kept at full brightness and tempered, with a slight warm tint on top.
+ let t = hd_sky::sun_transmittance(sun.y);
+ let hue = t / t.max_element().max(1e-4);
+ let color = glam::Vec3::new(hue.x.powf(0.6), hue.y.powf(0.6), hue.z.powf(0.6))
+ * glam::Vec3::new(1.0, 0.93, 0.78);
+ (sun, 0.7 * up * clear, color)
} else {
let moon = -sun;
let up = smooth(-0.02, 0.18, moon.y);
@@ -2661,8 +2883,9 @@ mod tests {
("ao", AO),
("ao blur", AO_BLUR),
("water", WATER),
+ ("sky", hd_sky::SKY_PASS),
] {
- let source = format!("{COMMON}{body}");
+ let source = assemble(body);
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
panic!("{name}: {}", e.emit_to_string(&source));
});
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
new file mode 100644
index 000000000..b5c231a7b
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
@@ -0,0 +1,324 @@
+//! AirDef fork: the HD renderer's atmosphere. A small sky-view texture (azimuth from the sun by
+//! elevation, plus a row of ambient samples) is marched each frame from single scattering of
+//! Rayleigh and Mie particles and ozone absorption with a cheap multiple-scattering term; the
+//! lighting and water shaders read it for the sky, the aerial-perspective haze, the water's
+//! reflection fallback and the colour of the sky's light. The sunlight's colour is the same
+//! atmosphere's transmittance at the sun's elevation (`sun_transmittance`).
+//!
+//! The coefficients, the density profiles (scale heights, the ozone layer), the Henyey-Greenstein
+//! Mie parameters and the Chapman-function transmittance come from Photon by sixthsurge (MIT
+//! licence, see THIRD_PARTY_PHOTON.md), shaders/include/sky/atmosphere.glsl at commit 26c2906.
+
+/// The sky-view texture: 128 azimuths by 64 elevations, then 8 rows for the ambient samples
+/// (row 64: texel 0 the zenith, texel 1 the horizon's average).
+pub const SKY_LUT_SIZE: [u32; 2] = [128, 72];
+pub const SKY_LUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
+
+/// The atmosphere's constants and the pass that marches the sky-view texture (after `COMMON`).
+pub const SKY_PASS: &str = r#"
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+
+// Photon's atmosphere (sixthsurge, MIT): metres, Earth's size; the camera stands on the surface.
+const PI: f32 = 3.14159265;
+const PLANET_R: f32 = 6371000.0;
+const ATMOSPHERE_TOP: f32 = 6481000.0;
+const GROUND_R: f32 = 6370000.0;
+const SCALE_HEIGHTS: vec2<f32> = vec2<f32>(8400.0, 1250.0);
+const RAYLEIGH: vec3<f32> = vec3<f32>(8.059375432e-06, 1.671209429e-05, 4.080133294e-05);
+const MIE: vec3<f32> = vec3<f32>(1.666442358e-06, 1.812685127e-06, 1.958927896e-06);
+const OZONE: vec3<f32> = vec3<f32>(8.304280072e-07, 1.314911970e-06, 5.440679729e-08);
+const MIE_ALBEDO: f32 = 0.9;
+const MIE_G: f32 = 0.77;
+// Sunlight in space (Photon's AM0 colour) and how bright the sun and the moon shine here.
+const SUN_I: vec3<f32> = vec3<f32>(1.051, 0.985, 0.940) * 24.0;
+const MOON_I: vec3<f32> = vec3<f32>(0.62, 0.78, 1.0) * 0.07;
+// The share of the light scattered a second time, spread evenly (the haze's lift).
+const MULTI_SCATTER: f32 = 0.6;
+const STEPS: i32 = 32;
+
+// Photon's density: Rayleigh, Mie (exponential) and the ozone layer, at an altitude in metres.
+fn density_at(altitude: f32) -> vec3<f32> {
+ let rm = exp(-max(altitude, 0.0) / SCALE_HEIGHTS);
+ let km = altitude * 1e-3;
+ let o1 = 12.5 * exp(-km / 8.0);
+ let o2 = 30.0 * exp((18.0 - km) * (km - 18.0) / 80.0);
+ let o3 = 75.0 * exp((23.5 - km) * (km - 23.5) / 50.0);
+ let o4 = 50.0 * exp((30.0 - km) * (km - 30.0) / 150.0);
+ return vec3<f32>(rm, 7.428e-3 * (o1 + o2 + o3 + o4));
+}
+
+// Chapman function approximation (thetenthplanet.de/archives/4519, as Photon uses it).
+fn chapman(x: f32, cos_theta: f32) -> f32 {
+ let c = sqrt(1.5707963 * x);
+ if cos_theta >= 0.0 {
+ return c / ((c - 1.0) * cos_theta + 1.0);
+ }
+ let sin_theta = sqrt(clamp(1.0 - cos_theta * cos_theta, 0.0, 1.0));
+ return c / ((c - 1.0) * cos_theta - 1.0) + 2.0 * c * exp(min(x - x * sin_theta, 80.0)) * sqrt(sin_theta);
+}
+
+// The light reaching a point r from the planet's centre (altitude h, whose sky direction makes
+// cos mu with the up there) from the sun or the moon; 0 when the planet is in the way.
+fn light_transmittance(mu: f32, r: f32, h: f32) -> vec3<f32> {
+ if mu < 0.0 && r * r * (1.0 - mu * mu) < PLANET_R * PLANET_R { return vec3<f32>(0.0); }
+ let dens = exp(-max(h, 0.0) / SCALE_HEIGHTS);
+ var air = SCALE_HEIGHTS * dens;
+ air.x *= chapman(r / SCALE_HEIGHTS.x, mu);
+ air.y *= chapman(r / SCALE_HEIGHTS.y, mu);
+ return clamp(exp(-(RAYLEIGH * air.x + MIE * air.y + OZONE * air.x)), vec3<f32>(0.0), vec3<f32>(1.0));
+}
+
+fn rayleigh_phase(nu: f32) -> f32 {
+ return 3.0 / (16.0 * PI) * (1.0 + nu * nu);
+}
+
+fn mie_phase(nu: f32) -> f32 {
+ let g = MIE_G;
+ return (1.0 - g * g) / (4.0 * PI * pow(1.0 + g * g - 2.0 * g * nu, 1.5));
+}
+
+// The radiance the camera sees along `view` (y up) with the sun and the moon in the directions
+// given: single scattering plus a flat multiple-scattering term.
+fn march(view: vec3<f32>, sun: vec3<f32>, moon: vec3<f32>) -> vec3<f32> {
+ let b = PLANET_R * view.y;
+ let t_max = -b + sqrt(b * b + ATMOSPHERE_TOP * ATMOSPHERE_TOP - PLANET_R * PLANET_R);
+ let nu_sun = dot(view, sun);
+ let nu_moon = dot(view, moon);
+ let phase_r_sun = rayleigh_phase(nu_sun);
+ let phase_m_sun = mie_phase(nu_sun);
+ let phase_r_moon = rayleigh_phase(nu_moon);
+ let phase_m_moon = mie_phase(nu_moon);
+ var od = vec3<f32>(0.0);
+ var sum = vec3<f32>(0.0);
+ var t0 = 0.0;
+ for (var i = 0; i < STEPS; i++) {
+ let u = (f32(i) + 1.0) / f32(STEPS);
+ let t1 = t_max * u * u;
+ let dt = t1 - t0;
+ let t = 0.5 * (t0 + t1);
+ t0 = t1;
+ let r2 = PLANET_R * PLANET_R + 2.0 * PLANET_R * t * view.y + t * t;
+ let r = sqrt(r2);
+ // The altitude without the cancellation of r - planet radius.
+ let altitude = (2.0 * PLANET_R * t * view.y + t * t) / (r + PLANET_R);
+ let dens = density_at(altitude);
+ let extinction = RAYLEIGH * dens.x + MIE * dens.y + OZONE * dens.z;
+ let view_t = exp(-(od + 0.5 * extinction * dt));
+ od += extinction * dt;
+ let mu_sun = (PLANET_R * sun.y + t * nu_sun) / r;
+ let mu_moon = (PLANET_R * moon.y + t * nu_moon) / r;
+ let sun_t = light_transmittance(mu_sun, r, altitude);
+ let moon_t = light_transmittance(mu_moon, r, altitude);
+ let scatter_r = RAYLEIGH * dens.x;
+ let scatter_m = MIE * MIE_ALBEDO * dens.y;
+ let single = (scatter_r * phase_r_sun + scatter_m * phase_m_sun) * sun_t * SUN_I
+ + (scatter_r * phase_r_moon + scatter_m * phase_m_moon) * moon_t * MOON_I;
+ let multi = (scatter_r + scatter_m) * (1.0 / (4.0 * PI)) * MULTI_SCATTER
+ * (sun_t * SUN_I + moon_t * MOON_I) * 0.5;
+ sum += view_t * (single + multi) * dt;
+ }
+ return sum;
+}
+
+@fragment
+fn fs_sky_lut(in: FullOut) -> @location(0) vec4<f32> {
+ let px = vec2<i32>(in.position.xy);
+ // The texture's frame: the sun in the xy plane, the moon opposite.
+ let sin_e = clamp(frame.sky_sun.y, -1.0, 1.0);
+ let sun = vec3<f32>(sqrt(1.0 - sin_e * sin_e), sin_e, 0.0);
+ let moon = -sun;
+ // A faint night glow, so the night sky is not black.
+ let night = vec3<f32>(0.0006, 0.0011, 0.0026);
+ if px.y < 64 {
+ let phi = f32(px.x) / 127.0 * PI;
+ let v = f32(px.y) / 63.0;
+ let l = v * v * PI * 0.5;
+ let dir = vec3<f32>(cos(l) * cos(phi), sin(l), cos(l) * sin(phi));
+ return vec4<f32>(march(dir, sun, moon) + night, 1.0);
+ }
+ if px.y == 64 && px.x == 0 {
+ return vec4<f32>(march(vec3<f32>(0.0, 1.0, 0.0), sun, moon) + night, 1.0);
+ }
+ if px.y == 64 && px.x == 1 {
+ var sum = vec3<f32>(0.0);
+ for (var k = 0; k < 6; k++) {
+ let phi = (f32(k) + 0.5) / 6.0 * PI;
+ let l = 0.3;
+ sum += march(vec3<f32>(cos(l) * cos(phi), sin(l), cos(l) * sin(phi)), sun, moon);
+ }
+ return vec4<f32>(sum / 6.0 + night, 1.0);
+ }
+ return vec4<f32>(0.0);
+}
+"#;
+
+/// Reading the sky-view texture, in the lighting and the water shaders (`SKYBIND` is replaced by
+/// the binding of the texture; `frame` and the `linear` sampler come before it).
+pub const SKY_FUNCS: &str = r#"
+@group(0) @binding(SKYBIND) var sky_lut: texture_2d<f32>;
+const SKY_PI: f32 = 3.14159265;
+// How bright the sky is shown (the exposure of the physical radiance) and its gamma.
+const SKY_EXPOSURE: f32 = 0.85;
+
+// Where the direction `dir` (world, y up) lies in the sky-view texture.
+fn sky_uv(dir: vec3<f32>) -> vec2<f32> {
+ let s = frame.sky_sun.xyz;
+ let l = asin(clamp(dir.y, 0.0, 1.0));
+ var cphi = 1.0;
+ let dl = length(dir.xz);
+ let sl = length(s.xz);
+ if dl > 1e-4 && sl > 1e-4 { cphi = dot(dir.xz / dl, s.xz / sl); }
+ let u = acos(clamp(cphi, -1.0, 1.0)) / SKY_PI;
+ let v = sqrt(l / (SKY_PI * 0.5));
+ return vec2<f32>((u * 127.0 + 0.5) / 128.0, (v * 63.0 + 0.5) / 72.0);
+}
+
+// The physical sky's colour as it is shown: a soft shoulder, then the display's gamma.
+fn sky_show(radiance: vec3<f32>) -> vec3<f32> {
+ // Compressed by the brightest channel, so the hue stays as the atmosphere made it.
+ let peak = max(max(radiance.r, radiance.g), max(radiance.b, 1e-6));
+ let c = pow(radiance * ((1.0 - exp(-peak * SKY_EXPOSURE)) / peak), vec3<f32>(1.0 / 1.9));
+ // A little more colour (Photon boosts the atmosphere's saturation too).
+ // The low sun gets more, for orange and pink in place of pastel.
+ let boost = 1.2 + 0.45 * (1.0 - smoothstep(0.0, 0.35, frame.sky_sun.y));
+ return max(mix(vec3<f32>(dot(c, vec3<f32>(0.2126, 0.7152, 0.0722))), c, boost), vec3<f32>(0.0));
+}
+
+// The vanilla sky's own gradient (the overcast and the rain keep it).
+fn vanilla_gradient(dir: vec3<f32>) -> vec3<f32> {
+ return mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, max(dir.y, 0.0)));
+}
+
+// The sky along `dir`: the atmosphere, greyed towards vanilla's colours in rain.
+fn atmosphere_sky(dir: vec3<f32>) -> vec3<f32> {
+ let lin = textureSampleLevel(sky_lut, linear, sky_uv(dir), 0.0).rgb;
+ var c = sky_show(lin);
+ let overcast = frame.sky_extra.z;
+ if overcast > 0.0 { c = mix(c, vanilla_gradient(dir), overcast); }
+ return c;
+}
+
+// The colour distant things fade to: the sky just above the horizon in their direction (so the
+// haze leans towards the sun), or vanilla's fog colour where the environment sets its own fog.
+fn haze_colour(dir: vec3<f32>) -> vec3<f32> {
+ if frame.sky_sun.w < 0.5 || frame.fog.x < 1.0e6 { return frame.fog_color.rgb; }
+ let up = max(dir.y, 0.0) * 0.5 + 0.035;
+ return atmosphere_sky(normalize(vec3<f32>(dir.x, 0.0, dir.z) + vec3<f32>(0.0, up, 0.0)));
+}
+
+// The sky's light for a surface of normal n: the horizon's colour from the sides, the zenith's
+// from above, as a hue (luminance 1) to tint vanilla's sky light with.
+fn sky_light_tint(n: vec3<f32>) -> vec3<f32> {
+ let zenith = sky_show(textureLoad(sky_lut, vec2<i32>(0, 64), 0).rgb);
+ let horizon = sky_show(textureLoad(sky_lut, vec2<i32>(1, 64), 0).rgb);
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
+ let c = mix(horizon, zenith, smoothstep(0.35, 1.0, up));
+ let luma = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
+ return c / max(luma, 0.003);
+}
+
+// vanilla's sky (terrain.wgsl: fs_sky), to tell what the world drew on the sky pixel (the sun and
+// moon sprites, the stars, the clouds) from its plain gradient.
+fn vanilla_disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
+ if d.y * y <= 0.0 { return vec2<f32>(-1.0); }
+ let p = d * (y / d.y);
+ let h = length(p.xz);
+ let phi = atan2(p.z, p.x);
+ let sector = SKY_PI / 4.0;
+ let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
+ let edge = 512.0 * cos(sector * 0.5) / cos(delta);
+ if h > edge { return vec2<f32>(-1.0); }
+ let s = h / edge;
+ let ay = abs(y);
+ let rim = sqrt(512.0 * 512.0 + y * y);
+ return vec2<f32>(ay + s * (rim - ay), ay + s * (512.0 - ay));
+}
+fn vanilla_linear_fog(d: f32, start: f32, end: f32) -> f32 {
+ if d <= start { return 0.0; }
+ if d >= end { return 1.0; }
+ return (d - start) / (end - start);
+}
+fn vanilla_rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3<f32>(v.x, -v.z, v.y); }
+fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
+ let alpha = frame.sunrise.a;
+ if alpha <= 0.001 { return 0.0; }
+ let flip = select(0.0, 180.0, frame.sky_sun.x > 0.0);
+ let theta = radians(flip + 90.0);
+ let c = cos(theta);
+ let s = sin(theta);
+ var best = 0.0;
+ let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
+ for (var i = 0; i < 16; i++) {
+ let a0 = f32(i) * 2.0 * SKY_PI / 16.0;
+ let a1 = f32(i + 1) * 2.0 * SKY_PI / 16.0;
+ let l0 = vec3<f32>(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
+ let l1 = vec3<f32>(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
+ let r0 = vanilla_rotate_x90(vec3<f32>(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
+ let r1 = vanilla_rotate_x90(vec3<f32>(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
+ let e1 = r0 - apex;
+ let e2 = r1 - apex;
+ let pv = cross(d, e2);
+ let det = dot(e1, pv);
+ if abs(det) < 1e-6 { continue; }
+ let tv = -apex;
+ let u = dot(tv, pv) / det;
+ let qv = cross(tv, e1);
+ let v = dot(d, qv) / det;
+ let t = dot(e2, qv) / det;
+ if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
+ best = max(best, 1.0 - u - v);
+ }
+ }
+ return best;
+}
+fn vanilla_sky(d: vec3<f32>) -> vec3<f32> {
+ var color = frame.fog_color.rgb;
+ let dist = vanilla_disc_distance(d, 16.0);
+ if dist.x >= 0.0 {
+ let end = frame.sky_extra.x;
+ let f = max(vanilla_linear_fog(dist.x, 0.0, end), vanilla_linear_fog(dist.y, end, end));
+ color = mix(frame.sky_color.rgb, frame.fog_color.rgb, f * frame.fog_color.a);
+ }
+ let w = vanilla_sunrise_weight(d);
+ if w > 0.0 {
+ color = mix(color, frame.sunrise.rgb, w * frame.sunrise.a);
+ }
+ return color;
+}
+"#;
+
+/// The sun's light at the ground: the atmosphere's transmittance along the sun's direction
+/// (`sin_elevation`, the y of the unit vector towards it), linear, as a fraction of the light
+/// outside the air. Same Chapman model as the sky pass.
+pub fn sun_transmittance(sin_elevation: f32) -> glam::Vec3 {
+ const PLANET: f32 = 6_371_000.0;
+ const SCALE: [f32; 2] = [8400.0, 1250.0];
+ const RAYLEIGH: glam::Vec3 = glam::Vec3::new(8.059_375e-6, 1.671_209_4e-5, 4.080_133_4e-5);
+ const MIE: glam::Vec3 = glam::Vec3::new(1.666_442_4e-6, 1.812_685e-6, 1.958_927_9e-6);
+ const OZONE: glam::Vec3 = glam::Vec3::new(8.304_28e-7, 1.314_912e-6, 5.440_679_7e-8);
+ // A sun below the horizon keeps the horizon's colour (its strength is faded elsewhere).
+ let mu = sin_elevation.clamp(0.0, 1.0);
+ let chapman = |x: f32, c: f32| {
+ let k = (std::f32::consts::FRAC_PI_2 * x).sqrt();
+ k / ((k - 1.0) * c + 1.0)
+ };
+ let air = [
+ SCALE[0] * chapman(PLANET / SCALE[0], mu),
+ SCALE[1] * chapman(PLANET / SCALE[1], mu),
+ ];
+ let tau = RAYLEIGH * air[0] + MIE * air[1] + OZONE * air[0];
+ glam::Vec3::new((-tau.x).exp(), (-tau.y).exp(), (-tau.z).exp())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_low_sun_is_redder_than_the_high_one() {
+ let noon = sun_transmittance(1.0);
+ let low = sun_transmittance(0.1);
+ assert!(noon.x > 0.85 && noon.z > 0.6, "{noon:?}");
+ assert!(low.z / low.x < noon.z / noon.x * 0.6, "{low:?} vs {noon:?}");
+ }
+}
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index e892d5a0f..0a2a6cdba 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -197,7 +197,7 @@ struct WaterOut {
if layer != WATER_STILL && layer != WATER_FLOW { discard; }
// The biome's colour at the texture's mean brightness, not the texture: the HD water draws
// its own surface (waves, reflections), and vanilla's ripples on top would look painted on.
- let color = vec3(0.62) * in.tint.rgb;
+ let color = vec3(0.85) * in.tint.rgb;
var out: WaterOut;
out.tint = vec4(color, clamp(in.lm.y, 0.0, 1.0));
out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
--
2.54.0 (Apple Git-157)
@@ -1,42 +0,0 @@
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 19:02:16 +0300
Subject: [PATCH 22/42] HD water: a darker blue, a little clearer, ripples a
little higher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd.rs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 031e9f2de..f078d423c 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -558,7 +558,8 @@ fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
let phase = dot(k, xz) - omega * frame.water.y + w.w;
grad += (k / len) * (w.z * fade * cos(phase));
}
- return grad;
+ // A little higher than the table's slopes.
+ return grad * 1.25;
}
fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
@@ -713,10 +714,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
// depth rather than as bright paint.
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.78, 0.86, 0.95);
// The colours the water takes up fastest are the ones it has the least of.
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
- let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.6 * (thickness + 0.3));
+ let refracted = behind * absorb + colour * volume_light * mix(0.65, 0.3, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
var r = reflect(-v, n);
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,992 +0,0 @@
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 22:56:15 +0300
Subject: [PATCH 25/42] HD fixes: rain level from the world not from rain
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
without a depth heuristic (#35), air fog once over water (#37), translucent
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
air capped; mods: render.set-time-override (API level 13) so replays show
their recorded time of day (#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/entity.wgsl | 2 +
crates/horizon-client/src/environment.rs | 5 +
crates/horizon-client/src/game.rs | 18 +-
crates/horizon-client/src/gfx.rs | 52 ++-
crates/horizon-client/src/gfx/hd.rs | 411 ++++++++++++++++-------
crates/horizon-client/src/gfx/hd_sky.rs | 8 +
crates/horizon-client/src/terrain.wgsl | 8 +
crates/horizon-mod-sdk/wit/horizon.wit | 5 +
crates/horizon-mods/src/api_level.rs | 6 +-
crates/horizon-mods/src/runtime.rs | 12 +
docs/api/mods-reference.md | 1 +
11 files changed, 387 insertions(+), 141 deletions(-)
diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl
index 61bc1f92a..b3fc9bc82 100644
--- a/crates/horizon-client/src/entity.wgsl
+++ b/crates/horizon-client/src/entity.wgsl
@@ -20,6 +20,8 @@ struct Globals {
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
outline_params: vec4<f32>,
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
+ hd_flags: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
diff --git a/crates/horizon-client/src/environment.rs b/crates/horizon-client/src/environment.rs
index 20855b749..9b8acf13f 100644
--- a/crates/horizon-client/src/environment.rs
+++ b/crates/horizon-client/src/environment.rs
@@ -141,6 +141,9 @@ pub struct Sky {
pub fog_end: f32,
/// `visual/cloud_fog_end_distance`.
pub cloud_fog_end: f32,
+ /// AirDef fork: the world's rain level (0..1), set by `apply_weather`. The HD sky reads this
+ /// rather than the presence of rain geometry near the camera.
+ pub rain: f32,
}
impl Environment {
pub fn load(root: &Path) -> Result<Self> {
@@ -305,6 +308,7 @@ impl Environment {
.base
.get("minecraft:visual/fog_end_distance")
.map_or(1024.0, |v| v[0]),
+ rain: 0.0,
cloud_fog_end: self
.base
.get("minecraft:visual/cloud_fog_end_distance")
@@ -315,6 +319,7 @@ impl Environment {
/// WeatherAttributes: rain then thunder layers lerped by their levels
/// (rain counts only the part not covered by thunder).
pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
+ sky.rain = rain.clamp(0.0, 1.0);
let grey = |c: [f32; 3]| c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
let lerp3 = |t: f32, a: [f32; 3], b: [f32; 3]| -> [f32; 3] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 366be39c4..fad8de187 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -8370,6 +8370,14 @@ impl Game<'_> {
.collect()
}
+ /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's.
+ fn shown_day_time(&self) -> i64 {
+ self.mods
+ .mods
+ .time_override()
+ .map_or(self.world.day_time, |t| t as i64)
+ }
+
fn sky_darken(&self) -> i32 {
if self.dim == Dim::Nether {
return 11;
@@ -8378,12 +8386,12 @@ impl Game<'_> {
let sky = self
.res
.end_environment()
- .sample(self.world.day_time, None, None);
+ .sample(self.shown_day_time(), None, None);
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
}
let (rain, thunder) = self.world.weather.levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
- let key = (self.world.day_time, rain.to_bits(), thunder.to_bits());
+ let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
if let Some((k, darken)) = self.sky_darken_memo.get()
&& k == key
{
@@ -8392,7 +8400,7 @@ impl Game<'_> {
let mut sky = self
.res
.environment()
- .sample(self.world.day_time, None, None);
+ .sample(self.shown_day_time(), None, None);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
@@ -8898,7 +8906,7 @@ impl Game<'_> {
"first_quarter",
"waxing_gibbous",
];
- let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
+ let phase = phases[(self.shown_day_time() / 24000).rem_euclid(8) as usize];
body(
&format!("environment/celestial/moon/{phase}"),
sky.moon_angle,
@@ -11608,7 +11616,7 @@ impl Game<'_> {
);
// visual/water_fog_color: no dimension sets it, its default is #050533.
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
- let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
+ let mut sky = environment.sample(self.shown_day_time(), Some(biome_sky), Some(biome_fog));
let (rain, thunder) = if self.dim == Dim::Overworld {
self.world.weather.levels(alpha as f32)
} else {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 072090e33..a8031c1f6 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -194,6 +194,9 @@ struct Globals {
crumbling_sprite: [f32; 4],
/// Framebuffer size, selection stroke width, perspective layering scale.
outline_params: [f32; 4],
+ /// AirDef fork. x: 1 while the HD water draws the water (fs_translucent then leaves vanilla's
+ /// water out of the colour and keeps its depth).
+ hd_flags: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
}
@@ -578,7 +581,7 @@ pub struct Renderer {
hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
/// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
taa_frame: u32,
- taa_prev: Option<(Mat4, glam::DVec3)>,
+ taa_prev: Option<(Mat4, glam::DVec3, Mat4)>,
hd: Option<hd::Hd>,
mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
@@ -1207,17 +1210,15 @@ impl Renderer {
if sprite_arrays > 1 {
log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays");
}
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water (and
+ // fs_translucent clears while the HD water draws it instead).
let sprite_constants = [
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
("SPRITE_SPLIT", f64::from(split)),
- ];
- // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
- let water_constants = [
- ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
- ("SPRITE_SPLIT", f64::from(split)),
("WATER_STILL", f64::from(water_layers[0])),
("WATER_FLOW", f64::from(water_layers[1])),
];
+ let water_constants = sprite_constants;
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon terrain bindings"),
entries: &[
@@ -2823,7 +2824,7 @@ impl Renderer {
mip_levels,
mipmaps: mip_levels - 1,
terrain_layout: bindings,
- terrain_sprites: sprite_constants.map(|(_, v)| v),
+ terrain_sprites: [f64::from(sprite_arrays), f64::from(split)],
sprite_sampler,
lightmap_view,
lightmap_sampler,
@@ -4049,7 +4050,7 @@ impl Renderer {
};
let taa_prev = std::mem::replace(
&mut self.taa_prev,
- taa.then_some((unjittered, frame.camera)),
+ taa.then_some((unjittered, frame.camera, view_proj)),
);
if frame.title {
self.panorama.prepare(
@@ -4135,6 +4136,7 @@ impl Renderer {
frame.glint_ticks,
],
outline_params: outline_params(self.config.width, self.config.height),
+ hd_flags: [0.0; 4],
dynamic_lights: {
let mut lights = [[0.0; 4]; crate::dynamic_light::MAX];
for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) {
@@ -4260,11 +4262,14 @@ impl Renderer {
taa: taa && taa_prev.is_some(),
jitter_px,
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
+ prev_view_proj_jittered: taa_prev.map_or(view_proj, |p| p.2),
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
dark: 1.0 - frame.sky.sky_light_factor,
- rain: if frame.weather.is_empty() { 0.0 } else { 0.7 },
+ // The world's rain level (fades in and out with the weather), not the rain geometry
+ // near the camera: hiding the particles or standing under a roof leaves the sky alone.
+ rain: frame.sky.rain.clamp(0.0, 1.0) * 0.7,
fog_color,
fog,
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
@@ -4827,7 +4832,20 @@ impl Renderer {
+ shadow_quads
+ translucent_particle_quads
+ translucent_item_quads;
- let cloud_quads = if let Some(input) = &frame.clouds {
+ // AirDef fork: vanilla's clouds (HD's volumetric ones off) take the low sun's colour in their
+ // own colour, so a mountain or glass behind them does not change it.
+ let cloud_input = frame.clouds.as_ref().map(|c| {
+ if !hd_wanted || !hd_scene.sky || hd::clouds_enabled() {
+ return crate::clouds::Input { ..*c };
+ }
+ let [r, g, b, a] = c.color;
+ let [tr, tg, tb] = hd::vanilla_cloud_tint(&hd_scene);
+ crate::clouds::Input {
+ color: [r * tr, g * tg, b * tb, a],
+ ..*c
+ }
+ });
+ let cloud_quads = if let Some(input) = &cloud_input {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
let vertices = self.cloud_mesh.vertices.len() as u64;
@@ -5154,6 +5172,15 @@ impl Renderer {
if let Some(settings) = frame.hd {
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
}
+ // vanilla's translucent water leaves the colour while the HD water draws it.
+ let hd_flags_at = std::mem::offset_of!(Globals, hd_flags) as u64;
+ let hd_flags: [f32; 4] = [if hd.water_active() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0];
+ self.uploads.buffer(
+ &mut encoder,
+ &self.globals,
+ hd_flags_at,
+ bytemuck::bytes_of(&hd_flags),
+ );
hd.set_timing(device, &self.queue, self.pass_timing);
hd.timing_begin(device, &self.queue);
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
@@ -5184,6 +5211,11 @@ impl Renderer {
}
self.queue
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
+ self.queue.write_buffer(
+ &hd.main_globals,
+ hd_flags_at,
+ bytemuck::bytes_of(&hd_flags),
+ );
// Facing the light only: the nearest surface to it is always one that does.
let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
for (c, cascade) in hd_cascades.iter().enumerate() {
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 3b9daabf4..e84a618a9 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -133,6 +133,10 @@ pub struct HdScene {
pub view_proj: Mat4,
/// Last frame's view-projection without jitter, and the camera's move since (TAA).
pub prev_view_proj: Mat4,
+ /// The same frame's matrix as it was drawn, jitter included. History kept on the jittered
+ /// pixel grid (`fs_taa`, scale 1) is looked up with this; `fs_taau` resolves the history on the
+ /// unjittered output grid and uses `prev_view_proj`.
+ pub prev_view_proj_jittered: Mat4,
pub camera_delta: glam::Vec3,
/// This frame is anti-aliased over time (the projection is jittered).
pub taa: bool,
@@ -403,31 +407,104 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
return sum / total;
}
+struct LightOut {
+ // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
+ // object in front of the water (the scene as drawn).
+ @location(0) lit: vec4<f32>,
+ // Water: the air between the camera and the surface (scattered light, transmittance);
+ // in front of the water: the floor lit (rgb); else nothing.
+ @location(1) air: vec4<f32>,
+}
+
+// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo.
+fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
+ let sun = frame.sun.xyz;
+ let sun_on = frame.sun.w;
+ let atmosphere = frame.sky_sun.w > 0.5;
+ let count = u32(frame.params.x);
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
+ let surface_start = tiles[tile];
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
+ let n = normalize(textureLoad(normal_tex, px, 0).xyz);
+ let albedo = textureLoad(albedo_tex, px, 0);
+ let lm = textureLoad(light_tex, px, 0).xy;
+ // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
+ let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
+ let sky_reach = smoothstep(0.35, 0.95, lm.x);
+ var s = 1.0;
+ let ndl = dot(n, sun);
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
+ // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
+ // direct light by the face's angle.
+ let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
+ // the mods' lights by half, not on the sun or on emission.
+ var occlusion = 1.0;
+ // (The water dims and blurs the floor under it: no occlusion there.)
+ if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
+ // The sky's light is bluish from above and carries the horizon's colour from the sides;
+ // faces turned up take more of it than the ones turned down.
+ var sky_tint = vec3<f32>(1.0);
+ if atmosphere {
+ let reach = smoothstep(0.25, 0.9, lm.x);
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
+ }
+ var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
+ // The mods' lights.
+ for (var k = 0u; k < surface_count; k++) {
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
+ let to = l.pos.xyz - p;
+ let dist = length(to);
+ if dist >= l.pos.w { continue; }
+ let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
+ let falloff = l.color.w / (dist * dist + 1.0) * window;
+ let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
+ light += l.color.rgb * falloff * k * ao_lights;
+ }
+ return albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
+}
+
@fragment
-fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
+fn fs_light(in: FullOut) -> LightOut {
let px = vec2<i32>(in.position.xy);
let scene = textureLoad(scene_color, px, 0).rgb;
let d = textureLoad(scene_depth, px, 0);
let g = textureLoad(gbuffer_depth, px, 0);
- // Water in front of the opaque world with nothing translucent over it: here the floor is lit
- // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
- var water = false;
- if frame.water.x > 0.5 && g > 0.0 {
- let wd = textureLoad(water_depth, px, 0);
- water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
- }
- let dd = select(d, g, water);
+ // Water over the opaque world (or over nothing): the water composite lays the surface over
+ // the floor, which is lit here with no air. Something translucent in front of the water
+ // (`front`: nearer than its surface) keeps the scene as drawn; the floor goes beside it.
+ var wd = 0.0;
+ var water_here = false;
+ if frame.water.x > 0.5 {
+ wd = textureLoad(water_depth, px, 0);
+ water_here = wd > 0.0 && (g <= 0.0 || wd >= g);
+ }
+ let front = water_here && g > 0.0 && d - wd > d * 0.002;
+ let water = water_here && !front;
+ let has_floor = water && g > 0.0;
+ let dd = select(d, g, has_floor);
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
let h = frame.inverse_view_proj * ndc;
let p = h.xyz / h.w;
let view = normalize(p);
+ // Where the air ends: the water's surface over water, else the pixel itself.
+ var ps = p;
+ if water {
+ let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(wd, 0.000001), 1.0);
+ ps = hf.xyz / hf.w;
+ }
+ let view_s = normalize(ps);
let sun = frame.sun.xyz;
let sun_on = frame.sun.w;
let count = u32(frame.params.x);
let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
- let surface_start = tiles[tile];
- let surface_all = tiles[tile + 1u] == 0xffffffffu;
- let surface_count = select(tiles[tile + 1u], count, surface_all);
let halo_start = tiles[tile + 2u];
let halo_all = tiles[tile + 3u] == 0xffffffffu;
let halo_count = select(tiles[tile + 3u], count, halo_all);
@@ -455,113 +532,81 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
c = c * cl.a + cl.rgb;
}
}
- // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
- // sun's colour at dusk and dawn instead of vanilla's flat white.
- if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
- let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
- c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
- }
- let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
+ // (Vanilla's clouds, when the volumetric ones are off, take the low sun's colour in their own
+ // pass: `vanilla_cloud_tint`, applied to their colour on the CPU.)
+ let surface = has_floor || (!water_here && d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
if surface {
- let n = normalize(textureLoad(normal_tex, px, 0).xyz);
- let albedo = textureLoad(albedo_tex, px, 0);
- let lm = textureLoad(light_tex, px, 0).xy;
- // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
- let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
- let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
- let sky_reach = smoothstep(0.35, 0.95, lm.x);
- var s = 1.0;
- let ndl = dot(n, sun);
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
- // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
- // direct light by the face's angle.
- let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
- // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
- // the mods' lights by half, not on the sun or on emission.
- var occlusion = 1.0;
- // (The water dims and blurs the floor under it: no occlusion there.)
- if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
- let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
- let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
- // The sky's light is bluish from above and carries the horizon's colour from the sides;
- // faces turned up take more of it than the ones turned down.
- var sky_tint = vec3<f32>(1.0);
- if atmosphere {
- let reach = smoothstep(0.25, 0.9, lm.x);
- let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
- let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
- let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
- sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
- }
- var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
- // The mods' lights.
- for (var k = 0u; k < surface_count; k++) {
- let l = lights[select(tiles[surface_start + k], k, surface_all)];
- let to = l.pos.xyz - p;
- let dist = length(to);
- if dist >= l.pos.w { continue; }
- let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
- let falloff = l.color.w / (dist * dist + 1.0) * window;
- let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
- light += l.color.rgb * falloff * k * ao_lights;
- }
- c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
- // Vanilla's fog over it again.
- let sph = length(p);
- let cyl = max(length(p.xz), abs(p.y));
- let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
- let haze = haze_colour(view);
- c = mix(c, haze, f * frame.fog_color.a);
- // Aerial perspective: far things take the horizon's colour a little before the fog does.
- if atmosphere && frame.fog.x > 1.0e6 {
- c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
+ c = shade_surface(px, p, water);
+ // Vanilla's fog over it again (the water composite does it at the surface).
+ if !water {
+ let sph = length(p);
+ let cyl = max(length(p.xz), abs(p.y));
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
+ let haze = haze_colour(view);
+ c = mix(c, haze, f * frame.fog_color.a);
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
+ if atmosphere && frame.fog.x > 1.0e6 {
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
+ }
}
}
- // The volumetric fog: the light the air scatters towards the camera (shafts through the trees,
- // mist, haze), and how much of the pixel it hides.
- let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
+ // What lies behind a translucent thing in front of the water: the floor, lit.
+ var floor_lit = vec3<f32>(0.0);
+ if front {
+ let hg = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(g, 0.000001), 1.0);
+ floor_lit = shade_surface(px, hg.xyz / hg.w, true);
+ }
+ // The air between the camera and the pixel (the water's surface over water): the volumetric
+ // fog (what it hides, what it scatters), the sun's rays between the shadows, and the halos
+ // round the mods' lights. Over water they go to the composite, which lays them over the
+ // finished water; elsewhere over the pixel here.
+ var air_t = 1.0;
+ var air_s = vec3<f32>(0.0);
+ let far = select(min(length(ps), frame.shadow.z), frame.shadow.z, d <= 0.0);
let density = frame.ambient.z;
let volumetric = frame.vfog.x > 0.5 && atmosphere;
if volumetric {
var fog_len = 192.0;
- if d > 0.0 {
- var pf = p;
- if water {
- let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
- pf = hf.xyz / hf.w;
- }
- fog_len = min(length(pf), 192.0);
- }
+ if d > 0.0 { fog_len = min(length(ps), 192.0); }
let fg = fog_at(px, fog_len);
- c = c * fg.a + fg.rgb;
+ air_t = fg.a;
+ air_s = fg.rgb;
}
- // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the
- // halos round the mods' lights.
if !volumetric && sun_on > 0.0 && density > 0.0 {
let steps = 14;
let jitter = ign(in.position.xy);
var acc = 0.0;
for (var i = 0; i < steps; i++) {
let t = (f32(i) + jitter) / f32(steps) * far;
- acc += lit_at(view * t);
+ acc += lit_at(view_s * t);
}
// Henyey-Greenstein, forward scattering: brightest looking towards the sun.
- let cos_t = dot(view, sun);
+ let cos_t = dot(view_s, sun);
let gk = 0.6;
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
- c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
+ air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
}
for (var k = 0u; k < halo_count; k++) {
let l = lights[select(tiles[halo_start + k], k, halo_all)];
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
- let t0 = dot(l.pos.xyz, view);
- let hh = length(l.pos.xyz - view * t0) + 0.5;
+ let t0 = dot(l.pos.xyz, view_s);
+ let hh = length(l.pos.xyz - view_s * t0) + 0.5;
if hh > l.pos.w * 0.5 { continue; }
let a0 = atan((0.0 - t0) / hh);
let a1 = atan((far - t0) / hh);
- c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
+ // Thicker air glows more round a light, but only so far: the mist at dusk and many fires
+ // at once would otherwise wash the picture out.
+ air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9));
+ }
+ var out: LightOut;
+ if water {
+ out.lit = vec4<f32>(c, 0.5);
+ out.air = vec4<f32>(air_s, air_t);
+ } else {
+ out.lit = vec4<f32>(c * air_t + air_s, select(1.0, 0.6, front));
+ out.air = vec4<f32>(floor_lit, 0.0);
}
- return vec4<f32>(c, select(1.0, 0.5, water));
+ return out;
}
"#;
@@ -580,6 +625,9 @@ const REFLECT_RANGE: f32 = 96.0;
@group(0) @binding(8) var lightmap: texture_2d<f32>;
@group(0) @binding(9) var linear: sampler;
//SKY10
+// What the lighting left beside the lit world: over water the air in front of the surface (scattered
+// light, transmittance), in front of the water the lit floor.
+@group(0) @binding(11) var air_tex: texture_2d<f32>;
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -761,6 +809,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
let px = vec2<i32>(in.position.xy);
let base = textureLoad(lit_tex, px, 0);
if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
+ // Alpha 0.6: something translucent stands in front of the water here (base is the scene).
+ let front = base.a > 0.55;
+ let aux = textureLoad(air_tex, px, 0);
let wd = textureLoad(water_depth, px, 0);
let g = textureLoad(gbuffer_depth, px, 0);
let tint = textureLoad(water_tint, px, 0);
@@ -792,23 +843,35 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
// Refraction: where the ray through the waves meets the world, against where the flat surface
- // would send it (so still water looks as vanilla's and only the waves distort).
- let floor0 = position_at(in.uv, g);
- let depth0 = max(length(floor0) - dist, 0.0);
- let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
+ // would send it (so still water looks as vanilla's and only the waves distort). Not where
+ // something translucent is in front of the water, nor onto texels that hold such a thing.
+ let has_floor = g > 0.0;
var uv_floor = in.uv;
var floor_z = g;
- let moved = project(floor0 + bent * min(depth0, 24.0));
- if moved.z > 0.0 && on_screen(moved.xy) {
- let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
- // Only what lies behind the water: not the bank or an entity in front of it.
- if z > 0.0 && z <= wd {
- uv_floor = moved.xy;
- floor_z = z;
+ if has_floor && !front {
+ let floor0 = position_at(in.uv, g);
+ let depth0 = max(length(floor0) - dist, 0.0);
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
+ let moved = project(floor0 + bent * min(depth0, 24.0));
+ if moved.z > 0.0 && on_screen(moved.xy) {
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
+ // The four texels the filter blends: none may be a pixel with a translucent thing
+ // in front of the water.
+ let marks = textureGather(3, lit_tex, linear, moved.xy);
+ let tainted = any((marks > vec4<f32>(0.55)) & (marks < vec4<f32>(0.75)));
+ // Only what lies behind the water: not the bank or an entity in front of it.
+ if z > 0.0 && z <= wd && !tainted {
+ uv_floor = moved.xy;
+ floor_z = z;
+ }
}
}
- let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
- let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
+ var thickness = 48.0;
+ if has_floor {
+ thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
+ }
+ var behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
+ if front { behind = aux.rgb; }
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
// depth rather than as bright paint.
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
@@ -840,6 +903,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
}
+ if front {
+ // The scene as drawn, with the water's change to the floor laid under the translucent thing.
+ c = base.rgb + (c - aux.rgb) * 0.7;
+ } else {
+ // The air between the camera and the surface: volumetric fog, rays, halos.
+ c = c * aux.a + aux.rgb;
+ }
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
}
"#;
@@ -1413,6 +1483,8 @@ pub struct Hd {
water_normal: wgpu::TextureView,
water_depth: wgpu::TextureView,
lit_water: wgpu::TextureView,
+ /// The lighting's second target: the air over water, the floor beside translucent things.
+ air: wgpu::TextureView,
water_layout: wgpu::BindGroupLayout,
water_pipeline: wgpu::RenderPipeline,
water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
@@ -1849,6 +1921,7 @@ impl Hd {
FLOAT,
SAMPLER,
FLOAT,
+ UNFILTERED,
]),
});
let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1961,15 +2034,39 @@ impl Hd {
},
alpha: wgpu::BlendComponent::REPLACE,
};
- let light_pipeline = fullscreen(
- device,
- "HD lighting",
- &light_layout,
- &light,
- "fs_light",
- HDR,
- None,
- );
+ let light_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD lighting"),
+ bind_group_layouts: &[Some(&light_layout)],
+ ..Default::default()
+ });
+ let target = Some(wgpu::ColorTargetState {
+ format: HDR,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD lighting"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &light,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &light,
+ entry_point: Some("fs_light"),
+ compilation_options: Default::default(),
+ targets: &[target.clone(), target],
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let prefilter = fullscreen(
device,
"HD bloom prefilter",
@@ -2108,6 +2205,7 @@ impl Hd {
)
.view,
lit: target(device, "HD lit", [1, 1], HDR).view,
+ air: target(device, "HD air", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
@@ -2199,6 +2297,7 @@ impl Hd {
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
+ self.air = target(device, "HD air", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
@@ -2322,7 +2421,12 @@ impl Hd {
0.0
};
let frame = HdFrame {
- prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
+ prev_view_proj: if self.upscaling() {
+ scene.prev_view_proj
+ } else {
+ scene.prev_view_proj_jittered
+ }
+ .to_cols_array_2d(),
taa: [
scene.camera_delta.x,
scene.camera_delta.y,
@@ -2685,15 +2789,28 @@ impl Hd {
return;
};
self.water_composited = false;
- Self::draw(
- encoder,
- "HD lighting",
- &self.lit,
- false,
- &self.light_pipeline,
- group,
- stamp(&mut self.timing, "hd lighting"),
- );
+ let clear = |view| {
+ Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })
+ };
+ {
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("HD lighting"),
+ timestamp_writes: stamp(&mut self.timing, "hd lighting"),
+ color_attachments: &[clear(&self.lit), clear(&self.air)],
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.light_pipeline);
+ pass.set_bind_group(0, group, &[]);
+ pass.draw(0..3, 0..1);
+ }
}
/// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
@@ -2987,6 +3104,7 @@ impl Hd {
wgpu::BindingResource::TextureView(lightmap.0),
wgpu::BindingResource::Sampler(&self.sampler),
wgpu::BindingResource::TextureView(&self.sky_lut),
+ wgpu::BindingResource::TextureView(&self.air),
],
);
self.water_group = Some((key, group));
@@ -3098,6 +3216,20 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
}
}
+/// The factor on vanilla's cloud colour that lights the clouds with the low sun's colour at dusk
+/// and dawn instead of a flat white. It is the clouds' own colour (the cloud pass gets it), not a
+/// guess from the depth buffer, so what stands behind a cloud cannot change it.
+pub fn vanilla_cloud_tint(scene: &HdScene) -> [f32; 3] {
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
+ let low = 1.0 - smooth(0.05, 0.5, sun.y);
+ let overcast = (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85;
+ let (_, _, color) = light_of(scene);
+ let warm = color.lerp(glam::Vec3::ONE, 0.45) * 1.1;
+ let t = low * 0.7 * (1.0 - overcast);
+ let tint = glam::Vec3::ONE.lerp(warm, t);
+ [tint.x, tint.y, tint.z]
+}
+
/// The x of the sun's direction: positive in the morning, negative in the evening.
fn sun_dir_x(scene: &HdScene) -> f32 {
scene.sun.normalize_or(glam::Vec3::Y).x
@@ -3201,6 +3333,37 @@ mod tests {
assert_eq!(seen.len(), 16);
}
+ #[test]
+ fn taa_history_lookup_follows_the_jittered_grid() {
+ // A static point under two frames' different Halton jitters: fs_taa finds this frame's
+ // point in the history by the previous frame's matrix *as drawn*, which lands on the pixel
+ // the previous frame wrote it to; the unjittered one is off by that frame's jitter.
+ let size = [1920u32, 1080];
+ let unjittered = Mat4::perspective_rh(1.0, 16.0 / 9.0, 0.05, 1000.0);
+ let to_uv = |m: Mat4, p: glam::Vec3| {
+ let c = m * p.extend(1.0);
+ [c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5]
+ };
+ let drawn = |frame: u32| {
+ let j = jitter(frame, size, 16);
+ Mat4::from_translation(glam::Vec3::new(j[0], j[1], 0.0)) * unjittered
+ };
+ let p = glam::Vec3::new(0.0, 0.0, -20.0);
+ for frame in 1..16 {
+ let prev = drawn(frame - 1);
+ // The pixel (in uv) the previous frame drew the point at: what the history holds there.
+ let wrote = to_uv(prev, p);
+ let old = to_uv(unjittered, p);
+ let err = [
+ (old[0] - wrote[0]).abs() * size[0] as f32,
+ (old[1] - wrote[1]).abs() * size[1] as f32,
+ ];
+ let pj = jitter(frame - 1, size, 16);
+ assert!((err[0] - pj[0].abs() * size[0] as f32 * 0.5).abs() < 1e-3);
+ assert!((err[1] - pj[1].abs() * size[1] as f32 * 0.5).abs() < 1e-3);
+ }
+ }
+
#[test]
fn jitter_covers_the_upscale_ratio() {
assert_eq!(jitter_count(1.0), 16);
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
index ec55de4c8..dbe8c8853 100644
--- a/crates/horizon-client/src/gfx/hd_sky.rs
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
@@ -283,6 +283,14 @@ fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
let theta = radians(flip + 90.0);
let c = cos(theta);
let s = sin(theta);
+ // Above the horizon the fan reaches only the sun's side (x of the same sign as the apex's, -s)
+ // and rises at most alpha/3 per block of that distance (its rim stands 40*alpha high over a
+ // 120-block radius and the points above the horizon lie at least 120 * the share of the rim
+ // from the axis); anything else cannot hit it, so most of the sky skips the 16 triangles.
+ if d.y > 0.0 {
+ let side = -s * d.x;
+ if side <= 0.0 || d.y > alpha * (1.0 / 3.0) * side * 1.02 + 0.001 { return 0.0; }
+ }
var best = 0.0;
let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
for (var i = 0; i < 16; i++) {
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index 0a2a6cdba..cb098a64a 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -22,6 +22,8 @@ struct Globals {
// Atlas rect of the current destroy stage (read by entity.wgsl).
crumbling_sprite: vec4<f32>,
outline_params: vec4<f32>,
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
+ hd_flags: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
@@ -211,6 +213,12 @@ struct WaterOut {
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.01 { discard; }
+ // The HD water composite draws the water itself: vanilla's stays out of the colour (alpha 0
+ // blends to nothing) but keeps its depth, which the lighting reads.
+ let layer = in.layer & 0x7fffu;
+ if globals.hd_flags.x > 0.5 && (layer == WATER_STILL || layer == WATER_FLOW) {
+ return vec4(0.0);
+ }
return apply_fog(color, in.spherical, in.cylindrical);
}
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 8411ac9aa..5fa5e8a27 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -694,6 +694,11 @@ interface render {
/// Switches the HD lighting on or off for this mod's world, with its
/// exposure and bloom; off, the world is drawn as the game draws it.
set-lighting: func(lighting: lighting);
+ /// Shows the world at this time of day (AirDef fork): the sky, the sun
+ /// and moon, the sky light and the fog, as `day-time` ticks (24000 a day,
+ /// as `clock`). Only what the player sees changes: the world's own clock,
+ /// its saves and its rules do not. None shows the world's own time.
+ set-time-override: func(day-time: option<u64>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 8cf350044..bc1ade067 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 12;
+pub const LEVEL: u32 = 13;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 66] = [
+pub const FUNCTIONS: [(&str, u32); 67] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -82,6 +82,8 @@ pub const FUNCTIONS: [(&str, u32); 66] = [
("render.query-sections", 12),
("render.sections", 12),
("render.world-changes", 12),
+ // AirDef fork: a replay shows its recorded time of day.
+ ("render.set-time-override", 13),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 2189a99e1..fb6180f1c 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -507,6 +507,8 @@ struct State {
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
lights: Vec<PointLight>,
lighting: Option<ModLighting>,
+ /// `render.set-time-override` (AirDef fork).
+ time_override: Option<u64>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -749,6 +751,7 @@ impl Mods {
views: Vec::new(),
lights: Vec::new(),
lighting: None,
+ time_override: None,
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1334,6 +1337,12 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().lighting)
}
+ /// The time of day the first running mod asked to show, if any.
+ pub fn time_override(&self) -> Option<u64> {
+ self.entries
+ .iter()
+ .find_map(|e| e.run.as_ref()?.store.data().time_override)
+ }
/// The columns each running mod asked the ground height of, taken.
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
self.entries
@@ -2237,6 +2246,9 @@ impl render::Host for State {
};
self.lighting = Some(lighting);
}
+ fn set_time_override(&mut self, day_time: Option<u64>) {
+ self.time_override = day_time.map(|t| t.min(1 << 40));
+ }
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
let room = 512usize.saturating_sub(self.ground_queries.len());
self.ground_queries.extend(columns.into_iter().take(room));
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index 2afb5bf62..b58f863c2 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -646,6 +646,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
+| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
## `events`
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
@@ -1,52 +0,0 @@
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:22:19 +0300
Subject: [PATCH 27/42] HD clouds: noise scales that divide the camera's
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd/cloud_common.wgsl | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
index 393ea564b..e2352f008 100644
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
@@ -21,6 +21,8 @@ fn dampen(x: f32) -> f32 {
}
// Where a camera-relative point lies in the world's cloud pattern (the wind moves the pattern).
+// The camera's x, z come wrapped to 65536 blocks: every noise scale below must divide that (powers
+// of two), or the pattern jumps where the wrap happens (at x or z = 0).
fn cloud_xz(pos: vec3<f32>) -> vec2<f32> {
return vec2<f32>(pos.x + frame.cloud_cam.x, pos.z + frame.cloud_cam.y)
+ vec2<f32>(0.8, 0.6) * (CLOUD_WIND * frame.clouds.w);
@@ -33,8 +35,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
let h = (pos.y - frame.clouds.x) / frame.clouds.y;
if h <= 0.0 || h >= 1.0 { return 0.0; }
let w = cloud_xz(pos);
- let cover = textureSampleLevel(noise2, repeat_s, w / 16000.0, 0.0).r;
- let shape = textureSampleLevel(noise2, repeat_s, w / 3000.0, 0.0).g;
+ let cover = textureSampleLevel(noise2, repeat_s, w / 16384.0, 0.0).r;
+ let shape = textureSampleLevel(noise2, repeat_s, w / 4096.0, 0.0).g;
let cu = mix(0.56, 0.80, cover) + frame.cloud_cam.z;
var d = linear_step(1.0 - cu, 1.0, shape);
// Carve an egg shape, thin out the bottom.
@@ -43,9 +45,9 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
if d < 0.002 { return 0.0; }
if detail > 0.0 {
let p3 = vec3<f32>(w.x, pos.y, w.y);
- let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1100.0, 0.0).b - 0.5) * 0.4;
- let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 420.0 + vec3<f32>(warp), 0.0).r;
- let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 90.0 + vec3<f32>(warp * 3.0), 0.0).g;
+ let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
+ let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
+ let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
let fade = 0.20 * smoothstep(0.85, 1.0, 1.0 - h) - 0.35 * smoothstep(0.05, 0.5, h) + 0.6;
d -= 0.55 * w0 * w0 * dampen(1.0 - d);
d -= 0.45 * w1 * w1 * dampen(1.0 - d) * fade;
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
@@ -1,603 +0,0 @@
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:26:09 +0300
Subject: [PATCH 29/42] HD clouds composited by depth so terrain and vehicles
inside or behind the layer are hidden and those in front stay in front (#67);
world-anchored noise everywhere (cloud detail by height above the layer, fog,
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
only, gameplay sky darkening uses the world's time (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 37 +++--
crates/horizon-client/src/game/render_tick.rs | 2 +-
crates/horizon-client/src/gfx/hd/cloud.wgsl | 54 ++++++-
.../src/gfx/hd/cloud_common.wgsl | 3 +-
crates/horizon-client/src/gfx/hd/fog.wgsl | 6 +-
crates/horizon-client/src/gfx/hd/light.wgsl | 42 +++++-
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
crates/horizon-client/src/gfx/hd/volumes.rs | 135 +++++++++++++++---
8 files changed, 239 insertions(+), 44 deletions(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 2130d8d88..f21b3dcdd 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -1838,6 +1838,8 @@ struct Game<'a> {
events: events::Events,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
+ /// The same for the time a mod shows (a replay's): render only.
+ shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
persist: persist::EntityMemory,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
@@ -8020,7 +8022,7 @@ impl Game<'_> {
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
- let darken = self.sky_darken();
+ let darken = self.shown_sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
glam::DVec3::new(
@@ -8378,33 +8380,43 @@ impl Game<'_> {
.map_or(self.world.day_time, |t| t as i64)
}
+ /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always
+ /// the world's real time, never a mod's render-only override.
fn sky_darken(&self) -> i32 {
+ self.sky_darken_at(self.world.day_time, &self.sky_darken_memo)
+ }
+
+ /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's
+ /// time override, so a replay's night looks dark without touching the rules.
+ fn shown_sky_darken(&self) -> i32 {
+ self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo)
+ }
+
+ fn sky_darken_at(
+ &self,
+ time: i64,
+ memo: &std::cell::Cell<Option<((i64, u32, u32), i32)>>,
+ ) -> i32 {
if self.dim == Dim::Nether {
return 11;
}
if self.dim == Dim::End {
- let sky = self
- .res
- .end_environment()
- .sample(self.shown_day_time(), None, None);
+ let sky = self.res.end_environment().sample(time, None, None);
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
}
let (rain, thunder) = self.world.weather.levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
- let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
- if let Some((k, darken)) = self.sky_darken_memo.get()
+ let key = (time, rain.to_bits(), thunder.to_bits());
+ if let Some((k, darken)) = memo.get()
&& k == key
{
return darken;
}
- let mut sky = self
- .res
- .environment()
- .sample(self.shown_day_time(), None, None);
+ let mut sky = self.res.environment().sample(time, None, None);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
- self.sky_darken_memo.set(Some((key, darken)));
+ memo.set(Some((key, darken)));
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
@@ -13954,6 +13966,7 @@ impl<'a> Game<'a> {
mods: Default::default(),
native_shaders: Default::default(),
sky_darken_memo: Default::default(),
+ shown_darken_memo: Default::default(),
persist: Default::default(),
remote: None,
lan: None,
diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs
index 6807d7718..9912970a5 100644
--- a/crates/horizon-client/src/game/render_tick.rs
+++ b/crates/horizon-client/src/game/render_tick.rs
@@ -241,7 +241,7 @@ impl Game<'_> {
eye[2].floor() as i32,
];
let packed = self.world.light(b[0], b[1], b[2]);
- let sky = i32::from(packed >> 4) - self.sky_darken();
+ let sky = i32::from(packed >> 4) - self.shown_sky_darken();
let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8;
lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw)
}
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
index fad234a46..39a239f4d 100644
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
@@ -1,6 +1,17 @@
@group(0) @binding(6) var history: texture_2d<f32>;
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
+@group(0) @binding(8) var scene_depth: texture_depth_2d;
+@group(0) @binding(9) var history_dist: texture_2d<f32>;
+
+// What the sky pixels report as the length of their ray (nothing stops them).
+const SKY_LEN: f32 = 20000.0;
+
+struct CloudOut {
+ @location(0) cloud: vec4<f32>,
+ // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
+ @location(1) dist: vec4<f32>,
+}
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -65,7 +76,26 @@ fn cloud_scatter(
}
@fragment
-fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
+fn fs_cloud(in: FullOut) -> CloudOut {
+ // The nearest of the four world pixels this texel stands for: the march ends at the surface
+ // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front
+ // and what stands behind it is hidden by it.
+ let size = vec2<i32>(frame.resolution.xy);
+ let base = vec2<i32>(in.position.xy) * 2;
+ var zmax = 0.0;
+ var best = base;
+ for (var k = 0; k < 4; k++) {
+ let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
+ let z = textureLoad(scene_depth, q, 0);
+ if z > zmax {
+ zmax = z;
+ best = q;
+ }
+ }
+ let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
+ let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
+ let sh = frame.inverse_view_proj * sndc;
+ let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
let hh = frame.inverse_view_proj * ndc;
let dir = normalize(hh.xyz / hh.w);
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
var hit_t = 900.0;
let y0 = frame.clouds.x;
let y1 = y0 + frame.clouds.y;
- if abs(dir.y) > 0.004 {
- let ta = y0 / dir.y;
- let tb = y1 / dir.y;
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
+ {
+ let ta = y0 / dy;
+ let tb = y1 / dy;
let lo = max(min(ta, tb), 0.0);
- let hi = min(max(ta, tb), 4200.0);
+ let hi = min(max(ta, tb), min(4200.0, scene_len));
if hi > lo {
let dither = ign(in.position.xy);
let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26);
@@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
cur = vec4<f32>(colour, trans);
}
}
+ var out: CloudOut;
+ out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
if frame.cloud_cam.w > 0.5 {
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(1.0)) {
let old = textureSampleLevel(history, linear, huv, 0.0);
+ // A history that was marched to another surface (an edge, something that moved in
+ // front) does not describe this ray.
+ let hs = vec2<f32>(textureDimensions(history_dist));
+ let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
+ let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff));
+ let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
cur = mix(cur, old, keep);
}
}
}
- return cur;
+ out.cloud = cur;
+ return out;
}
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
index e2352f008..8ee014420 100644
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
d *= smoothstep(0.0, 0.2, h);
if d < 0.002 { return 0.0; }
if detail > 0.0 {
- let p3 = vec3<f32>(w.x, pos.y, w.y);
+ // Height above the layer's base, not the camera's height: the noise stays put in the world.
+ let p3 = vec3<f32>(w.x, pos.y - frame.clouds.x, w.y);
let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
index 38b9098e3..d035bbf0d 100644
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
}
// The air's extinction per block at a camera-relative point: a haze that thins with height above
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; none
+// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
// deep underground.
fn fog_sigma(p: vec3<f32>) -> f32 {
let wy = p.y + frame.vfog.y;
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
s += frame.sky_extra.z * 0.003;
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
- let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b;
+ let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
// Fade out far below the sea's height: caves keep their own dark air.
return s * patchy * smoothstep(28.0, 58.0, wy);
@@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
- vec3<f32>(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z;
let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r;
- let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g;
+ let n2 = textureSampleLevel(noise3, repeat_s, w / 32.0, 0.0).g;
let n = 0.55 * n1 + 0.45 * n2;
// Thin smoke breaks up into wisps, thick smoke stays whole.
let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12);
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
index 2534a3db8..870ae0d7d 100644
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
@@ -30,6 +30,9 @@ struct Light {
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
+// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
+@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
+const SKY_RAY: f32 = 20000.0;
const CLOUD_SHADOW_R: f32 = 1200.0;
// The pixel being lit, for the shadow filter's noise.
var<private> frag_px: vec2<f32>;
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
return sum / total;
}
+// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four
+// nearest half-resolution texels weighted by closeness and by how near the length they were
+// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it.
+fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
+ let size = vec2<i32>(textureDimensions(cloud_tex));
+ let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
+ let base = vec2<i32>(floor(f));
+ let t = f - floor(f);
+ var sum = vec4<f32>(0.0);
+ var total = 0.0;
+ for (var j = 0; j < 2; j++) {
+ for (var i = 0; i < 2; i++) {
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
+ let s = textureLoad(cloud_tex, q, 0);
+ let sd = textureLoad(cloud_dist_tex, q, 0).x;
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
+ let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4);
+ let w = bw / (1.0 + dz * dz * dz * dz);
+ sum += s * w;
+ total += w;
+ }
+ }
+ if total < 0.0001 {
+ return textureLoad(cloud_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0);
+ }
+ return sum / total;
+}
+
struct LightOut {
// The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
// object in front of the water (the scene as drawn).
@@ -246,7 +277,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
let flat_up = smoothstep(0.9, 0.98, n.y);
let soak = wet * open_sky * (0.35 + 0.65 * flat_up);
let wp = frame.cloud_cam.xy + p.xz;
- let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2<f32>(17.0, 5.0));
+ let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(17.0, 5.0));
puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up;
surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle);
rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal));
@@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut {
c = sky + kept;
// The volumetric clouds over the sky and the sun.
if frame.clouds.z > 0.5 {
- let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0);
+ let cl = cloud_at(px, SKY_RAY);
c = c * cl.a + cl.rgb;
}
}
@@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut {
air_t = fg.a;
air_s = fg.rgb;
}
+ // The clouds between the camera and the surface (inside the layer, or in front of a mountain
+ // or a flying thing): laid over the surface and under the fog, as the sky's are.
+ if d > 0.0 && atmosphere && frame.clouds.z > 0.5 {
+ let cl = cloud_at(px, length(ps));
+ air_s += air_t * cl.rgb;
+ air_t *= cl.a;
+ }
if !volumetric && sun_on > 0.0 && density > 0.0 {
let steps = 14;
let jitter = ign(in.position.xy);
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
index da56d00ba..d26f1e513 100644
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
pub(super) const LIGHT: &str = include_str!("light.wgsl");
/// The lighting's group 0, binding by binding (see the contract above).
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
UNIFORM,
UNFILTERED,
DEPTH,
@@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
UNFILTERED,
FLOAT,
FLOAT,
+ UNFILTERED,
];
/// The outside views and sampler the lighting group reads.
@@ -219,6 +220,7 @@ impl Hd {
wgpu::BindingResource::TextureView(&self.volumes.fog_dist),
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
+ wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
];
let entries: Vec<_> = resources
.into_iter()
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
index a338d756c..d3b55e2f2 100644
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
@@ -26,8 +26,8 @@
use super::noise as hd_noise;
use super::{
- DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module,
- stamp, stamped_pass, target,
+ DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
+ fullscreen, module, stamp, stamped_pass, target,
};
pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -86,6 +86,9 @@ pub struct HdVolumes {
shadow_sampler: wgpu::Sampler,
/// The clouds' half-resolution target and its history (read one, write the other).
pub clouds: [wgpu::TextureView; 2],
+ /// How far each cloud texel's ray was marched (ping-pong with `clouds`).
+ cloud_dist: [wgpu::TextureView; 2],
+ cloud_depth: Option<wgpu::TextureView>,
flip: usize,
history_ready: bool,
pub cloud_shadow: wgpu::TextureView,
@@ -111,9 +114,10 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture {
};
/// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky
-/// texture, the previous cloud frame.
-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] =
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
+/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths.
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [
+ UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED,
+];
/// `fs_cloud_shadow`'s group 0: as the clouds' without the history.
pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
@@ -218,15 +222,40 @@ impl HdVolumes {
let cloud_module = module(device, "HD clouds", &cloud_src);
let shadow_module = module(device, "HD cloud shadows", &shadow_src);
let fog_module = module(device, "HD fog", &fog_body());
- let cloud_pipeline = fullscreen(
- device,
- "HD clouds",
- &cloud_layout,
- &cloud_module,
- "fs_cloud",
- CLOUD_FORMAT,
- None,
- );
+ let cloud_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD clouds"),
+ bind_group_layouts: &[Some(&cloud_layout)],
+ ..Default::default()
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD clouds"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &cloud_module,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &cloud_module,
+ entry_point: Some("fs_cloud"),
+ compilation_options: Default::default(),
+ targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| {
+ Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })
+ }),
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let shadow_pipeline = fullscreen(
device,
"HD cloud shadows",
@@ -286,6 +315,11 @@ impl HdVolumes {
one("HD clouds", [1, 1], CLOUD_FORMAT),
one("HD clouds", [1, 1], CLOUD_FORMAT),
],
+ cloud_dist: [
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
+ ],
+ cloud_depth: None,
flip: 0,
history_ready: false,
cloud_shadow: one(
@@ -316,6 +350,10 @@ impl HdVolumes {
one("HD clouds", CLOUD_FORMAT),
one("HD clouds", CLOUD_FORMAT),
];
+ self.cloud_dist = [
+ one("HD cloud distance", FOG_DIST_FORMAT),
+ one("HD cloud distance", FOG_DIST_FORMAT),
+ ];
self.fog = one("HD fog", FOG_FORMAT);
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
self.history_ready = false;
@@ -342,6 +380,11 @@ impl HdVolumes {
&self.clouds[self.flip]
}
+ /// The march lengths that go with `cloud_view`.
+ pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
+ &self.cloud_dist[self.flip]
+ }
+
fn group(
&mut self,
device: &wgpu::Device,
@@ -372,7 +415,12 @@ impl HdVolumes {
frame: &wgpu::Buffer,
sky_lut: &wgpu::TextureView,
atlas: &wgpu::TextureView,
+ depth: &wgpu::TextureView,
) {
+ if self.cloud_depth.as_ref() != Some(depth) {
+ self.cloud_groups = None;
+ self.cloud_depth = Some(depth.clone());
+ }
if self.shadow_group.is_none() {
let layout = self.shadow_layout.clone();
let (n3, n2, clamp, repeat) = (
@@ -405,6 +453,7 @@ impl HdVolumes {
self.repeat.clone(),
);
let hist = self.clouds.clone();
+ let hist_dist = self.cloud_dist.clone();
let groups = [0, 1].map(|read| {
self.group(
device,
@@ -419,6 +468,8 @@ impl HdVolumes {
wgpu::BindingResource::TextureView(sky_lut),
wgpu::BindingResource::TextureView(&hist[read]),
wgpu::BindingResource::TextureView(atlas),
+ wgpu::BindingResource::TextureView(depth),
+ wgpu::BindingResource::TextureView(&hist_dist[read]),
],
)
});
@@ -455,7 +506,7 @@ impl HdVolumes {
stamped_pass(
encoder,
"HD clouds",
- &[&self.clouds[write]],
+ &[&self.clouds[write], &self.cloud_dist[write]],
&self.cloud_pipeline,
&groups[read],
stamp,
@@ -525,8 +576,13 @@ impl Hd {
depth: &wgpu::TextureView,
) {
if self.clouds_active {
- self.volumes
- .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
+ self.volumes.prepare_clouds(
+ device,
+ &self.frame,
+ &self.sky.lut,
+ &self.smoke.atlas,
+ depth,
+ );
self.volumes
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
self.volumes
@@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
("fog", fog_body(), &FOG_BINDINGS),
]
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to
+ /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0).
+ #[test]
+ fn noise_scales_divide_the_camera_wrap() {
+ let mut found = 0;
+ for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
+ for key in ["w / ", "p3 / "] {
+ for part in src.split(key).skip(1) {
+ let num: String = part
+ .chars()
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
+ .collect();
+ let Ok(scale) = num.parse::<f64>() else {
+ continue;
+ };
+ found += 1;
+ assert!(
+ scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0,
+ "{name}: the scale {scale} does not divide 65536"
+ );
+ }
+ }
+ }
+ assert!(found >= 8, "found only {found} noise lookups");
+ }
+
+ /// The cloud noise's height is measured from the layer's base, not from the camera: the same
+ /// world point gives the same noise coordinate at any camera height.
+ #[test]
+ fn cloud_noise_height_is_anchored_to_the_layer() {
+ assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x"));
+ // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera.
+ for cam in [64.0f32, 210.0, 1000.0] {
+ let world_y = 230.0f32;
+ let rel = world_y - cam;
+ let base_rel = CLOUD_BASE - cam;
+ assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3);
+ }
+ }
+}
--
2.54.0 (Apple Git-157)
@@ -1,59 +0,0 @@
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:44:45 +0300
Subject: [PATCH 30/42] HD smoke: young trails drawn at least as wide as a grid
cell (density scaled to keep the mass), gentler bulges, so a trail is a
smooth ribbon and not a chain of puffs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd/smoke.rs | 12 ++++++++++++
crates/horizon-client/src/gfx/hd/smoke_grid.wgsl | 2 +-
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
index dee0932e8..c751df4f4 100644
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
@@ -46,6 +46,8 @@ const MAX_INDICES: usize = 120_000;
pub const MAX_LIGHTS: usize = 16;
/// How fast the wind carries smoke (blocks per second, x and z).
const WIND: [f32; 2] = [1.6, 1.2];
+/// The thinnest a trail is drawn, in blocks of radius (a cell is `CELL`).
+const MIN_TUBE: f32 = 3.6;
/// Smoke is on unless `HORIZON_HD_SMOKE=0`.
pub fn smoke_enabled() -> bool {
@@ -402,6 +404,16 @@ fn bake(s: &HdSmoke) -> Option<Baked> {
// Spreading thins it (the core's mass over a wider tube), and it fades out towards its end.
let thin = (r0 / radius).powf(0.5);
let density = s.shape[2] * (1.0 - u).powi(2) * thin * (t / 0.25).min(1.0).max(0.2);
+ // A tube thinner than the grid's cells is sampled unevenly (cells near its axis get it, their
+ // neighbours miss it): a chain of blobs. Trails are kept at least a cell and a bit wide, with
+ // the same mass per length, so a young trail is a continuous ribbon.
+ let d = [s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]];
+ let tube = d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25;
+ let (radius, density) = if tube && radius < MIN_TUBE {
+ (MIN_TUBE, density * radius / MIN_TUBE)
+ } else {
+ (radius, density)
+ };
let heat = s.shape[3] * (-t / 1.5).exp();
let clear_amount = (1.0 - u).powf(0.8);
let clear = s.motion[1] * (1.0 + 0.4 * u);
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
index 01b6e082a..0adcd55f6 100644
--- a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
@@ -40,7 +40,7 @@ fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
let seed = segs[i + 3u].x;
let len = sqrt(len2);
// Bulges along the tube, so it is not a pipe.
- let r = pa.w * (1.0 + 0.22 * sin(along * len * 0.6 + seed * 40.0));
+ let r = pa.w * (1.0 + 0.07 * sin(along * len * 0.35 + seed * 40.0));
if d < r && pb.w > 0.0 {
let q = d / r;
var w = (1.0 - q * q);
--
2.54.0 (Apple Git-157)
@@ -1,642 +0,0 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:59:41 +0300
Subject: [PATCH 31/42] Mods: render.set-main-view (API level 15) renders the
screen's world from a mod camera with the full pipeline, so replays get HD
and the post effects; small mod views get the post pass with their own camera
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 31 +++-
crates/horizon-client/src/game/mod_models.rs | 9 ++
crates/horizon-client/src/gfx.rs | 39 ++++-
crates/horizon-client/src/gfx/mod_post.rs | 152 +++++++++++++++----
crates/horizon-client/src/gfx/mod_views.rs | 69 ++++++---
crates/horizon-mod-sdk/wit/horizon.wit | 10 ++
crates/horizon-mods/src/api_level.rs | 6 +-
crates/horizon-mods/src/runtime.rs | 12 ++
docs/api/mods-reference.md | 1 +
9 files changed, 269 insertions(+), 60 deletions(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index f21b3dcdd..0e779aaa0 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -5950,7 +5950,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
- let show_hud = !self.ui.hide_gui;
+ let show_hud = !self.ui.hide_gui && self.mod_main_view(self.clock.fraction()).is_none();
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
// the action bar sit above it.
let mut bar_height = 39;
@@ -11572,6 +11572,13 @@ impl Game<'_> {
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
+ // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
+ // player's, everything seen is drawn from the camera with the full pipeline.
+ let main_view = self.mod_main_view(alpha);
+ let (camera, underwater, view_yaw, view_pitch) = match &main_view {
+ Some(v) => (v.camera, false, v.yaw, v.pitch),
+ None => (camera, underwater, view_yaw, view_pitch),
+ };
let block = [
camera.x.floor() as i32,
camera.y.floor() as i32,
@@ -11579,6 +11586,7 @@ impl Game<'_> {
];
// The camera inside an opaque block sees nothing through it: disable.
let cave_culling = self.settings.horizon.cave_culling
+ && main_view.is_none()
&& !self
.res
.facts()
@@ -11670,7 +11678,11 @@ impl Game<'_> {
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
- let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
+ let outline = if self.screen == Screen::Playing
+ && !self.ui.hide_gui
+ && main_view.is_none()
+ && self.may_build()
+ {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -11733,17 +11745,19 @@ impl Game<'_> {
};
let fov_degrees = if title {
85.0
+ } else if let Some(v) = &main_view {
+ v.fov
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
- let bob = if title {
+ let bob = if title || main_view.is_some() {
glam::Mat4::IDENTITY
} else {
self.bob_matrix(camera_alpha as f32)
};
- let spin = if title {
+ let spin = if title || main_view.is_some() {
glam::Mat4::IDENTITY
} else {
self.spinning_matrix(camera_alpha as f32)
@@ -11791,7 +11805,7 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
- let hand = if title || self.scoping() {
+ let hand = if title || self.scoping() || main_view.is_some() {
Vec::new()
} else {
self.hand_geometry(camera_alpha)
@@ -11829,7 +11843,12 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
- let mod_views = self.mod_views(alpha);
+ // The main view is the frame itself, not a picture of its own.
+ let mod_views: Vec<_> = self
+ .mod_views(alpha)
+ .into_iter()
+ .filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
+ .collect();
let hd_lights = self.mod_lights(camera, alpha);
let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
let frame = Frame {
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index f5e68566a..7a4d742df 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -71,6 +71,9 @@ impl Game<'_> {
pub(super) fn mod_views(&self, _alpha: f64) -> Vec<horizon_client::gfx::ModView> {
Vec::new()
}
+ pub(super) fn mod_main_view(&self, _alpha: f64) -> Option<horizon_client::gfx::ModView> {
+ None
+ }
pub(super) fn mod_hd(&self) -> Option<horizon_client::gfx::HdSettings> {
None
}
@@ -300,6 +303,12 @@ impl Game<'_> {
.collect()
}
+ /// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame.
+ pub(super) fn mod_main_view(&self, alpha: f64) -> Option<horizon_client::gfx::ModView> {
+ let id = self.mods.mods.main_view()?;
+ self.mod_views(alpha).into_iter().find(|v| v.id == id)
+ }
+
/// Draws every mod model between its last two poses, lit by the world
/// (or at full brightness), its faces shaded by its own rotation.
pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec<Vertex>) {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 93a2f7c63..1a4e68b88 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -3363,7 +3363,8 @@ impl Renderer {
view: &t.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
- store: wgpu::StoreOp::Discard,
+ // The mod's post pass reads it.
+ store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
@@ -3411,6 +3412,33 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
+ drop(pass);
+ // The mod's post pass with this camera: its blasts, flashes and glows in the picture,
+ // as much as the picture's size is worth. The picture's own camera and world are
+ // lit by the pass itself (no HD lighting here: it is the world's, not a picture's).
+ t.post = false;
+ if let Some(post) = &self.mod_post
+ && let Some((blasts, lights)) = mod_views::post_budget(t.size)
+ {
+ let mut params = self.mod_params.0.clone();
+ if params.len() >= 4 {
+ // Mode 0: the pass lights the land and draws the smoke itself.
+ params[3] = 0.0;
+ params[1] = params[1].min(blasts as f32);
+ params[2] = params[2].min(lights as f32);
+ }
+ post.write_to(
+ &self.queue,
+ &t.post_buffer,
+ (d.view, d.projection, d.camera),
+ frame.sky.sun_angle,
+ t.size,
+ &params,
+ self.mod_params.1,
+ );
+ post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx);
+ t.post = true;
+ }
}
encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size);
}
@@ -3703,7 +3731,12 @@ impl Renderer {
} else {
self.config.format.remove_srgb_suffix()
};
- self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?);
+ self.mod_post = Some(mod_post::ModPost::new(
+ &self.device,
+ source,
+ format,
+ self.config.format.remove_srgb_suffix(),
+ )?);
self.mod_post_source = Some(source.to_owned());
}
}
@@ -4424,6 +4457,8 @@ impl Renderer {
id: v.id,
view_proj,
view,
+ projection: world_projection(v.fov, aspect, frame.depth_far),
+ camera: v.camera,
aspect,
fov: v.fov,
visible: seen,
diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs
index d1f54a4bb..3d67ff25b 100644
--- a/crates/horizon-client/src/gfx/mod_post.rs
+++ b/crates/horizon-client/src/gfx/mod_post.rs
@@ -106,6 +106,8 @@ fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4<f32> {
pub struct ModPost {
pipeline: wgpu::RenderPipeline,
+ /// The same pass onto the mod cameras' pictures (the surface's format).
+ view_pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
buffer: wgpu::Buffer,
sampler: wgpu::Sampler,
@@ -144,6 +146,7 @@ impl ModPost {
device: &wgpu::Device,
source: &str,
format: wgpu::TextureFormat,
+ view_format: wgpu::TextureFormat,
) -> Result<Self, String> {
let module = compile(source)?;
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
@@ -189,31 +192,35 @@ impl ModPost {
label: Some("Mod post"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
- let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
- label: Some("Mod post"),
- layout: Some(&pipeline_layout),
- vertex: wgpu::VertexState {
- module: &shader,
- entry_point: Some("horizon_mod_post_vs"),
- compilation_options: Default::default(),
- buffers: &[],
- },
- primitive: Default::default(),
- depth_stencil: None,
- multisample: Default::default(),
- fragment: Some(wgpu::FragmentState {
- module: &shader,
- entry_point: Some("horizon_mod_post_fs"),
- compilation_options: Default::default(),
- targets: &[Some(wgpu::ColorTargetState {
- format,
- blend: None,
- write_mask: wgpu::ColorWrites::ALL,
- })],
- }),
- multiview_mask: None,
- cache: None,
- });
+ let make = |format: wgpu::TextureFormat| {
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("Mod post"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("horizon_mod_post_vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("horizon_mod_post_fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
+ let pipeline = make(format);
+ let view_pipeline = make(view_format);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(e.to_string());
}
@@ -231,6 +238,7 @@ impl ModPost {
});
Ok(Self {
pipeline,
+ view_pipeline,
layout,
buffer,
sampler,
@@ -253,7 +261,30 @@ impl ModPost {
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
* frame.bob
* frame.spin;
- let a = frame.sky.sun_angle.to_radians();
+ self.write_to(
+ queue,
+ &self.buffer,
+ (view, projection, frame.camera),
+ frame.sky.sun_angle,
+ size,
+ params,
+ partial,
+ );
+ }
+
+ /// The same for another camera (a mod view's picture) into its own `buffer`.
+ #[allow(clippy::too_many_arguments)]
+ pub fn write_to(
+ &self,
+ queue: &wgpu::Queue,
+ buffer: &wgpu::Buffer,
+ (view, projection, camera): (Mat4, Mat4, glam::DVec3),
+ sun_angle: f32,
+ size: [u32; 2],
+ params: &[f32],
+ partial: f32,
+ ) {
+ let a = sun_angle.to_radians();
let sun = [-a.sin(), a.cos(), 0.0, 0.0];
let mut p = [[0.0f32; 4]; PARAMS / 4];
for (i, v) in params.iter().take(PARAMS).enumerate() {
@@ -264,12 +295,7 @@ impl ModPost {
view_proj: m(projection * view),
view_inverse: m(view.inverse()),
projection_inverse: m(projection.inverse()),
- camera: [
- frame.camera.x as f32,
- frame.camera.y as f32,
- frame.camera.z as f32,
- 0.0,
- ],
+ camera: [camera.x as f32, camera.y as f32, camera.z as f32, 0.0],
resolution: [
size[0] as f32,
size[1] as f32,
@@ -285,7 +311,67 @@ impl ModPost {
sun,
params: p,
};
- queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
+ queue.write_buffer(buffer, 0, bytemuck::bytes_of(&inputs));
+ }
+
+ /// A buffer for one more camera's `write_to`.
+ pub fn new_buffer(device: &wgpu::Device) -> wgpu::Buffer {
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Mod post frame (view)"),
+ size: size_of::<ModFrame>() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ })
+ }
+
+ /// Draws the pass over a mod camera's picture (`world`: colour and depth, `buffer`: what
+ /// `write_to` filled) onto `target`, in the surface's format.
+ pub fn run_view(
+ &self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ buffer: &wgpu::Buffer,
+ world: (&wgpu::TextureView, &wgpu::TextureView),
+ target: &wgpu::TextureView,
+ ) {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod post (view)"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: buffer.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(world.0),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(world.1),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ });
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod post (view)"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.view_pipeline);
+ pass.set_bind_group(0, &group, &[]);
+ pass.draw(0..3, 0..1);
}
/// Draws the pass over the world target `world` (colour, depth) onto `screen`.
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
index aa2c5b971..bb65480ea 100644
--- a/crates/horizon-client/src/gfx/mod_views.rs
+++ b/crates/horizon-client/src/gfx/mod_views.rs
@@ -26,15 +26,34 @@ pub(super) const PER_FRAME: usize = 3;
pub(super) const RADIUS: i32 = 12;
pub(super) struct Target {
- size: [u32; 2],
+ pub size: [u32; 2],
pub color: wgpu::TextureView,
pub depth: wgpu::TextureView,
pub globals: wgpu::Buffer,
blit: wgpu::BindGroup,
+ /// The picture after the mod's post pass (`post`), and what shows it.
+ pub fx: wgpu::TextureView,
+ blit_fx: wgpu::BindGroup,
+ /// The post pass's uniform for this camera.
+ pub post_buffer: wgpu::Buffer,
+ /// The picture shown went through the post pass this time.
+ pub post: bool,
/// Drawn at least once: the HUD shows nothing before.
pub drawn: bool,
}
+/// How much of the mod's post pass a picture of `size` pays for: (blasts, lights) the pass
+/// marches, or None for a picture too small to see them in. Full-screen pictures get all.
+pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> {
+ let area = size[0] as u64 * size[1] as u64;
+ match area {
+ 0..4096 => None,
+ 4096..150_000 => Some((4, 12)),
+ 150_000..600_000 => Some((12, 32)),
+ _ => Some((48, 79)),
+ }
+}
+
/// A camera drawn this frame.
pub(super) struct Due {
pub id: u32,
@@ -42,6 +61,9 @@ pub(super) struct Due {
/// world's other geometry are.
pub view_proj: Mat4,
pub view: Mat4,
+ /// Its own projection and place, for the mod's post pass.
+ pub projection: Mat4,
+ pub camera: DVec3,
pub aspect: f32,
pub fov: f32,
pub visible: Vec<super::resident::Visible>,
@@ -239,22 +261,31 @@ impl Views {
let depth = texture(
"Mod view depth",
wgpu::TextureFormat::Depth32Float,
- wgpu::TextureUsages::RENDER_ATTACHMENT,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
- let blit = device.create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("Mod view picture"),
- layout: &self.layout,
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: wgpu::BindingResource::TextureView(&color),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::Sampler(&self.sampler),
- },
- ],
- });
+ let fx = texture(
+ "Mod view post",
+ self.format,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+ );
+ let group = |view: &wgpu::TextureView| {
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod view picture"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ })
+ };
+ let blit = group(&color);
+ let blit_fx = group(&fx);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Mod view globals"),
size: globals_size,
@@ -267,6 +298,10 @@ impl Views {
depth,
globals,
blit,
+ fx,
+ blit_fx,
+ post_buffer: super::mod_post::ModPost::new_buffer(device),
+ post: false,
drawn: false,
}
}
@@ -306,7 +341,7 @@ impl Views {
pass.set_viewport(x, y, w, h, 0.0, 1.0);
pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
pass.set_pipeline(&self.pipeline);
- pass.set_bind_group(0, &t.blit, &[]);
+ pass.set_bind_group(0, if t.post { &t.blit_fx } else { &t.blit }, &[]);
let mode = view.mode.min(2);
pass.draw(0..3, mode..mode + 1);
}
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index fba8f162d..bae0d2134 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -664,6 +664,16 @@ interface render {
/// Replaces the mod's cameras: up to 8. A `view` shape shows one; the
/// game renders up to three a frame, in turn.
set-views: func(views: list<camera-view>);
+ /// Renders the screen's world from one of the `set-views` cameras
+ /// (AirDef fork), with everything the player's own view has: the HD
+ /// lighting, sky, clouds, fog, smoke, TAA and the post passes, at the
+ /// window's resolution. The picture size of that camera is ignored, the
+ /// player's hand, block outline and the game's HUD are not drawn (the mod's
+ /// own `set-hud` shapes are); a `view` shape of that camera draws nothing.
+ /// For a full-screen replay or a director's camera; the small pictures of
+ /// monitors stay `view` shapes. None (or an unknown id) is the player's
+ /// own view. The chunks stay those round the player.
+ set-main-view: func(view: option<u32>);
/// A light the mod's things give off (AirDef fork): rocket flames,
/// flares, blasts. Drawn by the HD lighting (`set-lighting`).
record point-light {
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index f438eadbd..e578de688 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 14;
+pub const LEVEL: u32 = 15;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 68] = [
+pub const FUNCTIONS: [(&str, u32); 69] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -86,6 +86,8 @@ pub const FUNCTIONS: [(&str, u32); 68] = [
("render.set-time-override", 13),
// AirDef fork: volumetric smoke and exhaust trails.
("render.set-smoke", 14),
+ // AirDef fork: a mod camera as the screen's own view.
+ ("render.set-main-view", 15),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 495ef6cae..74da34673 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -506,6 +506,8 @@ struct State {
post_params: Vec<f32>,
/// `render.set-views`' cameras (AirDef fork).
views: Vec<CameraView>,
+ /// `render.set-main-view`: the camera the screen's world is rendered from.
+ main_view: Option<u32>,
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
lights: Vec<PointLight>,
lighting: Option<ModLighting>,
@@ -754,6 +756,7 @@ impl Mods {
post: None,
post_params: Vec::new(),
views: Vec::new(),
+ main_view: None,
lights: Vec::new(),
lighting: None,
time_override: None,
@@ -1363,6 +1366,12 @@ impl Mods {
t.smoke_active = on;
}
}
+ /// The camera the first running mod asked the screen's world from, if any.
+ pub fn main_view(&self) -> Option<u32> {
+ self.entries
+ .iter()
+ .find_map(|e| e.run.as_ref()?.store.data().main_view)
+ }
/// The time of day the first running mod asked to show, if any.
pub fn time_override(&self) -> Option<u64> {
self.entries
@@ -2230,6 +2239,9 @@ impl render::Host for State {
})
.collect();
}
+ fn set_main_view(&mut self, view: Option<u32>) {
+ self.main_view = view;
+ }
fn set_lights(&mut self, lights: Vec<PointLight>) {
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
let unit = |v: f32| {
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index fefc4903f..1bd2c7eae 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -667,6 +667,7 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
+| `set-main-view` | `func(view: option<u32>)` | Renders the screen's world from one of the `set-views` cameras (AirDef fork), with everything the player's own view has: the HD lighting, sky, clouds, fog, smoke, TAA and the post passes, at the window's resolution. The picture size of that camera is ignored, the player's hand, block outline and the game's HUD are not drawn (the mod's own `set-hud` shapes are); a `view` shape of that camera draws nothing. For a full-screen replay or a director's camera; the small pictures of monitors stay `view` shapes. None (or an unknown id) is the player's own view. The chunks stay those round the player. |
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,446 +0,0 @@
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:19:32 +0300
Subject: [PATCH 35/42] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/film_clock.rs | 32 +++--
crates/horizon-client/src/game.rs | 25 +++-
crates/horizon-client/src/game/film.rs | 140 ++++++++++++++++---
crates/horizon-client/src/game/mod_models.rs | 6 +-
crates/horizon-client/src/gfx.rs | 2 +-
crates/horizon-client/src/gfx/hd/mod.rs | 4 +-
crates/horizon-client/src/gfx/hd/smoke.rs | 24 ++++
7 files changed, 196 insertions(+), 37 deletions(-)
diff --git a/crates/horizon-client/src/film_clock.rs b/crates/horizon-client/src/film_clock.rs
index 6396e4c1f..f001f0e78 100644
--- a/crates/horizon-client/src/film_clock.rs
+++ b/crates/horizon-client/src/film_clock.rs
@@ -6,26 +6,34 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
-static FILM: Mutex<Option<Instant>> = Mutex::new(None);
+/// While a film records: where its clock began (real time) and how much film time has passed.
+static FILM: Mutex<Option<(Instant, Duration)>> = Mutex::new(None);
-/// The film's moment (frozen for the frame), or None to run on real time again.
-pub fn set(now: Option<Instant>) {
+/// The film's moment (frozen for the frame) as (its origin, film time since it), or None to run on
+/// real time again.
+pub fn set(now: Option<(Instant, Duration)>) {
if let Ok(mut f) = FILM.lock() {
*f = now;
}
}
+fn film() -> Option<(Instant, Duration)> {
+ FILM.lock().ok().and_then(|f| *f)
+}
+
/// What the animations take as now.
pub fn now() -> Instant {
- FILM.lock()
- .ok()
- .and_then(|f| *f)
- .unwrap_or_else(Instant::now)
+ film().map_or_else(Instant::now, |(origin, t)| origin + t)
}
-/// Time since `start` on this clock.
+/// Time since `start` on this clock. In a film that is the film's own time since it began
+/// whatever `start` was (an animation that began with the program would otherwise carry the
+/// real time the program ran before the film): two exports of one recording agree frame by frame.
pub fn since(start: Instant) -> Duration {
- now().saturating_duration_since(start)
+ match film() {
+ Some((_, t)) => t,
+ None => Instant::now().saturating_duration_since(start),
+ }
}
#[cfg(test)]
@@ -35,10 +43,14 @@ mod tests {
#[test]
fn film_time_is_the_frames_not_the_wall() {
let origin = Instant::now();
- set(Some(origin + Duration::from_millis(50)));
+ // Whatever the animation started at, a film second is a second of its animation.
+ let long_ago = origin.checked_sub(Duration::from_secs(1000)).unwrap_or(origin);
+ set(Some((origin, Duration::from_millis(50))));
assert_eq!(since(origin), Duration::from_millis(50));
+ assert_eq!(since(long_ago), Duration::from_millis(50));
std::thread::sleep(Duration::from_millis(5));
assert_eq!(since(origin), Duration::from_millis(50));
+ assert_eq!(now(), origin + Duration::from_millis(50));
set(None);
assert!(since(origin) >= Duration::from_millis(5));
}
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 3f776fd3f..7a87e9ffb 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11584,6 +11584,8 @@ impl Game<'_> {
),
};
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
+ // AirDef fork: the position stays the player's (a mod places its sounds round it); the
+ // orientation is the main view's below, in every filming mode, preview and export alike.
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
// AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
@@ -11598,11 +11600,10 @@ impl Game<'_> {
r.cut = true;
}
}
- if let Some(v) = &main_view
- && self.film.recording()
- {
+ if let Some(v) = &main_view {
+ let (yaw, pitch) = film::listener_orientation(Some((v.yaw, v.pitch)), (view_yaw, view_pitch));
self.audio
- .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch);
+ .set_listener([camera.x, camera.y, camera.z], yaw, pitch);
}
let (camera, underwater, view_yaw, view_pitch) = match &main_view {
Some(v) => (v.camera, false, v.yaw, v.pitch),
@@ -11701,6 +11702,9 @@ impl Game<'_> {
let debug_lines = horizon_client::gizmos::camera_relative(&lines, at, view);
let debug_lines_on_top = horizon_client::gizmos::camera_relative(&on_top, at, view);
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
+ // AirDef fork: while a film records, the clouds' wind runs on the film's own frames, not the
+ // world's ticks (so two exports of one recording show the same sky at frame N).
+ let cloud_clock = self.film_cloud_clock();
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
@@ -11880,7 +11884,14 @@ impl Game<'_> {
.collect();
let hd_lights = self.mod_lights(camera, alpha);
let hd_spots = self.mod_spots(camera, alpha);
- let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
+ // The smoke volume is ray-marched inside the fog pass: it is drawn only where that runs (fog
+ // on, an Overworld sky, not under water). The mods are told exactly that, so a mod keeps its
+ // own smoke where HD would draw none.
+ let smoke_drawn = horizon_client::gfx::hd_smoke_possible()
+ && self.dim == Dim::Overworld
+ && self.settings.horizon.sky
+ && !underwater;
+ let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha, smoke_drawn);
// AirDef fork: HD's fog reads the ground's height round the camera.
let hd_ground = |x: i32, z: i32| {
self.world
@@ -11969,8 +11980,8 @@ impl Game<'_> {
texture: &self.pack.clouds,
uv: [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0],
camera,
- time: self.world.game_time,
- partial_tick: alpha,
+ time: cloud_clock.map_or(self.world.game_time, |c| c.0),
+ partial_tick: cloud_clock.map_or(alpha, |c| c.1),
range: self.settings.cloud_range,
fast: self.settings.clouds == "fast",
color: sky.cloud_color,
diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs
index 2ae449fc5..1535eb281 100644
--- a/crates/horizon-client/src/game/film.rs
+++ b/crates/horizon-client/src/game/film.rs
@@ -446,6 +446,40 @@ fn write_wav(raw: &Path, wav: &Path) -> Result<bool, String> {
Ok(true)
}
+/// The frames left in flight at the end, in order, each to the writer exactly once. A readback
+/// or send error is returned (the film is then not passed off as complete).
+fn send_rest(
+ rest: Result<Vec<CapturedFrame>, String>,
+ sink: Option<&mpsc::SyncSender<Msg>>,
+ sent: &mut u32,
+) -> Result<(), String> {
+ let frames = rest.map_err(|e| format!("the last frames could not be read back: {e}"))?;
+ for f in frames {
+ let Some(tx) = sink else {
+ return Err("the writer is gone: the last frames were lost".into());
+ };
+ tx.send(Msg::Frame(f))
+ .map_err(|_| "the writer stopped: the last frames were lost".to_string())?;
+ *sent += 1;
+ }
+ Ok(())
+}
+
+/// The cloud clock of frame number `stepped` of a film at `fps`: (whole game ticks, the fraction
+/// of a tick), from the film's own frame count alone.
+pub(super) fn film_cloud_time(stepped: u64, fps: u32) -> (i64, f64) {
+ let fps = u64::from(fps.max(1));
+ let ticks = stepped * 20 / fps;
+ let rest = (stepped * 20 % fps) as f64 / fps as f64;
+ (ticks as i64, rest)
+}
+
+/// The listener's orientation: the main view's (a replay or film camera) whenever there is one,
+/// else the player's own. Only the orientation: the position is the player's either way.
+pub(super) fn listener_orientation(main: Option<(f32, f32)>, own: (f32, f32)) -> (f32, f32) {
+ main.unwrap_or(own)
+}
+
impl FilmState {
/// Whether frames are being recorded now.
pub(super) fn recording(&self) -> bool {
@@ -456,6 +490,12 @@ impl FilmState {
}
impl Game<'_> {
+ /// The clouds' time for the frame being drawn while a film records, else None (the world's).
+ pub(super) fn film_cloud_clock(&self) -> Option<(i64, f64)> {
+ let film = self.film.film.as_ref().filter(|f| f.phase == Phase::Recording)?;
+ Some(film_cloud_time(film.stepped, film.req.fps))
+ }
+
/// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the
/// frame by: `real` normally, exactly one frame of the film while it records.
pub(super) fn film_step(&mut self, real: Duration) -> Duration {
@@ -479,9 +519,10 @@ impl Game<'_> {
film.stepped += 1;
let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps;
// Animations on wall time follow the film's frames, not the machine's speed.
- horizon_client::film_clock::set(Some(
- film.origin + Duration::from_nanos(at(n + 1)),
- ));
+ horizon_client::film_clock::set(Some((
+ film.origin,
+ Duration::from_nanos(at(n + 1)),
+ )));
Duration::from_nanos(at(n + 1) - at(n))
}
_ => real,
@@ -523,6 +564,7 @@ impl Game<'_> {
let cancelled = film.cancel;
let before = film.before;
let audio = film.req.audio;
+ let note = film.note.clone();
self.film.film = None;
if audio {
self.audio.film_end();
@@ -533,7 +575,9 @@ impl Game<'_> {
frames,
size,
file: o.file,
- message: if cancelled && !o.ok {
+ message: if !note.is_empty() && (cancelled || !o.ok) {
+ note
+ } else if cancelled && !o.ok {
"cancelled".into()
} else {
o.message
@@ -659,7 +703,7 @@ impl Game<'_> {
film.phase = Phase::Recording;
film.stepped = 0;
film.origin = std::time::Instant::now();
- horizon_client::film_clock::set(Some(film.origin));
+ horizon_client::film_clock::set(Some((film.origin, Duration::ZERO)));
if film.req.audio {
self.audio.film_begin();
}
@@ -779,21 +823,17 @@ impl Game<'_> {
};
}
Phase::Recording => {
+ let mut cancel = cancel;
if let Some(r) = self.renderer.as_mut() {
r.capture_next = false;
- let rest = if cancel {
+ if cancel {
r.capture_discard();
- Ok(Vec::new())
} else {
- r.capture_ready(0).and_then(|_| {
- // Everything in flight, waited for.
- r.capture_ready(0)
- })
- };
- if let (Ok(frames), Some(tx)) = (rest, &film.sink) {
- for f in frames {
- film.sent += 1;
- let _ = tx.send(Msg::Frame(f));
+ // One wait for everything in flight; every frame of it goes on.
+ let rest = r.capture_ready(0).map_err(|e| e.to_string());
+ if let Err(e) = send_rest(rest, film.sink.as_ref(), &mut film.sent) {
+ film.note = e;
+ cancel = true;
}
}
}
@@ -821,3 +861,71 @@ impl Game<'_> {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame(n: u8) -> CapturedFrame {
+ CapturedFrame {
+ size: [1, 1],
+ bgra: true,
+ data: vec![n, 0, 0, 255],
+ }
+ }
+
+ fn drain(rx: &mpsc::Receiver<Msg>) -> Vec<u8> {
+ rx.try_iter()
+ .filter_map(|m| match m {
+ Msg::Frame(f) => Some(f.data[0]),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// #79: whatever is still in flight at the stop (0, 1 or 2 frames, ready or not) reaches the
+ /// writer once, in order; none is thrown away.
+ #[test]
+ fn the_frames_in_flight_at_the_stop_all_reach_the_writer() {
+ for pending in 0..=3u8 {
+ let (tx, rx) = mpsc::sync_channel(8);
+ let mut sent = 7;
+ let rest = (0..pending).map(|i| frame(10 + i)).collect();
+ send_rest(Ok(rest), Some(&tx), &mut sent).unwrap();
+ assert_eq!(sent, 7 + u32::from(pending));
+ assert_eq!(drain(&rx), (0..pending).map(|i| 10 + i).collect::<Vec<_>>());
+ }
+ }
+
+ /// #84: the main view's orientation is used with or without a capture.
+ #[test]
+ fn the_listener_follows_the_main_view_in_every_filming_mode() {
+ assert_eq!(listener_orientation(Some((90.0, 5.0)), (0.0, 0.0)), (90.0, 5.0));
+ assert_eq!(listener_orientation(None, (12.0, -3.0)), (12.0, -3.0));
+ }
+
+ /// #80: the cloud wind of frame N depends on N and the frame rate only.
+ #[test]
+ fn the_cloud_clock_is_the_films_frames() {
+ // One second of film is 20 ticks at any frame rate; the frame in between is a fraction.
+ for fps in [24u32, 30, 60] {
+ assert_eq!(film_cloud_time(u64::from(fps), fps), (20, 0.0));
+ assert_eq!(film_cloud_time(u64::from(fps) * 5, fps).0, 100);
+ }
+ let (t, p) = film_cloud_time(1, 60);
+ assert_eq!(t, 0);
+ assert!((p - 20.0 / 60.0).abs() < 1e-12);
+ // Pure: the same frame, the same sky.
+ assert_eq!(film_cloud_time(777, 30), film_cloud_time(777, 30));
+ }
+
+ #[test]
+ fn a_failed_readback_or_dead_writer_is_an_error_not_a_short_success() {
+ let (tx, rx) = mpsc::sync_channel(8);
+ let mut sent = 0;
+ assert!(send_rest(Err("gpu".into()), Some(&tx), &mut sent).is_err());
+ drop(rx);
+ assert!(send_rest(Ok(vec![frame(1)]), Some(&tx), &mut sent).is_err());
+ assert_eq!(sent, 0);
+ }
+}
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 78ee8448b..283acaa8a 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -96,6 +96,7 @@ impl Game<'_> {
&mut self,
_camera: glam::DVec3,
_alpha: f64,
+ _drawn: bool,
) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
(Vec::new(), 0.0)
}
@@ -310,8 +311,11 @@ impl Game<'_> {
&mut self,
camera: DVec3,
alpha: f64,
+ drawn: bool,
) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
- let on = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled();
+ // What the mod is told is what is drawn: HD on, the smoke pass possible, and this frame's
+ // fog pass (an Overworld sky, above water) running.
+ let on = self.mod_hd().is_some() && drawn;
self.mods.mods.set_smoke_active(on);
let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else {
return (Vec::new(), 0.0);
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index a2a6e78b6..06fa09705 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -87,7 +87,7 @@ mod native_post;
pub use hd::set_film_quality;
pub use hd::{
HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS,
- smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled,
+ smoke_enabled as hd_smoke_enabled, smoke_possible as hd_smoke_possible, spots_enabled as hd_spots_enabled,
};
pub use mod_views::ModView;
mod native_sky;
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
index d8c015a28..7744784d5 100644
--- a/crates/horizon-client/src/gfx/hd/mod.rs
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
@@ -97,7 +97,7 @@ pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanil
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, SHADOW_TINT, cascades, color_shadows_enabled};
-pub use smoke::{HdSmoke, smoke_enabled};
+pub use smoke::{HdSmoke, smoke_enabled, smoke_possible};
pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled};
pub use volumes::clouds_enabled;
@@ -746,7 +746,7 @@ impl Hd {
lights: &[HdLight],
clock: f32,
) {
- if scene.sky && volumes::fog_enabled() && smoke_enabled() {
+ if scene.sky && smoke::smoke_possible() {
self.smoke.prepare(queue, scene, smoke, lights, clock);
} else {
self.smoke.frame = Default::default();
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
index 266f5fdef..34f3fd77c 100644
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
@@ -64,6 +64,30 @@ pub fn smoke_enabled() -> bool {
*ON.get_or_init(|| std::env::var("HORIZON_HD_SMOKE").map_or(true, |v| v != "0"))
}
+/// Whether the smoke can be drawn at all: its volume is ray-marched in the fog pass, so it needs
+/// the volumetric fog as well as `HORIZON_HD_SMOKE`. This is what the mods are told.
+pub fn smoke_possible() -> bool {
+ smoke_possible_with(super::volumes::fog_enabled(), smoke_enabled())
+}
+
+fn smoke_possible_with(fog: bool, smoke: bool) -> bool {
+ fog && smoke
+}
+
+#[cfg(test)]
+mod possible_tests {
+ use super::smoke_possible_with;
+
+ /// #81: the answer to the mods is the real one: no fog pass, no smoke promised.
+ #[test]
+ fn smoke_is_promised_only_with_the_fog_pass() {
+ assert!(smoke_possible_with(true, true));
+ assert!(!smoke_possible_with(false, true));
+ assert!(!smoke_possible_with(true, false));
+ assert!(!smoke_possible_with(false, false));
+ }
+}
+
/// A segment of smoke as the game hands it over for a frame, relative to the camera.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 19:07:37 +0300
Subject: [PATCH 38/42] HD clouds: rays from a far point, so the view bob's eye
offset does not bend them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd/cloud.wgsl | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
index 18aac24f6..abe69df78 100644
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
@@ -233,7 +233,8 @@ fn fs_cloud(in: FullOut) -> CloudOut {
let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
let sh = frame.inverse_view_proj * sndc;
let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
- let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
+ // A far point: the view bob offsets the eye, and from a point 10 cm away (depth 0.5) that bends the ray.
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.0005, 1.0);
let hh = frame.inverse_view_proj * ndc;
let dir = normalize(hh.xyz / hh.w);
let m = march_cumulus(dir, scene_len, ign(in.position.xy), false);
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,110 +0,0 @@
From a6dcb0b5f73640e0324c8bec3508e8e396e713bd Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Sat, 31 Oct 2026 03:20:06 +0300
Subject: [PATCH 42/42] game.world-seed (API 25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game/mods.rs | 3 +++
crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++
crates/horizon-mods/src/api_level.rs | 6 ++++--
crates/horizon-mods/src/runtime.rs | 5 +++++
docs/api/mods-reference.md | 1 +
5 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index 7f9714a18..e96744775 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -599,6 +599,9 @@ impl Game<'_> {
.is_some()
.then(|| self.mods.world_id.clone())
.flatten(),
+ world_seed: (self.player.is_some()
+ && self.mods.world_id.as_deref().is_some_and(|w| w.starts_with("world:")))
+ .then_some(self.level.seed),
}
}
/// The first block along the player's view within
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 805c2a515..774cc5851 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -51,6 +51,10 @@ interface game {
/// world, `server:<address>` on a server; none on the title screen.
/// Mods keep their state apart by it.
world-id: func() -> option<string>;
+ /// The open saved world's seed (AirDef fork, API level 25): with the
+ /// folder name it tells a new world from an old one that had the same
+ /// name. None on the title screen and on a server.
+ world-seed: func() -> option<s64>;
/// A block along the player's view.
record hit {
/// The block hit.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 579af9e25..3af36e7c1 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 24;
+pub const LEVEL: u32 = 25;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 90] = [
+pub const FUNCTIONS: [(&str, u32); 91] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -118,6 +118,8 @@ pub const FUNCTIONS: [(&str, u32); 90] = [
("render.set-canvas", 24),
("render.canvas-events", 24),
("render.canvas-open", 24),
+ // AirDef fork: the open world's seed, so a new world is not an old one of the same name.
+ ("game.world-seed", 25),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 81286975f..910986549 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -189,6 +189,8 @@ pub struct Snapshot {
pub creative: bool,
/// The open world (`game.world-id`, AirDef fork).
pub world: Option<String>,
+ /// The open saved world's seed (`game.world-seed`, AirDef fork).
+ pub world_seed: Option<i64>,
}
/// Capability `recipes`'s tables: `items` in the registry's order, which
@@ -1930,6 +1932,9 @@ impl game::Host for State {
fn world_id(&mut self) -> Option<String> {
self.snapshot.world.clone()
}
+ fn world_seed(&mut self) -> Option<i64> {
+ self.snapshot.world_seed
+ }
fn raycast(&mut self, distance: f32) -> Option<Hit> {
let (at, hit) = self.snapshot.view_hit.as_ref()?;
// NaN compares false: no hit.
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index cf6e61440..3ab23b490 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -126,6 +126,7 @@ A block along the player's view.
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
+| `world-seed` | `func() -> option<s64>` | The open saved world's seed (AirDef fork, API level 25): with the folder name it tells a new world from an old one that had the same name. None on the title screen and on a server. |
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
## `chat`
--
2.54.0 (Apple Git-157)
@@ -1,887 +0,0 @@
From 2fff36c3c6183a440d55371b2db0f3bd6eb586c5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Sun, 1 Nov 2026 02:30:48 +0300
Subject: [PATCH 1/2] Mods read live blocks while replay shows history (#105)
Co-Authored-By: Kimi <noreply@moonshot.ai>
---
crates/horizon-client/src/game.rs | 9 +
crates/horizon-client/src/game/mod_world.rs | 80 +-----
crates/horizon-client/src/game/mods.rs | 10 +-
crates/horizon-client/src/lib.rs | 1 +
crates/horizon-client/src/streaming.rs | 4 +-
crates/horizon-client/src/world.rs | 97 ++++++-
crates/horizon-client/src/world_log.rs | 135 ++++++++-
crates/horizon-client/src/world_view.rs | 296 ++++++++++++++++++++
8 files changed, 549 insertions(+), 83 deletions(-)
create mode 100644 crates/horizon-client/src/world_view.rs
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index d7529b12d..a2099747b 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -10798,6 +10798,15 @@ impl Game<'_> {
HeightmapType::MotionBlocking.is_opaque(properties.heightmap_state(s))
})
}
+ /// `rain_height` over the real blocks: what a mod's ground query reads, whatever a film's
+ /// view of an earlier time shows (the weather's rain follows the picture).
+ fn live_rain_height(&self, x: i32, z: i32) -> i32 {
+ use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
+ let properties = &self.pack.assets.properties;
+ self.world.live_motion_blocking(x, z, |s| {
+ HeightmapType::MotionBlocking.is_opaque(properties.heightmap_state(s))
+ })
+ }
fn tick_weather_effects(&mut self) {
if self.flash > 0 {
self.flash -= 1;
diff --git a/crates/horizon-client/src/game/mod_world.rs b/crates/horizon-client/src/game/mod_world.rs
index 7e3a97b0c..2cda0fae7 100644
--- a/crates/horizon-client/src/game/mod_world.rs
+++ b/crates/horizon-client/src/game/mod_world.rs
@@ -4,11 +4,13 @@
//! the game holds: a chunk it doesn't have is `unknown`, never air. The
//! walk and the bit packing are `horizon_mods::world_query`; this is the
//! game's side: which blocks count under each policy, and the chunks.
+//! The chunks are read through `horizon_client::world_view`, which puts back
+//! every block a film's view of an earlier time shows otherwise: what a mod
+//! simulates is the real world, whatever the picture (and #105).
use super::Game;
-use horizon_client::chunk::{Chunk, MIN_Y, SECTIONS};
-use horizon_client::world::World;
+use horizon_client::world_view::{SectionView, WorldProbe};
use horizon_core::geometry::Axis;
-use horizon_mods::world_query::{self, BlockProbe, Cell, SectionBits, Trace};
+use horizon_mods::world_query::{SectionBits, Trace};
use horizon_mods::{
SectionFill, WorldChanges, WorldPolicy, WorldRay, WorldRayAnswer, WorldSection, WorldVerdict,
};
@@ -43,41 +45,6 @@ fn mask(policy: WorldPolicy) -> u8 {
}
}
-/// The blocks of the resident chunks, for [`world_query::trace`].
-struct Probe<'a> {
- world: &'a World,
- tables: &'a WorldTables,
- mask: u8,
- last: Option<((i32, i32), &'a Arc<Chunk>)>,
- cells: usize,
-}
-impl BlockProbe for Probe<'_> {
- fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_> {
- self.cells += 1;
- let key = (x >> 4, z >> 4);
- let chunk = match self.last {
- Some((k, c)) if k == key => c,
- _ => {
- let Some(c) = self.world.chunks.get(&key) else {
- return Cell::Unknown;
- };
- self.last = Some((key, c));
- c
- }
- };
- let state = chunk.get(x & 15, y, z & 15) as usize;
- if self
- .tables
- .flags
- .get(state)
- .is_none_or(|f| f & self.mask == 0)
- {
- return Cell::Empty;
- }
- Cell::Solid(self.tables.boxes[state].as_deref())
- }
-}
-
impl Game<'_> {
/// The tables for this world, made when it is new.
fn world_tables(&mut self) -> Arc<WorldTables> {
@@ -158,7 +125,7 @@ impl Game<'_> {
let (changed, overflow) = self.world.mod_changes.take();
let changes = WorldChanges {
epoch: self.world.epoch,
- revision: self.world.revision,
+ revision: self.world.live_revision,
overflow,
sections: changed,
};
@@ -192,14 +159,8 @@ impl Game<'_> {
if budget == 0 {
return (unknown(r.id, 0.0), 0);
}
- let mut probe = Probe {
- world: &self.world,
- tables,
- mask: mask(r.policy),
- last: None,
- cells: 0,
- };
- let t = world_query::trace(
+ let mut probe = WorldProbe::new(&self.world, &tables.flags, &tables.boxes, mask(r.policy));
+ let t = horizon_mods::world_query::trace(
&mut probe,
[r.start.0, r.start.1, r.start.2],
[r.end.0, r.end.1, r.end.2],
@@ -238,25 +199,12 @@ impl Game<'_> {
fill: SectionFill::Unknown,
bits: Vec::new(),
};
- let Some(chunk) = self.world.chunks.get(&(x, z)) else {
- return s;
- };
- let index = y - (MIN_Y >> 4);
- if !(0..SECTIONS as i32).contains(&index) {
- s.fill = SectionFill::Empty;
- return s;
- }
- let section = &chunk.sections[index as usize];
- let mut states = [0u32; 4096];
- if let Some(v) = section.blocks.uniform() {
- states.fill(v);
- } else {
- section.unpack(&mut states);
- }
- match world_query::section_bits(&states, &tables.flags, mask) {
- SectionBits::Empty => s.fill = SectionFill::Empty,
- SectionBits::Full => s.fill = SectionFill::Full,
- SectionBits::Mixed(bits) => {
+ match self.world.live_section(x, y, z, &tables.flags, mask) {
+ SectionView::Unknown => {}
+ SectionView::Empty => s.fill = SectionFill::Empty,
+ SectionView::Bits(SectionBits::Empty) => s.fill = SectionFill::Empty,
+ SectionView::Bits(SectionBits::Full) => s.fill = SectionFill::Full,
+ SectionView::Bits(SectionBits::Mixed(bits)) => {
s.fill = SectionFill::Mixed;
s.bits = bits.to_vec();
}
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index e96744775..e95aaed2d 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -789,8 +789,9 @@ impl Game<'_> {
tickets.truncate(CHUNK_TICKETS);
}
}
- /// `render.query-ground`'s answer: the heightmap, or `i32::MIN` for a column whose chunk a
- /// mod asked to load and which is not there yet.
+ /// `render.query-ground`'s answer: the heightmap of the real blocks, or `i32::MIN` for a
+ /// column whose chunk a mod asked to load and which is not there yet. A film's view of an
+ /// earlier time is the picture only: a simulation's ground is the one it flies over.
fn mod_ground(&self, x: i32, z: i32) -> i32 {
let key = (x >> 4, z >> 4);
if !self.world.chunks.contains_key(&key)
@@ -798,7 +799,7 @@ impl Game<'_> {
{
return i32::MIN;
}
- self.rain_height(x, z)
+ self.live_rain_height(x, z)
}
/// The mods' persistent sound sources (API level 20): their changes go to the mixer, and the
/// sources of a mod that no longer runs are released. The generation the mods read is the
@@ -941,6 +942,9 @@ impl Game<'_> {
match wanted {
Some(id) => {
if self.world.log.as_ref().is_none_or(|l| l.id != id) {
+ // A log replaced while it still shows earlier blocks puts them back first:
+ // only the log knows which block is real where the picture differs.
+ self.world.view_log(None, usize::MAX, None);
self.world.log = Some(WorldLog::new(id, self.ticks));
}
}
diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs
index 879133e81..d7fa5303b 100644
--- a/crates/horizon-client/src/lib.rs
+++ b/crates/horizon-client/src/lib.rs
@@ -186,6 +186,7 @@ pub mod win_screen;
pub mod world;
pub mod world_log;
pub mod world_spawn;
+pub mod world_view;
pub mod worldborder;
pub mod zip;
pub use horizon_client_core::Result;
diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs
index 6588ac32d..4432626f0 100644
--- a/crates/horizon-client/src/streaming.rs
+++ b/crates/horizon-client/src/streaming.rs
@@ -626,7 +626,7 @@ impl Streaming {
let key = (chunk.x, chunk.z);
self.lighting.remove(&key);
self.light_snapshots.remove(&key);
- world.chunks.insert(key, Arc::new(chunk));
+ world.insert_chunk(key, Arc::new(chunk));
self.lit.insert(key);
self.generated_total += 1;
for dz in -1..=1 {
@@ -770,7 +770,7 @@ impl Streaming {
// A chunk saved with its light needs no light job; its unlit
// neighbours still light themselves from it.
let loaded = chunk.light_loaded;
- world.chunks.insert(key, Arc::new(chunk));
+ world.insert_chunk(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
for sy in 0..SECTIONS as i32 {
diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs
index d42e51ac8..5c2b0c267 100644
--- a/crates/horizon-client/src/world.rs
+++ b/crates/horizon-client/src/world.rs
@@ -52,7 +52,12 @@ pub struct World {
pub fast_lava: bool,
pub chunks: crate::chunk_map::ChunkMap,
pub random: LegacyRandom,
+ /// The revision of the blocks shown: a real change, and a view of an earlier time that
+ /// changes the picture (the renderer and the acoustics follow it). Mods cache on
+ /// `live_revision`: a film's view is no change of the world they simulate.
pub revision: u64,
+ /// The revision of the real blocks, which a view of an earlier time leaves alone.
+ pub live_revision: u64,
pub health_rules: HealthRules,
pub air: BlockStateId,
/// Sections whose geometry changed and need remeshing.
@@ -130,6 +135,23 @@ impl ModChanges {
self.sections.insert((x >> 4, y >> 4, z >> 4));
}
}
+ /// A column's residency changed (a chunk loaded, was replaced by a reload
+ /// or is gone): every section of the column counts as changed, for caches
+ /// keyed to sections. Bounded like `note` is.
+ pub fn column(&mut self, x: i32, z: i32) {
+ if self.sections.len() < Self::MAX {
+ for sy in 0..SECTIONS as i32 {
+ self.sections.insert((x, sy, z));
+ }
+ }
+ if self.sections.len() >= Self::MAX {
+ self.overflow = true;
+ }
+ }
+ /// The change log overflowed or the resident columns were cleared: every cached section may be stale.
+ pub fn overflow(&mut self) {
+ self.overflow = true;
+ }
/// What changed since the last call, as the mod reads it.
pub fn take(&mut self) -> (Vec<(i32, i32, i32)>, bool) {
(
@@ -209,6 +231,7 @@ impl World {
chunks: Default::default(),
random: LegacyRandom::new(seed),
revision: 0,
+ live_revision: 0,
health_rules: HealthRules::default(),
air,
fast_lava: false,
@@ -335,6 +358,7 @@ impl World {
self.watched_writes.push(((x, y, z), previous, state.0));
}
self.revision = self.revision.wrapping_add(1);
+ self.live_revision = self.live_revision.wrapping_add(1);
if self.mod_changes.watching {
self.mod_changes.note(x, y, z);
}
@@ -378,7 +402,9 @@ impl World {
}
/// A mod's view of the world at an earlier tick (`render.set-world-time-view`): moves it
/// toward `target` by at most `budget` changes, leaving alone the blocks around `protect`
- /// (a block position whose neighbourhood stays as it is: the player's). Only the client's copy of the blocks changes. True when it has got there.
+ /// (a block position whose neighbourhood stays as it is: the player's). Only the picture
+ /// changes: the renderer meshes these chunks, while a simulation reads the real blocks
+ /// ([`Self::live_block`]). True when it has got there.
pub fn view_log(
&mut self,
target: Option<u64>,
@@ -400,7 +426,9 @@ impl World {
done
}
}
-/// The world as a log's view writes it: only what is drawn changes.
+/// The world as a log's view writes it: the picture the renderer meshes changes, and nothing
+/// else. The real state of every block written stays in the log (`WorldLog::real`), which is
+/// what a simulation reads; the world's own change signals stay the real ones.
struct ShownBlocks<'a>(&'a mut World);
impl crate::world_log::Blocks for ShownBlocks<'_> {
fn get(&self, pos: [i32; 3]) -> Option<u32> {
@@ -419,10 +447,10 @@ impl crate::world_log::Blocks for ShownBlocks<'_> {
};
let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state);
if previous != state {
+ // The picture changed, so the renderer and the acoustics look at it again; the
+ // blocks did not, so `live_revision` and `mod_changes` — what mods cache and
+ // simulate by — stay as they are.
w.revision = w.revision.wrapping_add(1);
- if w.mod_changes.watching {
- w.mod_changes.note(x, y, z);
- }
if w.sound_changes.watching {
w.sound_changes.note(x, y, z);
}
@@ -452,6 +480,65 @@ impl World {
.get(&(x >> 4, z >> 4))
.map_or(self.air, |chunk| chunk.block(x & 15, y, z & 15))
}
+ /// Inserts a whole current column. A replay keeps its picture, but the real states behind it
+ /// are refreshed from this column and simulations hear the change.
+ pub fn insert_chunk(&mut self, key: (i32, i32), mut chunk: Arc<crate::chunk::Chunk>) -> Option<Arc<crate::chunk::Chunk>> {
+ if let Some(log) = &mut self.log {
+ let current = self.chunks.get(&key).cloned();
+ log.reload_column(key.0, key.1, current.as_deref(), Arc::make_mut(&mut chunk));
+ }
+ self.live_revision = self.live_revision.wrapping_add(1);
+ if self.mod_changes.watching {
+ self.mod_changes.column(key.0, key.1);
+ }
+ self.chunks.insert(key, chunk)
+ }
+ /// The real block at `(x, y, z)`: what a simulation reads, where a view of an earlier
+ /// time shows something else for a film ([`Self::view_log`]). [`Self::block`] stays the
+ /// picture, which is what the renderer meshes.
+ pub fn live_block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
+ match self.log.as_ref().and_then(|log| log.real([x, y, z])) {
+ Some(real) => BlockStateId(real),
+ None => self.block(x, y, z),
+ }
+ }
+ /// Whether a view of an earlier time shows a block of the column `(x >> 4, z >> 4)`
+ /// otherwise: until it does, the column reads as the real one.
+ pub fn view_shows_in_column(&self, cx: i32, cz: i32) -> bool {
+ self.log
+ .as_ref()
+ .is_some_and(|log| log.shows_in_column(cx, cz))
+ }
+ /// The blocks of section `(cx, sy, cz)` (vanilla section coordinates, `sy = y >> 4`), in
+ /// `chunk::index` order, with every block a view of an earlier time shows otherwise put
+ /// back to its real state.
+ pub fn live_section_states(&self, cx: i32, sy: i32, cz: i32, states: &mut [u32; 4096]) {
+ let Some(log) = &self.log else {
+ return;
+ };
+ if !log.shows_in_section(cx, sy, cz) {
+ return;
+ }
+ for ([x, y, z], real) in log.real_in_section(cx, sy, cz) {
+ states[crate::chunk::index(x, y, z)] = real;
+ }
+ }
+ /// [`Self::motion_blocking`] over the real blocks: a mod's ground query, whatever a view
+ /// of an earlier time shows.
+ pub fn live_motion_blocking(
+ &self,
+ x: i32,
+ z: i32,
+ blocks: impl Fn(BlockStateId) -> bool,
+ ) -> i32 {
+ if !self.view_shows_in_column(x >> 4, z >> 4) {
+ return self.motion_blocking(x, z, blocks);
+ }
+ (MIN_Y..MIN_Y + (SECTIONS as i32) * 16)
+ .rev()
+ .find(|&y| blocks(self.live_block(x, y, z)))
+ .map_or(MIN_Y, |y| y + 1)
+ }
/// Whether a block of the box `lo..=hi` may match, from the palettes of
/// the sections it reaches (a palette of the whole registry may hold
/// anything): `false` means [`Self::block`] matches in none of its cells,
diff --git a/crates/horizon-client/src/world_log.rs b/crates/horizon-client/src/world_log.rs
index d0c0b1158..315388c00 100644
--- a/crates/horizon-client/src/world_log.rs
+++ b/crates/horizon-client/src/world_log.rs
@@ -7,6 +7,11 @@
//! while the cursor is behind the end is logged and kept as the block's real state, but not
//! written (it lies after the view's time): it shows when the cursor passes it, or when the
//! view ends and every displaced block is put back. Pure over [`Blocks`]; the world is one.
+//!
+//! A block written here is the picture (what the renderer meshes): the real state of every
+//! displaced block stays in `shown`, and what stands for the simulation reads it
+//! ([`WorldLog::real`], `World::live_block`). Moving the view changes the picture alone, so it
+//! is no block change of the world a mod simulates.
use std::collections::{HashMap, VecDeque};
pub type Pos = [i32; 3];
@@ -39,6 +44,10 @@ pub struct WorldLog {
cursor: u64,
/// Blocks shown other than they are: their real state.
shown: HashMap<Pos, u32>,
+ /// How many blocks of each section (`x >> 4, y >> 4, z >> 4`) and column (`x >> 4,
+ /// z >> 4`) are in `shown`: absent means none, so a query passes those over whole.
+ sections: HashMap<(i32, i32, i32), usize>,
+ columns: HashMap<(i32, i32), usize>,
cap: usize,
/// The tick the log began, and the tick of the newest change it let go.
start: u64,
@@ -56,6 +65,8 @@ impl WorldLog {
base: 0,
cursor: 0,
shown: HashMap::new(),
+ sections: HashMap::new(),
+ columns: HashMap::new(),
cap: cap.max(1),
start: tick,
lost: None,
@@ -82,6 +93,54 @@ impl WorldLog {
pub fn displaced(&self) -> usize {
self.shown.len()
}
+ /// The real state of a block the view shows otherwise, which is what a simulation reads:
+ /// None where the block is shown as it is.
+ pub fn real(&self, pos: Pos) -> Option<u32> {
+ self.shown.get(&pos).copied()
+ }
+ /// Whether the view shows a block of section `(x >> 4, y >> 4, z >> 4)` otherwise.
+ pub fn shows_in_section(&self, cx: i32, sy: i32, cz: i32) -> bool {
+ self.sections.contains_key(&(cx, sy, cz))
+ }
+ /// Whether the view shows a block of column `(x >> 4, z >> 4)` otherwise.
+ pub fn shows_in_column(&self, cx: i32, cz: i32) -> bool {
+ self.columns.contains_key(&(cx, cz))
+ }
+ /// The blocks of a section the view shows otherwise, with their real states.
+ pub fn real_in_section(
+ &self,
+ cx: i32,
+ sy: i32,
+ cz: i32,
+ ) -> impl Iterator<Item = (Pos, u32)> + '_ {
+ self.shown.iter().filter_map(move |(&pos, &real)| {
+ (pos[0] >> 4 == cx && pos[1] >> 4 == sy && pos[2] >> 4 == cz).then_some((pos, real))
+ })
+ }
+ /// A whole column arrived from the server or generation: its current blocks replace the real
+ /// states saved behind the picture, while the picture itself stays at the replay's time.
+ pub fn reload_column(
+ &mut self,
+ cx: i32,
+ cz: i32,
+ current: Option<&crate::chunk::Chunk>,
+ next: &mut crate::chunk::Chunk,
+ ) {
+ let changes: Vec<(Pos, u32, u32)> = self
+ .shown
+ .iter()
+ .filter(|(pos, _)| pos[0] >> 4 == cx && pos[2] >> 4 == cz)
+ .map(|(&pos, &real)| {
+ let fresh = next.get(pos[0] & 15, pos[1], pos[2] & 15);
+ let picture = current.map_or(real, |chunk| chunk.get(pos[0] & 15, pos[1], pos[2] & 15));
+ (pos, fresh, picture)
+ })
+ .collect();
+ for (pos, fresh, picture) in changes {
+ next.set(pos[0] & 15, pos[1], pos[2] & 15, picture);
+ self.shown.insert(pos, fresh);
+ }
+ }
/// The first sequence number whose change came after tick `t` (the world at `t` is the real
/// one with those undone).
fn seq_after(&self, t: u64) -> u64 {
@@ -114,7 +173,7 @@ impl WorldLog {
self.cursor = self.end();
}
if deferred {
- self.shown.insert(pos, new);
+ self.displace(pos, new);
if at_end {
// Displaced blocks wait for their restoration with the cursor at the end.
self.cursor = self.end();
@@ -162,7 +221,7 @@ impl WorldLog {
.collect();
for (pos, real) in rest {
blocks.put(pos, real);
- self.shown.remove(&pos);
+ self.put_back(pos);
}
}
self.cursor == goal && (goal != self.end() || self.shown.is_empty())
@@ -186,13 +245,45 @@ impl WorldLog {
None => return,
},
};
- if !blocks.put(pos, state) {
- self.shown.remove(&pos);
- } else if state == real {
- self.shown.remove(&pos);
+ if !blocks.put(pos, state) || state == real {
+ self.put_back(pos);
} else {
- self.shown.insert(pos, real);
+ self.displace(pos, real);
+ }
+ }
+ /// Keeps `real` as the real state of a block shown otherwise.
+ fn displace(&mut self, pos: Pos, real: u32) {
+ if self.shown.insert(pos, real).is_some() {
+ return;
+ }
+ count(
+ &mut self.sections,
+ (pos[0] >> 4, pos[1] >> 4, pos[2] >> 4),
+ 1,
+ );
+ count(&mut self.columns, (pos[0] >> 4, pos[2] >> 4), 1);
+ }
+ /// Forgets a displaced block: what is shown is its real state again.
+ fn put_back(&mut self, pos: Pos) {
+ if self.shown.remove(&pos).is_none() {
+ return;
}
+ count(
+ &mut self.sections,
+ (pos[0] >> 4, pos[1] >> 4, pos[2] >> 4),
+ -1,
+ );
+ count(&mut self.columns, (pos[0] >> 4, pos[2] >> 4), -1);
+ }
+}
+
+/// Keeps the number of displaced blocks under `key`, dropping it at zero.
+fn count<K: std::hash::Hash + Eq>(counts: &mut HashMap<K, usize>, key: K, by: isize) {
+ let n = counts.get(&key).copied().unwrap_or(0) as isize + by;
+ if n > 0 {
+ counts.insert(key, n as usize);
+ } else {
+ counts.remove(&key);
}
}
@@ -369,4 +460,34 @@ mod tests {
log.view(&mut w, None, 100, all);
assert_eq!(w.shows([0, 0, 0]), 7);
}
+
+ #[test]
+ fn the_real_blocks_of_a_view_are_read_where_it_shows_others() {
+ let (mut w, mut log) = raid();
+ log.view(&mut w, Some(5), 100, all);
+ // The sections and columns the view touches, and the real state of what it shows otherwise.
+ assert_eq!(log.real([0, 0, 0]), Some(7));
+ assert_eq!(log.real([9, 0, 0]), None);
+ assert!(log.shows_in_column(0, 0));
+ assert!(!log.shows_in_column(1, 0));
+ assert!(log.shows_in_section(0, 0, 0));
+ assert!(!log.shows_in_section(0, 1, 0));
+ let displaced: Vec<_> = log.real_in_section(0, 0, 0).collect();
+ assert_eq!(displaced.len(), 3);
+ for (pos, real) in [([0, 0, 0], 7), ([1, 0, 0], 0), ([2, 0, 0], 0)] {
+ assert!(displaced.contains(&(pos, real)));
+ }
+ // A change arriving behind the view is the block's real state, shown nowhere.
+ w.set(&mut log, 40, [20, 20, 3], 5);
+ assert_eq!(w.shows([20, 20, 3]), 0);
+ assert_eq!(log.real([20, 20, 3]), Some(5));
+ assert!(log.shows_in_column(1, 0) && log.shows_in_section(1, 1, 0));
+ // The end puts every block back: nothing is left to read.
+ assert!(log.view(&mut w, None, 100, all));
+ for pos in [[0, 0, 0], [20, 20, 3]] {
+ assert_eq!(log.real(pos), None);
+ }
+ assert!(!log.shows_in_column(0, 0) && !log.shows_in_column(1, 0));
+ assert!(!log.shows_in_section(0, 0, 0) && !log.shows_in_section(1, 1, 0));
+ }
}
diff --git a/crates/horizon-client/src/world_view.rs b/crates/horizon-client/src/world_view.rs
new file mode 100644
index 000000000..fdc3f6515
--- /dev/null
+++ b/crates/horizon-client/src/world_view.rs
@@ -0,0 +1,296 @@
+//! The world as a mod's simulation reads it (capability `render`, AirDef fork): the real
+//! blocks. A film's view of an earlier time (`World::view_log`) is written into the chunks
+//! the renderer meshes, so a query that stands for a simulation has to read past it: a raid
+//! does not go back in time with the picture (#105).
+use crate::chunk::{Chunk, MIN_Y, SECTIONS};
+use crate::world::World;
+use horizon_mods::world_query::{BlockProbe, Cell, SectionBits, section_bits};
+use std::sync::Arc;
+
+/// The resident chunks, with every block a view of an earlier time shows otherwise put back
+/// to its real state.
+pub struct WorldProbe<'a> {
+ world: &'a World,
+ /// Per state: the policy bits that it stops.
+ flags: &'a [u8],
+ /// Per state: its collision boxes in block space; none for a full cube (or a state that
+ /// stops nothing).
+ boxes: &'a [Option<Box<[[f64; 6]]>>],
+ mask: u8,
+ /// The last column read, and whether the view shows any of its blocks otherwise.
+ last: Option<((i32, i32), &'a Arc<Chunk>, bool)>,
+ /// Cells read.
+ pub cells: usize,
+}
+
+impl<'a> WorldProbe<'a> {
+ pub fn new(
+ world: &'a World,
+ flags: &'a [u8],
+ boxes: &'a [Option<Box<[[f64; 6]]>>],
+ mask: u8,
+ ) -> Self {
+ Self {
+ world,
+ flags,
+ boxes,
+ mask,
+ last: None,
+ cells: 0,
+ }
+ }
+}
+
+impl BlockProbe for WorldProbe<'_> {
+ fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_> {
+ self.cells += 1;
+ let key = (x >> 4, z >> 4);
+ let (chunk, view) = match self.last {
+ Some((k, chunk, view)) if k == key => (chunk, view),
+ _ => {
+ let Some(chunk) = self.world.chunks.get(&key) else {
+ return Cell::Unknown;
+ };
+ let view = self.world.view_shows_in_column(key.0, key.1);
+ self.last = Some((key, chunk, view));
+ (chunk, view)
+ }
+ };
+ // Only a column the view touches costs a lookup per block.
+ let state = if view {
+ self.world.live_block(x, y, z).0
+ } else {
+ chunk.get(x & 15, y, z & 15)
+ } as usize;
+ if self.flags.get(state).is_none_or(|f| f & self.mask == 0) {
+ return Cell::Empty;
+ }
+ Cell::Solid(self.boxes[state].as_deref())
+ }
+}
+
+/// A section as a mod's query reads it: the real blocks, never a film's picture.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SectionView {
+ /// No chunk holds the column.
+ Unknown,
+ /// The section lies outside the world's height: nothing there.
+ Empty,
+ /// Its real blocks under the state table.
+ Bits(SectionBits),
+}
+
+impl World {
+ /// A section (`x >> 4, y >> 4, z >> 4`: vanilla section coordinates) under a state table
+ /// and a policy mask, read from the real blocks.
+ pub fn live_section(&self, cx: i32, sy: i32, cz: i32, flags: &[u8], mask: u8) -> SectionView {
+ let Some(chunk) = self.chunks.get(&(cx, cz)) else {
+ return SectionView::Unknown;
+ };
+ let index = sy - (MIN_Y >> 4);
+ if !(0..SECTIONS as i32).contains(&index) {
+ return SectionView::Empty;
+ }
+ let section = &chunk.sections[index as usize];
+ let mut states = [0u32; 4096];
+ if let Some(v) = section.blocks.uniform() {
+ states.fill(v);
+ } else {
+ section.unpack(&mut states);
+ }
+ self.live_section_states(cx, sy, cz, &mut states);
+ SectionView::Bits(section_bits(&states, flags, mask))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::chunk::index;
+ use crate::world_log::WorldLog;
+ use horizon_mods::world_query::{Trace, trace};
+ use horizon_world::registry::BlockStateId;
+ use std::sync::Arc;
+
+ const AIR: u32 = 0;
+ const STONE: u32 = 1;
+ /// The policy bits of `COLLISION | SIGHT | SOUND`, as `game::mod_world` makes them.
+ const ALL: u8 = 0b111;
+ /// The section of the wall at y 100 (`y >> 4`).
+ const SY: i32 = 6;
+
+ type Tables = (Vec<u8>, Vec<Option<Box<[[f64; 6]]>>>);
+
+ /// A floor at y 70 and a wall at x 10, z 0, in the one resident chunk.
+ fn walled() -> World {
+ let mut world = World::new(BlockStateId(AIR), 0);
+ world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
+ for z in 0..=2 {
+ for x in 0..=14 {
+ world.set(x, 70, z, BlockStateId(STONE)).unwrap();
+ }
+ }
+ world.set(10, 100, 0, BlockStateId(STONE)).unwrap();
+ world
+ }
+
+ /// State flags and collision boxes: stone stops every policy, as a full cube.
+ fn tables() -> Tables {
+ let mut flags = vec![0u8; 8];
+ flags[STONE as usize] = ALL;
+ (flags, vec![None; 8])
+ }
+
+ /// A collision flight from x 9.5 to 11.5 at y 100.5, along `z`, as a mod asks one.
+ fn ray(world: &World, z: i32, tables: &Tables) -> Trace {
+ let mut probe = WorldProbe::new(world, &tables.0, &tables.1, 0b001);
+ let z = f64::from(z) + 0.5;
+ trace(&mut probe, [9.5, 100.5, z], [11.5, 100.5, z])
+ }
+
+ /// Whether the section's presented bits have the block `(10, y, 0)` set.
+ fn presented_bit(world: &World, flags: &[u8], y: i32) -> bool {
+ let chunk = world.chunks.get(&(0, 0)).unwrap();
+ let mut states = [0u32; 4096];
+ chunk.sections[((y - MIN_Y) >> 4) as usize].unpack(&mut states);
+ let i = index(10, y, 0);
+ match section_bits(&states, flags, 0b001) {
+ SectionBits::Mixed(bits) => bits[i >> 3] & (1 << (i & 7)) != 0,
+ _ => false,
+ }
+ }
+
+ #[test]
+ fn a_films_view_of_earlier_blocks_is_no_change_of_the_simulated_world() {
+ let tables = tables();
+ let mut world = walled();
+ // The wall stops the flight.
+ let wall = ray(&world, 0, &tables);
+ assert!(
+ matches!(
+ wall,
+ Trace::Hit {
+ block: [10, 100, 0],
+ ..
+ }
+ ),
+ "{wall:?}"
+ );
+ world.log = Some(WorldLog::new(1, 0));
+ // The raid breaks it: a real change, logged.
+ world.log_tick = 10;
+ world.set(10, 100, 0, BlockStateId(AIR)).unwrap();
+ let cleared = ray(&world, 0, &tables);
+ assert_eq!(cleared, Trace::Clear);
+ assert_eq!(
+ world.live_section(0, SY, 0, &tables.0, 0b001),
+ SectionView::Bits(SectionBits::Empty)
+ );
+ let live_revision = world.live_revision;
+ // Watching for block changes, as a mod's world query does.
+ world.mod_changes.watching = true;
+ let _ = world.mod_changes.take();
+ // The film goes back to before the blast: the picture has the wall, the world has not.
+ assert!(world.view_log(Some(0), 100, None));
+ assert_eq!(world.block(10, 100, 0), BlockStateId(STONE), "the picture");
+ assert_eq!(world.live_block(10, 100, 0), BlockStateId(AIR), "the world");
+ assert!(presented_bit(&world, &tables.0, 100));
+ assert_eq!(
+ world.live_section(0, SY, 0, &tables.0, 0b001),
+ SectionView::Bits(SectionBits::Empty)
+ );
+ assert_eq!(
+ ray(&world, 0, &tables),
+ cleared,
+ "the same flight through the passage the raid made"
+ );
+ // The picture changed; no block change reached the mod, and its revision stands.
+ assert_eq!(world.mod_changes.take(), (Vec::new(), false));
+ assert_eq!(world.live_revision, live_revision);
+ // A real change during the film: the simulation meets it, the picture stays at tick 0.
+ world.log_tick = 20;
+ world.set(10, 100, 2, BlockStateId(STONE)).unwrap();
+ assert_eq!(world.block(10, 100, 2), BlockStateId(AIR), "not shown");
+ assert!(
+ matches!(
+ ray(&world, 2, &tables),
+ Trace::Hit {
+ block: [10, 100, 2],
+ ..
+ }
+ ),
+ "a change behind the view is the real block"
+ );
+ assert_ne!(world.mod_changes.take(), (Vec::new(), false));
+ assert_eq!(world.live_revision, live_revision + 1);
+ // Seeking about changes nothing else either.
+ assert!(world.view_log(None, usize::MAX, None));
+ assert_eq!(world.mod_changes.take(), (Vec::new(), false));
+ assert_eq!(world.live_revision, live_revision + 1);
+ // The film ends: the picture is the real world again, and so are both flights.
+ world.log = None;
+ assert_eq!(world.block(10, 100, 0), BlockStateId(AIR));
+ assert_eq!(world.block(10, 100, 2), BlockStateId(STONE));
+ assert_eq!(ray(&world, 0, &tables), cleared);
+ assert!(matches!(
+ ray(&world, 2, &tables),
+ Trace::Hit {
+ block: [10, 100, 2],
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn a_ground_query_reads_the_real_blocks() {
+ let mut world = walled();
+ let opaque = |s: BlockStateId| s.0 != AIR;
+ assert_eq!(world.motion_blocking(10, 0, opaque), 101);
+ world.log = Some(WorldLog::new(1, 0));
+ world.log_tick = 10;
+ world.set(10, 100, 0, BlockStateId(AIR)).unwrap();
+ assert_eq!(world.live_motion_blocking(10, 0, opaque), 71);
+ world.view_log(Some(0), 100, None);
+ assert_eq!(world.motion_blocking(10, 0, opaque), 101, "the picture");
+ assert_eq!(world.live_motion_blocking(10, 0, opaque), 71, "the world");
+ // A column of the same chunk with no displaced block reads the same height either way.
+ assert_eq!(world.motion_blocking(5, 0, opaque), 71);
+ assert_eq!(world.live_motion_blocking(5, 0, opaque), 71);
+ world.view_log(None, usize::MAX, None);
+ assert_eq!(world.motion_blocking(10, 0, opaque), 71);
+ assert_eq!(world.live_motion_blocking(10, 0, opaque), 71);
+ }
+
+ #[test]
+ fn a_server_chunk_replacement_refreshes_the_real_blocks_behind_the_picture() {
+ let tables = tables();
+ let mut world = walled();
+ world.log = Some(WorldLog::new(1, 0));
+ world.log_tick = 10;
+ world.set(10, 100, 0, BlockStateId(AIR)).unwrap();
+ world.view_log(Some(0), 100, None);
+ assert_eq!(world.block(10, 100, 0), BlockStateId(STONE), "the old wall is shown");
+ world.mod_changes.watching = true;
+ let _ = world.mod_changes.take();
+
+ let mut replacement = Chunk::empty(0, 0);
+ replacement.set(10, 100, 2, STONE);
+ world.insert_chunk((0, 0), Arc::new(replacement));
+ assert_eq!(world.live_block(10, 100, 0), BlockStateId(AIR));
+ assert_eq!(world.live_block(10, 100, 2), BlockStateId(STONE));
+ assert_eq!(world.block(10, 100, 0), BlockStateId(STONE), "the picture is unchanged");
+ assert!(matches!(ray(&world, 2, &tables), Trace::Hit { block: [10, 100, 2], .. }));
+ assert_eq!(world.mod_changes.take().0.len(), crate::chunk::SECTIONS);
+ }
+
+ #[test]
+ fn a_ray_into_an_unloaded_column_is_unknown() {
+ let tables = tables();
+ let world = walled();
+ let mut probe = WorldProbe::new(&world, &tables.0, &tables.1, 0b001);
+ assert_eq!(
+ trace(&mut probe, [20.5, 100.5, 0.5], [40.5, 100.5, 0.5]),
+ Trace::Unknown { known_to: 0.0 }
+ );
+ }
+}
--
2.54.0 (Apple Git-157)
@@ -1,927 +0,0 @@
From b1dd9f55c1af556b95797e34211319909910ab49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Sun, 1 Nov 2026 02:30:58 +0300
Subject: [PATCH 2/2] Acoustics follows chunk residency and voice lifecycle
(#110 #111 #112)
Co-Authored-By: Kimi <noreply@moonshot.ai>
---
crates/horizon-client/src/acoustics.rs | 480 +++++++++++++++++++-
crates/horizon-client/src/chunk_map.rs | 97 +++-
crates/horizon-client/src/game/acoustics.rs | 18 +-
3 files changed, 574 insertions(+), 21 deletions(-)
diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs
index 7dd895d83..a5281d1b6 100644
--- a/crates/horizon-client/src/acoustics.rs
+++ b/crates/horizon-client/src/acoustics.rs
@@ -13,13 +13,25 @@
//! at a time, and applied a tick or two later. While a film is being made, everything is traced
//! at once and under the same kind of budget (cells, never time), so the sound of a film does not
//! depend on the speed of the machine. Results are cached by the cells (2 blocks) of the source
-//! and the listener, and dropped when a block changes in a section a path passed through.
+//! and the listener, and dropped when a block changes in a section a path passed through, or when
+//! the chunk underneath them arrives, is replaced by a reload or goes away. A trace that met a
+//! block nobody knows is marked so and is traced again once it is a bit old, so a wall that
+//! streams in is found without anything else happening; answers that come back possibly after a
+//! chunk moved are checked against that change and dropped when it was theirs.
+//!
+//! What the mixer is told is also scoped: a sound that leaves the acoustic range (beyond
+//! [`RANGE`], too quiet to hear, or a world that is gone for a moment) is explicitly given the
+//! plain numbers again — the air's absorption at its distance, no wall, no echoes, no reverb sends
+//! — so what was once traced for it is not remembered for it; when it comes back, the current
+//! geometry applies.
//!
//! Stage 2 adds echo taps for loud one-shots (`echo`): rays from the source and the listener find
//! the walls, and each wall that is really there gives a copy of the sound delayed by the longer
//! path. They are traced on the same worker, cached by the 1-block cells of the source and the
//! listener, and handed to the mixer by [`Acoustics::take_echoes`]; only [`ECHO_VOICES`] sounds
-//! at a time get them.
+//! get them at once (the loudest that deserve them) — a sound that drops out of that set has its
+//! taps recalled, which the mixer fades out over about 50 ms, so the taps actually sounding may
+//! briefly reach another round of [`ECHO_VOICES`] while a rotation plays out.
//!
//! Stage 3 adds the reverb (`room`): rays from the listener (and from each echo source, in its
//! echo trace) measure how enclosed, how large and how reflective the space is, which gives the
@@ -39,7 +51,7 @@ pub use room::Room;
use spr_occlusion::run_occlusion;
use crate::chunk::{Chunk, HEIGHT, MIN_Y};
-use crate::fast_hash::FxHashMap;
+use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
@@ -61,9 +73,17 @@ const JOB_CELLS: usize = 400_000;
const JOB_SOURCES: usize = 32;
/// Sources that get the nine paths; the rest get one.
const FULL_PATHS: usize = 16;
+/// Ticks a trace that met unknown cells is kept before it is traced again: the snapshot then may
+/// miss chunks that are not there yet, and they arrive on their own. A section change always
+/// drops the trace at once; this only bounds how long a partial answer is mixed with.
+const UNKNOWN_TICKS: u64 = 40;
/// Cached estimates.
const CACHE: usize = 4096;
-/// Sounds that have echo taps at the same time (the loudest of those that deserve them).
+/// Sounds that have echo taps at the same time (the loudest of those that deserve them). This is
+/// the budget of the engine's work, not of the mixer's taps alone: a sound that leaves the set has
+/// its taps recalled, which the mixer fades out over about 50 ms (about five mixer buffers), so
+/// right after a full rotation up to twice this many taps are sounding, and within that fade the
+/// steady number is `ECHO_VOICES` again.
pub const ECHO_VOICES: usize = 8;
/// Echo traces one worker job holds.
const ECHO_JOB: usize = 4;
@@ -100,6 +120,9 @@ pub struct Snapshot {
pub reflect: Arc<Vec<f32>>,
pub epoch: u32,
pub revision: u64,
+ /// The residency of the columns it was made from (`ChunkMap::content_revision`): a reload,
+ /// an unload or a new column means the ranks' contents may differ from these `Arc`s.
+ pub chunk_rev: u64,
pub tick: u64,
pub center: [f64; 3],
}
@@ -177,6 +200,10 @@ pub struct Changes {
pub epoch: u32,
pub overflow: bool,
pub sections: Vec<(i32, i32, i32)>,
+ /// What the resident chunks held (`ChunkMap::content_revision`): when it moves, a worker job
+ /// made from the snapshot before it applies to chunks that may be gone, and its answers are
+ /// dropped. The `sections` of the same change still drop the individual cached estimates.
+ pub residency: u64,
}
type Cell = (i32, i32, i32);
@@ -187,6 +214,11 @@ struct Entry {
/// The box (in blocks) the paths lay in.
min: [i32; 3],
max: [i32; 3],
+ /// The tick it was traced at, so a trace that met unknown cells (see
+ /// [`UNKNOWN_TICKS`]) is not kept for ever.
+ born: u64,
+ /// The walk met blocks nobody knows (a chunk the snapshot is missing).
+ unk: bool,
used: u64,
}
impl Entry {
@@ -206,12 +238,16 @@ struct EchoEntry {
room: Room,
min: [i32; 3],
max: [i32; 3],
+ born: u64,
+ unk: bool,
used: u64,
}
/// The space round the listener's block.
#[derive(Clone, Copy, Debug)]
struct RoomEntry {
room: Room,
+ born: u64,
+ unk: bool,
used: u64,
}
/// The box a room measure from `cell` looked in.
@@ -270,7 +306,7 @@ struct Done {
seq: u64,
entries: Vec<(Key, Entry)>,
echoes: Vec<(Key, EchoEntry, u64)>,
- rooms: Vec<(Cell, Room)>,
+ rooms: Vec<(Cell, Room, bool)>,
cells: usize,
unknown: usize,
}
@@ -294,7 +330,15 @@ fn trace_echo(geometry: &dyn Geometry, key: Key, budget: &mut usize, clock: u64)
let m = room::measure(geometry, centre(key.0), &mut room_budget);
let us = start.elapsed().as_micros() as u64;
(
- EchoEntry { taps: t.taps, room: m.room, min: t.min, max: t.max, used: clock },
+ EchoEntry {
+ taps: t.taps,
+ room: m.room,
+ min: t.min,
+ max: t.max,
+ born: clock,
+ unk: t.unknown + m.unknown > 0,
+ used: clock,
+ },
t.cells + m.cells,
t.unknown + m.unknown,
us,
@@ -320,6 +364,8 @@ fn trace(geometry: &dyn Geometry, listener: [f64; 3], at: [f64; 3], paths: usize
paths: paths as u8,
min: [lo(0), lo(1), lo(2)],
max: [hi(0), hi(1), hi(2)],
+ born: 0,
+ unk: unknown > 0,
used: 0,
},
before - *budget,
@@ -334,6 +380,12 @@ pub struct Acoustics {
cache: HashMap<Key, Entry>,
clock: u64,
applied: HashMap<u32, Applied>,
+ /// The sounds that were in the acoustic range at the last step (with the geometry present),
+ /// so one that leaves it (beyond `RANGE`, inaudible, or the world gone) is told so plainly.
+ scoped: FxHashSet<u32>,
+ /// The residency (`Changes::residency`) the last step saw: worker jobs made before a change
+ /// are dropped (`floor`) when it moves.
+ residency: Option<u64>,
log: std::collections::VecDeque<(u64, Cell)>,
seq: u64,
/// Jobs up to this number belong to a world that is gone.
@@ -360,6 +412,8 @@ impl Acoustics {
cache: HashMap::new(),
clock: 0,
applied: HashMap::new(),
+ scoped: FxHashSet::default(),
+ residency: None,
log: Default::default(),
seq: 0,
floor: 0,
@@ -381,6 +435,8 @@ impl Acoustics {
pub fn reset(&mut self) {
self.cache.clear();
self.applied.clear();
+ self.scoped.clear();
+ self.residency = None;
self.echo_cache.clear();
self.echo_applied.clear();
self.echo_pending.clear();
@@ -427,7 +483,7 @@ impl Acoustics {
let m = room::measure(&*job.geometry, centre(cell), &mut b);
cells += m.cells;
unknown += m.unknown;
- rooms.push((cell, m.room));
+ rooms.push((cell, m.room, m.unknown > 0));
}
if done_tx.send(Done { seq: job.seq, entries, echoes, rooms, cells, unknown }).is_err() {
break;
@@ -453,6 +509,7 @@ impl Acoustics {
self.floor = self.seq;
self.in_flight = None;
self.applied.clear();
+ self.scoped.clear();
return;
}
for &s in &changes.sections {
@@ -473,6 +530,7 @@ impl Acoustics {
fn insert(&mut self, key: Key, mut entry: Entry) {
entry.used = self.clock;
+ entry.born = self.clock;
self.cache.insert(key, entry);
if self.cache.len() > CACHE {
let mut ages: Vec<u64> = self.cache.values().map(|e| e.used).collect();
@@ -484,6 +542,9 @@ impl Acoustics {
fn insert_echo(&mut self, key: Key, mut entry: EchoEntry) {
entry.used = self.clock;
+ if entry.born == 0 {
+ entry.born = self.clock;
+ }
self.echo_cache.insert(key, entry);
if self.echo_cache.len() > ECHO_CACHE {
let mut ages: Vec<u64> = self.echo_cache.values().map(|e| e.used).collect();
@@ -510,6 +571,14 @@ impl Acoustics {
) -> Vec<Applied> {
self.clock += 1;
self.invalidate(changes);
+ // The resident chunks changed: a job traced from the snapshot before them applies to
+ // chunks that may be gone, so its answers are dropped with the old residency's answers.
+ if self.residency != Some(changes.residency) {
+ self.residency = Some(changes.residency);
+ self.seq += 1;
+ self.floor = self.seq;
+ self.in_flight = None;
+ }
// The worker's results; one that began before a change in its box is not kept.
if let Some((_, rx)) = &self.worker {
let done: Vec<Done> = rx.try_iter().collect();
@@ -535,13 +604,16 @@ impl Acoustics {
self.insert_echo(key, e);
}
}
- for (cell, room) in d.rooms {
+ for (cell, room, unknown) in d.rooms {
let (lo, hi) = room_box(cell);
self.stats.room_traces += 1;
if self.log.iter().any(|(s, c)| *s >= d.seq && box_touches(lo, hi, *c)) {
self.stats.dropped_late += 1;
} else {
- self.room_cache.insert(cell, RoomEntry { room, used: self.clock });
+ self.room_cache.insert(
+ cell,
+ RoomEntry { room, born: self.clock, unk: unknown, used: self.clock },
+ );
}
}
for (key, e) in d.entries {
@@ -562,8 +634,11 @@ impl Acoustics {
self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
self.sends_applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
self.echo_applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
+ // A sound that left the acoustic range no longer holds what it was once told: the mixer
+ // gets the plain numbers for it (and no taps, no sends), instead of its last wall.
+ let mut told = self.settle_scope(sources, &live, listener, geometry.is_none());
let Some(geometry) = geometry else {
- return self.settle(&live, listener, None);
+ return told;
};
// The loudest first; ties by id, so the order never depends on a hash.
let mut order: Vec<usize> = (0..live.len()).collect();
@@ -577,7 +652,9 @@ impl Acoustics {
let want = if rank < FULL_PATHS { 9 } else { 1 };
if let Some(e) = self.cache.get_mut(&key) {
e.used = self.clock;
- if e.paths as usize >= want || e.paths == 9 {
+ // A trace that met unknown cells is not kept for ever: a chunk may have arrived.
+ let partial = e.unk && self.clock >= e.born + UNKNOWN_TICKS;
+ if !partial && (e.paths as usize >= want || e.paths == 9) {
self.stats.hits += 1;
continue;
}
@@ -621,7 +698,8 @@ impl Acoustics {
}
}
self.stats.cached = self.cache.len();
- self.settle(&live, listener, Some(lcell))
+ told.extend(self.settle(&live, listener, Some(lcell)));
+ told
}
/// Gives the loudest echo sources their taps from the cache, traces the missing ones at once
@@ -638,10 +716,22 @@ impl Acoustics {
}
if served >= ECHO_VOICES {
self.stats.echo_capped += 1;
+ // It was served before (its taps are in the mixer): they are recalled, so the
+ // cap holds on the taps that sound, not only on these traced just now.
+ self.recall_echo(s.id);
continue;
}
served += 1;
let key = (block_of(s.at), lcell);
+ // A trace that met unknown cells is not kept for ever: a chunk may have arrived
+ // under it, and it is traced again like a missing one would be.
+ if self
+ .echo_cache
+ .get(&key)
+ .is_some_and(|e| e.unk && self.clock >= e.born + UNKNOWN_TICKS)
+ {
+ self.echo_cache.remove(&key);
+ }
if let Some(e) = self.echo_cache.get_mut(&key) {
e.used = self.clock;
self.stats.echo_hits += 1;
@@ -677,24 +767,38 @@ impl Acoustics {
}
/// The space the listener is in: from the cache, measured at once (a film) or asked of the
- /// worker (the cell is returned). The last known space stays until the new one is.
+ /// worker (the cell is returned). The last known space stays until the new one is. A measure
+ /// that met unknown cells is not kept for ever: a chunk may have arrived under it.
fn settle_room(&mut self, geometry: &dyn Geometry, listener: [f64; 3]) -> Vec<Cell> {
let cell = block_of(listener);
if let Some(e) = self.room_cache.get_mut(&cell) {
e.used = self.clock;
- self.stats.room_hits += 1;
- self.room_now = Some(e.room);
- } else if self.sync {
+ if !(e.unk && self.clock >= e.born + UNKNOWN_TICKS) {
+ self.stats.room_hits += 1;
+ self.room_now = Some(e.room);
+ return self.room_cache_tidy();
+ }
+ self.room_cache.remove(&cell);
+ }
+ if self.sync {
let mut budget = ROOM_CELLS;
let m = room::measure(geometry, centre(cell), &mut budget);
self.stats.room_traces += 1;
self.stats.room_cells += m.cells as u64;
self.stats.unknown_cells += m.unknown as u64;
- self.room_cache.insert(cell, RoomEntry { room: m.room, used: self.clock });
+ self.room_cache.insert(
+ cell,
+ RoomEntry { room: m.room, born: self.clock, unk: m.unknown > 0, used: self.clock },
+ );
self.room_now = Some(m.room);
} else {
return vec![cell];
}
+ self.room_cache_tidy()
+ }
+
+ /// Keeps the room cache bounded and carries the listener's space into the stats.
+ fn room_cache_tidy(&mut self) -> Vec<Cell> {
if self.room_cache.len() > ROOM_CACHE {
let mut ages: Vec<u64> = self.room_cache.values().map(|e| e.used).collect();
ages.sort_unstable();
@@ -741,6 +845,76 @@ impl Acoustics {
std::mem::take(&mut self.sends_pending)
}
+ /// A sound that left the acoustic range is not traced any more, but what the mixer was told
+ /// keeps playing: the sounds that just left the range (beyond [`RANGE`], too quiet to hear, or
+ /// with the world gone for a step) are given the plain numbers again — the air's absorption
+ /// and no wall — and their echo taps are recalled and their reverb sends to nothing. Nothing
+ /// is kept for a sound the mixer never had numbers for.
+ fn settle_scope(&mut self, sources: &[Source], live: &[Source], listener: [f64; 3], no_geometry: bool) -> Vec<Applied> {
+ let mut out = Vec::new();
+ let mut was = std::mem::take(&mut self.scoped);
+ if no_geometry {
+ // The world is gone for a step: nothing can be traced for the live ones either, so
+ // the plain numbers stand for them until the world is read again.
+ for s in live {
+ if was.remove(&s.id) {
+ let cutoff = AIR.powf(dist(s.at, listener) as f32);
+ out.extend(self.unhold(s.id, 1.0, cutoff));
+ }
+ }
+ } else {
+ for s in live {
+ was.remove(&s.id);
+ }
+ }
+ // What is left are the sounds that left the range while the geometry was there: beyond
+ // RANGE, or quieter than the ear hears. The air still takes the highs by the distance,
+ // so the numbers continue the open air's numbers without the old wall.
+ for s in sources {
+ if !was.remove(&s.id) {
+ continue;
+ }
+ let cutoff = AIR.powf(dist(s.at, listener) as f32);
+ out.extend(self.unhold(s.id, 1.0, cutoff));
+ }
+ if !no_geometry {
+ self.scoped.extend(live.iter().map(|s| s.id));
+ }
+ out
+ }
+
+ /// Puts one sound's acoustics back to the plain numbers. The mixer hears them only when they
+ /// differ; the echo taps are recalled (they fade out there) and the reverb send goes to
+ /// nothing (it fades in the mixer too). Returns the numbers, if the mixer has to hear them.
+ fn unhold(&mut self, id: u32, gain: f32, cutoff: f32) -> Option<Applied> {
+ let out = self
+ .applied
+ .get(&id)
+ .is_some_and(|p| {
+ (p.gain - gain).abs() >= 0.002 || (p.cutoff - cutoff).abs() >= 0.002 * p.cutoff.max(0.01)
+ })
+ .then_some(Applied { id, gain, cutoff });
+ if let Some(now) = out {
+ self.applied.insert(id, now);
+ }
+ self.recall_echo(id);
+ if self.sends_applied.get(&id).is_some_and(|s| s.iter().any(|v| v > &0.0)) {
+ self.sends_applied.insert(id, [0.0; 4]);
+ self.sends_pending.push((id, [0.0; 4]));
+ self.stats.sends += 1;
+ }
+ out
+ }
+
+ /// The echo taps of one sound are recalled (the mixer fades them out, so a tap that comes
+ /// back a moment later picks up where it left off). A sound that had none gains none.
+ fn recall_echo(&mut self, id: u32) {
+ if self.echo_applied.get(&id).is_some_and(|t| !t.is_empty()) {
+ self.echo_applied.insert(id, Vec::new());
+ self.echo_pending.push((id, Vec::new()));
+ }
+ }
+
/// The numbers for each live source from the cache; only the changed ones are returned.
fn settle(&mut self, live: &[Source], listener: [f64; 3], lcell: Option<Cell>) -> Vec<Applied> {
let lcell = lcell.unwrap_or_else(|| cell_of(listener));
@@ -1033,4 +1207,276 @@ mod tests {
}
panic!("the worker did not answer");
}
+
+ /// A wall appears at x = 40..41 once `arrived` is set; while it is not, its columns are
+ /// unknown to the walk (a chunk that is not resident yet), and open ground otherwise.
+ struct Arriving {
+ arrived: std::sync::atomic::AtomicU32,
+ wall: std::ops::Range<i32>,
+ }
+ impl Geometry for Arriving {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ let arrived = self.arrived.load(std::sync::atomic::Ordering::Relaxed);
+ let wall = self.wall.clone();
+ Box::new(move |x, _, _| match (arrived, wall.contains(&x)) {
+ (0, true) => None,
+ (1, true) => Some(1.0),
+ _ => Some(0.0),
+ })
+ }
+ }
+
+ /// (AirDef fork #110.) A wall streams in underneath a trace made while its columns were
+ /// unknown: nothing invalidates the cache, but the unknown trace does not outlive it.
+ #[test]
+ fn an_unknown_trace_is_not_kept_and_is_traced_again_when_the_chunks_answer() {
+ let concrete = Arc::new(Arriving { arrived: 0.into(), wall: 40..42 });
+ let g: Arc<dyn Geometry> = concrete.clone();
+ let mut a = Acoustics::new(true);
+ let l = [0.5, 70.5, 0.5];
+ let before = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default());
+ assert!(before[0].cutoff > 0.5, "nothing is known yet: {before:?}");
+ assert!(a.stats.unknown_cells > 0);
+ concrete.arrived.store(1, std::sync::atomic::Ordering::Relaxed);
+ // The partial answer is mixed with only for a bounded while.
+ for _ in 1..UNKNOWN_TICKS {
+ let out = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default());
+ assert!(out.is_empty(), "coasting on the partial answer: {out:?}");
+ }
+ // Then it is traced again against the geometry that now knows the wall: muted.
+ let after = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default());
+ assert_eq!(after.len(), 1);
+ assert!(after[0].cutoff < 0.01, "{after:?}");
+ assert!(a.stats.traces >= 2);
+ // And being out of partial answers, the cache answers again.
+ assert!(a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default()).is_empty());
+ }
+
+ /// (AirDef fork #110.) The column is reloaded (a chunk packet replaces a loaded one) and
+ /// then it goes away: the estimates traced through its sections are dropped both ways.
+ #[test]
+ fn a_reload_of_the_same_column_with_other_content_changes_the_sound() {
+ let mut a = Acoustics::new(true);
+ let open: Arc<dyn Geometry> = Arc::new(Arriving { arrived: 0.into(), wall: 0..0 });
+ let l = [0.5, 70.5, 0.5];
+ let first = a.step(Some(&open), l, &[src(1, 90.5)], &Changes::default());
+ assert!(first[0].cutoff > 0.5, "{first:?}");
+ // Reloaded with a wall in it: its section counts as changed, and the estimate traced
+ // through it is not kept.
+ let walled: Arc<dyn Geometry> = Arc::new(Arriving { arrived: 1.into(), wall: 40..42 });
+ let reload = a.step(
+ Some(&walled),
+ l,
+ &[src(1, 90.5)],
+ &Changes { residency: 3, sections: vec![(2, 4, 0)], ..Default::default() },
+ );
+ assert!(reload[0].cutoff < 0.01, "{reload:?}");
+ // Unloaded again: bright once more.
+ let gone = a.step(
+ Some(&open),
+ l,
+ &[src(1, 90.5)],
+ &Changes { residency: 4, sections: vec![(2, 4, 0)], ..Default::default() },
+ );
+ assert!(gone[0].cutoff > 0.5, "{gone:?}");
+ }
+
+ /// (AirDef fork #111.) A sound (or a listener) that walks out of the acoustic range gives
+ /// up its old wall and takes the plain numbers of its new distance until it comes back.
+ #[test]
+ fn a_sound_that_leaves_the_range_lets_the_wall_go_and_comes_back_traced_anew() {
+ let mut a = Acoustics::new(true);
+ let wall = Wall::new(100..103, 1.0);
+ let l = [0.5, 70.5, 0.5];
+ let first = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
+ assert!(first[0].cutoff < 0.001, "{first:?}");
+ // The same sound moves past the range: the wall it was behind stops applying.
+ let far = a.step(Some(&wall), l, &[src(1, 700.5)], &Changes::default());
+ assert_eq!(far.len(), 1);
+ assert_eq!(far[0].gain, 1.0);
+ assert!((far[0].cutoff - AIR.powf(700.0)).abs() < 1e-6, "{far:?}");
+ // Already there: the mixer hears nothing new.
+ assert!(a.step(Some(&wall), l, &[src(1, 700.5)], &Changes::default()).is_empty());
+ // It walks back into the range and the actual wall applies again.
+ let back = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
+ assert!(back[0].cutoff < 0.001 && (back[0].gain - 0.41).abs() < 0.01, "{back:?}");
+ // A listener walking past the range falls back the same way.
+ let away = a.step(Some(&wall), [800.5, 70.5, 0.5], &[src(1, 200.5)], &Changes::default());
+ assert!((away[0].cutoff - AIR.powf(600.0)).abs() < 1e-6, "{away:?}");
+ let here = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
+ assert!(here[0].cutoff < 0.001, "{here:?}");
+ // One sound, one set of numbers: nothing piles up.
+ assert_eq!(a.applied.len(), 1);
+ }
+
+ /// (AirDef fork #111.) A sound the distance gain makes inaudible stops holding the wall.
+ #[test]
+ fn a_temporarily_inaudible_sound_goes_plain_and_gets_the_wall_back_when_loud() {
+ let mut a = Acoustics::new(true);
+ let wall = Wall::new(40..42, 1.0);
+ let l = [0.5, 70.5, 0.5];
+ a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default());
+ let quiet = [Source { loud: 0.0001, ..src(1, 90.5) }];
+ let plain = a.step(Some(&wall), l, &quiet, &Changes::default());
+ assert_eq!(plain.len(), 1, "{plain:?}");
+ assert_eq!(plain[0].gain, 1.0);
+ assert!((plain[0].cutoff - AIR.powf(90.0)).abs() < 1e-6);
+ let loud_again = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default());
+ assert!(loud_again[0].cutoff < 0.01, "{loud_again:?}");
+ }
+
+ /// (AirDef fork #111.) A world that is gone for a step (no geometry to read) does not keep
+ /// any sound's numbers beyond its last trace, and the real numbers apply when it returns.
+ #[test]
+ fn a_world_that_is_gone_for_a_step_lets_every_sound_go_until_it_can_be_read() {
+ let mut a = Acoustics::new(true);
+ let wall = Wall::new(100..103, 1.0);
+ let l = [0.5, 70.5, 0.5];
+ let muted = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
+ assert!(muted[0].cutoff < 0.001, "{muted:?}");
+ // No geometry: the plain numbers, and nothing gets traced.
+ let plain = a.step(None, l, &[src(1, 200.5)], &Changes::default());
+ assert_eq!(plain.len(), 1);
+ assert_eq!(plain[0].gain, 1.0);
+ assert!((plain[0].cutoff - AIR.powf(200.0)).abs() < 1e-6);
+ assert!(a.step(None, l, &[src(1, 200.5)], &Changes::default()).is_empty(), "no repeats");
+ assert_eq!(a.stats.traces, 1, "nothing was traced without the geometry");
+ // The world is back: the actual numbers apply again.
+ let back = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
+ assert!(back[0].cutoff < 0.001 && (back[0].gain - 0.41).abs() < 0.01, "{back:?}");
+ assert_eq!(a.applied.len(), 1);
+ }
+
+ /// (AirDef fork #111.) An echo sound out of the range recalls its taps (an empty set, which
+ /// the mixer fades out) and takes the current geometry's taps when it comes back.
+ #[test]
+ fn an_echo_sound_leaving_the_range_recalls_its_taps_and_takes_them_back() {
+ let mut a = Acoustics::new(true);
+ let g = gorge(true);
+ a.step(Some(&g), LISTENER, &[shot(1, 0.5)], &Changes::default());
+ assert_eq!(a.take_echoes().len(), 1, "served once");
+ a.take_sends();
+ assert_eq!(a.echo_applied.len(), 1);
+ assert_eq!(a.echo_applied[&1].len(), 2);
+ a.step(Some(&g), LISTENER, &[shot(1, 700.5)], &Changes::default());
+ assert_eq!(a.take_echoes(), [(1, Vec::new())], "recalled");
+ a.take_sends();
+ // Nothing more while it stays out.
+ a.step(Some(&g), LISTENER, &[shot(1, 700.5)], &Changes::default());
+ assert!(a.take_echoes().is_empty() && a.take_sends().is_empty());
+ // Back in range: the current taps apply again.
+ a.step(Some(&g), LISTENER, &[shot(1, 0.5)], &Changes::default());
+ assert_eq!(a.echo_applied[&1].len(), 2);
+ assert_eq!(a.echo_applied.len(), 1, "one sound, one record");
+ }
+
+ /// (AirDef fork #111.) A sound out of the range stops its reverb send explicitly.
+ #[test]
+ fn a_sound_leaving_the_range_sends_nothing_to_the_reverb_until_it_returns() {
+ let mut a = Acoustics::new(true);
+ let inside = [12.5, 64.5, 12.5];
+ let source = Source { id: 1, at: [12.5, 64.5, 18.5], loud: 0.5, echo: true };
+ a.step(Some(&hall()), inside, &[source], &Changes::default());
+ assert!(!a.take_sends().is_empty(), "the hall sends");
+ // Far away: the send goes to nothing explicitly (the mixer fades it out).
+ let far = Source { at: [700.5, 64.5, 12.5], ..source };
+ a.step(Some(&hall()), inside, &[far], &Changes::default());
+ assert_eq!(a.take_sends(), [(1, [0.0; 4])]);
+ // Already nothing: no repeats.
+ assert!(a.step(Some(&hall()), inside, &[far], &Changes::default()).is_empty());
+ // Back in the hall: its sends apply again.
+ a.step(Some(&hall()), inside, &[source], &Changes::default());
+ let again = a.take_sends();
+ assert_eq!(again.len(), 1);
+ assert!(again[0].1.iter().any(|v| *v > 0.0), "{again:?}");
+ }
+
+ /// (AirDef fork #112.) Rotating the loudest ECHO_VOICES around: the voices that leave the
+ /// budget have their taps recalled, so the budget holds on the taps that sound.
+ #[test]
+ fn the_echo_budget_recalls_the_taps_of_the_voices_that_leave_it() {
+ let mut a = Acoustics::new(true);
+ let rocks: Vec<Source> = (0..16).map(|i| shot(i, f64::from(i) * 1.5 + 0.5)).collect();
+ a.step(Some(&gorge(true)), LISTENER, &rocks, &Changes::default());
+ a.take_echoes();
+ // Equal loudnesses: the first eight ids take the budget, each with taps.
+ assert_eq!(a.echo_applied.len(), 8);
+ assert_eq!(a.echo_applied[&0].len(), 2);
+ // Now the even ids are louder: they take the budget, the odd ones' taps are recalled.
+ let swapped: Vec<Source> = (0..16)
+ .map(|i| Source { loud: if i % 2 == 0 { 0.9 } else { 0.1 }, ..shot(i, f64::from(i) * 1.5 + 0.5) })
+ .collect();
+ a.step(Some(&gorge(true)), LISTENER, &swapped, &Changes::default());
+ let told = a.take_echoes();
+ assert_eq!(
+ told.iter().filter(|(_, t)| t.is_empty()).map(|(id, _)| *id).collect::<Vec<_>>(),
+ [1, 3, 5, 7],
+ "the recalled voices",
+ );
+ assert_eq!(
+ told.iter().filter(|(_, t)| !t.is_empty()).map(|(id, _)| *id).collect::<Vec<_>>(),
+ [8, 10, 12, 14],
+ "the new voices",
+ );
+ // Exactly the cap holds on the voices that carry taps, through the rotation.
+ assert_eq!(a.echo_applied.iter().filter(|(_, t)| !t.is_empty()).count(), ECHO_VOICES);
+ assert_eq!(a.echo_applied.iter().filter(|(_, t)| t.is_empty()).count(), 4);
+ // And rotating back does not pile anything up.
+ a.step(Some(&gorge(true)), LISTENER, &rocks, &Changes::default());
+ a.take_echoes();
+ assert_eq!(a.echo_applied.iter().filter(|(_, t)| !t.is_empty()).count(), ECHO_VOICES);
+ assert_eq!(a.echo_applied.len(), 12);
+ assert_eq!(a.stats.echo_capped, 24);
+ }
+
+ /// A geometry whose walk is slow, so that a worker job can be watched without a race.
+ struct Slow(std::ops::Range<i32>);
+ impl Geometry for Slow {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ Box::new(move |x, _, _| {
+ std::hint::black_box(
+ (0..3_000).fold(1u64, |s, i| s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i)),
+ );
+ Some(if self.0.contains(&x) { 1.0 } else { 0.0 })
+ })
+ }
+ }
+
+ /// (AirDef fork #110.) A worker job that traced the snapshot before the resident chunks
+ /// changed is dropped as a late answer; the job made after the change applies.
+ #[test]
+ fn an_answer_made_from_the_old_residency_is_dropped_and_the_new_one_applies() {
+ let mut a = Acoustics::new(false);
+ let slow: Arc<dyn Geometry> = Arc::new(Slow(40..42));
+ let l = [0.5, 70.5, 0.5];
+ let s = [src(1, 90.5)];
+ a.step(Some(&slow), l, &s, &Changes::default());
+ // The one-path estimate goes to the worker for its nine paths; before the answer lands,
+ // the resident columns change: its answer is dropped with the old residency's answers.
+ a.step(Some(&slow), l, &s, &Changes { residency: 1, ..Default::default() });
+ let after = Changes { residency: 7, ..Default::default() };
+ for _ in 0..200 {
+ a.step(None, l, &s, &after);
+ if a.stats.jobs >= 1 && a.stats.dropped_late > 0 {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+ assert!(a.stats.dropped_late >= 1, "the job was not dropped: {:?}", &a.stats);
+ assert_eq!(a.cache.len(), 1, "only the game thread's own estimate is cached");
+ assert_eq!(a.cache.values().next().unwrap().paths, 1, "the worker's refined answer never landed");
+ // The next job, made after the change, is kept. Wait for its actual result rather than
+ // for the number of unrelated echo/room jobs that happened to finish first.
+ a.step(Some(&slow), l, &s, &after);
+ for _ in 0..400 {
+ a.step(None, l, &s, &after);
+ if a.cache.values().next().is_some_and(|e| e.paths == 9) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+ assert!(a.stats.jobs >= 2);
+ assert_eq!(a.cache.len(), 1, "the new residency's answer is cached");
+ assert_eq!(a.cache.values().next().unwrap().paths, 9, "it refined to nine paths");
+ }
}
diff --git a/crates/horizon-client/src/chunk_map.rs b/crates/horizon-client/src/chunk_map.rs
index fddded9e1..a53ffda5e 100644
--- a/crates/horizon-client/src/chunk_map.rs
+++ b/crates/horizon-client/src/chunk_map.rs
@@ -1,7 +1,9 @@
-//! Resident chunk membership, with a token for consumers that cache its keys.
-use crate::{chunk::Chunk, fast_hash::FxHashMap};
+//! Resident chunk membership, with a token for consumers that cache its keys and the columns
+//! whose residency changed (AirDef fork #110: the acoustics must hear that too).
+use crate::{chunk::Chunk, fast_hash::{FxHashMap, FxHashSet}};
use std::{
collections::hash_map::{Iter, ValuesMut},
+ collections::VecDeque,
ops::Deref,
sync::{
Arc,
@@ -16,18 +18,31 @@ fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
}
+/// Columns kept in the changed log past this many of them: everything counts as changed, then.
+const CHANGE_CAP: usize = 256;
/// Read access behaves like a map. Structural writes are explicit so edits to
/// blocks or light don't invalidate consumers of the loaded chunk key set.
/// Tokens are process-wide: replacing the entire map also invalidates caches.
pub struct ChunkMap {
columns: Columns,
revision: u64,
+ /// Bumped by every change of what the columns hold: an insertion (new or
+ /// replacing a loaded one) and a removal. Edits through `get_mut` are
+ /// block writes, which the world reports by itself.
+ content: u64,
+ /// The columns whose residency changed since `take_changed` last read,
+ /// in the order they changed, up to [`CHANGE_CAP`] of them.
+ changed: VecDeque<Key>,
+ changed_dropped: bool,
}
impl Default for ChunkMap {
fn default() -> Self {
Self {
columns: Columns::default(),
revision: next_revision(),
+ content: next_revision(),
+ changed: VecDeque::default(),
+ changed_dropped: false,
}
}
}
@@ -35,17 +50,41 @@ impl ChunkMap {
pub fn membership_revision(&self) -> u64 {
self.revision
}
+ /// What the columns hold, not just which of them are resident: a consumer
+ /// that keeps chunk contents behind `Arc`s (the acoustics' snapshot) must
+ /// be made again when this moves.
+ pub fn content_revision(&self) -> u64 {
+ self.content
+ }
+ /// The columns whose residency changed since the last call, and whether
+ /// changes were dropped (every column counts as changed, then).
+ pub fn take_changed(&mut self) -> (FxHashSet<Key>, bool) {
+ let dropped = std::mem::take(&mut self.changed_dropped);
+ let changed = self.changed.drain(..).collect();
+ (changed, dropped)
+ }
+ fn changed_column(&mut self, key: Key) {
+ if self.changed.len() >= CHANGE_CAP {
+ self.changed_dropped = true;
+ self.changed.clear();
+ }
+ self.changed.push_back(key);
+ }
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
let previous = self.columns.insert(key, chunk);
+ self.content = next_revision();
if previous.is_none() {
self.revision = next_revision();
}
+ self.changed_column(key);
previous
}
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
let previous = self.columns.remove(key);
if previous.is_some() {
self.revision = next_revision();
+ self.content = next_revision();
+ self.changed_column(*key);
}
previous
}
@@ -53,6 +92,9 @@ impl ChunkMap {
if !self.columns.is_empty() {
self.columns.clear();
self.revision = next_revision();
+ self.content = next_revision();
+ self.changed_dropped = true;
+ self.changed.clear();
}
}
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
@@ -122,4 +164,55 @@ mod tests {
map = ChunkMap::default();
assert_ne!(cleared, map.membership_revision());
}
+
+ #[test]
+ fn insertions_replacements_and_removals_change_the_content_token_and_are_reported() {
+ let mut map = ChunkMap::default();
+ let chunk = Arc::new(Chunk::empty(0, 0));
+ let (changed, dropped) = map.take_changed();
+ assert!(changed.is_empty() && !dropped);
+ // An insertion is reported once.
+ map.insert((0, 0), chunk.clone());
+ let content = map.content_revision();
+ let membership = map.membership_revision();
+ let had_it = map.take_changed();
+ assert_eq!(had_it.0.len(), 1);
+ assert!(had_it.0.contains(&(0, 0)));
+ // A replacement (a reload with other content): the key set is the
+ // same, what the column holds is not.
+ let mut other = Chunk::empty(0, 0);
+ other.set(0, 0, 0, 9);
+ let reload = map.insert((0, 0), Arc::new(other)).is_some();
+ assert!(reload);
+ assert_eq!(membership, map.membership_revision(), "the keys did not move");
+ assert_ne!(content, map.content_revision(), "the content did");
+ let reloaded = map.take_changed();
+ assert_eq!(reloaded.0.len(), 1);
+ assert!(reloaded.0.contains(&(0, 0)));
+ // A removal is reported; reads of an idle map report nothing.
+ assert!(map.remove(&(0, 0)).is_some());
+ let removed = map.take_changed();
+ assert_eq!(removed.0.len(), 1);
+ assert!(removed.0.contains(&(0, 0)));
+ let (idle, dropped) = map.take_changed();
+ assert!(idle.is_empty() && !dropped, "{dropped}");
+ // Endless change stays bounded: the drop says everything counts.
+ for x in 0..1_000 {
+ map.insert((x % 5, 0), chunk.clone());
+ }
+ let (_, dropped) = map.take_changed();
+ assert!(dropped);
+ // Idle again; a clear of a loaded map says so.
+ let (empty, dropped) = map.take_changed();
+ assert!(empty.is_empty() && !dropped);
+ let was = map.content_revision();
+ map.clear();
+ let (_, dropped) = map.take_changed();
+ assert!(dropped, "a clear loses every column");
+ assert_ne!(was, map.content_revision());
+ // A clear of an idle map is idle.
+ map.clear();
+ let (empty, dropped) = map.take_changed();
+ assert!(empty.is_empty() && !dropped);
+ }
}
diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs
index fe2c26de5..ddaa85a0e 100644
--- a/crates/horizon-client/src/game/acoustics.rs
+++ b/crates/horizon-client/src/game/acoustics.rs
@@ -60,13 +60,16 @@ impl Game<'_> {
(table, reflect)
}
- /// The snapshot of the chunks round the listener, made again when a block changed or the
- /// listener went far from where it was made.
+ /// The snapshot of the chunks round the listener, made again when a block changed, the
+ /// resident columns arrived, were replaced or went away (AirDef fork #110), or the listener
+ /// went far from where it was made.
fn acoustic_snapshot(&mut self) -> Arc<Snapshot> {
let at = self.audio.listener;
+ let chunk_rev = self.world.chunks.content_revision();
if let Some(s) = &self.mods.acoustic.snapshot
&& s.epoch == self.world.epoch
&& s.revision == self.world.revision
+ && s.chunk_rev == chunk_rev
&& (s.center[0] - at[0]).abs() < 96.0
&& (s.center[2] - at[2]).abs() < 96.0
{
@@ -87,6 +90,7 @@ impl Game<'_> {
reflect,
epoch: self.world.epoch,
revision: self.world.revision,
+ chunk_rev,
tick: self.ticks as u64,
center: at,
});
@@ -101,6 +105,15 @@ impl Game<'_> {
return;
}
self.world.sound_changes.watching = true;
+ // The columns whose residency changed (a chunk arrived, was replaced by a reload or is
+ // gone) count as changed sections for the acoustics (AirDef fork #110).
+ let (columns, dropped) = self.world.chunks.take_changed();
+ for (x, z) in columns {
+ self.world.sound_changes.column(x, z);
+ }
+ if dropped {
+ self.world.sound_changes.overflow();
+ }
if !self.audio.acoustics_wanted() {
return;
}
@@ -109,6 +122,7 @@ impl Game<'_> {
epoch: self.world.epoch,
overflow,
sections,
+ residency: self.world.chunks.content_revision(),
};
let geometry: Arc<dyn Geometry> = self.acoustic_snapshot();
self.audio.tick_acoustics(Some(&geometry), &changes);
--
2.54.0 (Apple Git-157)
+63 -66
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""AirDef: сборка, установка и запуск на Windows, macOS и Linux. Только стандартная библиотека Python.
python3 tools/airdef.py fork # скачать Horizon нужной версии и наложить патчи форка (horizon/)
python3 tools/airdef.py fork # инициализировать закреплённый сабмодуль Horizon (horizon/)
python3 tools/airdef.py game # собрать игру (форк) — нужен Rust
python3 tools/airdef.py mod # собрать мод airdef.hmod — нужен Rust с wasm32
python3 tools/airdef.py install # положить мод в папку игры и включить
@@ -26,7 +26,6 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FORK = ROOT / "horizon"
UPSTREAM = "https://github.com/ludovic111/horizon.git"
EXE = ".exe" if os.name == "nt" else ""
@@ -56,51 +55,46 @@ def cargo() -> str:
return found
def fork_stamp(commit: str, patches) -> str:
"""Что должно стоять в horizon/: коммит апстрима и содержимое патчей."""
h = hashlib.sha256(commit.encode())
for p in patches:
h.update(p.name.encode())
h.update(p.read_bytes())
return h.hexdigest()
def engine_commit() -> str:
"""The index pins the engine, including a deliberate not-yet-committed gitlink update."""
row = subprocess.check_output(["git", "ls-files", "--stage", "--", "horizon"], cwd=ROOT, text=True).strip().split()
if len(row) < 2 or row[0] != "160000":
sys.exit("Нет закреплённого сабмодуля horizon/: проверьте checkout AirDef")
return row[1]
def engine_identity() -> str:
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=FORK, text=True).strip()
diff = subprocess.check_output(["git", "diff", "--binary", "HEAD"], cwd=FORK)
return commit + ("-" + hashlib.sha256(diff).hexdigest()[:16] if diff else "")
def fork():
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/.
Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория);
прежняя остаётся в ветке airdef-prev."""
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
stamp = fork_stamp(commit, patches)
stamp_file = FORK / ".git" / "airdef-stamp"
if not (FORK / ".git").exists():
# Без CRLF на Windows: патчи накладываются на файлы как в апстриме.
run(["git", "clone", "-c", "core.autocrlf=false", "--filter=blob:none", UPSTREAM, FORK])
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
print("horizon/: ветка airdef на нужных коммите и патчах")
return
dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"],
capture_output=True, text=True).stdout.strip()
if dirty:
sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.")
exists = subprocess.run(["git", "-C", FORK, "rev-parse", "--verify", "-q", "refs/heads/airdef"],
capture_output=True).returncode == 0
if exists:
print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)")
run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"])
run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit])
run(["git", "-C", FORK, "checkout", "-B", "airdef", commit])
if patches:
run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost",
"am", "--3way", *patches])
stamp_file.write_text(stamp + "\n")
"""Initialize the exact gitlink; never replace a modified developer checkout."""
expected = engine_commit()
if (FORK / ".git").exists():
current = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=FORK, text=True).strip()
if current == expected:
print(f"horizon/: закреплённый коммит {expected}")
return
dirty = subprocess.check_output(["git", "status", "--porcelain"], cwd=FORK, text=True).strip()
if dirty:
sys.exit("horizon/: есть изменения при другом закреплённом SHA; сохраните их перед обновлением сабмодуля")
if (FORK / ".git").is_dir():
# Migrate the old standalone clone without deleting it or any local branches.
run(["git", "submodule", "absorbgitdirs", "--", "horizon"], cwd=ROOT)
url = subprocess.check_output(["git", "config", "-f", ".gitmodules", "--get", "submodule.horizon.url"], cwd=ROOT, text=True).strip()
run(["git", "remote", "set-url", "origin", url], cwd=FORK)
elif FORK.exists() and any(FORK.iterdir()):
sys.exit("horizon/: непустой каталог без Git; сохраните его перед инициализацией сабмодуля")
run(["git", "submodule", "update", "--init", "--recursive", "--", "horizon"], cwd=ROOT)
current = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=FORK, text=True).strip()
if current != expected:
sys.exit(f"horizon/: ожидался {expected}, получен {current}")
def game():
if not (FORK / "Cargo.toml").exists():
fork()
fork()
run([cargo(), "build", "--profile", "fast", "-p", "horizon-client", "--features", "mods"], cwd=FORK)
@@ -113,19 +107,28 @@ def built_game() -> Path:
sys.exit("Нет собранной игры: python3 tools/airdef.py game (или AIRDEF_GAME=путь к horizon-client)")
def packer_path() -> Path:
return ROOT / ".horizon-cache" / "packer" / engine_identity() / "bin" / ("cargo-horizon-mod" + EXE)
def mod():
tool = shutil.which("cargo-horizon-mod") or Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE)
if not (FORK / "Cargo.toml").exists():
fork()
# Упаковщик должен быть от этого форка: старый не знает новых уровней API.
stamp_file = FORK / ".git" / "airdef-packer-stamp"
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
stamp = fork_stamp(commit, sorted((ROOT / "horizon-patches").glob("*.patch")))
if not Path(tool).exists() or not stamp_file.exists() or stamp_file.read_text().strip() != stamp:
# Without trial runs (wasmtime): building a mod needs only packing, a fraction of the compile.
run([cargo(), "install", "--locked", "--force", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
stamp_file.write_text(stamp + "\n")
run([cargo(), "horizon-mod", "build"], cwd=ROOT / "airdef-mod")
fork()
tool = packer_path()
if not tool.exists():
run([cargo(), "install", "--locked", "--no-default-features", "--root", tool.parent.parent,
"--path", FORK / "crates" / "cargo-horizon-mod"])
# Invoke the pinned executable directly; PATH may contain an unrelated old packer.
build_env = dict(os.environ)
build_env["PATH"] = str(Path(cargo()).parent) + os.pathsep + build_env.get("PATH", "")
run([tool, "horizon-mod", "build"], cwd=ROOT / "airdef-mod", env=build_env)
artifact = hmod()
identity = {"airdef": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"engine": engine_identity(), "engine_pin": engine_commit(),
"wit_sha256": hashlib.sha256((FORK / "crates/horizon-mod-sdk/wit/horizon.wit").read_bytes()).hexdigest(),
"artifact_sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
"airdef_dirty": bool(subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=ROOT)),
"airdef_diff_sha256": hashlib.sha256(subprocess.check_output(["git", "diff", "--binary", "HEAD"], cwd=ROOT)).hexdigest()}
artifact.with_name("build-identity.json").write_text(json.dumps(identity, indent=2) + "\n")
def hmod() -> Path:
@@ -211,9 +214,7 @@ def game_level():
def check():
"""Говорит прямо, совпадают ли форк, упаковщик, уровень API мода с игрой и ресурсы. Код 0 — да, 1 — нет."""
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
stamp = fork_stamp(commit, patches)
commit = engine_commit()
problems = []
def line(name, ok, text):
@@ -221,16 +222,12 @@ def check():
if not ok:
problems.append(name)
stamp_file = FORK / ".git" / "airdef-stamp"
fork_ok = stamp_file.exists() and stamp_file.read_text().strip() == stamp
line("форк", fork_ok, f"коммит {commit[:12]}, патчей {len(patches)}" if fork_ok else
"horizon/ не собран из HORIZON_COMMIT и horizon-patches/: python3 tools/airdef.py fork")
tool = shutil.which("cargo-horizon-mod") or str(Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE))
packer_stamp = FORK / ".git" / "airdef-packer-stamp"
packer_ok = Path(tool).exists() and packer_stamp.exists() and packer_stamp.read_text().strip() == stamp
line("упаковщик", packer_ok, "собран из этого форка" if packer_ok else
"не установлен или собран из другого форка: python3 tools/airdef.py mod")
current = subprocess.run(["git", "rev-parse", "HEAD"], cwd=FORK, capture_output=True, text=True).stdout.strip() if (FORK / ".git").exists() else ""
fork_ok = current == commit
line("форк", fork_ok, f"gitlink {commit}, checkout {current or 'не инициализирован'}")
packer_ok = fork_ok and packer_path().exists()
line("упаковщик", packer_ok, "repo-local, соответствует engine identity" if packer_ok else
"нет упаковщика этого форка: python3 tools/airdef.py mod")
api = fork_api()
try:
+76
View File
@@ -0,0 +1,76 @@
"""Real-Git checks for pinned initialization and safe legacy migration; no network."""
import importlib.util
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('airdef_tool', Path(__file__).with_name('airdef.py'))
tool = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tool)
def git(where, *args):
return subprocess.check_output(['git', '-C', str(where), *args], stderr=subprocess.STDOUT, text=True).strip()
class CheckoutTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.engine = self.base / 'engine'
self.engine.mkdir()
git(self.engine, 'init', '-q')
git(self.engine, 'config', 'user.name', 'Fixture')
git(self.engine, 'config', 'user.email', 'fixture@example.invalid')
(self.engine / 'marker').write_text('old')
git(self.engine, 'add', 'marker')
git(self.engine, 'commit', '-qm', 'old')
self.old = git(self.engine, 'rev-parse', 'HEAD')
(self.engine / 'marker').write_text('new')
git(self.engine, 'commit', '-qam', 'new')
self.new = git(self.engine, 'rev-parse', 'HEAD')
self.parent = self.base / 'airdef'
self.parent.mkdir()
git(self.parent, 'init', '-q')
(self.parent / '.gitmodules').write_text('[submodule "horizon"]\n path = horizon\n url = ' + str(self.engine) + '\n')
git(self.parent, 'add', '.gitmodules')
self.pin(self.old)
self.addCleanup(patch.stopall)
patch.object(tool, 'ROOT', self.parent).start()
patch.object(tool, 'FORK', self.parent / 'horizon').start()
patch.dict(os.environ, {'GIT_ALLOW_PROTOCOL': 'file'}).start()
def pin(self, sha):
git(self.parent, 'update-index', '--add', '--cacheinfo', '160000,' + sha + ',horizon')
def test_initialization_uses_pin_instead_of_newer_tip(self):
tool.fork()
self.assertEqual(git(tool.FORK, 'rev-parse', 'HEAD'), self.old)
self.assertEqual((tool.FORK / 'marker').read_text(), 'old')
tool.fork() # idempotent
def test_dirty_checkout_is_preserved_when_pin_changes(self):
tool.fork()
(tool.FORK / 'marker').write_text('user changes')
self.pin(self.new)
with self.assertRaises(SystemExit):
tool.fork()
self.assertEqual((tool.FORK / 'marker').read_text(), 'user changes')
self.assertEqual(git(tool.FORK, 'rev-parse', 'HEAD'), self.old)
def test_old_standalone_clone_is_absorbed_without_losing_branches(self):
git(self.parent, 'clone', str(self.engine), 'horizon')
git(tool.FORK, 'checkout', '-qb', 'my-work', self.old)
self.pin(self.new)
tool.fork()
self.assertTrue((tool.FORK / '.git').is_file())
self.assertEqual(git(tool.FORK, 'rev-parse', 'HEAD'), self.new)
self.assertEqual(git(tool.FORK, 'rev-parse', 'my-work'), self.old)
if __name__ == '__main__':
unittest.main()