Skip to main content

oxihuman_viewer/
env_diffuse.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Environment diffuse — spherical harmonics irradiance from environment maps.
6
7use std::f32::consts::{FRAC_1_PI, PI};
8
9/// SH band 0-1 coefficients (9 coefficients for L0+L1+L2).
10pub const SH_COEFF_COUNT: usize = 9;
11
12/// SH irradiance probe.
13#[derive(Debug, Clone, PartialEq)]
14#[allow(dead_code)]
15pub struct EnvDiffuseProbe {
16    /// 9 SH coefficients per RGB channel.
17    pub sh_r: [f32; SH_COEFF_COUNT],
18    pub sh_g: [f32; SH_COEFF_COUNT],
19    pub sh_b: [f32; SH_COEFF_COUNT],
20    pub intensity: f32,
21    pub enabled: bool,
22}
23
24impl Default for EnvDiffuseProbe {
25    fn default() -> Self {
26        Self {
27            sh_r: [0.0; SH_COEFF_COUNT],
28            sh_g: [0.0; SH_COEFF_COUNT],
29            sh_b: [0.0; SH_COEFF_COUNT],
30            intensity: 1.0,
31            enabled: true,
32        }
33    }
34}
35
36/// Environment diffuse configuration.
37#[derive(Debug, Clone, PartialEq)]
38#[allow(dead_code)]
39pub struct EnvDiffuseConfig {
40    pub max_probes: usize,
41    pub blend_radius: f32,
42}
43
44impl Default for EnvDiffuseConfig {
45    fn default() -> Self {
46        Self {
47            max_probes: 8,
48            blend_radius: 10.0,
49        }
50    }
51}
52
53/// Environment diffuse system.
54#[derive(Debug, Clone, Default)]
55#[allow(dead_code)]
56pub struct EnvDiffuse {
57    pub config: EnvDiffuseConfig,
58    pub probes: Vec<EnvDiffuseProbe>,
59}
60
61/// Create new env diffuse system.
62#[allow(dead_code)]
63pub fn new_env_diffuse(cfg: EnvDiffuseConfig) -> EnvDiffuse {
64    EnvDiffuse {
65        config: cfg,
66        probes: Vec::new(),
67    }
68}
69
70/// Add a probe.
71#[allow(dead_code)]
72pub fn add_probe(e: &mut EnvDiffuse, probe: EnvDiffuseProbe) -> Option<usize> {
73    if e.probes.len() >= e.config.max_probes {
74        return None;
75    }
76    let idx = e.probes.len();
77    e.probes.push(probe);
78    Some(idx)
79}
80
81/// Probe count.
82#[allow(dead_code)]
83pub fn probe_count_env(e: &EnvDiffuse) -> usize {
84    e.probes.len()
85}
86
87/// Sample SH irradiance for a given normal direction.
88#[allow(dead_code)]
89pub fn sample_sh_irradiance(probe: &EnvDiffuseProbe, normal: [f32; 3]) -> [f32; 3] {
90    let (nx, ny, nz) = (normal[0], normal[1], normal[2]);
91    // SH basis evaluation for L0 and L1
92    let y0 = 0.282_094_8; // 1/(2*sqrt(PI))
93    let y1 = 0.488_602_5 * ny; // sqrt(3/(4*PI)) * y
94    let y2 = 0.488_602_5 * nz; // sqrt(3/(4*PI)) * z
95    let y3 = 0.488_602_5 * nx; // sqrt(3/(4*PI)) * x
96    let sh = [y0, y1, y2, y3, 0.0, 0.0, 0.0, 0.0, 0.0];
97    let mut r = 0.0f32;
98    let mut g = 0.0f32;
99    let mut b = 0.0f32;
100    #[allow(clippy::needless_range_loop)]
101    for i in 0..SH_COEFF_COUNT {
102        r += probe.sh_r[i] * sh[i];
103        g += probe.sh_g[i] * sh[i];
104        b += probe.sh_b[i] * sh[i];
105    }
106    [
107        r * probe.intensity,
108        g * probe.intensity,
109        b * probe.intensity,
110    ]
111}
112
113/// Constant ambient color using FRAC_1_PI.
114#[allow(dead_code)]
115pub fn ambient_color(probe: &EnvDiffuseProbe) -> [f32; 3] {
116    let scale = FRAC_1_PI * probe.intensity;
117    [
118        probe.sh_r[0] * scale,
119        probe.sh_g[0] * scale,
120        probe.sh_b[0] * scale,
121    ]
122}
123
124/// Compute solid angle weight for a hemisphere sample using PI.
125#[allow(dead_code)]
126pub fn hemisphere_pdf() -> f32 {
127    1.0 / (2.0 * PI)
128}
129
130/// Blend two probes by weight.
131#[allow(dead_code)]
132pub fn blend_probes_env(a: &EnvDiffuseProbe, b: &EnvDiffuseProbe, t: f32) -> EnvDiffuseProbe {
133    let t = t.clamp(0.0, 1.0);
134    let mut result = EnvDiffuseProbe::default();
135    #[allow(clippy::needless_range_loop)]
136    for i in 0..SH_COEFF_COUNT {
137        result.sh_r[i] = a.sh_r[i] + (b.sh_r[i] - a.sh_r[i]) * t;
138        result.sh_g[i] = a.sh_g[i] + (b.sh_g[i] - a.sh_g[i]) * t;
139        result.sh_b[i] = a.sh_b[i] + (b.sh_b[i] - a.sh_b[i]) * t;
140    }
141    result.intensity = a.intensity + (b.intensity - a.intensity) * t;
142    result
143}
144
145/// Export to JSON-like string.
146#[allow(dead_code)]
147pub fn env_diffuse_to_json(e: &EnvDiffuse) -> String {
148    format!(
149        r#"{{"probe_count":{},"max_probes":{}}}"#,
150        e.probes.len(),
151        e.config.max_probes
152    )
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn new_system_empty() {
161        let e = new_env_diffuse(EnvDiffuseConfig::default());
162        assert_eq!(probe_count_env(&e), 0);
163    }
164
165    #[test]
166    fn add_probe_ok() {
167        let mut e = new_env_diffuse(EnvDiffuseConfig::default());
168        let r = add_probe(&mut e, EnvDiffuseProbe::default());
169        assert!(r.is_some());
170    }
171
172    #[test]
173    fn add_probe_capacity_limit() {
174        let mut e = new_env_diffuse(EnvDiffuseConfig {
175            max_probes: 1,
176            ..Default::default()
177        });
178        add_probe(&mut e, EnvDiffuseProbe::default());
179        let r = add_probe(&mut e, EnvDiffuseProbe::default());
180        assert!(r.is_none());
181    }
182
183    #[test]
184    fn sample_sh_neutral_probe() {
185        let probe = EnvDiffuseProbe::default();
186        let color = sample_sh_irradiance(&probe, [0.0, 1.0, 0.0]);
187        assert_eq!(color, [0.0, 0.0, 0.0]);
188    }
189
190    #[test]
191    fn ambient_color_uses_frac1pi() {
192        let mut probe = EnvDiffuseProbe::default();
193        probe.sh_r[0] = PI;
194        let c = ambient_color(&probe);
195        assert!((c[0] - 1.0).abs() < 1e-4);
196    }
197
198    #[test]
199    fn hemisphere_pdf_correct() {
200        let pdf = hemisphere_pdf();
201        assert!((pdf - 1.0 / (2.0 * PI)).abs() < 1e-6);
202    }
203
204    #[test]
205    fn blend_probes_at_zero() {
206        let a = EnvDiffuseProbe {
207            intensity: 0.5,
208            ..Default::default()
209        };
210        let b = EnvDiffuseProbe {
211            intensity: 1.0,
212            ..Default::default()
213        };
214        let m = blend_probes_env(&a, &b, 0.0);
215        assert!((m.intensity - 0.5).abs() < 1e-6);
216    }
217
218    #[test]
219    fn blend_probes_at_one() {
220        let a = EnvDiffuseProbe {
221            intensity: 0.0,
222            ..Default::default()
223        };
224        let b = EnvDiffuseProbe {
225            intensity: 1.0,
226            ..Default::default()
227        };
228        let m = blend_probes_env(&a, &b, 1.0);
229        assert!((m.intensity - 1.0).abs() < 1e-6);
230    }
231
232    #[test]
233    fn json_contains_probe_count() {
234        let e = new_env_diffuse(EnvDiffuseConfig::default());
235        assert!(env_diffuse_to_json(&e).contains("probe_count"));
236    }
237
238    #[test]
239    fn probes_slice_not_empty() {
240        let mut e = new_env_diffuse(EnvDiffuseConfig::default());
241        add_probe(&mut e, EnvDiffuseProbe::default());
242        assert!(!e.probes.is_empty());
243    }
244}