Compare commits

...

2 Commits

Author SHA1 Message Date
chexit ab4a7899af TAA and cheaper shadows in HD (patch 16), model handles in the mod API so the mod sends no model names each tick (patch 17, #30); doc updated
build / mod (push) Successful in 3m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:02:30 +03:00
chexit 65dd84646a docs: HD renderer architecture, status and plan, kept up to date for handover
build / mod (push) Successful in 3m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:55:15 +03:00
21 changed files with 1610 additions and 35 deletions
+1
View File
@@ -11,6 +11,7 @@
## Запуск
Пошаговый гайд для Windows и macOS (готовая сборка из релиза или сборка из исходников): [docs/INSTALL.md](docs/INSTALL.md).
Устройство HD-рендера, его статус и план доработки: [docs/HD-RENDERER.md](docs/HD-RENDERER.md).
Коротко, из исходников:
+59
View File
@@ -0,0 +1,59 @@
//! Rough cost of handing 2000 model instances to the game each tick: allocations and bytes the mod
//! builds (counting allocator) and the bytes the canonical ABI then copies across (wasm32 layout:
//! `model-instance` 64 bytes + its name, `model-instance-id` 56 bytes).
//! Run: `cargo run --release --example emit_cost`.
use horizon_mod_sdk::render::{ModelInstance, ModelInstanceId};
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
static COUNT: AtomicUsize = AtomicUsize::new(0);
static BYTES: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
COUNT.fetch_add(1, Relaxed);
BYTES.fetch_add(l.size(), Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static A: Counting = Counting;
const NAMES: [&str; 4] = ["airdef:shahed", "airdef:geran_2", "airdef:iris_t", "airdef:explosion_core"];
fn measure(f: impl FnOnce()) -> (usize, usize) {
let (c, b) = (COUNT.load(Relaxed), BYTES.load(Relaxed));
f();
(COUNT.load(Relaxed) - c, BYTES.load(Relaxed) - b)
}
fn main() {
const N: usize = 2000;
let (mut old, mut by_id): (Vec<ModelInstance>, Vec<ModelInstanceId>) = (Vec::new(), Vec::new());
by_id.reserve(N);
let inst = |i: usize| (i as u32, NAMES[i % 4], i as f64);
let wire_old: usize = (0..N).map(|i| 64 + NAMES[i % 4].len()).sum();
let (c, b) = measure(|| {
old = (0..N)
.map(|i| {
let (id, n, x) = inst(i);
ModelInstance { id, model: n.to_string(), x, y: x, z: x, rotation: (0., 0., 0., 1.), scale: 1., glow: false }
})
.collect();
});
println!("old : {c} allocations, {b} bytes built per tick; {wire_old} bytes across, {N} strings lowered");
drop(old);
for tick in 0..2 {
let (c, b) = measure(|| {
by_id.clear();
for i in 0..N {
let (id, _, x) = inst(i);
by_id.push(ModelInstanceId { id, model: (i % 4) as u32, x, y: x, z: x, rotation: (0., 0., 0., 1.), scale: 1., glow: false });
}
});
println!("new : tick {tick}: {c} allocations, {b} bytes built; {} bytes across, no strings", N * 56);
}
}
+44 -20
View File
@@ -7,7 +7,7 @@ use horizon_mod_sdk::{
Mod, Registration, chat, command, game,
hud::{self, Corner},
input::{self, Event},
render::{self, Lighting, ModelInstance, ParticleSpawn, PointLight},
render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight},
storage,
ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget},
};
@@ -88,6 +88,12 @@ struct AirDef {
listener: DVec3,
/// Sounds on their way to the player at the speed of sound.
pending: Vec<PendingSound>,
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
/// a name the game doesn't know.
model_ids: std::collections::HashMap<&'static str, Option<u32>>,
model_generation: u32,
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
by_id: Vec<ModelInstanceId>,
}
/// A sound heard later: when, what, where, and how loud and high it was.
@@ -405,6 +411,38 @@ impl AirDef {
p
}
/// The models to the game by handle: each name crosses once, the buffer stays allocated.
fn send_models(&mut self, models: &[sim::Model]) {
let generation = render::model_generation();
if generation != self.model_generation {
// The game's registry changed (or this is the first tick): ask again.
self.model_generation = generation;
self.model_ids.clear();
}
self.by_id.clear();
for m in models {
let ids = &mut self.model_ids;
let handle = *ids.entry(m.name).or_insert_with(|| render::model_id(m.name));
// A name the game's registry doesn't have draws nothing, by name too.
let Some(model) = handle else { continue };
let r = m.rotation.as_quat();
self.by_id.push(ModelInstanceId {
id: m.id,
model,
x: m.at.x,
y: m.at.y,
z: m.at.z,
rotation: (r.x, r.y, r.z, r.w),
scale: m.scale,
glow: m.glow,
});
}
if !render::set_models_by_id(generation, &self.by_id) {
// The registry changed between the two calls: ask again next tick.
self.model_generation = u32::MAX;
}
}
fn emit(&mut self, out: Out) {
if self.sim.shader {
render::set_params(&self.shader_params(&out));
@@ -412,25 +450,8 @@ impl AirDef {
if self.hd {
render::set_lights(&self.lights(&out));
}
let models: Vec<ModelInstance> = out
.models
.into_iter()
.map(|m| {
let r = m.rotation.as_quat();
ModelInstance {
id: m.id,
model: m.name.to_string(),
x: m.at.x,
y: m.at.y,
z: m.at.z,
rotation: (r.x, r.y, r.z, r.w),
scale: m.scale,
glow: m.glow,
}
})
.collect();
if !models.is_empty() || self.sim.tick % 20 == 0 {
render::set_models(&models);
if !out.models.is_empty() || self.sim.tick % 20 == 0 {
self.send_models(&out.models);
}
if !out.particles.is_empty() {
let spawns: Vec<ParticleSpawn> = out
@@ -963,6 +984,9 @@ impl Mod for AirDef {
menu: None,
listener: DVec3::ZERO,
pending: Vec::new(),
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
by_id: Vec::new(),
};
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
+170
View File
@@ -0,0 +1,170 @@
# HD-рендер AirDef: устройство, статус, план
Документ для передачи работы (человеку или агенту). Ведётся по ходу разработки: каждый этап, попавший в
`main`, отмечается здесь вместе с коммитом. Если что-то в коде расходится с этим текстом — прав код, а текст
надо поправить.
## Что это
HD — третий пресет графики мода (`/airdef shader off|on|hd`): отложенное освещение в стиле шейдерпаков
(BSL/Complementary-класс), но написанное с нуля внутри нашего форка Horizon (Minecraft 26.3 на Rust/wgpu).
Код шейдерпаков (BSL, Complementary, Photon, Iris/OptiFine) **не копируем**: их лицензии запрещают перенос, а в
Horizon действует правило clean-room. Пишем по знанию техник (статьи, общеизвестные алгоритмы).
Пресеты должны оставаться: `off` (ваниль), `on` (пост-эффекты мода), `hd` (этот рендер), позже `rt` (аппаратная
трассировка, отдельно).
## Где код
| Что | Где |
|---|---|
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
| G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` |
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
Форк ведётся в `horizon/` (ветка `airdef`), в репозиторий мода попадает **патчами**:
```bash
cd horizon && git add -A crates && git commit -m "..." # коммит в форке
cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat HORIZON_COMMIT)..airdef -o ../horizon-patches
```
`tools/airdef.py fork` сверяет отпечаток `HORIZON_COMMIT` + патчей (`horizon/.git/airdef-stamp`) и пересобирает
ветку, если они изменились. После ручной перегенерации патчей у себя обнови отпечаток (см. `fork_stamp` в
`tools/airdef.py`), иначе следующий `airdef.py fork` пересоберёт ветку (безопасно: прежняя остаётся в `airdef-prev`).
## Конвейер кадра (HD включён)
Все цели в «масштабированном» разрешении мира (`world_post`), HDR — `Rgba16Float`. Глубина камеры — reversed-Z
(`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64`
на CPU.
1. **Каскады теней** (2 шт., `CASCADES = [40, 176]` блоков полуразмера, `SHADOW_SIZE = 2048`, массив глубины
из 2 слоёв). Источник — солнце днём, луна ночью (`light_of`). Матрицы `cascades()` привязаны к текселям
(тени не «ползут»). Во время прохода буфер глобалов мира подменяется на глобалы каскада (copy-трюк) и потом
возвращается. Рисуются секции террейна в коробке дальнего каскада (`hd_shadow_visible`) и сущности-тенеотбрасыватели
(`shadow_casters`: без оверлеев 4/9/10/40 — 40 это светящиеся модели мода).
2. **G-буфер** (свой depth): albedo `Rgba8Unorm` (a = эмиссия), нормаль `Rgba16Float` (мировая, из `dpdx/dpdy`),
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240). Террейн + дальние LOD-тайлы +
сущности. Вода/полупрозрачное в G-буфер **не** попадает (идёт ванильным путём) — см. план.
3. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
4. **Освещение** `fs_light` → `lit` (HDR): `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
ореолы вокруг источников. Точечные источники — по тайлам 16×16 (`tile_lists`, буфер `tiles`: на тайл 4 слова —
начало/длина списка для поверхностей и для ореолов, потом индексы; переполнение → «все источники»).
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
«подсветка огнём» выключена — свет даёт HD).
6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории —
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к
`view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат.
7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид),
выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая).
Bind groups кэшируются и пересоздаются только при resize/смене источника (`hd_bind_groups_last_frame()` → 0 в
установившемся режиме).
### Привязки `fs_light` (group 0)
| # | Что |
|---|---|
| 0 | `HdFrame` (uniform) |
| 1, 2 | цвет и глубина мира (ванильный проход) |
| 3, 4, 5 | albedo, нормаль, глубина G-буфера |
| 6 | источники света (storage, `HdLight`: pos+radius, color+intensity) |
| 7 | уровни света (небо, блоки) |
| 8, 9 | массив карт теней, comparison-сэмплер |
| 10, 11 | лайтмап, линейный сэмплер |
| 12 | тайлы источников (storage) |
| 13 | AO |
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке).
## Как проверять
```bash
cd horizon && cargo build --profile fast -p horizon-client --features mods # без предупреждений
cargo fmt -p horizon-client
cargo test -p horizon-client --lib hd # naga-разбор всех HD-шейдеров + тест тайлов
cd ../airdef-mod && cargo horizon-mod build && cargo test # мод и его шейдер
```
WGSL-ошибки в `hd.rs` всплывают только в рантайме — поэтому каждый новый шейдер добавляй в naga-тест
`lighting_shader_parses`.
Игра офскрин (только одна копия за раз; данные в `run-home/`, мир `Polygon`):
```bash
cd run-home && HORIZON_HOME=$PWD HORIZON_NO_AUDIO=1 ../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock \
launch --world Polygon --offscreen --wait --game ../horizon/target/fast/horizon-client \
--resources "$(ls -d "$HOME/Library/Application Support/horizon-launcher/versions"/*/.local/horizon-client/26.3 | sort -V | tail -1)"
H="../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock"
$H command "time set 4500"; $H command "tp @s 40 76 40 135 25" # полигон с техникой; z ≥ 60 — море
# команды мода — через чат: $H key t tap; $H type "/airdef shader hd"; $H key return tap
$H key f1 tap; $H screenshot out.png; $H bench 4; $H quit
```
Тайминги проходов на GPU: `HORIZON_SHADER_PACK_TIMING=1` (лог в `run-home/ctl.log`) или список F3.
Ориентиры (M5, 2560×1440): тени 0.6 + 2.85 мс (дальний каскад через кадр, ≈1.4 в среднем), G-буфер 0.8,
AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈ 15 мс GPU (замеры шумные).
Следующие цели по скорости: освещение (лучи 14 шагов на полном разрешении → полразрешения + апскейл) и AO.
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
плоскости YZ.
## Статус
| Этап | Состояние | Коммит |
|---|---|---|
| 1. G-буфер, точечный свет мода в HDR, bloom, тон-кривая; API `set-lights`/`set-lighting` | готово | 0a56f56 (форк) |
| 2. Тени солнца/луны, переосвещение мира, лучи, ореолы, туман, грейд | готово | патч 12 |
| Кэш bind groups, тени только от тенеотбрасывателей (#12, #13) | готово | патч 13 |
| Тайловые списки источников (#10) | готово | патч 14 |
| Тайминги HD-проходов (#9), GTAO | готово | патч 15, ed2db85 |
| Отсечение взрывов в `effects.wgsl` (#11), порядок дыма (#16), метрика перекрытия свечения (#14) | готово | 67a2a52, ed2db85 |
| Тени: только грани к солнцу, без «закопанных» секций, дальний каскад через кадр (`HORIZON_HD_SHADOW_FAST=0` — по-старому) | готово | патч 16 |
| TAA: джиттер Halton(2,3) ×16, перепроекция по глубине и сдвигу камеры, variance clip в YCoCg, история Catmull-Rom 0.9, лёгкий шарп в tonemap (`HORIZON_HD_TAA=0` — выкл) | готово | патч 16 |
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
## План (по порядку) и ТЗ
Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком.
1. **TAAU — временной апскейлер** (поверх TAA). Мир и G-буфер рендерятся в `render_scale` (например 0.67),
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
2. **Вода**: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
3. **Небо и атмосфера**: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
(`weather`), закат, Незер/Энд (там — ванильное небо).
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
мода), мокрые поверхности и лужи в дождь.
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
7. **Ветер** листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
9. Опционально: motion blur, DoF (для кинокамеры повторов).
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
отражения, затем GI. Отдельный пресет, HD остаётся.
## Правила
- Ничего не ломать в `off`/`on`; HD выключен — код HD не работает вовсе (`hd = None`).
- Без потери качества ради скорости без договорённости (шаги, разрешение, число эффектов).
- Каждый новый WGSL — в naga-тест. Каждый новый проход — в тайминги (`PassTiming`).
- Комментарии — короткие предложения о том «зачем»; в `gfx.rs` префикс `AirDef fork:`.
- Коммиты: в форке, затем патчи; подпись `Co-Authored-By: Claude <модель> <noreply@anthropic.com>`.
- Пуш в Gitea с машины без DNS: `ssh://git@100.92.6.40:2222/chexit/airdef.git` (ключ `~/Documents/claude-keys/deploy`).
- Обнови этот документ (статус + что изменилось в конвейере) в том же коммите, что и код.
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/15] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/17] Mod API (AirDef fork): world models, client particles,
sounds, ground heights
render.set-models draws block models with an interpolated pose per id;
@@ -1,7 +1,7 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:39:45 +0300
Subject: [PATCH 02/15] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/17] cargo-horizon-mod: report particles and sounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 13:33:10 +0300
Subject: [PATCH 03/15] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/17] Mod API (AirDef fork): full-screen post pass for mods
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
time, 256 vec4 of params, the world's colour and depth) and runs it after
@@ -1,7 +1,7 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:09:30 +0300
Subject: [PATCH 04/15] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/17] Mod models: draw far models as scaled impostors past
the far plane, cull at 4096
---
@@ -1,7 +1,7 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:31:00 +0300
Subject: [PATCH 05/15] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/17] Mod cameras: render.set-views draws the world from
other points into pictures that HUD view shapes show (API level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:08:21 +0300
Subject: [PATCH 06/15] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/17] Mods: more fuel per tick and per event (AirDef plans
raids)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:25:29 +0300
Subject: [PATCH 07/15] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/17] Mod cameras up to 1920x1080 (full-screen replays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:15:11 +0300
Subject: [PATCH 08/15] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/17] Mod cameras: distant terrain tiles, fog measured from
the camera, no impostors while one is shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:25:45 +0300
Subject: [PATCH 09/15] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/17] Mods: game.world-id, so mods keep state per world (API
level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 12:01:08 +0300
Subject: [PATCH 10/15] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/17] 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>
@@ -1,7 +1,7 @@
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:18:43 +0300
Subject: [PATCH 11/15] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/17] HD lighting: G-buffer pass, per-pixel mod point lights
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
level 10)
@@ -1,7 +1,7 @@
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:59:43 +0300
Subject: [PATCH 12/15] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/17] HD lighting stage 2: sun and moon shadow cascades, the
world relit (sun by face angle and shadow, sky and block light through the
lightmap), light shafts and halos, fog over it again, a light colour grade
@@ -1,7 +1,7 @@
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/15] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/17] 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>
@@ -1,7 +1,7 @@
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/15] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/17] 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>
@@ -1,7 +1,7 @@
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/15] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/17] 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>
@@ -0,0 +1,678 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 16/17] HD: shadows draw only sun-facing quads and skip buried
sections, the far cascade every second frame; TAA (Halton jitter,
reprojection by depth, YCoCg variance clipping) before bloom
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 126 +++++++++-
crates/horizon-client/src/gfx/hd.rs | 347 +++++++++++++++++++++++++++-
2 files changed, 457 insertions(+), 16 deletions(-)
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index e14429b03..eb152667c 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -569,6 +569,11 @@ pub struct Renderer {
shadow_terrain: wgpu::RenderPipeline,
shadow_entity: wgpu::RenderPipeline,
hd_shadow_visible: Vec<resident::Visible>,
+ /// The far cascade as last drawn (matrix, camera, light) and the frames since.
+ hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
+ /// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
+ taa_frame: u32,
+ taa_prev: Option<(Mat4, glam::DVec3)>,
hd: Option<hd::Hd>,
mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
@@ -2815,6 +2820,9 @@ impl Renderer {
shadow_terrain,
shadow_entity,
hd_shadow_visible: Vec::new(),
+ hd_far: None,
+ taa_frame: 0,
+ taa_prev: None,
hd: None,
mod_params: (Vec::new(), 0.0),
pass_timing: pack_timing::PassTiming::logging(),
@@ -3954,7 +3962,29 @@ impl Renderer {
frame.block_flicker,
frame.boss_darken,
);
- let view_proj = self.view_projection(frame);
+ let hd_wanted = !frame.title
+ && frame.hd.is_some()
+ && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
+ && !self.native.as_ref().is_some_and(|n| n.post.is_some());
+ // AirDef fork: HD's temporal anti-aliasing shifts the whole projection by a sub-pixel
+ // offset each frame; `unjittered` is what the next frame reprojects with.
+ let unjittered = self.view_projection(frame);
+ let taa = hd_wanted && hd::taa_enabled();
+ let view_proj = if taa {
+ let size = self
+ .world_post
+ .size()
+ .unwrap_or([self.config.width, self.config.height]);
+ let j = hd::jitter(self.taa_frame, size);
+ self.taa_frame = self.taa_frame.wrapping_add(1);
+ Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
+ } else {
+ unjittered
+ };
+ let taa_prev = std::mem::replace(
+ &mut self.taa_prev,
+ taa.then_some((unjittered, frame.camera)),
+ );
if frame.title {
self.panorama.prepare(
&self.queue,
@@ -4158,13 +4188,12 @@ impl Renderer {
}
self.drawn_sections = visible.len() + visible_lods.len();
// AirDef fork: the HD sun shadows draw every section in the farther cascade's box.
- let hd_wanted = !frame.title
- && frame.hd.is_some()
- && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some())
- && !self.native.as_ref().is_some_and(|n| n.post.is_some());
- let hd_scene = hd::HdScene {
+ let mut hd_scene = hd::HdScene {
+ cascades: [Mat4::IDENTITY; 2],
view_proj,
- camera,
+ taa: taa && taa_prev.is_some(),
+ prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
+ camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
dark: 1.0 - frame.sky.sky_light_factor,
@@ -4174,10 +4203,34 @@ impl Renderer {
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
};
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
- let hd_cascades = hd::cascades(camera, shadow_dir);
+ let mut hd_cascades = hd::cascades(camera, shadow_dir);
+ // The far cascade is redrawn every second frame (it is snapped to texels and the sun is
+ // slow); between, the lighting pass gets the matrix its map was drawn with.
+ let hd_far_fresh = if hd_wanted && shadow_strength > 0.0 {
+ let fresh = match self.hd_far {
+ Some((_, at, dir, age)) if hd_shadow_fast() => {
+ age >= 1
+ || (camera - at).length_squared() > 48.0 * 48.0
+ || dir.dot(shadow_dir) < 0.99995
+ || self.hd.is_none()
+ }
+ _ => true,
+ };
+ if fresh {
+ self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
+ } else if let Some((held, _, _, age)) = &mut self.hd_far {
+ hd_cascades[1] = *held;
+ *age += 1;
+ }
+ fresh
+ } else {
+ self.hd_far = None;
+ true
+ };
+ hd_scene.cascades = hd_cascades;
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
hd_shadow_visible.clear();
- if hd_wanted && shadow_strength > 0.0 {
+ if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
let radius =
((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
let light = Frustum {
@@ -4191,6 +4244,11 @@ impl Renderer {
|_, _| false,
&mut hd_shadow_visible,
);
+ if hd_shadow_fast() {
+ drop_buried(&mut hd_shadow_visible, |slot| {
+ self.sections.get(slot).solid > 0
+ });
+ }
}
// Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's.
let globals_size = std::mem::size_of::<Globals>() as u64;
@@ -5022,7 +5080,12 @@ impl Renderer {
}
self.queue
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
+ // Facing the light only: the nearest surface to it is always one that does.
+ let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
for (c, cascade) in hd_cascades.iter().enumerate() {
+ if c == 1 && !hd_far_fresh {
+ continue;
+ }
let light = Globals {
view_proj: cascade.to_cols_array_2d(),
..globals
@@ -5059,7 +5122,13 @@ impl Renderer {
page = Some(s.page);
}
let instance = hd_shadow_first + k as u32;
- pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
+ facing_runs(&s.sides, toward, v.origin, |first, quads| {
+ pass.draw_indexed(
+ 0..quads * 6,
+ s.start as i32 + first as i32 * 4,
+ instance..instance + 1,
+ );
+ });
}
if !self.shadow_casters.is_empty() {
pass.set_pipeline(&self.shadow_entity);
@@ -5652,9 +5721,13 @@ impl Renderer {
self.mod_params.1,
);
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
- hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
+ hd.finish(&self.device, &mut encoder, source, &view_target);
+ }
+ _ => {
+ let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Lit, depth);
+ hd.finish(&self.device, &mut encoder, source, &view_target);
}
- _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
}
} else if let Some((color, depth)) = &scaled
&& mod_post
@@ -6055,6 +6128,35 @@ fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: imp
None => {}
}
}
+/// `HORIZON_HD_SHADOW_FAST=0` draws the HD shadow cascades the slow way (every section, both
+/// faces, the far one every frame), for comparison.
+fn hd_shadow_fast() -> bool {
+ static FAST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *FAST.get_or_init(|| !std::env::var("HORIZON_HD_SHADOW_FAST").is_ok_and(|v| v == "0"))
+}
+/// Drops the sections that sit well under the ground of their own and all eight neighbouring
+/// columns: shut in by rock, what they hold neither lights nor shades anything the sun reaches.
+fn drop_buried(list: &mut Vec<resident::Visible>, has_solid: impl Fn(u32) -> bool) {
+ let mut top: std::collections::HashMap<(i32, i32), i32> = std::collections::HashMap::new();
+ for v in list.iter() {
+ if has_solid(v.slot) {
+ let e = top.entry((v.key.0, v.key.2)).or_insert(i32::MIN);
+ *e = (*e).max(v.key.1);
+ }
+ }
+ list.retain(|v| {
+ let mut lowest = i32::MAX;
+ for dx in -1..=1 {
+ for dz in -1..=1 {
+ match top.get(&(v.key.0 + dx, v.key.2 + dz)) {
+ Some(&t) => lowest = lowest.min(t),
+ None => return true,
+ }
+ }
+ }
+ v.key.1 > lowest - 3
+ });
+}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index d3944e97b..16610cb02 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -85,14 +85,26 @@ struct HdFrame {
ambient: [f32; 4],
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
shadow: [f32; 4],
+ /// The previous frame's camera-relative view-projection without jitter (for TAA).
+ prev_view_proj: [[f32; 4]; 4],
+ /// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
+ taa: [f32; 4],
+ /// x: the history's weight; y: the sharpening (0 without TAA).
+ taa_params: [f32; 4],
}
/// What the world around looks like this frame (from the renderer's globals).
#[derive(Clone, Copy, Debug)]
pub struct HdScene {
+ /// The shadow cascades' matrices (`cascades`; the far one may be a frame or two old).
+ pub cascades: [Mat4; 2],
+ /// The (jittered) projection the world was drawn with.
pub view_proj: Mat4,
- /// The camera in the world, for steady shadows.
- pub camera: glam::DVec3,
+ /// Last frame's view-projection without jitter, and the camera's move since (TAA).
+ pub prev_view_proj: Mat4,
+ pub camera_delta: glam::Vec3,
+ /// This frame is anti-aliased over time (the projection is jittered).
+ pub taa: bool,
/// Towards the sun, and the sky's darkening (0 day, 1 night).
pub sun: glam::Vec3,
pub dark: f32,
@@ -119,6 +131,9 @@ struct HdFrame {
fog: vec4<f32>,
ambient: vec4<f32>,
shadow: vec4<f32>,
+ prev_view_proj: mat4x4<f32>,
+ taa: vec4<f32>,
+ taa_params: vec4<f32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
@@ -507,7 +522,21 @@ const TONEMAP: &str = r#"
@fragment
fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
- var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb * frame.params.y;
+ var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb;
+ // A slight sharpen after TAA (its history resampling softens): the centre against its four
+ // neighbours, kept within their range so it can't ring.
+ let k = frame.taa_params.y;
+ if k > 0.0 {
+ let t = 1.0 / vec2<f32>(textureDimensions(hdr));
+ let n0 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(t.x, 0.0), 0.0).rgb;
+ let n1 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(t.x, 0.0), 0.0).rgb;
+ let n2 = textureSampleLevel(hdr, linear, in.uv + vec2<f32>(0.0, t.y), 0.0).rgb;
+ let n3 = textureSampleLevel(hdr, linear, in.uv - vec2<f32>(0.0, t.y), 0.0).rgb;
+ let lo = min(min(n0, n1), min(min(n2, n3), c));
+ let hi = max(max(n0, n1), max(max(n2, n3), c));
+ c = clamp(c * (1.0 + 4.0 * k) - (n0 + n1 + n2 + n3) * k, lo, hi);
+ }
+ c = c * frame.params.y;
c += textureSampleLevel(bloom, linear, in.uv, 0.0).rgb * frame.params.z;
// Up to 0.8 as it is (vanilla's look); above it, rolled off towards white by the
// brightest channel, so hues hold instead of clipping.
@@ -528,6 +557,130 @@ fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
}
"#;
+const TAA: &str = r#"
+struct TaaOut {
+ @location(0) color: vec4<f32>,
+ @location(1) history: vec4<f32>,
+}
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var source: texture_2d<f32>;
+@group(0) @binding(2) var depth_tex: texture_depth_2d;
+@group(0) @binding(3) var history: texture_2d<f32>;
+@group(0) @binding(4) var linear: sampler;
+
+fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
+ return vec3<f32>(c.r * 0.25 + c.g * 0.5 + c.b * 0.25, c.r * 0.5 - c.b * 0.5, c.g * 0.5 - c.r * 0.25 - c.b * 0.25);
+}
+fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
+ let t = c.x - c.z;
+ return vec3<f32>(t + c.y, c.x + c.z, t - c.y);
+}
+// Compress the range so a very bright pixel does not dominate the blend or the clamp.
+fn squash(c: vec3<f32>) -> vec3<f32> {
+ return c / (1.0 + max(c.r, max(c.g, c.b)));
+}
+fn unsquash(c: vec3<f32>) -> vec3<f32> {
+ return c / max(1.0 - max(c.r, max(c.g, c.b)), 0.001);
+}
+
+// The history at a uv, filtered with a Catmull-Rom spline (five bilinear taps) so that
+// resampling it every frame doesn't blur.
+fn history_color(uv: vec2<f32>) -> vec3<f32> {
+ let size = vec2<f32>(textureDimensions(history));
+ let pos = uv * size;
+ let c1 = floor(pos - 0.5) + 0.5;
+ let f = pos - c1;
+ let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
+ let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
+ let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
+ let w3 = f * f * (-0.5 + 0.5 * f);
+ let w12 = w1 + w2;
+ let t0 = (c1 - 1.0) / size;
+ let t3 = (c1 + 2.0) / size;
+ let t12 = (c1 + w2 / w12) / size;
+ var sum = textureSampleLevel(history, linear, vec2<f32>(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y);
+ var total = w12.x * w0.y;
+ sum += textureSampleLevel(history, linear, vec2<f32>(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y);
+ total += w0.x * w12.y;
+ sum += textureSampleLevel(history, linear, t12, 0.0).rgb * (w12.x * w12.y);
+ total += w12.x * w12.y;
+ sum += textureSampleLevel(history, linear, vec2<f32>(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y);
+ total += w3.x * w12.y;
+ sum += textureSampleLevel(history, linear, vec2<f32>(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
+ total += w12.x * w3.y;
+ return max(sum / total, vec3<f32>(0.0));
+}
+
+// Pulls `p` towards the box's centre until it is inside.
+fn clip_to_box(lo: vec3<f32>, hi: vec3<f32>, p: vec3<f32>) -> vec3<f32> {
+ let centre = (lo + hi) * 0.5;
+ let ext = max((hi - lo) * 0.5, vec3<f32>(0.00001));
+ let off = (p - centre) / ext;
+ let m = max(abs(off.x), max(abs(off.y), abs(off.z)));
+ return select(p, centre + (p - centre) / m, m > 1.0);
+}
+
+// Temporal anti-aliasing: the history, reprojected by depth and the camera's move, clamped to
+// the colours round this pixel (so what moved or was uncovered can't ghost), blended in.
+@fragment
+fn fs_taa(in: FullOut) -> TaaOut {
+ let size = vec2<i32>(textureDimensions(source));
+ let px = vec2<i32>(in.position.xy);
+ let d = textureLoad(depth_tex, px, 0);
+ var sum = vec3<f32>(0.0);
+ var sum2 = vec3<f32>(0.0);
+ var lo = vec3<f32>(1.0e9);
+ var hi = vec3<f32>(-1.0e9);
+ var centre = vec3<f32>(0.0);
+ for (var y = -1; y <= 1; y++) {
+ for (var x = -1; x <= 1; x++) {
+ let q = clamp(px + vec2<i32>(x, y), vec2<i32>(0), size - 1);
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
+ sum += s;
+ sum2 += s * s;
+ lo = min(lo, s);
+ hi = max(hi, s);
+ if x == 0 && y == 0 { centre = s; }
+ }
+ }
+ let sky = d <= 0.0;
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
+ let h = frame.inverse_view_proj * ndc;
+ let p = h.xyz / h.w;
+ let dist = select(length(p), 0.0, sky);
+ var resolved = centre;
+ if frame.taa.w > 0.5 {
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
+ if prev.w > 0.0 {
+ let pn = prev.xy / prev.w;
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
+ let hp = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - 1);
+ let seen = textureLoad(history, hp, 0).a;
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
+ // The same surface as last frame (not uncovered), by its distance.
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
+ let mean = sum / 9.0;
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
+ let box_lo = min(max(mean - sigma * 1.25, lo), centre);
+ let box_hi = max(min(mean + sigma * 1.25, hi), centre);
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
+ // Fast motion trusts the history less.
+ let moved = length((uv - in.uv) * vec2<f32>(size));
+ let keep = mix(frame.taa_params.x, frame.taa_params.x * 0.75, clamp(moved / 4.0, 0.0, 1.0));
+ resolved = mix(centre, past, keep);
+ }
+ }
+ }
+ }
+ let color = unsquash(from_ycocg(resolved));
+ var out: TaaOut;
+ out.color = vec4<f32>(color, 1.0);
+ out.history = vec4<f32>(color, dist);
+ return out;
+}
+"#;
+
struct Target {
view: wgpu::TextureView,
}
@@ -570,6 +723,35 @@ type LightKey = (
pub enum HdSource {
Lit,
Fx,
+ /// The anti-aliased result of `taa`.
+ Taa,
+}
+
+/// TAA is on unless `HORIZON_HD_TAA=0`.
+pub fn taa_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
+}
+
+/// The Halton sequence's `index`-th number in `base`, in (0, 1).
+fn halton(mut index: u32, base: u32) -> f32 {
+ let (mut f, mut r) = (1.0, 0.0);
+ while index > 0 {
+ f /= base as f32;
+ r += f * (index % base) as f32;
+ index /= base;
+ }
+ r
+}
+
+/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
+/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
+pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
+ let i = frame % 16 + 1;
+ [
+ (halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
+ (halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
+ ]
}
pub struct Hd {
@@ -589,6 +771,15 @@ pub struct Hd {
/// The lit world, and the post pass's output.
pub lit: wgpu::TextureView,
pub fx: wgpu::TextureView,
+ /// TAA: the resolved frame, and the two histories (one read, one written, alternating).
+ taa: wgpu::TextureView,
+ history: [wgpu::TextureView; 2],
+ history_ready: bool,
+ history_flip: usize,
+ taa_active: bool,
+ taa_layout: wgpu::BindGroupLayout,
+ taa_pipeline: wgpu::RenderPipeline,
+ taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
bloom: Vec<Target>,
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
/// lighting and the source-reading ones are keyed by the views they read.
@@ -902,6 +1093,44 @@ impl Hd {
UNFILTERED,
]),
});
+ let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD TAA"),
+ entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
+ });
+ let taa_module = module(device, "HD TAA", TAA);
+ let taa_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD TAA"),
+ bind_group_layouts: &[Some(&taa_layout)],
+ ..Default::default()
+ });
+ let target = Some(wgpu::ColorTargetState {
+ format: HDR,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD TAA"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &taa_module,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &taa_module,
+ entry_point: Some("fs_taa"),
+ compilation_options: Default::default(),
+ targets: &[target.clone(), target],
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("HD ambient occlusion"),
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
@@ -1080,6 +1309,17 @@ impl Hd {
.view,
lit: target(device, "HD lit", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
+ taa: target(device, "HD TAA", [1, 1], HDR).view,
+ history: [
+ target(device, "HD TAA history", [1, 1], HDR).view,
+ target(device, "HD TAA history", [1, 1], HDR).view,
+ ],
+ history_ready: false,
+ history_flip: 0,
+ taa_active: false,
+ taa_layout,
+ taa_pipeline,
+ taa_groups: None,
bloom: Vec::new(),
light_group: None,
prefilter_group: None,
@@ -1131,6 +1371,13 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ self.taa = target(device, "HD TAA", size, HDR).view;
+ self.history = [
+ target(device, "HD TAA history", size, HDR).view,
+ target(device, "HD TAA history", size, HDR).view,
+ ];
+ self.history_ready = false;
+ self.taa_groups = None;
let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
@@ -1206,9 +1453,22 @@ impl Hd {
}
// The sun by day, the moon (opposite) by night, faded out round the horizon.
let (dir, strength, color) = light_of(scene);
- let cascades = cascades(scene.camera, dir);
+ let cascades = scene.cascades;
let night = scene.dark.clamp(0.0, 1.0);
+ self.taa_active = scene.taa && taa_enabled();
+ // A long jump of the camera (a teleport) starts the history over.
+ if !self.taa_active || scene.camera_delta.length() > 64.0 {
+ self.history_ready = false;
+ }
let frame = HdFrame {
+ prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
+ taa: [
+ scene.camera_delta.x,
+ scene.camera_delta.y,
+ scene.camera_delta.z,
+ if self.history_ready { 1.0 } else { 0.0 },
+ ],
+ taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
resolution: [
self.size[0] as f32,
@@ -1489,6 +1749,7 @@ impl Hd {
let source = match source {
HdSource::Lit => self.lit.clone(),
HdSource::Fx => self.fx.clone(),
+ HdSource::Taa => self.taa.clone(),
};
if self
.prefilter_group
@@ -1572,6 +1833,70 @@ impl Hd {
);
}
+ /// Temporal anti-aliasing of `source` (with the world's `depth`): the source it left, to hand
+ /// to `finish` — the same one when TAA is off this frame.
+ pub fn taa(
+ &mut self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ source: HdSource,
+ depth: &wgpu::TextureView,
+ ) -> HdSource {
+ if !self.taa_active {
+ return source;
+ }
+ let input = match source {
+ HdSource::Lit => self.lit.clone(),
+ HdSource::Fx => self.fx.clone(),
+ HdSource::Taa => return source,
+ };
+ let key = (input, depth.clone());
+ if self.taa_groups.as_ref().is_none_or(|(k, _)| *k != key) {
+ let groups = [0, 1].map(|read| {
+ self.make_group(
+ device,
+ "HD TAA",
+ &self.taa_layout,
+ &[
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
+ wgpu::BindingResource::TextureView(&key.0),
+ wgpu::BindingResource::TextureView(depth),
+ wgpu::BindingResource::TextureView(&self.history[read]),
+ wgpu::BindingResource::Sampler(&self.sampler),
+ ],
+ )
+ });
+ self.taa_groups = Some((key, groups));
+ }
+ let Some((_, groups)) = &self.taa_groups else {
+ return source;
+ };
+ let (read, write) = (self.history_flip, 1 - self.history_flip);
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("HD TAA"),
+ timestamp_writes: stamp(&mut self.timing, "hd taa"),
+ color_attachments: &[&self.taa, &self.history[write]].map(|view| {
+ Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })
+ }),
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.taa_pipeline);
+ pass.set_bind_group(0, &groups[read], &[]);
+ pass.draw(0..3, 0..1);
+ drop(pass);
+ self.history_flip = write;
+ self.history_ready = true;
+ HdSource::Taa
+ }
+
/// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
/// its blur. Run after the G-buffer pass; nothing when the strength is 0.
pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
@@ -1698,6 +2023,7 @@ mod tests {
("light", LIGHT),
("bloom", BLOOM),
("tonemap", TONEMAP),
+ ("taa", TAA),
("ao", AO),
("ao blur", AO_BLUR),
] {
@@ -1714,6 +2040,19 @@ mod tests {
}
}
+ #[test]
+ fn jitter_stays_within_a_pixel() {
+ let size = [1920u32, 1080];
+ let mut seen = std::collections::HashSet::new();
+ for f in 0..32 {
+ let j = jitter(f, size);
+ assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
+ assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
+ seen.insert((j[0].to_bits(), j[1].to_bits()));
+ }
+ assert_eq!(seen.len(), 16);
+ }
+
#[test]
fn tile_lists_keep_lights_that_reach_a_tile() {
let size = [320, 160];
--
2.54.0 (Apple Git-157)
@@ -0,0 +1,643 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 17/17] 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)