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>
This commit is contained in:
2026-10-29 16:25:44 +03:00
committed by airdef
parent 72d9e70187
commit 2228844b92
+236 -6
View File
@@ -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));
}
}