Skip to main content

proof_engine/svogi/
cone_trace.rs

1use glam::{Vec3, Vec4};
2use super::octree::{SparseVoxelOctree, VoxelData};
3
4/// Configuration for cone tracing.
5#[derive(Debug, Clone)]
6pub struct ConeTraceConfig {
7    pub max_distance: f32,
8    pub step_multiplier: f32,
9    pub ao_weight: f32,
10    pub gi_weight: f32,
11}
12
13impl Default for ConeTraceConfig {
14    fn default() -> Self {
15        Self {
16            max_distance: 100.0,
17            step_multiplier: 1.0,
18            ao_weight: 1.0,
19            gi_weight: 1.0,
20        }
21    }
22}
23
24/// Result from a cone trace.
25#[derive(Debug, Clone, Copy)]
26pub struct ConeTraceResult {
27    pub color: Vec3,
28    pub occlusion: f32,
29    pub hit_distance: f32,
30}
31
32impl Default for ConeTraceResult {
33    fn default() -> Self {
34        Self {
35            color: Vec3::ZERO,
36            occlusion: 0.0,
37            hit_distance: f32::MAX,
38        }
39    }
40}
41
42/// Trace a cone through the octree.
43///
44/// cone_angle is the half-angle in radians.
45/// As the cone widens, coarser LOD levels are sampled.
46pub fn trace_cone(
47    octree: &SparseVoxelOctree,
48    origin: Vec3,
49    direction: Vec3,
50    cone_angle: f32,
51    config: &ConeTraceConfig,
52) -> ConeTraceResult {
53    let dir = direction.normalize_or_zero();
54    if dir.length_squared() < 0.5 {
55        return ConeTraceResult::default();
56    }
57
58    let voxel_size = octree.world_bounds.size().x / (1u32 << octree.max_depth) as f32;
59    let tan_half = cone_angle.tan();
60
61    let mut accumulated_color = Vec3::ZERO;
62    let mut accumulated_opacity = 0.0f32;
63    let mut hit_distance = f32::MAX;
64
65    // Start slightly offset to avoid self-intersection
66    let mut t = voxel_size * 1.0;
67
68    while t < config.max_distance && accumulated_opacity < 0.95 {
69        let sample_pos = origin + dir * t;
70
71        // Cone diameter at current distance
72        let diameter = 2.0 * t * tan_half;
73        let diameter = diameter.max(voxel_size);
74
75        // LOD level: log2(diameter / voxel_size), clamped to valid range
76        let lod = (diameter / voxel_size).log2().max(0.0).min(octree.max_depth as f32);
77        let lod_level = lod.round() as u8;
78
79        // Sample octree at this LOD
80        if let Some(data) = octree.lookup(sample_pos, lod_level) {
81            if !data.is_empty() {
82                let sample_color = Vec3::new(data.radiance.x, data.radiance.y, data.radiance.z);
83                let sample_opacity = data.opacity;
84
85                // Front-to-back compositing
86                let alpha = (1.0 - accumulated_opacity) * sample_opacity;
87                accumulated_color += sample_color * alpha * config.gi_weight;
88                accumulated_opacity += alpha;
89
90                if hit_distance == f32::MAX {
91                    hit_distance = t;
92                }
93            }
94        }
95
96        // Step size proportional to cone diameter (larger cones take bigger steps)
97        let step = diameter * config.step_multiplier;
98        t += step.max(voxel_size * 0.5);
99    }
100
101    ConeTraceResult {
102        color: accumulated_color,
103        occlusion: accumulated_opacity.min(1.0) * config.ao_weight,
104        hit_distance,
105    }
106}
107
108/// Compute diffuse global illumination by tracing multiple cones in a hemisphere.
109pub fn diffuse_gi(
110    octree: &SparseVoxelOctree,
111    position: Vec3,
112    normal: Vec3,
113    config: &ConeTraceConfig,
114) -> Vec3 {
115    let cones = hemisphere_cones(normal, 6);
116    let mut total_color = Vec3::ZERO;
117    let mut total_weight = 0.0f32;
118
119    for (dir, aperture) in &cones {
120        let result = trace_cone(octree, position, *dir, *aperture, config);
121        // Weight by cos(angle between direction and normal)
122        let weight = normal.dot(*dir).max(0.0);
123        total_color += result.color * weight;
124        total_weight += weight;
125    }
126
127    if total_weight > 0.0 {
128        total_color / total_weight
129    } else {
130        Vec3::ZERO
131    }
132}
133
134/// Compute specular global illumination using a single narrow cone in the reflection direction.
135pub fn specular_gi(
136    octree: &SparseVoxelOctree,
137    position: Vec3,
138    normal: Vec3,
139    view_dir: Vec3,
140    roughness: f32,
141    config: &ConeTraceConfig,
142) -> Vec3 {
143    let reflect_dir = view_dir - 2.0 * normal.dot(view_dir) * normal;
144    let reflect_dir = reflect_dir.normalize_or_zero();
145
146    // Cone aperture based on roughness: rough = wide cone, smooth = narrow
147    let aperture = (roughness * std::f32::consts::FRAC_PI_4).max(0.01);
148
149    let result = trace_cone(octree, position, reflect_dir, aperture, config);
150    result.color
151}
152
153/// Compute ambient occlusion by tracing wide cones above the surface.
154pub fn ambient_occlusion(
155    octree: &SparseVoxelOctree,
156    position: Vec3,
157    normal: Vec3,
158    config: &ConeTraceConfig,
159) -> f32 {
160    let cones = hemisphere_cones(normal, 4);
161    let mut total_occlusion = 0.0f32;
162
163    let ao_config = ConeTraceConfig {
164        max_distance: config.max_distance * 0.3, // AO is short-range
165        ao_weight: 1.0,
166        gi_weight: 0.0, // We only care about occlusion
167        ..*config
168    };
169
170    for (dir, aperture) in &cones {
171        let wide_aperture = aperture * 2.0; // Wider cones for AO
172        let result = trace_cone(octree, position, *dir, wide_aperture, &ao_config);
173        total_occlusion += result.occlusion;
174    }
175
176    let avg = total_occlusion / cones.len() as f32;
177    1.0 - avg.min(1.0)
178}
179
180/// Compute soft shadows by tracing a cone toward a light.
181pub fn soft_shadows(
182    octree: &SparseVoxelOctree,
183    position: Vec3,
184    light_dir: Vec3,
185    light_angle: f32,
186    config: &ConeTraceConfig,
187) -> f32 {
188    let result = trace_cone(octree, position, light_dir, light_angle, config);
189    1.0 - result.occlusion.min(1.0)
190}
191
192/// Generate cone directions distributed over a hemisphere.
193pub fn hemisphere_cones(normal: Vec3, count: usize) -> Vec<(Vec3, f32)> {
194    let n = normal.normalize_or_zero();
195    if n.length_squared() < 0.5 {
196        return vec![(Vec3::Y, 0.5); count];
197    }
198
199    // Build tangent frame
200    let up = if n.y.abs() < 0.99 { Vec3::Y } else { Vec3::X };
201    let tangent = n.cross(up).normalize();
202    let bitangent = n.cross(tangent).normalize();
203
204    let mut cones = Vec::with_capacity(count);
205    let aperture = std::f32::consts::PI / (count as f32 * 1.5);
206
207    match count {
208        1 => {
209            cones.push((n, aperture));
210        }
211        c => {
212            // Center cone
213            cones.push((n, aperture));
214
215            // Ring of remaining cones at ~60 degrees from normal
216            let ring_count = c - 1;
217            let ring_angle = std::f32::consts::FRAC_PI_3;
218            let cos_ring = ring_angle.cos();
219            let sin_ring = ring_angle.sin();
220
221            for i in 0..ring_count {
222                let phi = 2.0 * std::f32::consts::PI * i as f32 / ring_count as f32;
223                let dir = n * cos_ring
224                    + tangent * sin_ring * phi.cos()
225                    + bitangent * sin_ring * phi.sin();
226                cones.push((dir.normalize(), aperture * 1.2));
227            }
228        }
229    }
230
231    cones
232}
233
234/// Cone distribution presets.
235pub struct ConeDistribution;
236
237impl ConeDistribution {
238    /// 6 cones for diffuse illumination (1 center + 5 ring).
239    pub fn diffuse_6_cones(normal: Vec3) -> Vec<(Vec3, f32)> {
240        hemisphere_cones(normal, 6)
241    }
242
243    /// 16 cones for higher-quality diffuse.
244    pub fn diffuse_16_cones(normal: Vec3) -> Vec<(Vec3, f32)> {
245        let n = normal.normalize_or_zero();
246        let up = if n.y.abs() < 0.99 { Vec3::Y } else { Vec3::X };
247        let tangent = n.cross(up).normalize();
248        let bitangent = n.cross(tangent).normalize();
249
250        let mut cones = Vec::with_capacity(16);
251        let aperture = std::f32::consts::PI / 24.0;
252
253        // Center
254        cones.push((n, aperture));
255
256        // Inner ring at 30 deg
257        let inner_count = 5;
258        let inner_angle = std::f32::consts::FRAC_PI_6;
259        for i in 0..inner_count {
260            let phi = 2.0 * std::f32::consts::PI * i as f32 / inner_count as f32;
261            let dir = n * inner_angle.cos()
262                + tangent * inner_angle.sin() * phi.cos()
263                + bitangent * inner_angle.sin() * phi.sin();
264            cones.push((dir.normalize(), aperture));
265        }
266
267        // Outer ring at 60 deg
268        let outer_count = 10;
269        let outer_angle = std::f32::consts::FRAC_PI_3;
270        for i in 0..outer_count {
271            let phi = 2.0 * std::f32::consts::PI * i as f32 / outer_count as f32
272                + std::f32::consts::FRAC_PI_6 / outer_count as f32;
273            let dir = n * outer_angle.cos()
274                + tangent * outer_angle.sin() * phi.cos()
275                + bitangent * outer_angle.sin() * phi.sin();
276            cones.push((dir.normalize(), aperture * 1.5));
277        }
278
279        cones
280    }
281
282    /// 4 wide cones for ambient occlusion.
283    pub fn ao_4_cones(normal: Vec3) -> Vec<(Vec3, f32)> {
284        hemisphere_cones(normal, 4)
285    }
286}
287
288/// Embedded fragment shader for GPU cone tracing.
289pub const CONE_TRACE_FRAG_SRC: &str = r#"
290#version 450
291
292in vec2 vTexCoord;
293
294layout(location = 0) out vec4 fragColor;
295
296uniform sampler2D gPosition;
297uniform sampler2D gNormal;
298uniform sampler2D gAlbedo;
299uniform sampler3D voxelTexture;
300
301uniform mat4 voxelWorldToUVW; // Transform from world to [0,1] voxel UVW
302uniform float maxDistance;
303uniform float stepMultiplier;
304uniform float giIntensity;
305uniform float aoIntensity;
306uniform int maxDepth;
307
308// Cone trace through 3D texture
309vec4 traceCone(vec3 origin, vec3 dir, float aperture) {
310    float voxelSize = 1.0 / float(textureSize(voxelTexture, 0).x);
311    float t = voxelSize * 2.0;
312    vec3 color = vec3(0.0);
313    float alpha = 0.0;
314
315    while (t < maxDistance && alpha < 0.95) {
316        vec3 pos = origin + dir * t;
317        vec3 uvw = (voxelWorldToUVW * vec4(pos, 1.0)).xyz;
318
319        if (any(lessThan(uvw, vec3(0.0))) || any(greaterThan(uvw, vec3(1.0)))) break;
320
321        float diameter = 2.0 * t * tan(aperture);
322        float lod = log2(max(diameter / voxelSize, 1.0));
323
324        vec4 sample_val = textureLod(voxelTexture, uvw, lod);
325
326        float a = (1.0 - alpha) * sample_val.a;
327        color += sample_val.rgb * a;
328        alpha += a;
329
330        t += max(diameter * stepMultiplier, voxelSize * 0.5);
331    }
332
333    return vec4(color, alpha);
334}
335
336void main() {
337    vec3 worldPos = texture(gPosition, vTexCoord).xyz;
338    vec3 normal = normalize(texture(gNormal, vTexCoord).xyz);
339    vec3 albedo = texture(gAlbedo, vTexCoord).rgb;
340
341    if (length(normal) < 0.1) {
342        fragColor = vec4(0.0);
343        return;
344    }
345
346    // Build tangent frame
347    vec3 up = abs(normal.y) < 0.99 ? vec3(0,1,0) : vec3(1,0,0);
348    vec3 T = normalize(cross(normal, up));
349    vec3 B = normalize(cross(normal, T));
350
351    // Diffuse GI: 6 cones
352    vec3 diffuseGI = vec3(0.0);
353    float ao = 0.0;
354    float coneAperture = 0.5236; // ~30 degrees
355
356    // Center cone
357    vec4 c0 = traceCone(worldPos, normal, coneAperture);
358    diffuseGI += c0.rgb;
359    ao += c0.a;
360
361    // 5 ring cones at 60 degrees
362    float ringAngle = 1.0472; // 60 degrees
363    for (int i = 0; i < 5; i++) {
364        float phi = float(i) * 1.2566;
365        vec3 dir = normal * cos(ringAngle)
366                 + T * sin(ringAngle) * cos(phi)
367                 + B * sin(ringAngle) * sin(phi);
368        vec4 ci = traceCone(worldPos, normalize(dir), coneAperture * 1.2);
369        float weight = max(dot(normalize(dir), normal), 0.0);
370        diffuseGI += ci.rgb * weight;
371        ao += ci.a;
372    }
373
374    diffuseGI /= 6.0;
375    ao = 1.0 - min(ao / 6.0, 1.0);
376
377    vec3 finalColor = albedo * (diffuseGI * giIntensity + vec3(ao * aoIntensity * 0.1));
378    fragColor = vec4(finalColor, 1.0);
379}
380"#;
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::svogi::octree::{SparseVoxelOctree, VoxelData, Aabb};
386
387    fn make_empty_octree() -> SparseVoxelOctree {
388        SparseVoxelOctree::new(
389            Aabb::new(Vec3::ZERO, Vec3::splat(16.0)),
390            4,
391        )
392    }
393
394    fn make_filled_octree() -> SparseVoxelOctree {
395        let mut octree = make_empty_octree();
396        // Fill a 4x4x4 block of opaque voxels
397        for x in 4..8 {
398            for y in 4..8 {
399                for z in 4..8 {
400                    octree.insert(
401                        Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5),
402                        VoxelData {
403                            radiance: Vec4::new(1.0, 0.5, 0.25, 1.0),
404                            normal: Vec3::Y,
405                            opacity: 1.0,
406                            sh_coeffs: [0.0; 9],
407                        },
408                    );
409                }
410            }
411        }
412        octree.build_mipmaps();
413        octree
414    }
415
416    #[test]
417    fn test_empty_octree_no_occlusion() {
418        let octree = make_empty_octree();
419        let config = ConeTraceConfig::default();
420        let result = trace_cone(
421            &octree,
422            Vec3::new(8.0, 8.0, 0.0),
423            Vec3::Z,
424            0.5,
425            &config,
426        );
427        assert!(result.occlusion < 0.01, "Empty octree should produce no occlusion, got {}", result.occlusion);
428    }
429
430    #[test]
431    fn test_filled_octree_occlusion() {
432        let octree = make_filled_octree();
433        let config = ConeTraceConfig {
434            max_distance: 20.0,
435            ..Default::default()
436        };
437
438        // Trace from outside toward the filled block
439        let result = trace_cone(
440            &octree,
441            Vec3::new(6.0, 6.0, 0.0),
442            Vec3::Z,
443            0.1,
444            &config,
445        );
446        assert!(result.occlusion > 0.0, "Should have some occlusion from filled block, got {}", result.occlusion);
447    }
448
449    #[test]
450    fn test_diffuse_gi_returns_color() {
451        let octree = make_filled_octree();
452        let config = ConeTraceConfig {
453            max_distance: 20.0,
454            ..Default::default()
455        };
456
457        // Sample from just outside the block
458        let gi = diffuse_gi(&octree, Vec3::new(6.0, 9.0, 6.0), Vec3::Y, &config);
459        // May or may not see the block depending on cone spread; just check it runs
460        let _ = gi;
461    }
462
463    #[test]
464    fn test_specular_gi() {
465        let octree = make_filled_octree();
466        let config = ConeTraceConfig::default();
467
468        let spec = specular_gi(
469            &octree,
470            Vec3::new(6.0, 10.0, 6.0),
471            Vec3::Y,
472            Vec3::new(0.0, -1.0, -0.5).normalize(),
473            0.5,
474            &config,
475        );
476        let _ = spec;
477    }
478
479    #[test]
480    fn test_ambient_occlusion_value_range() {
481        let octree = make_empty_octree();
482        let config = ConeTraceConfig::default();
483
484        let ao = ambient_occlusion(&octree, Vec3::splat(8.0), Vec3::Y, &config);
485        assert!(ao >= 0.0 && ao <= 1.0, "AO should be in [0,1], got {ao}");
486        // Empty octree: no occlusion -> AO should be close to 1.0
487        assert!(ao > 0.5, "Empty scene should have high AO (low occlusion), got {ao}");
488    }
489
490    #[test]
491    fn test_soft_shadows() {
492        let octree = make_filled_octree();
493        let config = ConeTraceConfig::default();
494
495        let shadow = soft_shadows(
496            &octree,
497            Vec3::new(6.0, 0.0, 6.0),
498            Vec3::Y,
499            0.05,
500            &config,
501        );
502        assert!(shadow >= 0.0 && shadow <= 1.0);
503    }
504
505    #[test]
506    fn test_hemisphere_cones_count() {
507        let cones = hemisphere_cones(Vec3::Y, 6);
508        assert_eq!(cones.len(), 6);
509
510        let cones2 = hemisphere_cones(Vec3::Y, 1);
511        assert_eq!(cones2.len(), 1);
512    }
513
514    #[test]
515    fn test_hemisphere_cones_directions() {
516        let normal = Vec3::Y;
517        let cones = hemisphere_cones(normal, 6);
518
519        // All directions should be in the hemisphere (positive dot with normal)
520        for (dir, _) in &cones {
521            let dot = dir.dot(normal);
522            assert!(dot > 0.0, "Cone direction should be in hemisphere, dot={dot}");
523        }
524    }
525
526    #[test]
527    fn test_cone_distribution_presets() {
528        let n = Vec3::Z;
529        let d6 = ConeDistribution::diffuse_6_cones(n);
530        assert_eq!(d6.len(), 6);
531
532        let d16 = ConeDistribution::diffuse_16_cones(n);
533        assert_eq!(d16.len(), 16);
534
535        let ao4 = ConeDistribution::ao_4_cones(n);
536        assert_eq!(ao4.len(), 4);
537    }
538
539    #[test]
540    fn test_gi_color_matches_injected() {
541        let mut octree = SparseVoxelOctree::new(
542            Aabb::new(Vec3::ZERO, Vec3::splat(16.0)),
543            4,
544        );
545
546        // Insert bright red voxels
547        for x in 6..10 {
548            for y in 6..10 {
549                for z in 6..10 {
550                    octree.insert(
551                        Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5),
552                        VoxelData {
553                            radiance: Vec4::new(5.0, 0.0, 0.0, 1.0),
554                            normal: Vec3::Y,
555                            opacity: 1.0,
556                            sh_coeffs: [0.0; 9],
557                        },
558                    );
559                }
560            }
561        }
562        octree.build_mipmaps();
563
564        let config = ConeTraceConfig {
565            max_distance: 20.0,
566            step_multiplier: 1.0,
567            ao_weight: 1.0,
568            gi_weight: 1.0,
569        };
570
571        // Trace toward the red block
572        let result = trace_cone(
573            &octree,
574            Vec3::new(8.0, 8.0, 0.0),
575            Vec3::Z,
576            0.1,
577            &config,
578        );
579
580        if result.color.length() > 0.0 {
581            // If we hit something, it should be predominantly red
582            assert!(result.color.x >= result.color.y, "Color should be red-dominant");
583            assert!(result.color.x >= result.color.z, "Color should be red-dominant");
584        }
585    }
586}