roxlap_gpu/lights.rs
1//! QE.8b — dynamic-lighting upload, split verbatim out of `lib.rs`
2//! (stages DL + SL): the [`SceneLights`] per-frame rig, the packed
3//! [`GpuPointLight`] GPU mirror, the pack/upload helpers shared by
4//! the surface + headless paths, and the `set_scene_lights` entry
5//! points on both renderers.
6
7use bytemuck::{Pod, Zeroable};
8
9use crate::{GpuRenderer, HeadlessSceneRenderer, SceneDdaPerGridCamera};
10
11// ───────────────────────── DL — dynamic lighting ─────────────────────────
12// Stage DL (GPU-only). The scene-DDA pass gains a runtime sun + point
13// lights + stylized hard shadows. The host passes lights already
14// transformed into each grid's local frame (mirroring the per-grid
15// cameras); the shader works entirely in grid-local space. DL.0 wires the
16// buffers + uniform fields + bindings; the shader receives them but does
17// not yet read them (the hit-site shading lands in DL.1+).
18
19/// Max point lights honoured per frame. Excess are dropped with a warning
20/// (never silently truncated). The per-grid buffer is sized
21/// `grid_count * point_count`.
22pub const MAX_POINT_LIGHTS: usize = 32;
23/// Max simultaneous shadow casters (the sun counts as one). Lights flagged
24/// to cast beyond this are demoted to shadowless with a warning. Enforced
25/// in DL.3 (shadow stage); declared here so the budget is one constant.
26pub const MAX_SHADOW_CASTERS: usize = 4;
27
28/// A point light in a grid's **local** space, as handed to
29/// [`GpuRenderer::set_scene_lights`]. The facade transforms world-space
30/// `roxlap_render::PointLight`s into each grid's frame.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct GpuLight {
33 /// Grid-local position (voxel units).
34 pub position: [f32; 3],
35 /// Hard cutoff distance, world/voxel units.
36 pub radius: f32,
37 /// Linear RGB, `0..1`.
38 pub color: [f32; 3],
39 /// Scalar multiplier on [`Self::color`] in the diffuse term
40 /// (`albedo · color · intensity · N·L · falloff`). `1.0` = nominal;
41 /// values above 1 over-brighten.
42 pub intensity: f32,
43 /// Whether this light marches occlusion (shadow) rays. Honoured up
44 /// to the [`MAX_SHADOW_CASTERS`] budget (the sun counts as one);
45 /// over-budget lights are demoted to shadowless with a warning.
46 pub casts_shadow: bool,
47 /// SL — spot (cone) axis: unit direction the light shines **along**,
48 /// in the same frame as [`Self::position`] (grid-local for the scene
49 /// pass, world for the sprite pass). Ignored when [`Self::cos_outer`]
50 /// is `-1.0`.
51 pub spot_dir: [f32; 3],
52 /// SL — cosine of the inner cone half-angle (full brightness within it).
53 pub cos_inner: f32,
54 /// SL — cosine of the outer cone half-angle (zero past it; soft between).
55 /// `-1.0` (180° cone) ⇒ a pure point light: the cone mask is skipped.
56 pub cos_outer: f32,
57}
58
59/// The whole per-frame light environment, already transformed per grid.
60/// `grid_sun_dirs` and `grid_point_lights` are indexed by grid (outer
61/// length == `grid_count`); empty ⇒ that light type is off. Set each frame
62/// via [`GpuRenderer::set_scene_lights`]; [`Default`] = no lights (the
63/// pre-DL render).
64#[derive(Clone, Default, PartialEq)]
65pub struct SceneLights {
66 /// Whether a dynamic-lighting rig is active this frame. `false` (the
67 /// default) ⇒ the shader takes the unchanged baked-only path
68 /// (byte-identical to pre-DL). `true` ⇒ the lit path runs (ambient
69 /// term + sun + point lights), even with no sun/points set, so the
70 /// `ambient` multiplier still applies.
71 pub enabled: bool,
72 /// Per-grid unit direction **to** the sun (grid-local). Empty ⇒ no sun.
73 pub grid_sun_dirs: Vec<[f32; 3]>,
74 /// Sun colour, linear RGB `0..1`. The shader's sun term is
75 /// `albedo · sun_color · sun_intensity · N·L · shadow`.
76 pub sun_color: [f32; 3],
77 /// Scalar multiplier on [`Self::sun_color`]; `0.0` blacks the sun
78 /// out even when `grid_sun_dirs` is set.
79 pub sun_intensity: f32,
80 /// Whether the sun marches shadow rays. When `true` the sun takes
81 /// the first slot of the [`MAX_SHADOW_CASTERS`] budget.
82 pub sun_casts_shadow: bool,
83 /// Per-grid point lights (grid-local). Outer len == `grid_count`; the
84 /// inner len (the point count) is the same for every grid.
85 pub grid_point_lights: Vec<Vec<GpuLight>>,
86 /// Multiplier on the baked ambient byte.
87 pub ambient: [f32; 3],
88 /// Fraction of a caster's light removed at shadowed hits, `0..=1`
89 /// (`1.0` = fully black shadows, `0.0` = shadows invisible). The
90 /// shader applies `in_shadow = 1 - shadow_strength`; the facade
91 /// default is `0.7`.
92 pub shadow_strength: f32,
93 /// Shadow-ray origin offset along the hit's surface normal, in
94 /// voxel units — kills self-shadow acne. The facade passes
95 /// `LightRig::shadow_bias_voxels` (default `1.5`).
96 pub shadow_bias: f32,
97 /// Length cap for **sun** shadow rays, world/voxel units (facade
98 /// default `512`). Point-light shadow rays stop at the light itself
99 /// instead.
100 pub shadow_max_dist: f32,
101 /// Hard cap on voxel steps per shadow ray so the occlusion march
102 /// always terminates (the facade passes `256`). Past the cap the
103 /// point is treated as unshadowed.
104 pub shadow_max_steps: u32,
105 /// DL.4 — **world-space** unit direction to the sun, for the sprite
106 /// pass (sprites render in world space, not grid-local). `[0;3]` ⇒ no
107 /// sun. Empty `grid_sun_dirs` and a zero `world_sun_dir` both mean
108 /// "no sun" for their respective passes.
109 pub world_sun_dir: [f32; 3],
110 /// DL.4 — world-space point lights for the sprite pass (positions in
111 /// world coords; same colour/intensity/radius as the per-grid copies).
112 pub world_points: Vec<GpuLight>,
113 /// DL.6 — stylized cel banding: `0` = smooth, `≥1` = quantize the
114 /// diffuse to `bands + 1` levels + gradient-map the sun key.
115 pub style_bands: u32,
116 /// DL.6 — cool shadow/ambient tint (the stylized ramp's unlit end).
117 pub shadow_tint: [f32; 3],
118}
119
120/// One point light packed for the GPU (binding 18, std430, 64 bytes —
121/// four `vec4<f32>`). Mirrors `PointLight` in `scene_dda.wgsl` and
122/// `sprite_model_dda.wgsl`. SL grew this 48→64 for the spot (cone) fields;
123/// a `-1.0` `cos_outer` marks a pure point light (cone mask skipped).
124#[repr(C)]
125#[derive(Clone, Copy, Pod, Zeroable)]
126pub(crate) struct GpuPointLight {
127 pub(crate) pos: [f32; 3],
128 pub(crate) radius: f32,
129 pub(crate) color: [f32; 3],
130 pub(crate) intensity: f32,
131 // SL — spot (cone) fields. `cos_outer == -1.0` ⇒ omnidirectional point.
132 pub(crate) spot_dir: [f32; 3],
133 pub(crate) cos_outer: f32,
134 pub(crate) cos_inner: f32,
135 pub(crate) casts_shadow: u32,
136 pub(crate) _pad: [u32; 2],
137}
138
139// SL — the std430 stride must stay locked to the WGSL `PointLight` (four
140// `vec4<f32>`); a mismatch silently corrupts every light past the first.
141const _: () = assert!(core::mem::size_of::<GpuPointLight>() == 64);
142
143/// Build the per-grid point-light storage buffer (binding 18), grid-major:
144/// grid `g`'s lights occupy `[g*count .. (g+1)*count]`. Pads to one zeroed
145/// element when empty (wgpu rejects a zero-sized storage binding).
146pub(crate) fn upload_grid_point_lights(
147 device: &wgpu::Device,
148 lights: &[GpuPointLight],
149) -> wgpu::Buffer {
150 use wgpu::util::DeviceExt;
151 let one = [GpuPointLight::zeroed()];
152 let src: &[GpuPointLight] = if lights.is_empty() { &one } else { lights };
153 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
154 label: Some("roxlap-gpu scene_dda.grid_point_lights"),
155 contents: bytemuck::cast_slice(src),
156 usage: wgpu::BufferUsages::STORAGE,
157 })
158}
159
160/// DL — inject each grid's sun direction into `cam_vec[g].sun_dir`
161/// (binding 15). PF.5 — split from [`pack_scene_lights`]: the camera
162/// vector is rebuilt every frame, so the injection must run every frame,
163/// while the point-light pack is gated behind the lights dirty flag.
164pub(crate) fn inject_grid_sun_dirs(lights: &SceneLights, cam_vec: &mut [SceneDdaPerGridCamera]) {
165 if lights.grid_sun_dirs.is_empty() {
166 return;
167 }
168 for (g, cam) in cam_vec.iter_mut().enumerate() {
169 let d = lights.grid_sun_dirs.get(g).copied().unwrap_or([0.0; 3]);
170 cam.sun_dir = [d[0], d[1], d[2], 0.0];
171 }
172}
173
174/// DL — pack `lights` for the scene-DDA pass, shared by the surface and
175/// headless paths: the grid-major point-light rows (binding 18), returned
176/// as `(packed_lights, sun_flags, point_count)`. PF.4 — packing only; the
177/// caller uploads (the surface path into a persistent buffer, headless via
178/// `create_buffer_init`). PF.5 — the surface path calls this only when the
179/// lights changed (so the over-cap warnings below also fire once per
180/// change, not per frame). `sun_flags`: bit0 = sun enabled, bit1 = sun
181/// casts shadow, bit2 = dynamic lighting active. Over-cap point lights are
182/// dropped with a warning (never silently truncated).
183pub(crate) fn pack_scene_lights(
184 lights: &SceneLights,
185 grid_count: usize,
186) -> (Vec<GpuPointLight>, u32, u32) {
187 let sun_enabled = !lights.grid_sun_dirs.is_empty();
188 // Point-light count per grid (same across grids); capped + warned.
189 let mut point_count = lights
190 .grid_point_lights
191 .first()
192 .map_or(0, std::vec::Vec::len);
193 if point_count > MAX_POINT_LIGHTS {
194 eprintln!(
195 "roxlap-gpu: {point_count} point lights > MAX_POINT_LIGHTS ({MAX_POINT_LIGHTS}); dropping the excess"
196 );
197 point_count = MAX_POINT_LIGHTS;
198 }
199 // MAX_SHADOW_CASTERS cap (locked decision #5): the sun (if it casts) is
200 // the first caster; keep at most MAX_SHADOW_CASTERS shadow casters total
201 // and demote the rest to shadowless — never silently. The point list is
202 // identical across grids (only positions differ), so decide per index
203 // once from the representative (grid-0) row.
204 let mut budget = MAX_SHADOW_CASTERS;
205 if sun_enabled && lights.sun_casts_shadow {
206 budget = budget.saturating_sub(1);
207 }
208 let mut allow_shadow = vec![false; point_count];
209 let mut demoted = 0usize;
210 if let Some(rep) = lights.grid_point_lights.first() {
211 for (i, slot) in allow_shadow.iter_mut().enumerate() {
212 if rep.get(i).is_some_and(|l| l.casts_shadow) {
213 if budget > 0 {
214 *slot = true;
215 budget -= 1;
216 } else {
217 demoted += 1;
218 }
219 }
220 }
221 }
222 if demoted > 0 {
223 eprintln!(
224 "roxlap-gpu: {demoted} shadow-casting point lights > MAX_SHADOW_CASTERS ({MAX_SHADOW_CASTERS}); demoting the excess to shadowless"
225 );
226 }
227 // Grid-major point-light buffer: grid g at [g*count .. (g+1)*count].
228 let mut packed: Vec<GpuPointLight> = Vec::with_capacity(grid_count * point_count);
229 for g in 0..grid_count {
230 let row = lights.grid_point_lights.get(g);
231 for (i, &allow) in allow_shadow.iter().enumerate() {
232 let p = row.and_then(|r| r.get(i));
233 packed.push(p.map_or(GpuPointLight::zeroed(), |l| GpuPointLight {
234 pos: l.position,
235 radius: l.radius,
236 color: l.color,
237 intensity: l.intensity,
238 spot_dir: l.spot_dir,
239 cos_outer: l.cos_outer,
240 cos_inner: l.cos_inner,
241 casts_shadow: u32::from(l.casts_shadow && allow),
242 _pad: [0; 2],
243 }));
244 }
245 }
246 let sun_flags = u32::from(sun_enabled)
247 | (u32::from(sun_enabled && lights.sun_casts_shadow) << 1)
248 | (u32::from(lights.enabled) << 2);
249 (packed, sun_flags, point_count as u32)
250}
251
252impl GpuRenderer {
253 /// DL — set the per-frame dynamic lights (sun + point lights), already
254 /// transformed into each grid's local frame. Call once per frame before
255 /// [`Self::render_scene`] (the facade does this from
256 /// `FrameParams::lights`). [`SceneLights::default`] clears all lights —
257 /// the pre-DL render. GPU-only; the CPU backend has no analogue.
258 /// PF.5 — an unchanged rig is a no-op: `render_scene` re-packs +
259 /// re-uploads the light buffers only when this actually stores
260 /// something different, so a static rig costs nothing per frame.
261 pub fn set_scene_lights(&mut self, lights: SceneLights) {
262 if self.scene_lights != lights {
263 self.scene_lights = lights;
264 self.dirty.mark_lights_changed();
265 }
266 }
267}
268
269impl HeadlessSceneRenderer {
270 /// DL — set dynamic lights for subsequent [`Self::render`] calls
271 /// (already in grid-local space). Lets tests exercise the lit path
272 /// (sun N·L, point lights). Default = none (baked-only).
273 pub fn set_scene_lights(&mut self, lights: SceneLights) {
274 self.lights = lights;
275 }
276}