Skip to main content

unlit3d_examples/scenes/
lit_clusters.rs

1//! The cluster debug scene: the grid a lit frame looks its lights up in.
2//!
3//! Nothing of the lit scene is reused. What this scene has to show is where the
4//! light counts differ from cell to cell, and the lit scene's lights reach nearly
5//! every cell, which leaves that map flat: here one row of each light kind
6//! crosses a long floor, so each kind's footprint is a band of its own and a
7//! light written into the wrong cells moves a patch rather than shifting a level
8//! everywhere.
9
10use super::{
11    LitSurface, PlacedLight, SceneControl, SceneDef, SceneOptions, allocate_surface,
12    camera_looking_at, grid,
13};
14use unlit_ecs::bundle;
15use unlit_wgpu::pipeline::LitShader;
16use unlit3d::prelude::*;
17
18/// The frame's cluster grid, composited over the lighting.
19///
20/// The grid is what a fragment uses to find its lights, and it is built on the
21/// CPU from the camera and the target's size, so nothing about it is visible: a
22/// cell given the wrong lights, a grid built for another aspect, or a depth
23/// slice that does not match the fragment's own lookup all show up as a change
24/// of brightness and nothing else. This scene draws the grid itself, so a wrong
25/// assignment is a shape in the wrong place rather than a subtle change of
26/// level. It is a scene of its own rather than an overlay on the lit scene: the
27/// lit scene's lights all reach nearly every cell, which leaves the count map
28/// flat, and what a debug view has to show is where the counts differ.
29pub static CLUSTER_SCENE: SceneDef = SceneDef {
30    id: "lit_clusters",
31    title: "Cluster grid",
32    description: "a floor receding into the distance with the cluster grid drawn over it",
33    size: super::BASELINE_SIZE,
34    baseline: Some(super::BASELINE_SIZE),
35    clear: None,
36    frames: 1,
37    step_seconds: None,
38    samples: 1,
39    depth: true,
40    ui: false,
41    reproducible_ui: false,
42    build: build_clusters,
43};
44
45/// The depth range the debug scene's grid spans.
46///
47/// The grid's slices are spaced evenly in `ln(depth)`, so a grid spanning the
48/// library default's kilometre spends most of its slices on depths this scene
49/// never draws. Spanning the floor instead puts every slice on screen, which is
50/// what the view has to show.
51const CLUSTER_NEAR: f32 = 1.0;
52const CLUSTER_FAR: f32 = 120.0;
53
54/// The debug floor: how wide and how deep, and where its front edge sits.
55///
56/// It is far larger than the camera can see, so the frame is floor from edge to
57/// edge: the camera looks down at more than half the vertical field of view, and
58/// a floor that ended inside the frame would put a horizon across it and hide the
59/// slices furthest away -- exactly the ones a log-spaced grid packs together.
60const CLUSTER_FLOOR: (f32, f32, f32) = (700.0, 600.0, 10.0);
61
62/// Where the debug camera sits and what it looks at.
63///
64/// High, and tilted down by about 32 degrees: past half the vertical field of
65/// view, so the horizon is off the top of the frame, but no steeper, so the far
66/// slices still spread across the floor rather than piling onto the last one.
67const CLUSTER_EYE: glam::Vec3 = glam::Vec3::new(0.0, 9.5, 9.0);
68const CLUSTER_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 0.0, -6.5);
69
70/// The columns the debug floor's lights stand in.
71///
72/// A regular lattice, so a cell's count is predictable from where it is: a light
73/// reaches a disc of the floor whose radius follows from its height and its
74/// range, the discs of one row merge into a band, and the rows are far enough
75/// apart in depth that a cell holds the lights of one row and not its
76/// neighbour's.
77const CLUSTER_COLUMNS: [f32; 5] = [-8.0, -4.0, 0.0, 4.0, 8.0];
78
79/// Where each row of lights sits, nearest first: its depth, the height its lights
80/// hover at, and how far each one reaches.
81///
82/// One row per light kind -- point, spot, then quad -- so every kind's footprint
83/// is a band of its own and a kind written into the wrong cells shows up as a
84/// patch in the wrong band rather than as a change of level everywhere.
85const CLUSTER_ROWS: [(f32, f32, f32); 3] =
86    [(-1.0, 1.0, 5.5), (-9.0, 5.0, 12.0), (-18.0, 1.5, 10.0)];
87
88/// Build the cluster debug scene's world content.
89///
90/// One long floor, a handful of short-ranged lights and a fragment that overlays
91/// the grid on the lighting: nothing of the lit scene is reused, because
92/// what this scene has to show is where the counts differ, and the lit scene's
93/// lights reach nearly every cell.
94fn build_clusters(
95    world: &mut World,
96    context: RenderContext,
97    pass: Entity,
98    _ui: Option<Entity>,
99    size: (u32, u32),
100    _options: SceneOptions,
101) -> SceneControl {
102    let mut source = MeshSource::new(&world.root_scope(), context);
103    source.register_lit_family(&world.root_scope());
104    source.clustering = ClusterConfig {
105        first_slice_depth: CLUSTER_NEAR,
106        far: CLUSTER_FAR,
107        ..ClusterConfig::DEFAULT
108    };
109    let mut options = super::default_options();
110    options.unlit = false;
111    options.shader = Some(LitShader::new("lit::cluster_grid", "", "", cluster_grid()));
112    let key = LitPipelineKey::new(options);
113
114    let source_entity = world
115        .scope_mut(pass)
116        .expect("the pass is alive")
117        .spawn(bundle![Source::new(source)]);
118
119    // The floor is one plane: the grid is a screen-space structure, so its cells
120    // are not aligned with anything the geometry could carry.
121    // Wound right along x and up along -z, so the cross product of the two -- the
122    // side the front face is on -- is the world's up axis, which is the side the
123    // camera looks down at.
124    let mut floor = grid(
125        glam::Vec3::new(0.0, 0.0, CLUSTER_FLOOR.2 - CLUSTER_FLOOR.1 * 0.5),
126        glam::Vec3::X,
127        glam::Vec3::NEG_Z,
128        (CLUSTER_FLOOR.0, CLUSTER_FLOOR.1),
129        (1, 1),
130    );
131    floor.normals = Some(vec![glam::Vec3::Y.to_array(); floor.positions.len()]);
132    let mesh = allocate_surface(world, source_entity, &key, &floor);
133    let pipeline = GpuRenderPipeline::new(key);
134
135    let camera_entity = super::spawn_in(
136        world,
137        source_entity,
138        bundle![camera_looking_at(CLUSTER_EYE, CLUSTER_TARGET, size)],
139    );
140    // A dim ambient keeps the unoccupied cells readable beneath the grid, while
141    // the local lights remain visible through their cells.
142    super::spawn_in(
143        world,
144        source_entity,
145        bundle![AmbientLight::new(glam::Vec4::new(0.08, 0.08, 0.08, 0.0))],
146    );
147    super::spawn_in(world, source_entity, LitSurface { mesh, pipeline });
148
149    // One row per light kind, each light aimed straight down at the floor. The
150    // color follows the row, so a lit frame of this scene still tells the rows
151    // apart beneath the translucent count overlay.
152    let hue_color = |hue: f32| {
153        glam::Vec4::new(
154            0.5 + 0.5 * (hue * std::f32::consts::TAU).cos(),
155            0.5 + 0.5 * (hue * std::f32::consts::TAU + 2.094).cos(),
156            0.5 + 0.5 * (hue * std::f32::consts::TAU + 4.189).cos(),
157            0.0,
158        )
159    };
160    let [
161        (point_depth, point_height, point_range),
162        (spot_depth, spot_height, spot_range),
163        (quad_depth, quad_height, quad_range),
164    ] = CLUSTER_ROWS;
165
166    for (column, x) in CLUSTER_COLUMNS.into_iter().enumerate() {
167        let color = hue_color(column as f32 / CLUSTER_COLUMNS.len() as f32);
168        super::spawn_in(
169            world,
170            source_entity,
171            PlacedLight {
172                light: LightSource::new(PointLight {
173                    color,
174                    intensity: 0.5,
175                    range: point_range,
176                }),
177                translation: glam::Vec3::new(x, point_height, point_depth),
178                rotation: glam::Quat::IDENTITY,
179            },
180        );
181    }
182
183    for (column, x) in CLUSTER_COLUMNS.into_iter().enumerate() {
184        let color = hue_color(column as f32 / CLUSTER_COLUMNS.len() as f32 + 0.33);
185        let position = glam::Vec3::new(x, spot_height, spot_depth);
186        super::spawn_in(
187            world,
188            source_entity,
189            PlacedLight::aiming(
190                LightSource::new(SpotLight {
191                    color,
192                    intensity: 1.0,
193                    range: spot_range,
194                    inner_angle: 18f32.to_radians(),
195                    outer_angle: 32f32.to_radians(),
196                }),
197                position,
198                position + glam::Vec3::NEG_Y,
199            ),
200        );
201    }
202
203    for (column, x) in CLUSTER_COLUMNS.into_iter().enumerate() {
204        let color = hue_color(column as f32 / CLUSTER_COLUMNS.len() as f32 + 0.66);
205        let position = glam::Vec3::new(x, quad_height, quad_depth);
206        super::spawn_in(
207            world,
208            source_entity,
209            PlacedLight::aiming(
210                LightSource::new(QuadLight::rect(color, 0.6, quad_range, 1.6, 1.2)),
211                position,
212                position + glam::Vec3::NEG_Y,
213            ),
214        );
215    }
216
217    // A single directional light, which reaches every cell of the grid whatever
218    // its depth: the row it lights is the whole frame, so a grid that drops it
219    // from any cell shows up as that cell going dark.
220    super::spawn_in(
221        world,
222        source_entity,
223        PlacedLight {
224            light: LightSource::new(DirectionalLight {
225                color: glam::Vec4::new(0.9, 0.85, 0.75, 0.0),
226                intensity: 0.2,
227            }),
228            translation: glam::Vec3::ZERO,
229            rotation: glam::Quat::from_rotation_x(-1.0),
230        },
231    );
232
233    SceneControl {
234        advance: Box::new(move |world, _frame, _delta, frame_size| {
235            super::aim_camera(
236                world,
237                source_entity,
238                camera_entity,
239                CLUSTER_EYE,
240                CLUSTER_TARGET,
241                frame_size,
242            );
243        }),
244        snapshot: Box::new(move |frame| (frame == 0).then(|| "lit_clusters.webp".to_owned())),
245    }
246}
247
248/// The WESL the debug scene's fragment stage runs, spliced at the built-in
249/// stage's fragment hook.
250///
251/// The grid is composited over the lit color in the fragment hook: cell fills
252/// stay translucent so the original light pools remain visible, while borders
253/// and the directional-light mark are nearly opaque. Two things are drawn, each
254/// from the arithmetic the lookup itself runs, so a border is exactly where the
255/// lookup switches:
256///
257/// * the **cell fill**: a fixed heat ramp from an empty cell's dark blue to a
258///   full cell's red. Every record in a cell is a bounded light now -- the
259///   frame's directional lights live in the lighting uniform, not the grid -- so
260///   the whole count is what the ramp shows.
261/// * the **borders**, black for the cell's xy bounds and subdued white for its
262///   depth slice. Both are measured in pixels, so they neither thicken into
263///   bands nearby nor disappear in the distance. A magenta line along the left
264///   edge marks a frame that carries a directional light, which every fragment
265///   sees whatever its cell.
266///
267/// The cell borders cross the slice borders rather than one hiding the other, so
268/// where a cell ends and where a slice ends stay separate readings.
269fn cluster_grid() -> String {
270    r#"
271let grid_depth = view_depth(in.world);
272let grid_ndc = fragment_ndc(in.world);
273let grid_cell = fragment_cluster_index(grid_ndc, grid_depth);
274let grid_local_count = array_access::load_cell_word(grid_cell, 0u);
275// Fixed bands make equal colors mean equal counts. Red means the record is
276// full and further lights are being dropped.
277var grid = vec3<f32>(0.025, 0.035, 0.10);
278if grid_local_count > 0u { grid = vec3<f32>(0.04, 0.16, 0.48); }
279if grid_local_count > 2u { grid = vec3<f32>(0.02, 0.52, 0.56); }
280if grid_local_count > 4u { grid = vec3<f32>(0.12, 0.62, 0.20); }
281if grid_local_count > 6u { grid = vec3<f32>(0.82, 0.72, 0.08); }
282if grid_local_count > 8u { grid = vec3<f32>(0.95, 0.38, 0.035); }
283if grid_local_count >= cluster.dims.w { grid = vec3<f32>(0.92, 0.025, 0.025); }
284// The cell borders: the product the lookup floors, so a border is where its
285// floor changes. A border is measured in pixels, not in cells -- an edge's
286// distance to the nearest border divided by how much the lattice moves per
287// pixel -- so it stays the same width wherever the floor's depth changes fastest
288// and never thickens into a band.
289let grid_lattice = (grid_ndc * 0.5 + 0.5) * vec2<f32>(cluster.dims.xy);
290let grid_fract = fract(grid_lattice);
291let grid_edge = min(grid_fract, 1.0 - grid_fract);
292let grid_pixels = grid_edge / max(fwidth(grid_lattice), vec2<f32>(1e-6));
293let grid_border = 1.0 - smoothstep(0.5, 1.5, min(grid_pixels.x, grid_pixels.y));
294// The slice borders use the same continuous mapping as the actual lookup.
295let grid_slice = cluster_slice_coordinate(grid_depth);
296let grid_slice_fract = fract(grid_slice);
297let grid_slice_edge = min(grid_slice_fract, 1.0 - grid_slice_fract);
298let grid_slice_pixels = grid_slice_edge / max(fwidth(grid_slice), 1e-6);
299let grid_slice_border = 1.0 - smoothstep(0.5, 1.5, grid_slice_pixels);
300grid = mix(grid, vec3<f32>(0.0), grid_border * 0.9);
301grid = mix(grid, vec3<f32>(0.55), grid_slice_border * 0.7);
302var grid_opacity = 0.48;
303grid_opacity = max(grid_opacity, grid_border * 0.85);
304grid_opacity = max(grid_opacity, grid_slice_border * 0.7);
305// The frame's directional lights are outside the grid, so this edge mark counts
306// them from the uniform instead: one magenta line says the frame carries at
307// least one, and no line says it carries none.
308if lighting.directionals[0].color_intensity.w > 0.0 && grid_ndc.x < -0.985 {
309    grid = vec3<f32>(0.8, 0.08, 0.65);
310    grid_opacity = 0.85;
311}
312color = vec4<f32>(mix(color.rgb, grid, grid_opacity), color.a);
313    "#
314    .to_owned()
315}