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    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}