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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user