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 pub intensity: f32,
40 pub casts_shadow: bool,
41 /// SL — spot (cone) axis: unit direction the light shines **along**,
42 /// in the same frame as [`Self::position`] (grid-local for the scene
43 /// pass, world for the sprite pass). Ignored when [`Self::cos_outer`]
44 /// is `-1.0`.
45 pub spot_dir: [f32; 3],
46 /// SL — cosine of the inner cone half-angle (full brightness within it).
47 pub cos_inner: f32,
48 /// SL — cosine of the outer cone half-angle (zero past it; soft between).
49 /// `-1.0` (180° cone) ⇒ a pure point light: the cone mask is skipped.
50 pub cos_outer: f32,
51}
52
53/// The whole per-frame light environment, already transformed per grid.
54/// `grid_sun_dirs` and `grid_point_lights` are indexed by grid (outer
55/// length == `grid_count`); empty ⇒ that light type is off. Set each frame
56/// via [`GpuRenderer::set_scene_lights`]; [`Default`] = no lights (the
57/// pre-DL render).
58#[derive(Clone, Default, PartialEq)]
59pub struct SceneLights {
60 /// Whether a dynamic-lighting rig is active this frame. `false` (the
61 /// default) ⇒ the shader takes the unchanged baked-only path
62 /// (byte-identical to pre-DL). `true` ⇒ the lit path runs (ambient
63 /// term + sun + point lights), even with no sun/points set, so the
64 /// `ambient` multiplier still applies.
65 pub enabled: bool,
66 /// Per-grid unit direction **to** the sun (grid-local). Empty ⇒ no sun.
67 pub grid_sun_dirs: Vec<[f32; 3]>,
68 pub sun_color: [f32; 3],
69 pub sun_intensity: f32,
70 pub sun_casts_shadow: bool,
71 /// Per-grid point lights (grid-local). Outer len == `grid_count`; the
72 /// inner len (the point count) is the same for every grid.
73 pub grid_point_lights: Vec<Vec<GpuLight>>,
74 /// Multiplier on the baked ambient byte.
75 pub ambient: [f32; 3],
76 pub shadow_strength: f32,
77 pub shadow_bias: f32,
78 pub shadow_max_dist: f32,
79 pub shadow_max_steps: u32,
80 /// DL.4 — **world-space** unit direction to the sun, for the sprite
81 /// pass (sprites render in world space, not grid-local). `[0;3]` ⇒ no
82 /// sun. Empty `grid_sun_dirs` and a zero `world_sun_dir` both mean
83 /// "no sun" for their respective passes.
84 pub world_sun_dir: [f32; 3],
85 /// DL.4 — world-space point lights for the sprite pass (positions in
86 /// world coords; same colour/intensity/radius as the per-grid copies).
87 pub world_points: Vec<GpuLight>,
88 /// DL.6 — stylized cel banding: `0` = smooth, `≥1` = quantize the
89 /// diffuse to `bands + 1` levels + gradient-map the sun key.
90 pub style_bands: u32,
91 /// DL.6 — cool shadow/ambient tint (the stylized ramp's unlit end).
92 pub shadow_tint: [f32; 3],
93}
94
95/// One point light packed for the GPU (binding 18, std430, 64 bytes —
96/// four `vec4<f32>`). Mirrors `PointLight` in `scene_dda.wgsl` and
97/// `sprite_model_dda.wgsl`. SL grew this 48→64 for the spot (cone) fields;
98/// a `-1.0` `cos_outer` marks a pure point light (cone mask skipped).
99#[repr(C)]
100#[derive(Clone, Copy, Pod, Zeroable)]
101pub(crate) struct GpuPointLight {
102 pub(crate) pos: [f32; 3],
103 pub(crate) radius: f32,
104 pub(crate) color: [f32; 3],
105 pub(crate) intensity: f32,
106 // SL — spot (cone) fields. `cos_outer == -1.0` ⇒ omnidirectional point.
107 pub(crate) spot_dir: [f32; 3],
108 pub(crate) cos_outer: f32,
109 pub(crate) cos_inner: f32,
110 pub(crate) casts_shadow: u32,
111 pub(crate) _pad: [u32; 2],
112}
113
114// SL — the std430 stride must stay locked to the WGSL `PointLight` (four
115// `vec4<f32>`); a mismatch silently corrupts every light past the first.
116const _: () = assert!(core::mem::size_of::<GpuPointLight>() == 64);
117
118/// Build the per-grid point-light storage buffer (binding 18), grid-major:
119/// grid `g`'s lights occupy `[g*count .. (g+1)*count]`. Pads to one zeroed
120/// element when empty (wgpu rejects a zero-sized storage binding).
121pub(crate) fn upload_grid_point_lights(
122 device: &wgpu::Device,
123 lights: &[GpuPointLight],
124) -> wgpu::Buffer {
125 use wgpu::util::DeviceExt;
126 let one = [GpuPointLight::zeroed()];
127 let src: &[GpuPointLight] = if lights.is_empty() { &one } else { lights };
128 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
129 label: Some("roxlap-gpu scene_dda.grid_point_lights"),
130 contents: bytemuck::cast_slice(src),
131 usage: wgpu::BufferUsages::STORAGE,
132 })
133}
134
135/// DL — inject each grid's sun direction into `cam_vec[g].sun_dir`
136/// (binding 15). PF.5 — split from [`pack_scene_lights`]: the camera
137/// vector is rebuilt every frame, so the injection must run every frame,
138/// while the point-light pack is gated behind the lights dirty flag.
139pub(crate) fn inject_grid_sun_dirs(lights: &SceneLights, cam_vec: &mut [SceneDdaPerGridCamera]) {
140 if lights.grid_sun_dirs.is_empty() {
141 return;
142 }
143 for (g, cam) in cam_vec.iter_mut().enumerate() {
144 let d = lights.grid_sun_dirs.get(g).copied().unwrap_or([0.0; 3]);
145 cam.sun_dir = [d[0], d[1], d[2], 0.0];
146 }
147}
148
149/// DL — pack `lights` for the scene-DDA pass, shared by the surface and
150/// headless paths: the grid-major point-light rows (binding 18), returned
151/// as `(packed_lights, sun_flags, point_count)`. PF.4 — packing only; the
152/// caller uploads (the surface path into a persistent buffer, headless via
153/// `create_buffer_init`). PF.5 — the surface path calls this only when the
154/// lights changed (so the over-cap warnings below also fire once per
155/// change, not per frame). `sun_flags`: bit0 = sun enabled, bit1 = sun
156/// casts shadow, bit2 = dynamic lighting active. Over-cap point lights are
157/// dropped with a warning (never silently truncated).
158pub(crate) fn pack_scene_lights(
159 lights: &SceneLights,
160 grid_count: usize,
161) -> (Vec<GpuPointLight>, u32, u32) {
162 let sun_enabled = !lights.grid_sun_dirs.is_empty();
163 // Point-light count per grid (same across grids); capped + warned.
164 let mut point_count = lights
165 .grid_point_lights
166 .first()
167 .map_or(0, std::vec::Vec::len);
168 if point_count > MAX_POINT_LIGHTS {
169 eprintln!(
170 "roxlap-gpu: {point_count} point lights > MAX_POINT_LIGHTS ({MAX_POINT_LIGHTS}); dropping the excess"
171 );
172 point_count = MAX_POINT_LIGHTS;
173 }
174 // MAX_SHADOW_CASTERS cap (locked decision #5): the sun (if it casts) is
175 // the first caster; keep at most MAX_SHADOW_CASTERS shadow casters total
176 // and demote the rest to shadowless — never silently. The point list is
177 // identical across grids (only positions differ), so decide per index
178 // once from the representative (grid-0) row.
179 let mut budget = MAX_SHADOW_CASTERS;
180 if sun_enabled && lights.sun_casts_shadow {
181 budget = budget.saturating_sub(1);
182 }
183 let mut allow_shadow = vec![false; point_count];
184 let mut demoted = 0usize;
185 if let Some(rep) = lights.grid_point_lights.first() {
186 for (i, slot) in allow_shadow.iter_mut().enumerate() {
187 if rep.get(i).is_some_and(|l| l.casts_shadow) {
188 if budget > 0 {
189 *slot = true;
190 budget -= 1;
191 } else {
192 demoted += 1;
193 }
194 }
195 }
196 }
197 if demoted > 0 {
198 eprintln!(
199 "roxlap-gpu: {demoted} shadow-casting point lights > MAX_SHADOW_CASTERS ({MAX_SHADOW_CASTERS}); demoting the excess to shadowless"
200 );
201 }
202 // Grid-major point-light buffer: grid g at [g*count .. (g+1)*count].
203 let mut packed: Vec<GpuPointLight> = Vec::with_capacity(grid_count * point_count);
204 for g in 0..grid_count {
205 let row = lights.grid_point_lights.get(g);
206 for (i, &allow) in allow_shadow.iter().enumerate() {
207 let p = row.and_then(|r| r.get(i));
208 packed.push(p.map_or(GpuPointLight::zeroed(), |l| GpuPointLight {
209 pos: l.position,
210 radius: l.radius,
211 color: l.color,
212 intensity: l.intensity,
213 spot_dir: l.spot_dir,
214 cos_outer: l.cos_outer,
215 cos_inner: l.cos_inner,
216 casts_shadow: u32::from(l.casts_shadow && allow),
217 _pad: [0; 2],
218 }));
219 }
220 }
221 let sun_flags = u32::from(sun_enabled)
222 | (u32::from(sun_enabled && lights.sun_casts_shadow) << 1)
223 | (u32::from(lights.enabled) << 2);
224 (packed, sun_flags, point_count as u32)
225}
226
227impl GpuRenderer {
228 /// DL — set the per-frame dynamic lights (sun + point lights), already
229 /// transformed into each grid's local frame. Call once per frame before
230 /// [`Self::render_scene`] (the facade does this from
231 /// `FrameParams::lights`). [`SceneLights::default`] clears all lights —
232 /// the pre-DL render. GPU-only; the CPU backend has no analogue.
233 /// PF.5 — an unchanged rig is a no-op: `render_scene` re-packs +
234 /// re-uploads the light buffers only when this actually stores
235 /// something different, so a static rig costs nothing per frame.
236 pub fn set_scene_lights(&mut self, lights: SceneLights) {
237 if self.scene_lights != lights {
238 self.scene_lights = lights;
239 self.dirty.mark_lights_changed();
240 }
241 }
242}
243
244impl HeadlessSceneRenderer {
245 /// DL — set dynamic lights for subsequent [`Self::render`] calls
246 /// (already in grid-local space). Lets tests exercise the lit path
247 /// (sun N·L, point lights). Default = none (baked-only).
248 pub fn set_scene_lights(&mut self, lights: SceneLights) {
249 self.lights = lights;
250 }
251}