Skip to main content

proof_engine/svogi/
inject.rs

1use glam::{Vec3, Vec4, Mat4};
2use super::octree::{VoxelData, VoxelGrid};
3
4/// A directional light (e.g., sun).
5#[derive(Debug, Clone, Copy)]
6pub struct DirectionalLight {
7    pub direction: Vec3,
8    pub color: Vec3,
9    pub intensity: f32,
10}
11
12/// A point light.
13#[derive(Debug, Clone, Copy)]
14pub struct PointLight {
15    pub position: Vec3,
16    pub color: Vec3,
17    pub intensity: f32,
18    pub radius: f32,
19}
20
21/// A spot light.
22#[derive(Debug, Clone, Copy)]
23pub struct SpotLight {
24    pub position: Vec3,
25    pub direction: Vec3,
26    pub color: Vec3,
27    pub intensity: f32,
28    pub angle: f32,
29    pub falloff: f32,
30}
31
32/// A unified light source enum.
33#[derive(Debug, Clone, Copy)]
34pub enum LightSource {
35    Directional(DirectionalLight),
36    Point(PointLight),
37    Spot(SpotLight),
38}
39
40/// A shadow map with depth data.
41#[derive(Debug, Clone)]
42pub struct ShadowMap {
43    pub depth_data: Vec<f32>,
44    pub resolution: u32,
45    pub view_proj: Mat4,
46}
47
48impl ShadowMap {
49    pub fn new(resolution: u32, view_proj: Mat4) -> Self {
50        let count = (resolution * resolution) as usize;
51        Self {
52            depth_data: vec![1.0; count],
53            resolution,
54            view_proj,
55        }
56    }
57
58    /// Sample depth at the given UV coordinate.
59    fn sample_depth(&self, u: f32, v: f32) -> f32 {
60        let x = (u * self.resolution as f32) as i32;
61        let y = (v * self.resolution as f32) as i32;
62        if x < 0 || y < 0 || x >= self.resolution as i32 || y >= self.resolution as i32 {
63            return 1.0;
64        }
65        let idx = (y as u32 * self.resolution + x as u32) as usize;
66        if idx < self.depth_data.len() {
67            self.depth_data[idx]
68        } else {
69            1.0
70        }
71    }
72}
73
74/// Configuration for light injection.
75#[derive(Debug, Clone)]
76pub struct LightInjectionConfig {
77    pub bounce_emissive: bool,
78    pub shadow_bias: f32,
79    pub pcf_radius: f32,
80}
81
82impl Default for LightInjectionConfig {
83    fn default() -> Self {
84        Self {
85            bounce_emissive: true,
86            shadow_bias: 0.005,
87            pcf_radius: 1.0,
88        }
89    }
90}
91
92/// Test visibility of a world position against a shadow map.
93/// Returns 0.0 for fully shadowed, 1.0 for fully lit.
94pub fn shadow_test(world_pos: Vec3, shadow_map: &ShadowMap) -> f32 {
95    let clip = shadow_map.view_proj * Vec4::new(world_pos.x, world_pos.y, world_pos.z, 1.0);
96    if clip.w <= 0.0 {
97        return 1.0; // Behind the light
98    }
99    let ndc = Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
100
101    // Convert NDC to UV [0,1]
102    let u = ndc.x * 0.5 + 0.5;
103    let v = ndc.y * 0.5 + 0.5;
104    let depth = ndc.z * 0.5 + 0.5;
105
106    if u < 0.0 || u > 1.0 || v < 0.0 || v > 1.0 {
107        return 1.0; // Outside shadow map
108    }
109
110    let shadow_depth = shadow_map.sample_depth(u, v);
111    let bias = 0.005;
112    if depth - bias > shadow_depth {
113        0.0 // In shadow
114    } else {
115        1.0 // Lit
116    }
117}
118
119/// Percentage-closer filtering for soft shadows.
120pub fn pcf_sample(shadow_map: &ShadowMap, uv: (f32, f32), depth: f32, kernel_size: u32) -> f32 {
121    let texel_size = 1.0 / shadow_map.resolution as f32;
122    let half_k = kernel_size as i32 / 2;
123    let bias = 0.005;
124    let mut lit_count = 0.0;
125    let mut total = 0.0;
126
127    for dy in -half_k..=half_k {
128        for dx in -half_k..=half_k {
129            let su = uv.0 + dx as f32 * texel_size;
130            let sv = uv.1 + dy as f32 * texel_size;
131            let shadow_depth = shadow_map.sample_depth(su, sv);
132            if depth - bias <= shadow_depth {
133                lit_count += 1.0;
134            }
135            total += 1.0;
136        }
137    }
138
139    if total > 0.0 { lit_count / total } else { 1.0 }
140}
141
142/// Shadow test with PCF soft shadows.
143fn shadow_test_pcf(world_pos: Vec3, shadow_map: &ShadowMap, config: &LightInjectionConfig) -> f32 {
144    let clip = shadow_map.view_proj * Vec4::new(world_pos.x, world_pos.y, world_pos.z, 1.0);
145    if clip.w <= 0.0 {
146        return 1.0;
147    }
148    let ndc = Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
149    let u = ndc.x * 0.5 + 0.5;
150    let v = ndc.y * 0.5 + 0.5;
151    let depth = ndc.z * 0.5 + 0.5;
152
153    if u < 0.0 || u > 1.0 || v < 0.0 || v > 1.0 {
154        return 1.0;
155    }
156
157    let kernel = (config.pcf_radius * 2.0 + 1.0) as u32;
158    pcf_sample(shadow_map, (u, v), depth, kernel.max(1))
159}
160
161/// Compute light contribution from a light source at a given position.
162fn compute_light_contribution(
163    light: &LightSource,
164    world_pos: Vec3,
165    normal: Vec3,
166) -> Vec3 {
167    match light {
168        LightSource::Directional(dl) => {
169            let n_dot_l = normal.dot(-dl.direction.normalize()).max(0.0);
170            dl.color * dl.intensity * n_dot_l
171        }
172        LightSource::Point(pl) => {
173            let to_light = pl.position - world_pos;
174            let dist = to_light.length();
175            if dist > pl.radius || dist < 1e-6 {
176                return Vec3::ZERO;
177            }
178            let dir = to_light / dist;
179            let n_dot_l = normal.dot(dir).max(0.0);
180            let attenuation = 1.0 / (1.0 + dist * dist);
181            let range_falloff = (1.0 - (dist / pl.radius).powi(2)).max(0.0);
182            pl.color * pl.intensity * n_dot_l * attenuation * range_falloff
183        }
184        LightSource::Spot(sl) => {
185            let to_light = sl.position - world_pos;
186            let dist = to_light.length();
187            if dist < 1e-6 {
188                return Vec3::ZERO;
189            }
190            let dir = to_light / dist;
191            let n_dot_l = normal.dot(dir).max(0.0);
192            let spot_cos = (-dir).dot(sl.direction.normalize());
193            let cone_cos = sl.angle.cos();
194            if spot_cos < cone_cos {
195                return Vec3::ZERO;
196            }
197            let spot_factor = ((spot_cos - cone_cos) / (1.0 - cone_cos)).powf(sl.falloff);
198            let attenuation = 1.0 / (1.0 + dist * dist);
199            sl.color * sl.intensity * n_dot_l * attenuation * spot_factor
200        }
201    }
202}
203
204/// Compute the world position of a voxel in the grid.
205fn voxel_world_pos(grid: &VoxelGrid, x: u32, y: u32, z: u32, world_min: Vec3, voxel_size: Vec3) -> Vec3 {
206    world_min + Vec3::new(
207        (x as f32 + 0.5) * voxel_size.x,
208        (y as f32 + 0.5) * voxel_size.y,
209        (z as f32 + 0.5) * voxel_size.z,
210    )
211}
212
213/// Inject direct lighting into the voxel grid.
214pub fn inject_direct_light(
215    grid: &mut VoxelGrid,
216    lights: &[LightSource],
217    shadow_maps: &[ShadowMap],
218    world_min: Vec3,
219    world_size: Vec3,
220) {
221    inject_direct_light_with_config(grid, lights, shadow_maps, world_min, world_size, &LightInjectionConfig::default());
222}
223
224/// Inject direct lighting with configuration.
225pub fn inject_direct_light_with_config(
226    grid: &mut VoxelGrid,
227    lights: &[LightSource],
228    shadow_maps: &[ShadowMap],
229    world_min: Vec3,
230    world_size: Vec3,
231    config: &LightInjectionConfig,
232) {
233    let res = grid.resolution;
234    let voxel_size = world_size / Vec3::new(res.x as f32, res.y as f32, res.z as f32);
235
236    for z in 0..res.z {
237        for y in 0..res.y {
238            for x in 0..res.x {
239                let vd = grid.get(x, y, z);
240                if vd.is_empty() {
241                    continue;
242                }
243                let normal = vd.normal;
244                let existing_color = Vec3::new(vd.radiance.x, vd.radiance.y, vd.radiance.z);
245                let world_pos = voxel_world_pos(grid, x, y, z, world_min, voxel_size);
246
247                let mut total_light = Vec3::ZERO;
248                for (light_idx, light) in lights.iter().enumerate() {
249                    let contribution = compute_light_contribution(light, world_pos, normal);
250
251                    // Apply shadow
252                    let shadow_factor = if light_idx < shadow_maps.len() {
253                        shadow_test_pcf(world_pos, &shadow_maps[light_idx], config)
254                    } else {
255                        1.0
256                    };
257
258                    total_light += contribution * shadow_factor;
259                }
260
261                let vd = grid.get_mut(x, y, z);
262                // Modulate existing color by lighting
263                let lit_color = Vec3::new(
264                    existing_color.x * total_light.x,
265                    existing_color.y * total_light.y,
266                    existing_color.z * total_light.z,
267                ) + total_light * 0.1; // Small ambient term
268                vd.radiance = Vec4::new(lit_color.x, lit_color.y, lit_color.z, vd.radiance.w);
269            }
270        }
271    }
272}
273
274/// Inject emissive lighting: self-illuminating voxels contribute their emission as radiance.
275pub fn inject_emissive(grid: &mut VoxelGrid) {
276    let res = grid.resolution;
277    for z in 0..res.z {
278        for y in 0..res.y {
279            for x in 0..res.x {
280                let vd = grid.get(x, y, z);
281                if vd.is_empty() {
282                    continue;
283                }
284                // If the radiance w-component (emission flag) is high, boost radiance
285                let emission_strength = vd.radiance.w;
286                if emission_strength > 1.0 {
287                    let boost = emission_strength - 1.0;
288                    let idx = grid.index(x, y, z);
289                    let vd = &mut grid.data[idx];
290                    vd.radiance.x += boost;
291                    vd.radiance.y += boost;
292                    vd.radiance.z += boost;
293                }
294            }
295        }
296    }
297}
298
299/// Inject emissive lighting from a separate emission channel stored in sh_coeffs[0].
300pub fn inject_emissive_from_sh(grid: &mut VoxelGrid) {
301    let res = grid.resolution;
302    for z in 0..res.z {
303        for y in 0..res.y {
304            for x in 0..res.x {
305                let idx = grid.index(x, y, z);
306                let emission = grid.data[idx].sh_coeffs[0];
307                if emission > 0.0 {
308                    grid.data[idx].radiance.x += emission;
309                    grid.data[idx].radiance.y += emission;
310                    grid.data[idx].radiance.z += emission;
311                }
312            }
313        }
314    }
315}
316
317/// Embedded compute shader for GPU light injection.
318pub const INJECT_COMP_SRC: &str = r#"
319#version 450
320layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in;
321
322layout(rgba16f, binding = 0) uniform image3D voxelRadiance;
323layout(r32f, binding = 1) uniform readonly image3D voxelOpacity;
324
325struct DirectionalLight {
326    vec4 direction;
327    vec4 color; // xyz = color, w = intensity
328};
329
330layout(std140, binding = 2) uniform LightBlock {
331    DirectionalLight lights[8];
332    int lightCount;
333};
334
335layout(binding = 3) uniform sampler2D shadowMap;
336uniform mat4 shadowViewProj;
337uniform vec3 worldMin;
338uniform vec3 worldSize;
339uniform uint resolution;
340
341float pcfShadow(vec3 worldPos) {
342    vec4 clip = shadowViewProj * vec4(worldPos, 1.0);
343    vec3 ndc = clip.xyz / clip.w;
344    vec2 uv = ndc.xy * 0.5 + 0.5;
345    float depth = ndc.z * 0.5 + 0.5;
346
347    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
348
349    float shadow = 0.0;
350    float texelSize = 1.0 / float(textureSize(shadowMap, 0).x);
351    for (int dy = -1; dy <= 1; dy++) {
352        for (int dx = -1; dx <= 1; dx++) {
353            float d = texture(shadowMap, uv + vec2(dx, dy) * texelSize).r;
354            shadow += (depth - 0.005 > d) ? 0.0 : 1.0;
355        }
356    }
357    return shadow / 9.0;
358}
359
360void main() {
361    ivec3 coord = ivec3(gl_GlobalInvocationID.xyz);
362    if (any(greaterThanEqual(coord, ivec3(resolution)))) return;
363
364    float opacity = imageLoad(voxelOpacity, coord).r;
365    if (opacity <= 0.0) return;
366
367    vec3 voxelSize = worldSize / float(resolution);
368    vec3 worldPos = worldMin + (vec3(coord) + 0.5) * voxelSize;
369
370    vec4 currentRadiance = imageLoad(voxelRadiance, coord);
371    vec3 normal = normalize(currentRadiance.xyz * 2.0 - 1.0); // packed normal approximation
372
373    vec3 totalLight = vec3(0.0);
374    for (int i = 0; i < lightCount; i++) {
375        vec3 lightDir = -normalize(lights[i].direction.xyz);
376        float NdotL = max(dot(normal, lightDir), 0.0);
377        float shadow = pcfShadow(worldPos);
378        totalLight += lights[i].color.xyz * lights[i].color.w * NdotL * shadow;
379    }
380
381    imageStore(voxelRadiance, coord, vec4(totalLight, opacity));
382}
383"#;
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use glam::UVec3;
389
390    fn make_test_grid() -> VoxelGrid {
391        let mut grid = VoxelGrid::new(UVec3::new(4, 4, 4));
392        // Place a voxel at (1,1,1) with upward normal
393        grid.set(1, 1, 1, VoxelData {
394            radiance: Vec4::new(1.0, 1.0, 1.0, 1.0),
395            normal: Vec3::Y,
396            opacity: 1.0,
397            sh_coeffs: [0.0; 9],
398        });
399        // Place a voxel at (2,2,2)
400        grid.set(2, 2, 2, VoxelData {
401            radiance: Vec4::new(0.5, 0.5, 0.5, 1.0),
402            normal: Vec3::Y,
403            opacity: 1.0,
404            sh_coeffs: [0.0; 9],
405        });
406        grid
407    }
408
409    #[test]
410    fn test_inject_directional_light() {
411        let mut grid = make_test_grid();
412        let light = LightSource::Directional(DirectionalLight {
413            direction: Vec3::new(0.0, -1.0, 0.0),
414            color: Vec3::new(1.0, 1.0, 1.0),
415            intensity: 2.0,
416        });
417
418        inject_direct_light(
419            &mut grid,
420            &[light],
421            &[],
422            Vec3::ZERO,
423            Vec3::splat(4.0),
424        );
425
426        let vd = grid.get(1, 1, 1);
427        // Voxel with upward normal lit by downward light: NdotL = 1.0
428        assert!(vd.radiance.x > 0.0, "Lit voxel should have positive radiance");
429    }
430
431    #[test]
432    fn test_inject_point_light() {
433        let mut grid = make_test_grid();
434        let light = LightSource::Point(PointLight {
435            position: Vec3::new(1.5, 3.0, 1.5),
436            color: Vec3::ONE,
437            intensity: 5.0,
438            radius: 10.0,
439        });
440
441        inject_direct_light(&mut grid, &[light], &[], Vec3::ZERO, Vec3::splat(4.0));
442        let vd = grid.get(1, 1, 1);
443        assert!(vd.radiance.x > 0.0);
444    }
445
446    #[test]
447    fn test_inject_spot_light() {
448        let mut grid = make_test_grid();
449        let light = LightSource::Spot(SpotLight {
450            position: Vec3::new(1.5, 4.0, 1.5),
451            direction: Vec3::new(0.0, -1.0, 0.0),
452            color: Vec3::ONE,
453            intensity: 5.0,
454            angle: std::f32::consts::FRAC_PI_4,
455            falloff: 1.0,
456        });
457
458        inject_direct_light(&mut grid, &[light], &[], Vec3::ZERO, Vec3::splat(4.0));
459        let vd = grid.get(1, 1, 1);
460        assert!(vd.radiance.x > 0.0);
461    }
462
463    #[test]
464    fn test_shadow_test_fully_shadowed() {
465        // Create shadow map where everything is at depth 0 (everything behind)
466        let mut sm = ShadowMap::new(4, Mat4::IDENTITY);
467        for d in &mut sm.depth_data {
468            *d = 0.0;
469        }
470
471        let result = shadow_test(Vec3::new(0.0, 0.0, 0.5), &sm);
472        // Position maps to depth ~0.75 in [0,1], shadow depth is 0, so shadowed
473        assert!(result < 0.5, "Should be in shadow, got {result}");
474    }
475
476    #[test]
477    fn test_shadow_test_fully_lit() {
478        // Shadow map with max depth (nothing in shadow)
479        let sm = ShadowMap::new(4, Mat4::IDENTITY);
480
481        let result = shadow_test(Vec3::new(0.0, 0.0, 0.0), &sm);
482        assert!((result - 1.0).abs() < 0.01, "Should be fully lit, got {result}");
483    }
484
485    #[test]
486    fn test_pcf_sample() {
487        let sm = ShadowMap::new(8, Mat4::IDENTITY);
488        let result = pcf_sample(&sm, (0.5, 0.5), 0.4, 3);
489        assert!((result - 1.0).abs() < 0.01, "All texels at depth 1.0, should be fully lit");
490    }
491
492    #[test]
493    fn test_inject_emissive() {
494        let mut grid = VoxelGrid::new(UVec3::new(4, 4, 4));
495        grid.set(1, 1, 1, VoxelData {
496            radiance: Vec4::new(0.5, 0.5, 0.5, 2.0), // w > 1 = emissive
497            normal: Vec3::Y,
498            opacity: 1.0,
499            sh_coeffs: [0.0; 9],
500        });
501
502        inject_emissive(&mut grid);
503        let vd = grid.get(1, 1, 1);
504        assert!(vd.radiance.x > 0.5, "Emissive voxel should be brighter");
505    }
506
507    #[test]
508    fn test_inject_with_shadow_map() {
509        let mut grid = make_test_grid();
510        let light = LightSource::Directional(DirectionalLight {
511            direction: Vec3::new(0.0, -1.0, 0.0),
512            color: Vec3::ONE,
513            intensity: 2.0,
514        });
515
516        // Shadow map that blocks everything
517        let mut sm = ShadowMap::new(64, Mat4::IDENTITY);
518        for d in &mut sm.depth_data {
519            *d = 0.0;
520        }
521
522        inject_direct_light(&mut grid, &[light], &[sm], Vec3::ZERO, Vec3::splat(4.0));
523
524        // With everything in shadow, radiance should be minimal (only ambient)
525        let vd = grid.get(1, 1, 1);
526        // The light is mostly blocked, but there's a small ambient term
527        assert!(vd.radiance.x < 1.0, "Shadowed voxel should have reduced radiance");
528    }
529
530    #[test]
531    fn test_no_lights_no_change() {
532        let mut grid = make_test_grid();
533        let original_radiance = grid.get(1, 1, 1).radiance;
534        inject_direct_light(&mut grid, &[], &[], Vec3::ZERO, Vec3::splat(4.0));
535        // With no lights, there should be zero contribution
536        let vd = grid.get(1, 1, 1);
537        // Radiance is modulated by lighting (which is zero), so it becomes small
538        assert!(vd.radiance.x <= original_radiance.x);
539    }
540}