Skip to main content

oxihuman_viewer/
light_probe_debug.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Spherical harmonic light probe debug visualization.
5
6#![allow(dead_code)]
7
8use std::f32::consts::PI;
9
10/// 9-coefficient L2 SH probe (RGB).
11#[allow(dead_code)]
12#[derive(Debug, Clone)]
13pub struct ShProbe {
14    /// SH coefficients: `[coeff][rgb]`.
15    pub coeffs: [[f32; 3]; 9],
16}
17
18#[allow(dead_code)]
19impl Default for ShProbe {
20    fn default() -> Self {
21        Self {
22            coeffs: [[0.0; 3]; 9],
23        }
24    }
25}
26
27/// Debug visualization config.
28#[allow(dead_code)]
29#[derive(Debug, Clone)]
30pub struct LightProbeDebugConfig {
31    /// Sphere resolution for debug rendering.
32    pub sphere_subdivisions: u32,
33    /// Exposure multiplier.
34    pub exposure: f32,
35    /// Show axes overlay.
36    pub show_axes: bool,
37}
38
39#[allow(dead_code)]
40impl Default for LightProbeDebugConfig {
41    fn default() -> Self {
42        Self {
43            sphere_subdivisions: 32,
44            exposure: 1.0,
45            show_axes: true,
46        }
47    }
48}
49
50/// Create a new default config.
51#[allow(dead_code)]
52pub fn new_light_probe_debug_config() -> LightProbeDebugConfig {
53    LightProbeDebugConfig::default()
54}
55
56/// Create a new empty SH probe.
57#[allow(dead_code)]
58pub fn new_sh_probe() -> ShProbe {
59    ShProbe::default()
60}
61
62/// Evaluate the L0 SH coefficient (ambient).
63#[allow(dead_code)]
64pub fn sh_l0_scale() -> f32 {
65    1.0 / (2.0 * (PI).sqrt())
66}
67
68/// Evaluate SH irradiance at a direction given by spherical angles.
69#[allow(dead_code)]
70#[allow(clippy::needless_range_loop)]
71pub fn evaluate_sh_irradiance(probe: &ShProbe, theta: f32, phi: f32) -> [f32; 3] {
72    let x = theta.sin() * phi.cos();
73    let y = theta.cos();
74    let z = theta.sin() * phi.sin();
75    let basis = [
76        0.282_095f32,
77        0.488_603 * y,
78        0.488_603 * z,
79        0.488_603 * x,
80        1.092_548 * x * y,
81        1.092_548 * y * z,
82        0.315_392 * (3.0 * z * z - 1.0),
83        1.092_548 * x * z,
84        0.546_274 * (x * x - y * y),
85    ];
86    let mut out = [0.0f32; 3];
87    for c in 0..9 {
88        out[0] += probe.coeffs[c][0] * basis[c];
89        out[1] += probe.coeffs[c][1] * basis[c];
90        out[2] += probe.coeffs[c][2] * basis[c];
91    }
92    out
93}
94
95/// Set exposure.
96#[allow(dead_code)]
97pub fn lpd_set_exposure(cfg: &mut LightProbeDebugConfig, value: f32) {
98    cfg.exposure = value.max(0.0);
99}
100
101/// Reset probe coefficients to zero.
102#[allow(dead_code)]
103pub fn sh_probe_reset(probe: &mut ShProbe) {
104    *probe = ShProbe::default();
105}
106
107/// Compute total energy of the probe.
108#[allow(dead_code)]
109pub fn sh_probe_energy(probe: &ShProbe) -> f32 {
110    probe
111        .coeffs
112        .iter()
113        .flat_map(|c| c.iter())
114        .map(|v| v * v)
115        .sum::<f32>()
116        .sqrt()
117}
118
119/// Serialize config to JSON.
120#[allow(dead_code)]
121pub fn light_probe_debug_to_json(cfg: &LightProbeDebugConfig) -> String {
122    format!(
123        r#"{{"sphere_subdivisions":{},"exposure":{:.4},"show_axes":{}}}"#,
124        cfg.sphere_subdivisions, cfg.exposure, cfg.show_axes
125    )
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_default_config() {
134        let c = LightProbeDebugConfig::default();
135        assert_eq!(c.sphere_subdivisions, 32);
136        assert!((c.exposure - 1.0).abs() < 1e-6);
137    }
138
139    #[test]
140    fn test_sh_l0_scale() {
141        let s = sh_l0_scale();
142        assert!(s > 0.0 && s < 1.0);
143    }
144
145    #[test]
146    fn test_evaluate_sh_zero_probe() {
147        let probe = ShProbe::default();
148        let irr = evaluate_sh_irradiance(&probe, 0.0, 0.0);
149        assert!(irr[0].abs() < 1e-5);
150    }
151
152    #[test]
153    fn test_sh_probe_energy_zero() {
154        let probe = ShProbe::default();
155        assert!(sh_probe_energy(&probe) < 1e-6);
156    }
157
158    #[test]
159    fn test_sh_probe_energy_nonzero() {
160        let mut probe = ShProbe::default();
161        probe.coeffs[0][0] = 1.0;
162        assert!(sh_probe_energy(&probe) > 0.0);
163    }
164
165    #[test]
166    fn test_set_exposure_clamped() {
167        let mut c = LightProbeDebugConfig::default();
168        lpd_set_exposure(&mut c, -2.0);
169        assert!(c.exposure < 1e-6);
170    }
171
172    #[test]
173    fn test_sh_probe_reset() {
174        let mut p = ShProbe {
175            coeffs: [[1.0; 3]; 9],
176        };
177        sh_probe_reset(&mut p);
178        assert!(sh_probe_energy(&p) < 1e-6);
179    }
180
181    #[test]
182    fn test_to_json() {
183        let j = light_probe_debug_to_json(&LightProbeDebugConfig::default());
184        assert!(j.contains("sphere_subdivisions"));
185    }
186
187    #[test]
188    fn test_pi_consts_used() {
189        let s = sh_l0_scale();
190        let expected = 1.0 / (2.0 * PI.sqrt());
191        assert!((s - expected).abs() < 1e-5);
192    }
193}