Skip to main content

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}