Skip to main content

ling/gfx/
material.rs

1// src/gfx/material.rs — LingMaterial: principled BSDF with toon quantisation.
2//
3// "Photon-water" model
4// ════════════════════
5// Think of each pixel as a tiny pool. Every light source pours coloured photons
6// in. The pool level (energy) is then snapped to discrete toon steps — like
7// water settling into terraced channels on a hillside. This gives crisp cel
8// bands whose boundaries follow the surface curvature instead of the triangle
9// edges, so quads and hexagons look as clean as triangles.
10//
11// BSDF feature set (2030 baseline)
12// ─────────────────────────────────
13//   Core       — albedo · roughness · metallic
14//   Emission   — emission colour + strength (self-lit surfaces, neon, fire)
15//   Specular   — GGX lobe, quantised to a toon hotspot
16//   Subsurface — cheap SSS: colour bleed at shadow boundaries (skin, wax)
17//   Clearcoat  — secondary GGX layer (car paint, guitar lacquer)
18//   Iridescence— thin-film angle-dependent hue (bubbles, beetle wings, CD)
19//   Sheen      — retro-reflection for fabric (velvet, satin, felt)
20//   Anisotropy — elongated specular (brushed metal, hair, records)
21//   Transmission— simple glass/water: alpha-blends the background
22//
23// Toon overrides
24// ──────────────
25//   toon_bands        — number of discrete shading levels (2 = shadow+lit)
26//   shadow_softness   — cross-fade width at band boundaries (0 = hard step)
27//   outline_px        — ink-line thickness in pixels (0 = no outline)
28//   outline_color     — ink colour
29//   highlight_color   — colour of the brightest toon band (normally white)
30
31use crate::gfx::light::{Light, cel_quantize};
32
33// ── Material struct ───────────────────────────────────────────────────────────
34
35#[derive(Debug, Clone)]
36pub struct LingMaterial {
37    // Core
38    pub albedo:             u32,    // 0x00RRGGBB
39    pub roughness:          f32,    // 0 = mirror, 1 = diffuse
40    pub metallic:           f32,    // 0 = dielectric, 1 = conductor
41
42    // Emission
43    pub emission:           u32,
44    pub emission_strength:  f32,
45
46    // Specular
47    pub specular:           f32,    // base Fresnel reflectance at 0°
48    pub specular_tint:      f32,    // 0 = white hotspot, 1 = albedo-tinted
49
50    // Subsurface scattering (approximated)
51    pub subsurface:         f32,
52    pub subsurface_color:   u32,
53
54    // Clearcoat
55    pub clearcoat:          f32,
56    pub clearcoat_roughness:f32,
57
58    // Transmission
59    pub transmission:       f32,    // 0 = opaque, 1 = glass
60    pub ior:                f32,    // index of refraction
61
62    // 2030 extras
63    pub iridescence:        f32,    // thin-film interference [0..1]
64    pub sheen:              f32,    // fabric retro-reflection [0..1]
65    pub anisotropy:         f32,    // specular elongation [0..1]
66    pub anisotropy_angle:   f32,    // radians
67
68    // Toon overrides
69    pub toon_bands:         u32,    // discrete shading levels (≥2)
70    pub shadow_softness:    f32,    // band-boundary blend width [0..1]
71    pub outline_px:         f32,    // ink-line thickness (0 = off)
72    pub outline_color:      u32,
73    pub highlight_color:    u32,
74}
75
76impl Default for LingMaterial {
77    fn default() -> Self {
78        Self {
79            albedo:              0x00FF_FFFF,
80            roughness:           0.8,
81            metallic:            0.0,
82            emission:            0,
83            emission_strength:   0.0,
84            specular:            0.04,
85            specular_tint:       0.0,
86            subsurface:          0.0,
87            subsurface_color:    0x00FF_C8A0,
88            clearcoat:           0.0,
89            clearcoat_roughness: 0.03,
90            transmission:        0.0,
91            ior:                 1.5,
92            iridescence:         0.0,
93            sheen:               0.0,
94            anisotropy:          0.0,
95            anisotropy_angle:    0.0,
96            toon_bands:          3,
97            shadow_softness:     0.04,
98            outline_px:          0.0,
99            outline_color:       0x00_00_00,
100            highlight_color:     0x00FF_FFFF,
101        }
102    }
103}
104
105// ── Helpers ───────────────────────────────────────────────────────────────────
106
107#[inline]
108fn unpack(c: u32) -> (f32, f32, f32) {
109    (
110        ((c >> 16) & 0xFF) as f32 / 255.0,
111        ((c >> 8) & 0xFF) as f32 / 255.0,
112        (c & 0xFF) as f32 / 255.0,
113    )
114}
115
116#[inline]
117fn pack01(r: f32, g: f32, b: f32) -> u32 {
118    let r = (r.clamp(0.0, 1.0) * 255.0) as u32;
119    let g = (g.clamp(0.0, 1.0) * 255.0) as u32;
120    let b = (b.clamp(0.0, 1.0) * 255.0) as u32;
121    (r << 16) | (g << 8) | b
122}
123
124/// Schlick Fresnel: f0 + (1-f0)*(1-cosθ)^5
125#[inline]
126fn schlick(cos_theta: f32, f0: f32) -> f32 {
127    let c = (1.0 - cos_theta).clamp(0.0, 1.0);
128    let c2 = c * c;
129    f0 + (1.0 - f0) * c2 * c2 * c
130}
131
132/// GGX specular quantised to a toon hotspot: either 0 or 1.
133/// Low roughness → large bright hotspot; high roughness → the lobe disappears.
134#[inline]
135fn ggx_toon(n_dot_h: f32, roughness: f32) -> f32 {
136    let a2 = roughness * roughness * roughness * roughness; // α⁴
137    let d = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0;
138    let ggx = a2 / (std::f32::consts::PI * d * d + 1e-6);
139    // Normalise to [0,1] and snap: the hotspot is on when the lobe is bright
140    let t = (ggx * a2 * 3.0).clamp(0.0, 1.0);
141    // Hard step at 0.5 → binary toon hotspot
142    if t > 0.5 { 1.0 } else { 0.0 }
143}
144
145/// Smooth GGX specular: continuous [0,1], same normalisation as `ggx_toon`
146/// but without the hard snap. Use for smooth (non-toon) shading.
147#[inline]
148fn ggx_smooth(n_dot_h: f32, roughness: f32) -> f32 {
149    let a2 = roughness * roughness * roughness * roughness; // α⁴
150    let d = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0;
151    let ggx = a2 / (std::f32::consts::PI * d * d + 1e-6);
152    (ggx * a2 * 3.0).clamp(0.0, 1.0)
153}
154
155/// Toon diffuse — maps n·l to discrete bands with optional smooth cross-fade.
156///
157/// When `softness < 1e-3` the brightness is snapped hard via `cel_quantize`.
158/// When `softness > 0` the same cel brightness levels are used, but transitions
159/// between adjacent bands are smoothed over a `softness`-wide window at each
160/// band boundary. `toon_bands` controls how many equal-width n·l slices are
161/// mapped through `cel_quantize`; for the default 3 bands the levels match the
162/// legacy `cel_quantize` table exactly.
163#[inline]
164pub fn toon_diffuse(n_dot_l: f32, bands: u32, softness: f32) -> f32 {
165    let t = n_dot_l.clamp(0.0, 1.0);
166    if softness < 1e-3 {
167        return cel_quantize(t);
168    }
169    // Smooth cross-fade at each band boundary using smoothstep.
170    // Brightness levels come from cel_quantize so both paths stay consistent.
171    let n = bands.max(2) as f32;
172    let scaled = t * n;
173    let frac = scaled.fract();
174    let edge_width = softness.clamp(0.0, 0.5);
175
176    if frac > 1.0 - edge_width {
177        // Near the top edge of a band: blend into the next band's brightness.
178        let lo = cel_quantize((scaled.floor() / n).clamp(0.0, 1.0));
179        let hi = cel_quantize(((scaled.floor() + 1.0) / n).clamp(0.0, 1.0));
180        let s = ((frac - (1.0 - edge_width)) / edge_width).clamp(0.0, 1.0);
181        let blend = s * s * (3.0 - 2.0 * s); // smoothstep
182        lo + (hi - lo) * blend
183    } else {
184        // Inside the band — return its quantised brightness directly.
185        cel_quantize((scaled.floor() / n).clamp(0.0, 1.0))
186    }
187}
188
189#[inline]
190fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
191    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
192}
193
194#[inline]
195fn norm3(v: [f32; 3]) -> [f32; 3] {
196    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
197    if len < 1e-7 { [0.0, 0.0, 1.0] } else { [v[0] / len, v[1] / len, v[2] / len] }
198}
199
200// ── Main shade function ───────────────────────────────────────────────────────
201
202/// Evaluate LingMaterial at a surface point and return 0x00RRGGBB.
203///
204/// * `mat`       — material to evaluate
205/// * `normal`    — world-space face normal (need not be normalised)
206/// * `view_dir`  — direction from surface toward the camera (world space)
207/// * `centroid`  — world-space surface point
208/// * `lights`    — active point lights
209/// * `ambient`   — ambient fill level [0..1]
210pub fn shade(
211    mat:      &LingMaterial,
212    normal:   [f32; 3],
213    view_dir: [f32; 3],
214    centroid: [f32; 3],
215    lights:   &[Light],
216    ambient:  f32,
217) -> u32 {
218    let (ar, ag, ab) = unpack(mat.albedo);
219    let n = norm3(normal);
220    let v = norm3(view_dir);
221    let n_dot_v = dot3(n, v).abs().clamp(1e-4, 1.0);
222
223    // Ambient + emission
224    let mut acc_r = ar * ambient;
225    let mut acc_g = ag * ambient;
226    let mut acc_b = ab * ambient;
227    if mat.emission_strength > 0.0 {
228        let (er, eg, eb) = unpack(mat.emission);
229        acc_r += er * mat.emission_strength;
230        acc_g += eg * mat.emission_strength;
231        acc_b += eb * mat.emission_strength;
232    }
233
234    // Per-light contribution
235    for l in lights {
236        let dx = l.x - centroid[0];
237        let dy = l.y - centroid[1];
238        let dz = l.z - centroid[2];
239        let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(1e-6);
240        let atten = if l.radius > 0.0 { (1.0 - dist / l.radius).max(0.0) } else { 1.0 };
241        if atten <= 0.0 { continue; }
242
243        let ld = [dx / dist, dy / dist, dz / dist];
244        let n_dot_l = dot3(n, ld).abs(); // two-sided shading
245        let h = norm3([ld[0] + v[0], ld[1] + v[1], ld[2] + v[2]]);
246        let n_dot_h = dot3(n, h).clamp(0.0, 1.0);
247
248        // ── Diffuse: toon (toon_bands ≥ 1) or smooth Lambertian (toon_bands 0) ─
249        let smooth_mode = mat.toon_bands == 0;
250        let diff = if smooth_mode {
251            n_dot_l // raw Lambertian — no quantisation
252        } else {
253            toon_diffuse(n_dot_l, mat.toon_bands, mat.shadow_softness)
254        };
255
256        // Subsurface: tint the shadow zone toward subsurface_color
257        let (eff_r, eff_g, eff_b) = if mat.subsurface > 0.0 {
258            let (sr, sg, sb) = unpack(mat.subsurface_color);
259            let zone = ((0.3 - n_dot_l) * 3.33).clamp(0.0, 1.0) * mat.subsurface;
260            (ar + (sr - ar) * zone, ag + (sg - ag) * zone, ab + (sb - ab) * zone)
261        } else {
262            (ar, ag, ab)
263        };
264
265        let dr = eff_r * diff;
266        let dg = eff_g * diff;
267        let db = eff_b * diff;
268
269        // ── Specular: smooth GGX or binary toon hotspot ───────────────────────
270        let f0_dielectric = mat.specular * 0.08; // maps [0,1] → [0,0.08]
271        let f0 = f0_dielectric + mat.metallic * (ar - f0_dielectric);
272        let fresnel = schlick(n_dot_v, f0.clamp(0.0, 1.0));
273        let spec = if smooth_mode {
274            ggx_smooth(n_dot_h, mat.roughness.max(0.01)) * fresnel
275        } else {
276            ggx_toon(n_dot_h, mat.roughness.max(0.01)) * fresnel
277        };
278
279        let spec_white = spec * (1.0 - mat.specular_tint);
280        let sr = spec_white + spec * ar * mat.specular_tint;
281        let sg = spec_white + spec * ag * mat.specular_tint;
282        let sb = spec_white + spec * ab * mat.specular_tint;
283
284        // ── Clearcoat (white GGX on top) ──────────────────────────────────────
285        let coat = ggx_toon(n_dot_h, mat.clearcoat_roughness.max(0.01)) * mat.clearcoat;
286
287        // ── Iridescence (thin-film angle-dependent hue) ───────────────────────
288        // Phase-shifted cosines per channel → RGB rainbow at glancing angles
289        let (ir, ig, ib) = if mat.iridescence > 0.0 {
290            let p = n_dot_v * std::f32::consts::TAU * 2.0;
291            let tau3 = std::f32::consts::FRAC_PI_3 * 2.0;
292            let ir = (p.cos() * 0.5 + 0.5) * mat.iridescence;
293            let ig = ((p + tau3).cos() * 0.5 + 0.5) * mat.iridescence;
294            let ib = ((p + tau3 * 2.0).cos() * 0.5 + 0.5) * mat.iridescence;
295            (ir, ig, ib)
296        } else {
297            (0.0, 0.0, 0.0)
298        };
299
300        // ── Sheen (retro-reflection: peak at 90° incidence) ───────────────────
301        let sheen = if mat.sheen > 0.0 {
302            let t = (1.0 - n_dot_l).powi(3) * mat.sheen;
303            t
304        } else {
305            0.0
306        };
307
308        let intensity = l.intensity * atten;
309        acc_r += (dr + sr + coat + ir + sheen * ar) * l.r * intensity;
310        acc_g += (dg + sg + coat + ig + sheen * ag) * l.g * intensity;
311        acc_b += (db + sb + coat + ib + sheen * ab) * l.b * intensity;
312    }
313
314    pack01(acc_r, acc_g, acc_b)
315}
316
317/// Compute per-vertex material colours for a Gouraud-shaded triangle.
318/// Returns three 0x00RRGGBB colours.
319pub fn shade_vertices(
320    mat:        &LingMaterial,
321    normal:     [f32; 3],
322    va:         [f32; 3],
323    vb:         [f32; 3],
324    vc:         [f32; 3],
325    camera_pos: [f32; 3],
326    lights:     &[Light],
327    ambient:    f32,
328) -> (u32, u32, u32) {
329    let view = |v: [f32; 3]| [camera_pos[0]-v[0], camera_pos[1]-v[1], camera_pos[2]-v[2]];
330    (
331        shade(mat, normal, view(va), va, lights, ambient),
332        shade(mat, normal, view(vb), vb, lights, ambient),
333        shade(mat, normal, view(vc), vc, lights, ambient),
334    )
335}
336
337/// Compute per-vertex material colours for an n-gon (up to N vertices).
338/// Writes results into `out[0..n]`.
339pub fn shade_polygon(
340    mat:        &LingMaterial,
341    normal:     [f32; 3],
342    verts:      &[[f32; 3]],
343    n:          usize,
344    camera_pos: [f32; 3],
345    lights:     &[Light],
346    ambient:    f32,
347    out:        &mut [u32],
348) {
349    let view = |v: [f32; 3]| [camera_pos[0]-v[0], camera_pos[1]-v[1], camera_pos[2]-v[2]];
350    for i in 0..n.min(verts.len()).min(out.len()) {
351        out[i] = shade(mat, normal, view(verts[i]), verts[i], lights, ambient);
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn default_material_no_crash_no_lights() {
361        let mat = LingMaterial::default();
362        let c = shade(&mat, [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 0.0], &[], 0.5);
363        let lum = ((c >> 16 & 0xFF) as f32 * 0.299
364            + (c >> 8 & 0xFF) as f32 * 0.587
365            + (c & 0xFF) as f32 * 0.114) / 255.0;
366        // With ambient=0.5 and white albedo the result should be non-zero
367        assert!(lum > 0.1, "expected visible output, got lum={lum:.3}");
368    }
369
370    #[test]
371    fn metallic_tints_specular() {
372        let mut mat = LingMaterial::default();
373        mat.albedo = 0x00FF_0000; // red metal
374        mat.metallic = 1.0;
375        mat.specular_tint = 1.0;
376        mat.roughness = 0.1;
377
378        let light = crate::gfx::light::Light {
379            x: 0.0, y: 0.0, z: 10.0,
380            r: 1.0, g: 1.0, b: 1.0,
381            intensity: 2.0, radius: 0.0,
382        };
383        let c = shade(
384            &mat,
385            [0.0, 0.0, 1.0],
386            [0.0, 0.0, 1.0],  // view = straight ahead
387            [0.0, 0.0, 0.0],
388            &[light],
389            0.05,
390        );
391        let r = (c >> 16) & 0xFF;
392        let g = (c >> 8) & 0xFF;
393        // Red metal should have more red than green
394        assert!(r >= g, "metallic red should be reddish: r={r} g={g}");
395    }
396}