Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e33daac636 | |||
| 661c852bbf | |||
| 428713d4bc | |||
| 5839da129a | |||
| ab4a7899af | |||
| 65dd84646a |
@@ -11,6 +11,7 @@
|
||||
## Запуск
|
||||
|
||||
Пошаговый гайд для Windows и macOS (готовая сборка из релиза или сборка из исходников): [docs/INSTALL.md](docs/INSTALL.md).
|
||||
Устройство HD-рендера, его статус и план доработки: [docs/HD-RENDERER.md](docs/HD-RENDERER.md).
|
||||
|
||||
Коротко, из исходников:
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# HD-рендер AirDef: устройство, статус, план
|
||||
|
||||
Документ для передачи работы (человеку или агенту). Ведётся по ходу разработки: каждый этап, попавший в
|
||||
`main`, отмечается здесь вместе с коммитом. Если что-то в коде расходится с этим текстом — прав код, а текст
|
||||
надо поправить.
|
||||
|
||||
## Что это
|
||||
|
||||
HD — третий пресет графики мода (`/airdef shader off|on|hd`): отложенное освещение в стиле шейдерпаков
|
||||
(BSL/Complementary-класс), но написанное с нуля внутри нашего форка Horizon (Minecraft 26.3 на Rust/wgpu).
|
||||
Код шейдерпаков (BSL, Complementary, Iris/OptiFine и прочих) **не копируем**: их лицензии запрещают перенос, а в
|
||||
Horizon действует правило clean-room. Исключение — Photon (sixthsurge), коммит 26c2906ee02cc484359b835fba6993d443d45966: он под MIT, из него можно
|
||||
переносить идеи и код **с указанием авторства** (комментарий у кода + `gfx/THIRD_PARTY_PHOTON.md`). Остальное пишем по знанию техник (статьи, общеизвестные алгоритмы).
|
||||
|
||||
Пресеты должны оставаться: `off` (ваниль), `on` (пост-эффекты мода), `hd` (этот рендер), позже `rt` (аппаратная
|
||||
трассировка, отдельно).
|
||||
|
||||
## Где код
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
|
||||
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `horizon/crates/horizon-client/src/gfx/hd_sky.rs` (+ `THIRD_PARTY_PHOTON.md`) |
|
||||
| Встраивание в кадр: 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 включён)
|
||||
|
||||
Цели мира (G-буфер, AO, освещение, пост-проход мода) — в «масштабированном» разрешении мира (`world_post`; при `HORIZON_HD_SCALE` < 1 оно меньше окна), цели после TAA (история, bloom, tonemap) — в разрешении окна. HDR — `Rgba16Float`. Глубина камеры — reversed-Z
|
||||
(`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64`
|
||||
на CPU.
|
||||
|
||||
0. **Небо** (`hd.sky`, проход `hd sky`, перед освещением): текстура 128×72 (`Rgba16Float`): 128 азимутов (от солнца, 0..π) × 64 высот (v = √(h/(π/2))), плюс строка 64 с «окружающим светом» (текселы 0 — зенит, 1 — среднее у горизонта). Каждый тексел — марш по лучу (32 шага) однократного рассеяния Рэлея/Ми + озон, плоский член многократного рассеяния (`MULTI_SCATTER`), солнце и луна (луна — напротив) как источники; пропускание до источника — аналитическое (Chapman, как у Photon). Зависит только от высоты солнца → перерисовывается, лишь когда она сдвинулась больше чем на 0.001. Читают её `fs_light` и композит воды (привязки 15 и 10).
|
||||
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 и «Вода».
|
||||
3. **Вода** (`water_pass`, только если на экране есть полупрозрачные секции, нет `frame.underwater` и не `HORIZON_HD_WATER=0`):
|
||||
те же секции ещё раз с `fs_water` (terrain.wgsl), который отбрасывает всё, кроме слоёв спрайтов `water_still`/`water_flow`
|
||||
(номера слоёв передаются в `Renderer::new` из `sprite_layers` и ставятся override-константами `WATER_STILL/WATER_FLOW`; стекло,
|
||||
лёд и прочее остаются ванильными). Цели: цвет (спрайт × биомный тинт, a = свет блоков, `Rgba16Float`), нормаль грани (a = свет неба,
|
||||
`Rgba8Unorm`) и собственная глубина воды (`Depth32Float`, 0 = воды нет; тест глубины только среди воды, перекрытие
|
||||
землёй решают потребители). Пайплайны `gbuffer_water` (секции) и `flat.water` (flat-путь).
|
||||
3a. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
|
||||
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
|
||||
4. **Освещение** `fs_light` → `lit` (HDR): пиксель неба (глубина мира 0, Оверворлд, без env-тумана) — это небо атмосферы + то, что мир нарисовал поверх ванильного неба (`max(scene − vanilla_sky(view), 0)`: спрайты луны, звёзды; квадрат ванильного солнца вырезается вокруг направления солнца и заменяется круглым диском с гало); облака (глубина > 0, нет в G-буфере, y > 8, дальше 48 блоков) на закате/рассвете окрашиваются цветом солнца. Свет неба: ванильный `lightmap` × подкраска цветом неба (зенит сверху, горизонт сбоку; яркость 1) × форма по нормали (вверх 1.1, вниз 0.66), только там, где есть небесный свет. Тени: ближний каскад — PCSS-lite (поиск блокировщиков 8 выборок, ширина полутени от расстояния до блокировщика, 12 выборок спирали Фогеля, повёрнутых IGN; сглаживает TAA), дальний — 3×3. Туман: цвет — небо над горизонтом в направлении пикселя (`haze_colour`) + воздушная перспектива `1 − exp(−d/700)` × 0.5. Далее прежнее: пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет
|
||||
(`d - wd <= 0.2%`); тогда освещается **дно** по данным G-буфера (одна выборка тени, без AO), а в альфу `lit` пишется 0.5 (признак воды). `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
|
||||
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
|
||||
ореолы вокруг источников. Точечные источники — по тайлам 16×16 (`tile_lists`, буфер `tiles`: на тайл 4 слова —
|
||||
начало/длина списка для поверхностей и для ореолов, потом индексы; переполнение → «все источники»).
|
||||
4a. **Композит воды** `fs_water` (`hd.water_composite`, `lit` → `lit_water`, дальше везде `hd.lit_view()`; на пикселях без воды копия).
|
||||
Волны — сумма 10 синусоид в мировых координатах (векторы — целые кратные 2π/256, скорости — целые циклы за 600 с: период 256 блоков
|
||||
и 10 минут, не «плывут» с камерой; мелкие волны гасятся по размеру пикселя против мерцания); нормаль = нормаль грани + градиент
|
||||
(только на горизонтальных гранях). Преломление: смещение луча относительно плоской воды, выборка `lit` по uv со сдвигом (отбраковка
|
||||
по глубине: не берутся банк/сущности перед водой); толщина = расстояние от поверхности до дна; поглощение `exp(-(1-цвет)·0.8·(t+0.3))`
|
||||
+ рассеяние цветом воды при свете «объёма» (лайтмап + солнце с тенью). Отражение: SSR по глубине G-буфера (24 шага, растущие,
|
||||
джиттер IGN по кадру, 5 шагов бинарного уточнения, толщина `0.4+0.08·s`), при промахе — небо/туман (`sky_color`, `fog_color`, тусклее
|
||||
под навесом). Волны: к 10 крупным добавлены 16 мелких (длины 0.35–3 блока, уклон до 0.05, гасятся по размеру пикселя) — густая мелкая рябь для искорок блика; блик GGX с a=0.07. Цвет воды светлее: биом × 0.85, поглощение 0.8, рассеяние 0.85 (ночью до 0.4). Небо отражения и туман — из атмосферы. Френель Шлика F0=0.02 (SSR пропускается при F<0.04: взгляд вниз), блик солнца GGX (a=0.05) × тень, туман поверх.
|
||||
Подводный вид и `HORIZON_HD_WATER=0` — композит и проход воды не работают, `lit` как раньше.
|
||||
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
|
||||
«подсветка огнём» выключена — свет даёт HD).
|
||||
6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории —
|
||||
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к
|
||||
`view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат.
|
||||
**TAAU**: если мир меньше окна (`HORIZON_HD_SCALE=0.67`), тот же проход (`fs_taau`, в таймингах `hd taau`) восстанавливает
|
||||
полное разрешение. История, bloom и tonemap — в разрешении окна. Для каждого пикселя выхода берётся 3×3 выборки мира
|
||||
вокруг (с учётом джиттера в `taa_params.zw`), веса — полином, аппроксимирующий Lanczos-2, по расстоянию от центра
|
||||
пикселя выхода до выборки; variance clip в YCoCg по тем же 3×3; глубина — ближайшая из 2×2; история (Catmull-Rom,
|
||||
полное разрешение) вес 0.92, но ближе к ней там, где ближайшая выборка далеко от центра пикселя. Джиттер —
|
||||
Halton(2,3) на `max(16, ceil(8·ratio²))` кадров (0.67 → 18, 0.5 → 32). При масштабе 1 работает прежний `fs_taa`
|
||||
без изменений. С `HORIZON_HD_TAA=0` и масштабом < 1 остаётся простой билинейный апскейл в tonemap.
|
||||
7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид),
|
||||
выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая).
|
||||
|
||||
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 |
|
||||
| 14 | глубина воды (`water_depth`) |
|
||||
| 15 | текстура неба (`sky_lut`) |
|
||||
|
||||
`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, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира).
|
||||
|
||||
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
|
||||
|
||||
## Как проверять
|
||||
|
||||
```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 (замеры шумные).
|
||||
TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс; 0.67 — 13.4 мс; 0.5 — 10.4 мс (освещение 6.1 → 2.7 → 1.6, AO 3.5 → 1.4 → 0.8, `hd taau` ≈ 3.5 мс на любом масштабе, дальний каскад теней 3.4 мс не зависит от масштаба).
|
||||
Следующие цели по скорости: освещение (лучи 14 шагов на полном разрешении → полразрешения + апскейл) и AO.
|
||||
|
||||
Проверка мерцания: два скриншота с неподвижной камеры подряд и разница по пикселям (Pillow `ImageChops.difference`)
|
||||
— на мире `Hills` с травой и деревьями; после исправлений ≈2% пикселей с разницей > 20/255 (дрожание TAA на пиксельных
|
||||
текстурах и анимация воды), а при ошибке теней было 8.5%. Сравни с `HORIZON_HD_TAA=0` / `HORIZON_HD_AO=0` /
|
||||
`HORIZON_HD_SHADOW_FAST=0`, чтобы найти виновника.
|
||||
|
||||
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
|
||||
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
|
||||
плоскости YZ.
|
||||
|
||||
## Статус
|
||||
|
||||
| Этап | Состояние | Коммит |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| TAAU: мир в `HORIZON_HD_SCALE` (0.25–1, по умолчанию 1 = как раньше), восстановление в TAA-проходе `fs_taau`; без LOD-bias текстур (не доступен) | готово | патч 18 |
|
||||
| Исправлено мерцание теней у игрока: ближний каскад терял секции в кадры, когда дальний держится; держимая матрица дальнего каскада сдвигается на шаг камеры; у растений (наклонные квады) нормаль «вверх», а не к камере; TAA держит 94% истории в покое | готово | патч 18 |
|
||||
| Вода: проход `fs_water`, преломление, SSR, Френель, блик (`HORIZON_HD_WATER=0` — выкл) | готово | патч 19 (+20: без ванильной текстуры на поверхности, цвет темнее и глубже) |
|
||||
| Вода: светлее, густая мелкая рябь (26 волн), блик a=0.07 (мерцание на реке ≈1.4% пикселей >20/255) | готово | патч 21 |
|
||||
| Небо и атмосфера (Рэлей/Ми/озон, текстура неба, воздушная перспектива, цвет солнца из пропускания, голубоватый ambient по нормали, PCSS-lite ближнего каскада, диск солнца, цвет облаков на закате; порт констант и Chapman из Photon, MIT) | готово | патч 22 |
|
||||
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком.
|
||||
|
||||
1. **TAAU — временной апскейлер** (поверх TAA) — СДЕЛАНО (патч 18, см. статус; масштаб — `HORIZON_HD_SCALE`, переопределяет «масштаб разрешения» настроек Horizon только для HD; поля в `HdSettings` нет). Осталось: LOD-bias текстур террейна, MetalFX. Исходное ТЗ: Мир и G-буфер рендерятся в `render_scale` (например 0.67),
|
||||
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
|
||||
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
|
||||
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
|
||||
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
|
||||
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
|
||||
2. **Вода** — СДЕЛАНО (патч 19, см. «Вода» ниже). Исходное ТЗ: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
|
||||
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
|
||||
3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная 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 остаётся.
|
||||
|
||||
## Вода (патч 19): что знать преемнику
|
||||
|
||||
- Тайминги проходов: `hd water` (геометрия воды; на Metal его метка часто 0 — работа учитывается в соседних проходах) и `hd water composite`.
|
||||
Замеры 2560×1440, Hills, река на ~40% экрана, M5 (шумно: у меня GPU делят браузеры): композит 0.8–2 мс, `hd lighting` на 2–3 мс
|
||||
выше, чем без воды (теперь освещается дно под водой — раньше эти пиксели были ванильными); всего вода ≈ +3 мс при большой воде
|
||||
на экране, около 0.5–1 мс при малой. Цель «≤ 2 мс» при полной реке не достигнута. Идеи: отражения в полразрешения (с учётом маски воды
|
||||
на границе), SSR только при F>0.04 уже есть, ужать освещение дна (без точечных источников на глубине > N блоков).
|
||||
- Вода находится по слоям спрайтов. LOD-тайлы воды (`lod/mesh.rs`) идут тем же путём, если их слой — water_still; вода из resource pack с
|
||||
другим именем спрайта не распознается (останется ванильной). `water_overlay` (боковые грани у стекла) не трогаем.
|
||||
- Стекло/частицы/лёд перед водой: проверка `d - wd` возвращает ванильный вид пикселя (вода под стеклом без эффекта).
|
||||
- Известные ограничения: отражается только то, что есть на экране (и только непрозрачное: листва — да, вода/стекло — нет); дождь на воде,
|
||||
пена у берега, подводная каустика не сделаны; ночью вода слегка ярче неба (нужно привязать к модели неба — п. 4 плана «Небо»); отражение
|
||||
солнечного диска — только блик GGX (без диска в SSR). Проверка мерцания на реке: разница двух скриншотов ≈1.6% пикселей >20/255 (норма ~2%).
|
||||
|
||||
## Небо и свет (патч 22): что знать преемнику
|
||||
|
||||
- Тайминги (M5, 2560×1440, Hills, река в кадре): `hd sky` ≈ 2.4–4.7 мс по таймстемпам, но кадр не вырос (GPU avg 18.4 мс против 18.1) — на Metal метка крошечного прохода ловит ожидание соседних; с кэшем по высоте солнца в покое проход не выполняется. Освещение 4.0–5.1 мс (PCSS-lite и небо почти не добавили), композит воды 0.6–2.4, TAA 2.6–2.9.
|
||||
- Яркость: небо — физическая радиация → сжатие по максимальному каналу (`SKY_EXPOSURE` 0.85) → гамма 1/1.9 → насыщенность 1.2 (+0.45 у низкого солнца). Весь конвейер в гамма-пространстве, поэтому цвета неба и подкраски света — «показанные» значения; подкраска света неба имеет яркость 1 и не меняет экспозицию (яркость задаёт ванильный лайтмап × `ambient.x` = 0.62 днём, 0.95 ночью).
|
||||
- Цвет солнца (`light_of`): пропускание атмосферы вдоль солнца (`hd_sky::sun_transmittance`), нормировано на 1, степень 0.6, × (1, 0.97, 0.88). Луна — холодная, как раньше.
|
||||
- Дождь: `sky_extra.z` смешивает небо/дымку с ванильным градиентом (серым от ванили); Нижний мир/Энд/подводное — `sky_sun.w = 0`, всё как раньше (ванильное небо и туман).
|
||||
- Облака — по-прежнему ванильные блоки; объёмные облака — п. 4 плана. Окраска облаков цветом закатного солнца — эвристика по маске (глубина есть, G-буфера нет, высота и дальность), может задеть далёкие частицы.
|
||||
- Идеи дальше: многократное рассеяние по Hillaire вместо плоского члена, тени облаков, диск луны с фазой, звёзды/галактика по Photon (`sky.glsl`), розовый оттенок заката (`get_sun_tint` у Photon).
|
||||
|
||||
## Правила
|
||||
|
||||
- Код Photon (MIT) переносить можно только с атрибуцией (см. «Что это»); код других шейдерпаков — нельзя.
|
||||
- Ничего не ломать в `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`).
|
||||
- Обнови этот документ (статус + что изменилось в конвейере) в том же коммите, что и код.
|
||||
|
||||
## Тюнинг после проверки на полигоне (патч 22)
|
||||
|
||||
Голубая заливка неба по умолчанию делала белый бетон «снегом»: оттенок неба в заливке ослаблен до 0.4
|
||||
(`sky_light_tint`, в `fs_light`), заливка днём `ambient.x` = 0.5 (+0.45 ночью), солнце 0.7 и теплее
|
||||
(`light_of`: множитель (1, 0.93, 0.78)). Проверять баланс на `Polygon` в полдень (`time set 6000`) — белое должно
|
||||
оставаться белым с тёплым оттенком.
|
||||
|
||||
@@ -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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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)
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
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/21] 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)
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
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/21] 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)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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/21] 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)
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
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/21] 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)
|
||||
|
||||
Reference in New Issue
Block a user