Skip to main content

proof_engine/svogi/
propagate.rs

1use glam::{Vec3, Vec4, UVec3};
2use super::octree::{VoxelData, VoxelGrid, SparseVoxelOctree, Aabb};
3
4/// Configuration for light propagation.
5#[derive(Debug, Clone)]
6pub struct PropagationConfig {
7    pub iterations: u32,
8    pub damping: f32,
9    pub flux_weight: f32,
10}
11
12impl Default for PropagationConfig {
13    fn default() -> Self {
14        Self {
15            iterations: 4,
16            damping: 0.8,
17            flux_weight: 1.0,
18        }
19    }
20}
21
22/// SH coefficients for propagation (2nd order, 9 coefficients).
23#[derive(Debug, Clone, Copy)]
24pub struct SHCoeffs {
25    pub coeffs: [f32; 9],
26}
27
28impl Default for SHCoeffs {
29    fn default() -> Self {
30        Self { coeffs: [0.0; 9] }
31    }
32}
33
34impl SHCoeffs {
35    /// Evaluate the SH representation at a given direction.
36    pub fn evaluate(&self, direction: Vec3) -> f32 {
37        let d = direction.normalize_or_zero();
38        let x = d.x;
39        let y = d.y;
40        let z = d.z;
41
42        // SH basis functions (real, orthonormal)
43        let y00 = 0.282095;                        // L=0, M=0
44        let y1n1 = 0.488603 * y;                   // L=1, M=-1
45        let y10 = 0.488603 * z;                    // L=1, M=0
46        let y1p1 = 0.488603 * x;                   // L=1, M=1
47        let y2n2 = 1.092548 * x * y;              // L=2, M=-2
48        let y2n1 = 1.092548 * y * z;              // L=2, M=-1
49        let y20 = 0.315392 * (3.0 * z * z - 1.0); // L=2, M=0
50        let y2p1 = 1.092548 * x * z;              // L=2, M=1
51        let y2p2 = 0.546274 * (x * x - y * y);    // L=2, M=2
52
53        self.coeffs[0] * y00
54            + self.coeffs[1] * y1n1
55            + self.coeffs[2] * y10
56            + self.coeffs[3] * y1p1
57            + self.coeffs[4] * y2n2
58            + self.coeffs[5] * y2n1
59            + self.coeffs[6] * y20
60            + self.coeffs[7] * y2p1
61            + self.coeffs[8] * y2p2
62    }
63
64    /// Project a directional contribution into SH.
65    pub fn project(&mut self, direction: Vec3, value: f32) {
66        let d = direction.normalize_or_zero();
67        let x = d.x;
68        let y = d.y;
69        let z = d.z;
70
71        self.coeffs[0] += value * 0.282095;
72        self.coeffs[1] += value * 0.488603 * y;
73        self.coeffs[2] += value * 0.488603 * z;
74        self.coeffs[3] += value * 0.488603 * x;
75        self.coeffs[4] += value * 1.092548 * x * y;
76        self.coeffs[5] += value * 1.092548 * y * z;
77        self.coeffs[6] += value * 0.315392 * (3.0 * z * z - 1.0);
78        self.coeffs[7] += value * 1.092548 * x * z;
79        self.coeffs[8] += value * 0.546274 * (x * x - y * y);
80    }
81
82    pub fn add(&self, other: &SHCoeffs) -> SHCoeffs {
83        let mut result = SHCoeffs::default();
84        for i in 0..9 {
85            result.coeffs[i] = self.coeffs[i] + other.coeffs[i];
86        }
87        result
88    }
89
90    pub fn scale(&self, s: f32) -> SHCoeffs {
91        let mut result = SHCoeffs::default();
92        for i in 0..9 {
93            result.coeffs[i] = self.coeffs[i] * s;
94        }
95        result
96    }
97
98    /// Convolve with clamped cosine kernel (for diffuse transfer).
99    pub fn convolve_cosine(&self) -> SHCoeffs {
100        // Zonal harmonic coefficients for clamped cosine
101        let a0 = std::f32::consts::PI;
102        let a1 = 2.0 * std::f32::consts::PI / 3.0;
103        let a2 = std::f32::consts::PI / 4.0;
104
105        let mut result = *self;
106        result.coeffs[0] *= a0;
107        result.coeffs[1] *= a1;
108        result.coeffs[2] *= a1;
109        result.coeffs[3] *= a1;
110        result.coeffs[4] *= a2;
111        result.coeffs[5] *= a2;
112        result.coeffs[6] *= a2;
113        result.coeffs[7] *= a2;
114        result.coeffs[8] *= a2;
115        result
116    }
117
118    /// Create SH representation of a delta function in the given direction.
119    pub fn from_direction(dir: Vec3) -> SHCoeffs {
120        let mut sh = SHCoeffs::default();
121        sh.project(dir, 1.0);
122        sh
123    }
124
125    /// Total energy (L2 norm squared).
126    pub fn energy(&self) -> f32 {
127        self.coeffs.iter().map(|c| c * c).sum()
128    }
129}
130
131/// The six principal face directions for flux propagation.
132const FACE_DIRECTIONS: [Vec3; 6] = [
133    Vec3::new(1.0, 0.0, 0.0),
134    Vec3::new(-1.0, 0.0, 0.0),
135    Vec3::new(0.0, 1.0, 0.0),
136    Vec3::new(0.0, -1.0, 0.0),
137    Vec3::new(0.0, 0.0, 1.0),
138    Vec3::new(0.0, 0.0, -1.0),
139];
140
141/// Offsets corresponding to FACE_DIRECTIONS for neighbor lookup.
142const FACE_OFFSETS: [(i32, i32, i32); 6] = [
143    (1, 0, 0), (-1, 0, 0),
144    (0, 1, 0), (0, -1, 0),
145    (0, 0, 1), (0, 0, -1),
146];
147
148/// Compute outgoing flux through a face based on SH-weighted radiance.
149pub fn flux_through_face(voxel: &VoxelData, face_normal: Vec3) -> Vec3 {
150    let sh = SHCoeffs { coeffs: voxel.sh_coeffs };
151    let flux_weight = sh.evaluate(face_normal).max(0.0);
152    let radiance = Vec3::new(voxel.radiance.x, voxel.radiance.y, voxel.radiance.z);
153    radiance * flux_weight
154}
155
156/// Gather incoming light from 6 neighbors.
157pub fn gather_from_neighbors(
158    grid: &VoxelGrid,
159    x: u32,
160    y: u32,
161    z: u32,
162) -> Vec3 {
163    let mut incoming = Vec3::ZERO;
164
165    for (i, &(dx, dy, dz)) in FACE_OFFSETS.iter().enumerate() {
166        let nx = x as i32 + dx;
167        let ny = y as i32 + dy;
168        let nz = z as i32 + dz;
169
170        if grid.in_bounds(nx, ny, nz) {
171            let neighbor = grid.get(nx as u32, ny as u32, nz as u32);
172            if !neighbor.is_empty() {
173                // Neighbor sends flux through the face towards us
174                let outgoing_face = -FACE_DIRECTIONS[i];
175                let flux = flux_through_face(neighbor, outgoing_face);
176                incoming += flux;
177            }
178        }
179    }
180
181    incoming
182}
183
184/// Single propagation step: read from source grid, write to dest grid.
185pub fn propagate_step(
186    source: &VoxelGrid,
187    dest: &mut VoxelGrid,
188    config: &PropagationConfig,
189) {
190    let res = source.resolution;
191
192    for z in 0..res.z {
193        for y in 0..res.y {
194            for x in 0..res.x {
195                let src = source.get(x, y, z);
196                let idx = dest.index(x, y, z);
197
198                if src.is_empty() {
199                    dest.data[idx] = *src;
200                    continue;
201                }
202
203                let incoming = gather_from_neighbors(source, x, y, z);
204                let damped = incoming * config.damping * config.flux_weight;
205
206                let existing = Vec3::new(src.radiance.x, src.radiance.y, src.radiance.z);
207                let new_radiance = existing + damped;
208
209                dest.data[idx] = VoxelData {
210                    radiance: Vec4::new(new_radiance.x, new_radiance.y, new_radiance.z, src.radiance.w),
211                    normal: src.normal,
212                    opacity: src.opacity,
213                    sh_coeffs: src.sh_coeffs,
214                };
215
216                // Update SH coefficients based on incoming light direction
217                let mut sh = SHCoeffs { coeffs: dest.data[idx].sh_coeffs };
218                for (i, &(dx, dy, dz)) in FACE_OFFSETS.iter().enumerate() {
219                    let nx = x as i32 + dx;
220                    let ny = y as i32 + dy;
221                    let nz = z as i32 + dz;
222                    if source.in_bounds(nx, ny, nz) {
223                        let neighbor = source.get(nx as u32, ny as u32, nz as u32);
224                        if !neighbor.is_empty() {
225                            let luminance = neighbor.radiance.x * 0.299
226                                + neighbor.radiance.y * 0.587
227                                + neighbor.radiance.z * 0.114;
228                            let dir = -FACE_DIRECTIONS[i];
229                            sh.project(dir, luminance * config.damping * 0.1);
230                        }
231                    }
232                }
233                dest.data[idx].sh_coeffs = sh.coeffs;
234            }
235        }
236    }
237}
238
239/// Run multiple iterations of light propagation on a voxel grid.
240pub fn propagate_light(grid: &mut VoxelGrid, config: &PropagationConfig) {
241    let mut temp = VoxelGrid::new(grid.resolution);
242
243    for _ in 0..config.iterations {
244        propagate_step(grid, &mut temp, config);
245        std::mem::swap(grid, &mut temp);
246    }
247}
248
249/// Propagation on octree levels (hierarchical).
250pub fn propagate_hierarchical(octree: &mut SparseVoxelOctree, iterations: u32) {
251    // Build a temporary grid from each level and propagate
252    let max_depth = octree.max_depth;
253    let bounds = octree.world_bounds;
254
255    for level in (1..=max_depth).rev() {
256        let res = 1u32 << level;
257        let mut grid = VoxelGrid::new(UVec3::splat(res));
258        let voxel_size = bounds.size() / Vec3::splat(res as f32);
259
260        // Extract voxel data from octree at this level
261        for (pos, data) in octree.iter_leaves() {
262            let vx = ((pos.x - bounds.min.x) / voxel_size.x).floor() as u32;
263            let vy = ((pos.y - bounds.min.y) / voxel_size.y).floor() as u32;
264            let vz = ((pos.z - bounds.min.z) / voxel_size.z).floor() as u32;
265            if vx < res && vy < res && vz < res {
266                grid.set(vx, vy, vz, *data);
267            }
268        }
269
270        let config = PropagationConfig {
271            iterations,
272            damping: 0.8,
273            flux_weight: 1.0,
274        };
275        propagate_light(&mut grid, &config);
276
277        // Write back to octree
278        for z in 0..res {
279            for y in 0..res {
280                for x in 0..res {
281                    let vd = grid.get(x, y, z);
282                    if !vd.is_empty() {
283                        let pos = bounds.min + Vec3::new(
284                            (x as f32 + 0.5) * voxel_size.x,
285                            (y as f32 + 0.5) * voxel_size.y,
286                            (z as f32 + 0.5) * voxel_size.z,
287                        );
288                        octree.insert(pos, *vd);
289                    }
290                }
291            }
292        }
293    }
294
295    octree.build_mipmaps();
296}
297
298/// Embedded compute shader for GPU propagation.
299pub const PROPAGATE_COMP_SRC: &str = r#"
300#version 450
301layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in;
302
303layout(rgba16f, binding = 0) uniform readonly image3D srcRadiance;
304layout(rgba16f, binding = 1) uniform writeonly image3D dstRadiance;
305
306uniform float damping;
307uniform float fluxWeight;
308uniform uint resolution;
309
310// SH evaluation for directional weighting
311float evaluateSH(vec4 shCoeffs, vec3 dir) {
312    return shCoeffs.x * 0.282095
313         + shCoeffs.y * 0.488603 * dir.y
314         + shCoeffs.z * 0.488603 * dir.z
315         + shCoeffs.w * 0.488603 * dir.x;
316}
317
318void main() {
319    ivec3 coord = ivec3(gl_GlobalInvocationID.xyz);
320    if (any(greaterThanEqual(coord, ivec3(resolution)))) return;
321
322    vec4 current = imageLoad(srcRadiance, coord);
323    if (current.a <= 0.0) {
324        imageStore(dstRadiance, coord, current);
325        return;
326    }
327
328    // 6-directional gather
329    vec3 incoming = vec3(0.0);
330    ivec3 offsets[6] = ivec3[6](
331        ivec3(1,0,0), ivec3(-1,0,0),
332        ivec3(0,1,0), ivec3(0,-1,0),
333        ivec3(0,0,1), ivec3(0,0,-1)
334    );
335
336    for (int i = 0; i < 6; i++) {
337        ivec3 ncoord = coord + offsets[i];
338        if (all(greaterThanEqual(ncoord, ivec3(0))) && all(lessThan(ncoord, ivec3(resolution)))) {
339            vec4 neighbor = imageLoad(srcRadiance, ncoord);
340            if (neighbor.a > 0.0) {
341                incoming += neighbor.rgb * fluxWeight;
342            }
343        }
344    }
345
346    vec3 propagated = current.rgb + incoming * damping / 6.0;
347    imageStore(dstRadiance, coord, vec4(propagated, current.a));
348}
349"#;
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use glam::UVec3;
355
356    #[test]
357    fn test_sh_coeffs_evaluate_project() {
358        let mut sh = SHCoeffs::default();
359        let dir = Vec3::Y;
360        sh.project(dir, 1.0);
361        let val = sh.evaluate(dir);
362        assert!(val > 0.0, "SH evaluation in projected direction should be positive");
363    }
364
365    #[test]
366    fn test_sh_coeffs_energy() {
367        let sh = SHCoeffs::from_direction(Vec3::X);
368        assert!(sh.energy() > 0.0);
369    }
370
371    #[test]
372    fn test_propagation_energy_decreases_with_damping() {
373        let mut grid = VoxelGrid::new(UVec3::new(8, 8, 8));
374        // Place an emitter in the center
375        grid.set(4, 4, 4, VoxelData {
376            radiance: Vec4::new(10.0, 10.0, 10.0, 1.0),
377            normal: Vec3::Y,
378            opacity: 1.0,
379            sh_coeffs: [0.282095, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
380        });
381        // Place neighbors so they can receive light
382        for &(dx, dy, dz) in &[(1,0,0), (-1,0,0), (0,1,0), (0,-1,0), (0,0,1), (0,0,-1)] {
383            let x = (4 + dx) as u32;
384            let y = (4 + dy) as u32;
385            let z = (4 + dz) as u32;
386            grid.set(x, y, z, VoxelData {
387                radiance: Vec4::new(0.0, 0.0, 0.0, 0.01),
388                normal: Vec3::Y,
389                opacity: 0.5,
390                sh_coeffs: [0.282095, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
391            });
392        }
393
394        let initial_energy: f32 = grid.data.iter()
395            .map(|v| v.radiance.x + v.radiance.y + v.radiance.z)
396            .sum();
397
398        let config = PropagationConfig {
399            iterations: 1,
400            damping: 0.5,
401            flux_weight: 0.5,
402        };
403        propagate_light(&mut grid, &config);
404
405        // Check neighbors received some light
406        let neighbor_radiance = grid.get(5, 4, 4).radiance.x;
407        assert!(neighbor_radiance > 0.0, "Neighbor should receive light, got {neighbor_radiance}");
408    }
409
410    #[test]
411    fn test_single_emitter_reaches_neighbors() {
412        let mut grid = VoxelGrid::new(UVec3::new(8, 8, 8));
413
414        // Place emitter
415        grid.set(4, 4, 4, VoxelData {
416            radiance: Vec4::new(5.0, 5.0, 5.0, 1.0),
417            normal: Vec3::Y,
418            opacity: 1.0,
419            sh_coeffs: [0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
420        });
421
422        // Place empty but occupant neighbors
423        for &(dx, dy, dz) in &[(1i32,0,0), (-1,0,0), (0,1,0), (0,-1,0), (0,0,1), (0,0,-1)] {
424            grid.set((4+dx) as u32, (4+dy) as u32, (4+dz) as u32, VoxelData {
425                radiance: Vec4::new(0.0, 0.0, 0.0, 0.01),
426                normal: Vec3::Y,
427                opacity: 0.5,
428                sh_coeffs: [0.282095, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
429            });
430        }
431
432        let config = PropagationConfig {
433            iterations: 2,
434            damping: 0.9,
435            flux_weight: 1.0,
436        };
437        propagate_light(&mut grid, &config);
438
439        // All 6 neighbors should have some radiance now
440        for &(dx, dy, dz) in &[(1i32,0,0), (-1,0,0), (0,1,0), (0,-1,0), (0,0,1), (0,0,-1)] {
441            let vd = grid.get((4+dx) as u32, (4+dy) as u32, (4+dz) as u32);
442            let luminance = vd.radiance.x + vd.radiance.y + vd.radiance.z;
443            assert!(luminance > 0.0, "Neighbor at ({dx},{dy},{dz}) should have light: {luminance}");
444        }
445    }
446
447    #[test]
448    fn test_flux_through_face() {
449        let vd = VoxelData {
450            radiance: Vec4::new(1.0, 0.5, 0.25, 1.0),
451            normal: Vec3::Y,
452            opacity: 1.0,
453            sh_coeffs: [0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
454        };
455
456        let flux = flux_through_face(&vd, Vec3::X);
457        // With only c0 = 0.5, evaluation is 0.5 * 0.282095 = 0.141
458        assert!(flux.x > 0.0);
459    }
460
461    #[test]
462    fn test_propagation_config_default() {
463        let config = PropagationConfig::default();
464        assert_eq!(config.iterations, 4);
465        assert!(config.damping < 1.0);
466    }
467
468    #[test]
469    fn test_sh_convolve_cosine() {
470        let sh = SHCoeffs::from_direction(Vec3::Y);
471        let convolved = sh.convolve_cosine();
472        // Convolution should preserve the general direction
473        let val_y = convolved.evaluate(Vec3::Y);
474        let val_neg_y = convolved.evaluate(-Vec3::Y);
475        assert!(val_y > val_neg_y);
476    }
477}