HD lights culled per tile (fork patch 14; #10)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 16:25:44 +03:00
parent 0092ec64a9
commit a806635681
14 changed files with 375 additions and 13 deletions
@@ -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/13] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/14] 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/13] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/14] 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/13] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/14] 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/13] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/14] 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/13] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/14] 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/13] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/14] 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/13] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/14] 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/13] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/14] 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/13] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/14] 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/13] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/14] 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/13] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/14] 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/13] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/14] 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/13] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/14] 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>
@@ -0,0 +1,362 @@
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/14] HD: lights culled per 16x16 tile (surface and halo
lists built on the CPU), a naga test for the lighting shaders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd.rs | 242 +++++++++++++++++++++++++++-
1 file changed, 236 insertions(+), 6 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index fe2634f2c..2776219fc 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -16,6 +16,12 @@ pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The most lights drawn a frame.
pub const MAX_LIGHTS: usize = 512;
+/// The side (pixels) of a tile of the screen with its own short list of lights.
+const TILE: u32 = 16;
+/// The indices all the tile lists may hold together; a tile past it loops over every light.
+const TILE_INDICES: usize = 1 << 19;
+/// A list length that means "every light": the list overflowed.
+const EVERY_LIGHT: u32 = u32::MAX;
/// Bloom's mip levels.
const BLOOM_LEVELS: usize = 6;
@@ -55,7 +61,7 @@ struct HdFrame {
fog: [f32; 4],
/// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
ambient: [f32; 4],
- /// x: shadows on; y, z: the cascades' half sizes.
+ /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
shadow: [f32; 4],
}
@@ -123,6 +129,10 @@ struct Light {
@group(0) @binding(9) var shadow_sampler: sampler_comparison;
@group(0) @binding(10) var lightmap: texture_2d<f32>;
@group(0) @binding(11) var linear: sampler;
+// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
+// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
+// every light.
+@group(0) @binding(12) var<storage, read> tiles: array<u32>;
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
fn ign(px: vec2<f32>) -> f32 {
@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let sun = frame.sun.xyz;
let sun_on = frame.sun.w;
let count = u32(frame.params.x);
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
+ let surface_start = tiles[tile];
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
+ let halo_start = tiles[tile + 2u];
+ let halo_all = tiles[tile + 3u] == 0xffffffffu;
+ let halo_count = select(tiles[tile + 3u], count, halo_all);
var c = scene;
let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
if surface {
@@ -199,8 +216,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
// The mods' lights.
- for (var i = 0u; i < count; i++) {
- let l = lights[i];
+ for (var k = 0u; k < surface_count; k++) {
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
let to = l.pos.xyz - p;
let dist = length(to);
if dist >= l.pos.w { continue; }
@@ -233,8 +250,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
}
- for (var i = 0u; i < count; i++) {
- let l = lights[i];
+ for (var k = 0u; k < halo_count; k++) {
+ let l = lights[select(tiles[halo_start + k], k, halo_all)];
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
let t0 = dot(l.pos.xyz, view);
let hh = length(l.pos.xyz - view * t0) + 0.5;
@@ -403,6 +420,11 @@ pub struct Hd {
up_groups: Vec<wgpu::BindGroup>,
frame: wgpu::Buffer,
lights: wgpu::Buffer,
+ /// The tiles' light lists (see `tile_lists`), sized for the screen, and the tiles per row.
+ tiles: wgpu::Buffer,
+ tiles_x: u32,
+ tile_scratch: Vec<Vec<u16>>,
+ tile_words: Vec<u32>,
sampler: wgpu::Sampler,
light_layout: wgpu::BindGroupLayout,
light_pipeline: wgpu::RenderPipeline,
@@ -414,6 +436,137 @@ pub struct Hd {
tone: wgpu::RenderPipeline,
}
+/// The tiles' header (4 words each) and index lists for a screen of `size`.
+fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffer {
+ let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("HD light tiles"),
+ size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ })
+}
+
+/// Where a camera-relative sphere can show on the screen.
+enum Cover {
+ /// Nowhere: it is wholly behind the camera.
+ Nothing,
+ /// Anywhere: it crosses the camera's plane, or holds the camera.
+ Everywhere,
+ /// Inside this pixel rectangle (min x, min y, max x, max y).
+ Rect([f32; 4]),
+}
+
+/// The screen cover of the sphere at `c` of radius `r`: the projection of the 8 corners of the
+/// box round it. A box is convex and so is its projection while every corner is in front of the
+/// camera, so the rectangle holds the whole sphere. With some corners behind, the projection
+/// is not bounded and the cover is the whole screen; with all of them behind, no pixel ray
+/// (they all point forward) can meet the sphere.
+fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
+ if !c.is_finite() || !r.is_finite() {
+ return Cover::Everywhere;
+ }
+ // A little more than the shader's radius, against rounding.
+ let r = r * 1.01 + 0.01;
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
+ let mut front = 0;
+ for corner in 0..8 {
+ let o = glam::Vec3::new(
+ if corner & 1 == 0 { -r } else { r },
+ if corner & 2 == 0 { -r } else { r },
+ if corner & 4 == 0 { -r } else { r },
+ );
+ let h = *view_proj * (c + o).extend(1.0);
+ if h.w > 0.0 {
+ front += 1;
+ let ndc = glam::Vec2::new(h.x, h.y) / h.w;
+ let px = glam::Vec2::new(
+ (ndc.x * 0.5 + 0.5) * size[0] as f32,
+ (0.5 - ndc.y * 0.5) * size[1] as f32,
+ );
+ lo = lo.min(px);
+ hi = hi.max(px);
+ }
+ }
+ match front {
+ 0 => Cover::Nothing,
+ 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
+ _ => Cover::Everywhere,
+ }
+}
+
+/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
+/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
+/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
+/// surface list holds the lights whose sphere can touch what the tile sees. The halo list
+/// holds the lights whose halo can reach a ray of the tile: the shader adds a halo for the
+/// view *line* passing within half the radius of the light, so a ray pointing away from a light
+/// behind it is lit too, and the mirrored sphere is covered as well. Lights stay in order, so
+/// the sums are the ones of the full loop.
+fn tile_lists(
+ view_proj: &Mat4,
+ lights: &[HdLight],
+ size: [u32; 2],
+ scratch: &mut Vec<Vec<u16>>,
+ words: &mut Vec<u32>,
+) {
+ let tx = size[0].div_ceil(TILE).max(1) as usize;
+ let ty = size[1].div_ceil(TILE).max(1) as usize;
+ let tiles = tx * ty;
+ scratch.resize_with(tiles * 2, Vec::new);
+ scratch.truncate(tiles * 2);
+ scratch.iter_mut().for_each(Vec::clear);
+ let mut add = |list: usize, area: Cover, light: u16| match area {
+ Cover::Nothing => {}
+ Cover::Everywhere => {
+ for t in 0..tiles {
+ scratch[t * 2 + list].push(light);
+ }
+ }
+ Cover::Rect([x0, y0, x1, y1]) => {
+ let tile = TILE as f32;
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
+ return;
+ }
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
+ for y in from(y0, ty)..=from(y1, ty) {
+ for x in from(x0, tx)..=from(x1, tx) {
+ scratch[(y * tx + x) * 2 + list].push(light);
+ }
+ }
+ }
+ };
+ for (i, l) in lights.iter().enumerate() {
+ let c = glam::Vec3::new(l.pos[0], l.pos[1], l.pos[2]);
+ add(0, cover(view_proj, c, l.pos[3], size), i as u16);
+ // The shader's halo reach: the line within `radius / 2 - 0.5` of the light.
+ let reach = l.pos[3] * 0.5 - 0.5;
+ if reach > 0.0 || !reach.is_finite() {
+ add(1, cover(view_proj, c, reach, size), i as u16);
+ add(1, cover(view_proj, -c, reach, size), i as u16);
+ }
+ }
+ words.clear();
+ words.resize(tiles * 4, 0);
+ let mut room = TILE_INDICES;
+ for t in 0..tiles {
+ for list in 0..2 {
+ let ids = &mut scratch[t * 2 + list];
+ // Both the sphere and its mirror can name a light: once is enough.
+ ids.dedup();
+ let at = t * 4 + list * 2;
+ if ids.len() > room {
+ words[at + 1] = EVERY_LIGHT;
+ } else {
+ room -= ids.len();
+ words[at] = words.len() as u32;
+ words[at + 1] = ids.len() as u32;
+ words.extend(ids.iter().map(|&i| u32::from(i)));
+ }
+ }
+ }
+}
+
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
@@ -527,6 +680,11 @@ impl Hd {
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
FLOAT,
SAMPLER,
+ wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Storage { read_only: true },
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
]),
});
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -687,6 +845,10 @@ impl Hd {
up_groups: Vec::new(),
frame,
lights,
+ tiles: tiles_buffer(device, [1, 1]),
+ tiles_x: 1,
+ tile_scratch: Vec::new(),
+ tile_words: Vec::new(),
sampler,
light_layout,
light_pipeline,
@@ -713,6 +875,8 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ self.tiles = tiles_buffer(device, size);
+ self.tiles_x = size[0].div_ceil(TILE).max(1);
self.bloom = (0..BLOOM_LEVELS)
.map(|k| {
let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
@@ -743,6 +907,15 @@ impl Hd {
if n > 0 {
queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n]));
}
+ // Written every frame, empty too: the shader reads the counts, not `params.x`.
+ tile_lists(
+ &scene.view_proj,
+ &lights[..n],
+ self.size,
+ &mut self.tile_scratch,
+ &mut self.tile_words,
+ );
+ queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
self.frames = self.frames.wrapping_add(1);
// The sun by day, the moon (opposite) by night, faded out round the horizon.
let (dir, strength, color) = light_of(scene);
@@ -773,7 +946,7 @@ impl Hd {
if strength > 0.0 { 1.0 } else { 0.0 },
CASCADES[0],
CASCADES[1],
- 0.0,
+ self.tiles_x as f32,
],
};
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
@@ -933,6 +1106,10 @@ impl Hd {
binding: 11,
resource: wgpu::BindingResource::Sampler(lightmap.1),
},
+ wgpu::BindGroupEntry {
+ binding: 12,
+ resource: self.tiles.as_entire_binding(),
+ },
],
});
self.light_group = Some((key, group));
@@ -1102,3 +1279,56 @@ pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
(projection * view).as_mat4()
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn lighting_shader_parses() {
+ for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
+ let source = format!("{COMMON}{body}");
+ let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
+ panic!("{name}: {}", e.emit_to_string(&source));
+ });
+ naga::valid::Validator::new(
+ naga::valid::ValidationFlags::all(),
+ naga::valid::Capabilities::all(),
+ )
+ .validate(&module)
+ .unwrap_or_else(|e| panic!("{name}: {e:?}"));
+ }
+ }
+
+ #[test]
+ fn tile_lists_keep_lights_that_reach_a_tile() {
+ let size = [320, 160];
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
+ let light = |x: f32, y: f32, z: f32, r: f32| HdLight {
+ pos: [x, y, z, r],
+ color: [1.0; 4],
+ };
+ let lights = [
+ // Dead ahead and small; far to the side; behind the camera; holding the camera.
+ light(0.0, 0.0, -20.0, 4.0),
+ light(30.0, 0.0, -5.0, 4.0),
+ light(0.0, 0.0, 10.0, 8.0),
+ light(1.0, 0.0, 0.0, 6.0),
+ ];
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
+ tile_lists(&proj, &lights, size, &mut scratch, &mut words);
+ let list = |tile: usize, which: usize| -> Vec<u32> {
+ let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
+ words[start as usize..(start + len) as usize].to_vec()
+ };
+ // The centre tile sees the first light and the one at the camera, in both lists.
+ let centre = 5 * 20 + 10;
+ assert_eq!(list(centre, 0), vec![0, 3]);
+ assert!(list(centre, 1).contains(&0) && list(centre, 1).contains(&3));
+ // A far corner sees only the one at the camera.
+ assert_eq!(list(0, 0), vec![3]);
+ // The light behind the camera shows in the halo list where the view points away.
+ assert!(list(centre, 1).contains(&2));
+ assert!(!list(0, 0).contains(&2));
+ }
+}
--
2.54.0 (Apple Git-157)