Skip to main content

proof_engine/render/
glyph_depth_effects.rs

1//! Depth-aware effects for 3D glyphs: self-shadowing, ambient occlusion in
2//! concavities, rim lighting for readability, and subsurface scattering.
3
4use glam::{Vec2, Vec3, Vec4};
5
6// ── Configuration ───────────────────────────────────────────────────────────
7
8#[derive(Clone, Debug)]
9pub struct DepthEffectsConfig {
10    pub self_shadow: bool,
11    pub self_shadow_bias: f32,
12    pub ao_strength: f32,
13    pub ao_radius: f32,
14    pub ao_samples: u32,
15    pub rim_light: bool,
16    pub rim_color: Vec3,
17    pub rim_power: f32,
18    pub rim_intensity: f32,
19    pub inner_glow: bool,
20    pub glow_strength: f32,
21    pub glow_color: Vec3,
22}
23
24impl Default for DepthEffectsConfig {
25    fn default() -> Self {
26        Self {
27            self_shadow: true,
28            self_shadow_bias: 0.005,
29            ao_strength: 0.5,
30            ao_radius: 0.1,
31            ao_samples: 8,
32            rim_light: true,
33            rim_color: Vec3::new(0.8, 0.9, 1.0),
34            rim_power: 3.0,
35            rim_intensity: 0.5,
36            inner_glow: false,
37            glow_strength: 0.3,
38            glow_color: Vec3::new(1.0, 0.8, 0.5),
39        }
40    }
41}
42
43// ── Effect computations (CPU reference) ─────────────────────────────────────
44
45/// Self-shadow factor: front faces facing away from the light are shadowed.
46/// Returns 0.0 (fully shadowed) to 1.0 (fully lit).
47pub fn compute_self_shadow(normal: Vec3, light_dir: Vec3, bias: f32) -> f32 {
48    let n_dot_l = normal.dot(light_dir.normalize_or_zero());
49    // Smooth shadow edge to avoid hard terminator
50    let shadow = ((n_dot_l + bias) / (bias * 2.0 + 0.01)).clamp(0.0, 1.0);
51    shadow
52}
53
54/// Compute ambient occlusion for a point in a glyph concavity.
55/// `neighbors` are nearby surface positions. Points surrounded by geometry
56/// receive more occlusion.
57pub fn compute_ssao_for_glyph(
58    normal: Vec3,
59    position: Vec3,
60    neighbors: &[Vec3],
61    config: &DepthEffectsConfig,
62) -> f32 {
63    if neighbors.is_empty() || config.ao_samples == 0 {
64        return 1.0;
65    }
66
67    let mut occlusion = 0.0f32;
68    let mut samples = 0u32;
69
70    for neighbor in neighbors {
71        let to_neighbor = *neighbor - position;
72        let dist = to_neighbor.length();
73        if dist < 0.0001 || dist > config.ao_radius {
74            continue;
75        }
76
77        let dir = to_neighbor / dist;
78        let n_dot_d = normal.dot(dir).max(0.0);
79
80        // Closer neighbors in the hemisphere occlude more
81        let distance_factor = 1.0 - (dist / config.ao_radius).clamp(0.0, 1.0);
82        occlusion += n_dot_d * distance_factor;
83        samples += 1;
84
85        if samples >= config.ao_samples {
86            break;
87        }
88    }
89
90    let ao = if samples > 0 {
91        1.0 - (occlusion / samples as f32 * config.ao_strength).clamp(0.0, 1.0)
92    } else {
93        1.0
94    };
95
96    ao
97}
98
99/// Compute Fresnel-based rim lighting for glyph silhouette readability.
100/// Rim light is strongest at glancing angles (normal ⊥ view direction).
101pub fn compute_rim_light(normal: Vec3, view_dir: Vec3, config: &DepthEffectsConfig) -> Vec3 {
102    if !config.rim_light {
103        return Vec3::ZERO;
104    }
105
106    let n = normal.normalize_or_zero();
107    let v = view_dir.normalize_or_zero();
108    let n_dot_v = n.dot(v).max(0.0);
109
110    // Fresnel-like rim: 1.0 at edge, 0.0 at center
111    let rim = (1.0 - n_dot_v).powf(config.rim_power);
112
113    config.rim_color * rim * config.rim_intensity
114}
115
116/// Compute subsurface scattering approximation for thin glyph parts.
117/// Light wraps around thin geometry, creating a translucent glow.
118pub fn compute_subsurface(
119    normal: Vec3,
120    light_dir: Vec3,
121    view_dir: Vec3,
122    thickness: f32,
123    config: &DepthEffectsConfig,
124) -> f32 {
125    if !config.inner_glow || thickness <= 0.0 {
126        return 0.0;
127    }
128
129    let n = normal.normalize_or_zero();
130    let l = light_dir.normalize_or_zero();
131    let v = view_dir.normalize_or_zero();
132
133    // Wrap lighting: light that would be on the back side wraps through
134    let wrap = 0.5;
135    let n_dot_l = (n.dot(l) + wrap) / (1.0 + wrap);
136    let wrap_diffuse = n_dot_l.max(0.0);
137
138    // Back-face transmission: view and light on opposite sides of surface
139    let half = (l + v).normalize_or_zero();
140    let v_dot_h = v.dot(-half).max(0.0);
141    let back_light = v_dot_h.powf(2.0);
142
143    // Thickness modulates SSS: thinner = more transmission
144    let thickness_factor = (1.0 - thickness.clamp(0.0, 1.0)).powf(2.0);
145
146    (wrap_diffuse * 0.3 + back_light * 0.7) * thickness_factor * config.glow_strength
147}
148
149/// Apply all depth effects to a pixel's final color (CPU reference implementation).
150pub fn apply_depth_effects(
151    albedo: Vec4,
152    normal: Vec3,
153    position: Vec3,
154    view_dir: Vec3,
155    light_dir: Vec3,
156    neighbors: &[Vec3],
157    thickness: f32,
158    config: &DepthEffectsConfig,
159) -> Vec4 {
160    let mut color = Vec3::new(albedo.x, albedo.y, albedo.z);
161
162    // Self-shadow
163    if config.self_shadow {
164        let shadow = compute_self_shadow(normal, light_dir, config.self_shadow_bias);
165        color *= shadow * 0.7 + 0.3; // keep some ambient
166    }
167
168    // AO
169    let ao = compute_ssao_for_glyph(normal, position, neighbors, config);
170    color *= ao;
171
172    // Rim light
173    let rim = compute_rim_light(normal, view_dir, config);
174    color += rim;
175
176    // Subsurface
177    let sss = compute_subsurface(normal, light_dir, view_dir, thickness, config);
178    color += config.glow_color * sss;
179
180    Vec4::new(color.x.min(1.0), color.y.min(1.0), color.z.min(1.0), albedo.w)
181}
182
183// ── GLSL shader snippets ────────────────────────────────────────────────────
184
185/// Self-shadow computation in GLSL.
186pub const SELF_SHADOW_GLSL: &str = r#"
187float compute_self_shadow(vec3 normal, vec3 light_dir, float bias) {
188    float n_dot_l = dot(normal, normalize(light_dir));
189    return clamp((n_dot_l + bias) / (bias * 2.0 + 0.01), 0.0, 1.0);
190}
191"#;
192
193/// Screen-space AO kernel sampling in GLSL.
194pub const SSAO_GLSL: &str = r#"
195uniform sampler2D u_depth_tex;
196uniform sampler2D u_normal_tex;
197uniform vec2 u_screen_size;
198uniform float u_ao_radius;
199uniform float u_ao_strength;
200
201float hash(vec2 p) {
202    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
203}
204
205float compute_ssao(vec2 uv, vec3 position, vec3 normal) {
206    float occlusion = 0.0;
207    const int SAMPLES = 8;
208    for (int i = 0; i < SAMPLES; i++) {
209        float angle = float(i) * 6.2831853 / float(SAMPLES) + hash(uv + float(i));
210        float r = u_ao_radius * (float(i + 1) / float(SAMPLES));
211        vec2 offset = vec2(cos(angle), sin(angle)) * r / u_screen_size;
212        float neighbor_depth = texture(u_depth_tex, uv + offset).r;
213        vec3 neighbor_normal = texture(u_normal_tex, uv + offset).rgb * 2.0 - 1.0;
214        float depth_diff = position.z - neighbor_depth;
215        if (depth_diff > 0.001 && depth_diff < u_ao_radius) {
216            occlusion += (1.0 - depth_diff / u_ao_radius) * max(dot(normal, neighbor_normal), 0.0);
217        }
218    }
219    return 1.0 - clamp(occlusion / float(SAMPLES) * u_ao_strength, 0.0, 1.0);
220}
221"#;
222
223/// Fresnel rim light in GLSL.
224pub const RIM_LIGHT_GLSL: &str = r#"
225uniform vec3 u_rim_color;
226uniform float u_rim_power;
227uniform float u_rim_intensity;
228
229vec3 compute_rim(vec3 normal, vec3 view_dir) {
230    float n_dot_v = max(dot(normalize(normal), normalize(view_dir)), 0.0);
231    float rim = pow(1.0 - n_dot_v, u_rim_power);
232    return u_rim_color * rim * u_rim_intensity;
233}
234"#;
235
236/// Subsurface scattering approximation in GLSL.
237pub const SUBSURFACE_GLSL: &str = r#"
238uniform vec3 u_sss_color;
239uniform float u_sss_strength;
240
241float compute_sss(vec3 normal, vec3 light_dir, vec3 view_dir, float thickness) {
242    float wrap = 0.5;
243    float n_dot_l = (dot(normal, light_dir) + wrap) / (1.0 + wrap);
244    float wrap_diffuse = max(n_dot_l, 0.0);
245
246    vec3 half_vec = normalize(light_dir + view_dir);
247    float back_light = pow(max(dot(view_dir, -half_vec), 0.0), 2.0);
248
249    float thick_factor = pow(1.0 - clamp(thickness, 0.0, 1.0), 2.0);
250    return (wrap_diffuse * 0.3 + back_light * 0.7) * thick_factor * u_sss_strength;
251}
252"#;
253
254// ── Tests ───────────────────────────────────────────────────────────────────
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn self_shadow_facing_light() {
262        let shadow = compute_self_shadow(Vec3::Z, Vec3::Z, 0.005);
263        assert!(shadow > 0.9, "Face toward light should be lit: {}", shadow);
264    }
265
266    #[test]
267    fn self_shadow_facing_away() {
268        let shadow = compute_self_shadow(Vec3::Z, -Vec3::Z, 0.005);
269        assert!(shadow < 0.1, "Face away from light should be shadowed: {}", shadow);
270    }
271
272    #[test]
273    fn rim_light_strongest_at_edge() {
274        let config = DepthEffectsConfig::default();
275        let edge_rim = compute_rim_light(Vec3::Z, Vec3::X, &config); // normal ⊥ view
276        let center_rim = compute_rim_light(Vec3::Z, Vec3::Z, &config); // normal ∥ view
277        assert!(edge_rim.length() > center_rim.length(),
278            "Rim should be stronger at edge: edge={}, center={}", edge_rim.length(), center_rim.length());
279    }
280
281    #[test]
282    fn ao_no_neighbors_returns_one() {
283        let config = DepthEffectsConfig::default();
284        let ao = compute_ssao_for_glyph(Vec3::Z, Vec3::ZERO, &[], &config);
285        assert_eq!(ao, 1.0);
286    }
287
288    #[test]
289    fn ao_surrounded_is_darker() {
290        let config = DepthEffectsConfig { ao_radius: 1.0, ao_strength: 1.0, ..Default::default() };
291        let neighbors = vec![
292            Vec3::new(0.1, 0.0, 0.05),
293            Vec3::new(-0.1, 0.0, 0.05),
294            Vec3::new(0.0, 0.1, 0.05),
295            Vec3::new(0.0, -0.1, 0.05),
296        ];
297        let ao = compute_ssao_for_glyph(Vec3::Z, Vec3::ZERO, &neighbors, &config);
298        assert!(ao < 1.0, "Surrounded point should have AO < 1: {}", ao);
299    }
300
301    #[test]
302    fn subsurface_thin_is_positive() {
303        let config = DepthEffectsConfig { inner_glow: true, glow_strength: 1.0, ..Default::default() };
304        let sss = compute_subsurface(Vec3::Z, Vec3::Z, -Vec3::Z, 0.1, &config);
305        assert!(sss > 0.0, "Thin geometry should transmit light: {}", sss);
306    }
307
308    #[test]
309    fn subsurface_thick_is_less() {
310        let config = DepthEffectsConfig { inner_glow: true, glow_strength: 1.0, ..Default::default() };
311        let thin = compute_subsurface(Vec3::Z, Vec3::Z, -Vec3::Z, 0.1, &config);
312        let thick = compute_subsurface(Vec3::Z, Vec3::Z, -Vec3::Z, 0.9, &config);
313        assert!(thin > thick, "Thin should transmit more: thin={}, thick={}", thin, thick);
314    }
315
316    #[test]
317    fn apply_all_effects() {
318        let config = DepthEffectsConfig {
319            self_shadow: true,
320            rim_light: true,
321            inner_glow: true,
322            glow_strength: 0.5,
323            ..Default::default()
324        };
325        let result = apply_depth_effects(
326            Vec4::new(0.5, 0.5, 0.5, 1.0),
327            Vec3::Z,
328            Vec3::ZERO,
329            Vec3::new(0.5, 0.0, 0.5).normalize(),
330            Vec3::Z,
331            &[],
332            0.3,
333            &config,
334        );
335        assert!(result.w == 1.0, "Alpha should be preserved");
336        assert!(result.x >= 0.0 && result.x <= 1.0);
337    }
338}