Skip to main content

ling_graphics/
shading.rs

1//! shading — holographic cel lighting model for the Ling renderer.
2//!
3//! The look the engine targets is *anime / holographic cel*: smooth shading
4//! over the surface (no faceted triangle edges) but with **crisp posterised
5//! bands** rather than a muddy Gouraud gradient. We get this by:
6//!
7//!   1. lighting each **vertex** with smooth (averaged) normals — continuous
8//!      across the mesh,
9//!   2. interpolating the lit colour across the triangle (done by the
10//!      rasteriser), then
11//!   3. **posterising per pixel** (luminance banded, chroma preserved) so the
12//!      band boundaries are smooth curves over the surface — the anime line.
13//!
14//! On top of diffuse we add:
15//!   • **coloured lights** — each light contributes its own RGB,
16//!   • **coloured shadows** — unlit regions are tinted toward `shadow`
17//!     (a complementary colour) instead of going flat black,
18//!   • a **Fresnel rim** — a view-dependent edge glow for the holographic feel,
19//!   • an optional **normal-gradient sheen** (`holo`) that shifts hue with the
20//!     surface normal, like an iridescent film.
21//!
22//! All colours here are linear `[f32;3]` in `0..1`. The renderer converts to
23//! `0x00RRGGBB` at the end.
24
25/// A coloured point light in world space (mirror of the engine's `Light`).
26#[derive(Clone, Copy, Debug)]
27pub struct LightS {
28    pub pos: [f32; 3],
29    pub color: [f32; 3],
30    pub intensity: f32,
31    pub radius: f32, // 0 = no attenuation
32}
33
34/// Tunable parameters for the cel/holo model.
35#[derive(Clone, Copy, Debug)]
36pub struct ShadeParams {
37    /// Number of posterisation bands (>=2). Lower = chunkier cel look.
38    pub bands: u32,
39    /// Ambient fill 0..1 applied to the base colour.
40    pub ambient: f32,
41    /// Coloured-shadow tint added in unlit regions (linear rgb 0..1).
42    pub shadow: [f32; 3],
43    /// Fresnel rim strength (0 = off).
44    pub rim: f32,
45    /// Rim glow colour.
46    pub rim_color: [f32; 3],
47    /// Enable the normal-gradient holographic sheen.
48    pub holo: bool,
49}
50
51impl Default for ShadeParams {
52    fn default() -> Self {
53        Self {
54            bands: 4,
55            ambient: 0.22,
56            shadow: [0.10, 0.13, 0.30], // cool indigo shadow
57            rim: 0.6,
58            rim_color: [0.45, 0.85, 1.0], // cyan holo edge
59            holo: true,
60        }
61    }
62}
63
64#[inline] fn norm3(v: [f32; 3]) -> [f32; 3] {
65    let l = (v[0]*v[0] + v[1]*v[1] + v[2]*v[2]).sqrt();
66    if l < 1e-8 { [0.0, 0.0, 0.0] } else { [v[0]/l, v[1]/l, v[2]/l] }
67}
68#[inline] fn dot3(a: [f32;3], b: [f32;3]) -> f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] }
69
70/// Soft cel ramp for a single diffuse term: keeps smooth shading but gives the
71/// lit/shadow transition a gentle "step" so it reads as toon shading even
72/// before per-pixel posterisation. Smoothstep around two thresholds.
73#[inline]
74pub fn cel_ramp(d: f32) -> f32 {
75    // d in 0..1 (already clamped)
76    let lo = 0.30; let hi = 0.55;
77    if d < lo { 0.20 }
78    else if d > hi { 1.0 }
79    else {
80        // smoothstep lo..hi mapped to 0.20..1.0
81        let t = (d - lo) / (hi - lo);
82        let s = t * t * (3.0 - 2.0 * t);
83        0.20 + s * 0.80
84    }
85}
86
87/// Light one vertex. `base`, result in linear rgb 0..1.
88/// `n` = smooth world normal, `pos` = world position, `eye` = camera position.
89pub fn lit_vertex(
90    base: [f32; 3],
91    n: [f32; 3],
92    pos: [f32; 3],
93    eye: [f32; 3],
94    lights: &[LightS],
95    p: &ShadeParams,
96) -> [f32; 3] {
97    let n = norm3(n);
98    // coloured-shadow baseline: ambient base + shadow tint where unlit
99    let mut acc = [
100        base[0] * p.ambient + p.shadow[0] * (1.0 - p.ambient),
101        base[1] * p.ambient + p.shadow[1] * (1.0 - p.ambient),
102        base[2] * p.ambient + p.shadow[2] * (1.0 - p.ambient),
103    ];
104
105    for l in lights {
106        let d = [l.pos[0]-pos[0], l.pos[1]-pos[1], l.pos[2]-pos[2]];
107        let dist = (d[0]*d[0]+d[1]*d[1]+d[2]*d[2]).sqrt().max(1e-6);
108        let atten = if l.radius > 0.0 { (1.0 - dist/l.radius).max(0.0) } else { 1.0 };
109        if atten <= 0.0 { continue; }
110        let ldir = [d[0]/dist, d[1]/dist, d[2]/dist];
111        let diff = dot3(n, ldir).max(0.0);      // front-lit only → real shadow side
112        let shaded = cel_ramp(diff) * l.intensity * atten;
113        acc[0] += base[0] * shaded * l.color[0];
114        acc[1] += base[1] * shaded * l.color[1];
115        acc[2] += base[2] * shaded * l.color[2];
116    }
117
118    // Fresnel rim — bright at grazing angles (view perpendicular to normal)
119    if p.rim > 0.0 {
120        let vd = norm3([eye[0]-pos[0], eye[1]-pos[1], eye[2]-pos[2]]);
121        let f = (1.0 - dot3(n, vd).max(0.0)).clamp(0.0, 1.0);
122        let rim = f * f * f * p.rim;           // tighten to the silhouette
123        acc[0] += p.rim_color[0] * rim;
124        acc[1] += p.rim_color[1] * rim;
125        acc[2] += p.rim_color[2] * rim;
126    }
127
128    // Holographic normal-gradient sheen: iridescent hue tied to normal dir.
129    // Modulated by the base colour so it tints rather than washing to white.
130    if p.holo {
131        let s = 0.07;
132        acc[0] += (0.5 + 0.5 * n[0]) * s * (0.4 + 0.6*base[0]);
133        acc[1] += (0.5 + 0.5 * n[1]) * s * (0.4 + 0.6*base[1]);
134        acc[2] += (0.5 + 0.5 * n[2]) * s * (0.4 + 0.6*base[2]);
135    }
136
137    [acc[0].min(1.0), acc[1].min(1.0), acc[2].min(1.0)]
138}
139
140/// Posterise a colour into `bands` luminance levels while preserving chroma.
141/// This is what turns the smooth interpolated colour into crisp cel bands.
142#[inline]
143pub fn posterize(c: [f32; 3], bands: u32) -> [f32; 3] {
144    let bands = bands.max(2) as f32;
145    let lum = 0.299*c[0] + 0.587*c[1] + 0.114*c[2];
146    if lum < 1e-5 { return c; }
147    // quantise luminance to the nearest band, then rescale chroma to it
148    let q = ((lum * bands).floor() + 0.5) / bands;
149    let k = (q / lum).clamp(0.0, 4.0);
150    [(c[0]*k).min(1.0), (c[1]*k).min(1.0), (c[2]*k).min(1.0)]
151}
152
153/// Pack linear 0..1 rgb into 0x00RRGGBB.
154#[inline]
155pub fn pack(c: [f32; 3]) -> u32 {
156    let r = (c[0].clamp(0.0,1.0) * 255.0) as u32;
157    let g = (c[1].clamp(0.0,1.0) * 255.0) as u32;
158    let b = (c[2].clamp(0.0,1.0) * 255.0) as u32;
159    (r << 16) | (g << 8) | b
160}
161
162/// Unpack 0x00RRGGBB into linear 0..1 rgb.
163#[inline]
164pub fn unpack(rgb: u32) -> [f32; 3] {
165    [((rgb>>16)&0xFF) as f32/255.0, ((rgb>>8)&0xFF) as f32/255.0, (rgb&0xFF) as f32/255.0]
166}