diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index 1707bcb..58412cd 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/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; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index fcd8946..4a6b821 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/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 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index f733795..f4c2e87 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/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 diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index d47e4b9..057bffc 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/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 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index 0efca7d..69e4568 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/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 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index 5bba268..3678388 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/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 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index ef3796e..27e8f01 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/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 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index 330b8da..8529557 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/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 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index 913a1f1..685635e 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/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 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index 8ae20b7..c39976e 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/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 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index 7d7f366..113cfd5 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/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) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index bdd693b..6207ec1 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/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 diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index 6eee260..445cc59 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/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 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch new file mode 100644 index 0000000..f7d1a8d --- /dev/null +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -0,0 +1,362 @@ +From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 +From: chexit +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 +--- + 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; + @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 tiles: array; + + // Interleaved gradient noise: a different offset each pixel and frame, for the ray march. + fn ign(px: vec2) -> f32 { +@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + 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 { + 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 { + 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, + 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>, ++ tile_words: Vec, + 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::()) 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>, ++ words: &mut Vec, ++) { ++ 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 { ++ 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) +