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
64impl ShadeParams {
65    /// Wind Waker stylized toon shading preset.
66    pub fn windwaker() -> Self {
67        Self {
68            bands: 3,
69            ambient: 0.35,
70            shadow: [0.22, 0.32, 0.52], // ocean sky shadow tint
71            rim: 0.85,
72            rim_color: [0.95, 0.98, 1.0], // crisp silhouette rim light
73            holo: false,
74        }
75    }
76}
77
78#[inline]
79fn norm3(v: [f32; 3]) -> [f32; 3] {
80    let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
81    if l < 1e-8 {
82        [0.0, 0.0, 0.0]
83    } else {
84        [v[0] / l, v[1] / l, v[2] / l]
85    }
86}
87#[inline]
88fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
89    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
90}
91
92/// Soft cel ramp for a single diffuse term: keeps smooth shading but gives the
93/// lit/shadow transition a gentle "step" so it reads as toon shading even
94/// before per-pixel posterisation. Smoothstep around two thresholds.
95#[inline]
96pub fn cel_ramp(d: f32) -> f32 {
97    // d in 0..1 (already clamped)
98    let lo = 0.30;
99    let hi = 0.55;
100    if d < lo {
101        0.20
102    } else if d > hi {
103        1.0
104    } else {
105        // smoothstep lo..hi mapped to 0.20..1.0
106        let t = (d - lo) / (hi - lo);
107        let s = t * t * (3.0 - 2.0 * t);
108        0.20 + s * 0.80
109    }
110}
111
112/// Wind Waker style 2-band toon diffuse step ramp with crisp shadow threshold.
113#[inline]
114pub fn windwaker_cel_ramp(d: f32) -> f32 {
115    let lo = 0.28;
116    let hi = 0.38;
117    if d < lo {
118        0.25
119    } else if d > hi {
120        1.0
121    } else {
122        let t = (d - lo) / (hi - lo);
123        let s = t * t * (3.0 - 2.0 * t);
124        0.25 + s * 0.75
125    }
126}
127
128/// Light one vertex. `base`, result in linear rgb 0..1.
129/// `n` = smooth world normal, `pos` = world position, `eye` = camera position.
130pub fn lit_vertex(
131    base: [f32; 3],
132    n: [f32; 3],
133    pos: [f32; 3],
134    eye: [f32; 3],
135    lights: &[LightS],
136    p: &ShadeParams,
137) -> [f32; 3] {
138    let n = norm3(n);
139    // coloured-shadow baseline: ambient base + shadow tint where unlit
140    let mut acc = [
141        base[0] * p.ambient + p.shadow[0] * (1.0 - p.ambient),
142        base[1] * p.ambient + p.shadow[1] * (1.0 - p.ambient),
143        base[2] * p.ambient + p.shadow[2] * (1.0 - p.ambient),
144    ];
145
146    for l in lights {
147        let d = [l.pos[0] - pos[0], l.pos[1] - pos[1], l.pos[2] - pos[2]];
148        let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
149        let atten = if l.radius > 0.0 {
150            (1.0 - dist / l.radius).max(0.0)
151        } else {
152            1.0
153        };
154        if atten <= 0.0 {
155            continue;
156        }
157        let ldir = [d[0] / dist, d[1] / dist, d[2] / dist];
158        let diff = dot3(n, ldir).max(0.0); // front-lit only → real shadow side
159        let shaded = cel_ramp(diff) * l.intensity * atten;
160        acc[0] += base[0] * shaded * l.color[0];
161        acc[1] += base[1] * shaded * l.color[1];
162        acc[2] += base[2] * shaded * l.color[2];
163    }
164
165    // Fresnel rim — bright at grazing angles (view perpendicular to normal)
166    if p.rim > 0.0 {
167        let vd = norm3([eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]]);
168        let f = (1.0 - dot3(n, vd).max(0.0)).clamp(0.0, 1.0);
169        let rim = f * f * f * p.rim; // tighten to the silhouette
170        acc[0] += p.rim_color[0] * rim;
171        acc[1] += p.rim_color[1] * rim;
172        acc[2] += p.rim_color[2] * rim;
173    }
174
175    // Holographic normal-gradient sheen: iridescent hue tied to normal dir.
176    // Modulated by the base colour so it tints rather than washing to white.
177    if p.holo {
178        let s = 0.07;
179        acc[0] += (0.5 + 0.5 * n[0]) * s * (0.4 + 0.6 * base[0]);
180        acc[1] += (0.5 + 0.5 * n[1]) * s * (0.4 + 0.6 * base[1]);
181        acc[2] += (0.5 + 0.5 * n[2]) * s * (0.4 + 0.6 * base[2]);
182    }
183
184    [acc[0].min(1.0), acc[1].min(1.0), acc[2].min(1.0)]
185}
186
187/// Posterise a colour into `bands` luminance levels while preserving chroma.
188/// This is what turns the smooth interpolated colour into crisp cel bands.
189#[inline]
190pub fn posterize(c: [f32; 3], bands: u32) -> [f32; 3] {
191    let bands = bands.max(2) as f32;
192    let lum = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
193    if lum < 1e-5 {
194        return c;
195    }
196    // quantise luminance to the nearest band, then rescale chroma to it.
197    // Clamp the band index so lum == 1.0 lands in the top band instead of
198    // one past it (which would rescale brighter than the source colour).
199    let band = (lum * bands).floor().min(bands - 1.0);
200    let q = (band + 0.5) / bands;
201    let k = (q / lum).clamp(0.0, 4.0);
202    [
203        (c[0] * k).min(1.0),
204        (c[1] * k).min(1.0),
205        (c[2] * k).min(1.0),
206    ]
207}
208
209/// `posterize` with a crossfade back toward the raw colour: `softness = 0`
210/// is a crisp band edge, `softness = 1` is fully smooth (no banding). Lets a
211/// material soften its toon step without reintroducing the per-vertex
212/// quantisation that pins band edges to mesh vertices.
213#[inline]
214pub fn posterize_soft(c: [f32; 3], bands: u32, softness: f32) -> [f32; 3] {
215    if bands < 2 {
216        return c;
217    }
218    let s = softness.clamp(0.0, 1.0);
219    if s <= 0.0 {
220        return posterize(c, bands);
221    }
222    let hard = posterize(c, bands);
223    [
224        hard[0] + (c[0] - hard[0]) * s,
225        hard[1] + (c[1] - hard[1]) * s,
226        hard[2] + (c[2] - hard[2]) * s,
227    ]
228}
229
230/// Pack linear 0..1 rgb into 0x00RRGGBB.
231#[inline]
232pub fn pack(c: [f32; 3]) -> u32 {
233    let r = (c[0].clamp(0.0, 1.0) * 255.0) as u32;
234    let g = (c[1].clamp(0.0, 1.0) * 255.0) as u32;
235    let b = (c[2].clamp(0.0, 1.0) * 255.0) as u32;
236    (r << 16) | (g << 8) | b
237}
238
239/// Unpack 0x00RRGGBB into linear 0..1 rgb.
240#[inline]
241pub fn unpack(rgb: u32) -> [f32; 3] {
242    [
243        ((rgb >> 16) & 0xFF) as f32 / 255.0,
244        ((rgb >> 8) & 0xFF) as f32 / 255.0,
245        (rgb & 0xFF) as f32 / 255.0,
246    ]
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn posterize_preserves_hue_ratio() {
255        // A warm orange (r > g > b) must stay warm after quantisation — only
256        // luminance should move, not the channel ratios.
257        let c = [0.9, 0.5, 0.1];
258        let q = posterize(c, 4);
259        assert!(q[0] > q[1] && q[1] > q[2], "hue order must survive posterisation: {q:?}");
260    }
261
262    #[test]
263    fn posterize_four_bands_has_four_or_fewer_levels() {
264        // Near-black inputs (lum close to 0) are deliberately exempt: lifting
265        // them to the first band's brightness would need a gain the function
266        // caps at 4x, so they ramp smoothly toward it instead of snapping —
267        // that avoids a jarring flash on near-black pixels. Sample away from
268        // that zone to check the actual banding.
269        let mut levels = std::collections::HashSet::new();
270        let mut t: f32 = 0.1;
271        while t <= 1.0 {
272            let q = posterize([t, t, t], 4);
273            levels.insert((q[0] * 1000.0).round() as i32);
274            t += 0.01;
275        }
276        assert!(levels.len() <= 4, "expected at most 4 distinct levels, got {}", levels.len());
277    }
278
279    #[test]
280    fn posterize_soft_zero_matches_hard_posterize() {
281        let c = [0.9, 0.5, 0.1];
282        assert_eq!(posterize_soft(c, 4, 0.0), posterize(c, 4));
283    }
284
285    #[test]
286    fn posterize_soft_one_matches_raw_color() {
287        let c = [0.9, 0.5, 0.1];
288        assert_eq!(posterize_soft(c, 4, 1.0), c);
289    }
290
291    #[test]
292    fn posterize_soft_bands_below_two_is_passthrough() {
293        let c = [0.31, 0.62, 0.83];
294        assert_eq!(posterize_soft(c, 1, 0.0), c);
295    }
296}