Skip to main content

ling/gfx/
toon.rs

1// src/gfx/toon.rs — Unified tone ramp + screen-space toon post-processing.
2//
3// All lighting (shadow, mid-tone, highlight) passes through a single
4// ToneRamp — a gradient with sorted (t, brightness) stops and an optional
5// cubic Bezier curve that remaps the input luminance before stop lookup.
6//
7// Usage
8//   1. Set up ToneRamp stops — at least a dark and a bright stop.
9//   2. Call `apply()` after `queue.flush()` and before presenting the buffer.
10//
11// The ramp replaces the old per-pixel cel-snap in the rasteriser and the
12// separate `smooth_shadow_edges` / `draw_highlights` passes.
13//
14// Bezier control:
15//   The optional bezier remaps the raw normalised luminance t ∈ [0,1] before
16//   stop lookup.  Two control-point y-values [y1, y2] define a cubic Bézier:
17//
18//       f(t) = 3t(1-t)²·y1 + 3t²(1-t)·y2 + t³
19//
20//   with implicit anchors (0,0) and (1,1).  Setting [1/3, 2/3] gives the
21//   identity; [0, 0] makes dark tones dominant (ease-in); [1, 1] brightens
22//   the ramp (ease-out); [0.1, 0.9] gives a smooth S-curve.
23
24// ── Tone ramp ────────────────────────────────────────────────────────────────
25
26/// A single stop on the tone ramp.
27///
28/// `t`     — input luminance position [0..1]
29/// `value` — output brightness multiplier [0..1]
30#[derive(Debug, Clone, PartialEq)]
31pub struct ToneStop {
32    pub t:     f32,
33    pub value: f32,
34}
35
36/// Maps pixel luminance through a gradient of brightness stops.
37///
38/// `stops`  — sorted by `t` ascending.
39/// `smooth` — `false` = hard-snap to the left stop (cel shade);
40///            `true`  = linear-interpolate between stops (soft gradient).
41/// `bezier` — optional `[y1, y2]` cubic Bézier remap applied before lookup.
42///            `None` = identity (no remap).
43#[derive(Debug, Clone)]
44pub struct ToneRamp {
45    pub stops:  Vec<ToneStop>,
46    pub smooth: bool,
47    pub bezier: Option<[f32; 2]>,
48}
49
50impl Default for ToneRamp {
51    /// 3-band cel-shade matching the old hardcoded thresholds:
52    ///   shadow  t < 0.25 → 0.08
53    ///   mid     t < 0.60 → 0.50
54    ///   lit     t ≥ 0.60 → 1.00
55    fn default() -> Self {
56        Self {
57            stops: vec![
58                ToneStop { t: 0.00, value: 0.08 },
59                ToneStop { t: 0.25, value: 0.50 },
60                ToneStop { t: 0.60, value: 1.00 },
61                ToneStop { t: 1.00, value: 1.00 },
62            ],
63            smooth: false,
64            bezier: None,
65        }
66    }
67}
68
69/// Cubic Bézier remap: f(t) = 3t(1-t)²·y1 + 3t²(1-t)·y2 + t³.
70/// Anchors are (0,0) and (1,1); y1, y2 are the two control-point y-values.
71#[inline]
72fn bezier_remap(y1: f32, y2: f32, t: f32) -> f32 {
73    let mt = 1.0 - t;
74    3.0 * t * mt * mt * y1 + 3.0 * t * t * mt * y2 + t * t * t
75}
76
77/// Sample the ramp at normalised input `t_in` ∈ [0..1].
78/// Returns a brightness multiplier in [0..1].
79pub fn sample_ramp(ramp: &ToneRamp, t_in: f32) -> f32 {
80    let t = t_in.clamp(0.0, 1.0);
81    let t = match ramp.bezier {
82        Some([y1, y2]) => bezier_remap(y1, y2, t).clamp(0.0, 1.0),
83        None => t,
84    };
85
86    let stops = &ramp.stops;
87    if stops.is_empty() { return t; }
88
89    // Before first stop → first value
90    if t <= stops[0].t { return stops[0].value; }
91
92    let last = stops.len() - 1;
93    // At or past last stop → last value
94    if t >= stops[last].t { return stops[last].value; }
95
96    // Find the surrounding pair
97    for i in 0..last {
98        if t < stops[i + 1].t {
99            if ramp.smooth {
100                let span = stops[i + 1].t - stops[i].t;
101                let f = if span > 1e-6 { (t - stops[i].t) / span } else { 1.0 };
102                return stops[i].value + f * (stops[i + 1].value - stops[i].value);
103            } else {
104                return stops[i].value; // hard snap: use the left stop's output
105            }
106        }
107    }
108    stops[last].value
109}
110
111/// Apply the tone ramp to the entire framebuffer in-place.
112///
113/// Each pixel's luminance is computed, normalised, passed through the ramp,
114/// and the RGB channels are scaled to achieve the new luminance (hue is
115/// preserved).  Black pixels (lum ≈ 0) are left untouched.
116///
117/// When `zbuf` is provided and the same size as the buffer, pixels at
118/// `zbuf[i] == +∞` are treated as background and skipped.  This avoids
119/// tone-mapping the scene's background clear colour when depth-testing is
120/// enabled.  With the painter's path (no zbuf), the ramp is applied to all
121/// non-black pixels.
122pub fn apply_ramp(
123    buf:    &mut Vec<u32>,
124    zbuf:   &[f32],
125    width:  usize,
126    height: usize,
127    ramp:   &ToneRamp,
128) {
129    let n = width * height;
130    if buf.len() < n || ramp.stops.is_empty() { return; }
131    let use_zbuf = zbuf.len() >= n;
132
133    #[inline]
134    fn shade(p: u32, ramp: &ToneRamp) -> u32 {
135        let r = ((p >> 16) & 0xFF) as f32;
136        let g = ((p >>  8) & 0xFF) as f32;
137        let b = ( p        & 0xFF) as f32;
138        let lum = 0.299 * r + 0.587 * g + 0.114 * b;
139        if lum < 0.001 { return p; }
140        let new_val = sample_ramp(ramp, lum / 255.0);
141        let scale   = (new_val * 255.0 / lum).clamp(0.0, 8.0);
142        (((r * scale).min(255.0) as u32) << 16)
143            | (((g * scale).min(255.0) as u32) <<  8)
144            |  ((b * scale).min(255.0) as u32)
145    }
146
147    #[cfg(not(target_arch = "wasm32"))]
148    {
149        use rayon::prelude::*;
150        const ROWS: usize = 32;
151        let band = ROWS * width;
152        if use_zbuf {
153            buf[..n]
154                .par_chunks_mut(band)
155                .zip(zbuf[..n].par_chunks(band))
156                .for_each(|(bb, zz)| {
157                    for (px, z) in bb.iter_mut().zip(zz) {
158                        if z.is_finite() {
159                            *px = shade(*px, ramp);
160                        }
161                    }
162                });
163        } else {
164            buf[..n].par_chunks_mut(band).for_each(|bb| {
165                for px in bb.iter_mut() {
166                    *px = shade(*px, ramp);
167                }
168            });
169        }
170        return;
171    }
172    #[cfg(target_arch = "wasm32")]
173    for i in 0..n {
174        if use_zbuf && !zbuf[i].is_finite() { continue; }
175        buf[i] = shade(buf[i], ramp);
176    }
177}
178
179// ── Silhouette outline detection ──────────────────────────────────────────────
180
181/// Draw toon ink lines where the depth buffer has a sharp discontinuity.
182///
183/// For each pixel, we compare its camera-space z against its 4 neighbours.
184/// When the maximum depth difference exceeds `threshold`, that pixel is on a
185/// silhouette or a crease — we stamp a filled circle of radius `thickness`
186/// pixels in `color`.
187///
188/// `thickness` — half-width of the ink line in pixels (1.0 = single pixel, 2.0 = anime thick)
189/// `color`     — 0x00RRGGBB ink colour
190/// `threshold` — depth difference that triggers the edge (0.02–0.1 for typical scenes)
191pub fn draw_outlines(
192    buf:       &mut Vec<u32>,
193    zbuf:      &[f32],
194    width:     usize,
195    height:    usize,
196    thickness: f32,
197    color:     u32,
198    threshold: f32,
199) {
200    if zbuf.len() < width * height || buf.len() < width * height {
201        return;
202    }
203    let t   = thickness.clamp(0.5, 6.0);
204    let t_i = t.ceil() as i32;
205    let t2  = t * t;
206
207    for y in t_i..(height as i32 - t_i) {
208        for x in t_i..(width as i32 - t_i) {
209            let idx = y as usize * width + x as usize;
210            let z = zbuf[idx];
211            if !z.is_finite() { continue; }
212
213            let zn = zbuf[(y - 1) as usize * width + x as usize];
214            let zs = zbuf[(y + 1) as usize * width + x as usize];
215            let zw = zbuf[y as usize * width + (x - 1) as usize];
216            let ze = zbuf[y as usize * width + (x + 1) as usize];
217            let dmax = (z - zn).abs()
218                .max((z - zs).abs())
219                .max((z - zw).abs())
220                .max((z - ze).abs());
221            if dmax < threshold { continue; }
222
223            for dy in -t_i..=t_i {
224                for dx in -t_i..=t_i {
225                    let dist2 = (dx as f32) * (dx as f32) + (dy as f32) * (dy as f32);
226                    if dist2 > t2 { continue; }
227                    let nx = x + dx;
228                    let ny = y + dy;
229                    if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
230                        let ni = ny as usize * width + nx as usize;
231                        let cov = (t2 - dist2).sqrt() / t.max(1.0);
232                        let cov = cov.clamp(0.0, 1.0);
233                        if cov >= 0.999 {
234                            buf[ni] = color;
235                        } else {
236                            let dst = buf[ni];
237                            let dr = ((dst >> 16) & 0xFF) as f32;
238                            let dg = ((dst >>  8) & 0xFF) as f32;
239                            let db = ( dst        & 0xFF) as f32;
240                            let ir = ((color >> 16) & 0xFF) as f32;
241                            let ig = ((color >>  8) & 0xFF) as f32;
242                            let ib = ( color        & 0xFF) as f32;
243                            let r = (ir * cov + dr * (1.0 - cov)) as u32;
244                            let g = (ig * cov + dg * (1.0 - cov)) as u32;
245                            let b = (ib * cov + db * (1.0 - cov)) as u32;
246                            buf[ni] = (r << 16) | (g << 8) | b;
247                        }
248                    }
249                }
250            }
251        }
252    }
253}
254
255// ── ToonConfig ────────────────────────────────────────────────────────────────
256
257/// Post-process configuration stored in `GfxState`.
258///
259/// The `ramp` replaces the old separate shadow-softness and highlight passes.
260/// Lighting and shadowing are expressed as a single tone gradient — set stops,
261/// toggle `smooth`, optionally shape with a Bezier curve.
262#[derive(Debug, Clone)]
263pub struct ToonConfig {
264    /// Unified tone ramp: maps pixel luminance → output brightness.
265    /// Applied as a post-process after geometry rendering.
266    pub ramp: ToneRamp,
267    /// Outline thickness in pixels (0 = off).
268    pub outline_px:     f32,
269    /// Depth discontinuity that triggers an outline stamp.
270    pub outline_thresh: f32,
271    /// Ink colour (0x00RRGGBB).
272    pub outline_color:  u32,
273}
274
275impl Default for ToonConfig {
276    fn default() -> Self {
277        Self {
278            ramp:           ToneRamp::default(),
279            outline_px:     0.0,
280            outline_thresh: 0.05,
281            outline_color:  0x00_00_00,
282        }
283    }
284}
285
286/// Apply all enabled toon passes in the correct order.
287///
288/// 1. Tone ramp — luminance reshaping / cel-quantisation.
289/// 2. Outlines  — depth-discontinuity ink lines (optional).
290///
291/// Call this after `queue.flush()` and before presenting the buffer to screen.
292pub fn apply(
293    cfg:    &ToonConfig,
294    buf:    &mut Vec<u32>,
295    zbuf:   &[f32],
296    width:  usize,
297    height: usize,
298) {
299    apply_ramp(buf, zbuf, width, height, &cfg.ramp);
300
301    if cfg.outline_px > 0.0 {
302        draw_outlines(buf, zbuf, width, height,
303            cfg.outline_px, cfg.outline_color, cfg.outline_thresh);
304    }
305}
306
307// ── Unit tests ─────────────────────────────────────────────────────────────
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn make_toon_default() -> ToonConfig { ToonConfig::default() }
314
315    #[test]
316    fn sample_ramp_hard_snap_3band() {
317        let ramp = ToneRamp::default();
318        // Shadow band: t < 0.25 → 0.08
319        let v = sample_ramp(&ramp, 0.10);
320        assert!((v - 0.08).abs() < 1e-4, "shadow band: {v}");
321        // Mid band: 0.25 ≤ t < 0.60 → 0.50
322        let v = sample_ramp(&ramp, 0.40);
323        assert!((v - 0.50).abs() < 1e-4, "mid band: {v}");
324        // Lit band: t ≥ 0.60 → 1.00
325        let v = sample_ramp(&ramp, 0.80);
326        assert!((v - 1.00).abs() < 1e-4, "lit band: {v}");
327    }
328
329    #[test]
330    fn sample_ramp_smooth_lerps() {
331        let mut ramp = ToneRamp::default();
332        ramp.smooth = true;
333        // At t=0.125 (midpoint of shadow→mid segment [0.00, 0.25])
334        // Expected: lerp(0.08, 0.50, 0.5) = 0.29
335        let v = sample_ramp(&ramp, 0.125);
336        assert!((v - 0.29).abs() < 0.01, "smooth lerp: {v}");
337    }
338
339    #[test]
340    fn bezier_identity_at_1third_2third() {
341        // y1=1/3, y2=2/3 → exact identity f(t)=t
342        let ramp = ToneRamp {
343            stops: vec![ToneStop { t: 0.0, value: 0.0 }, ToneStop { t: 1.0, value: 1.0 }],
344            smooth: true,
345            bezier: Some([1.0 / 3.0, 2.0 / 3.0]),
346        };
347        for &t in &[0.0f32, 0.25, 0.5, 0.75, 1.0] {
348            let v = sample_ramp(&ramp, t);
349            assert!((v - t).abs() < 1e-4, "identity at t={t}: got {v}");
350        }
351    }
352
353    #[test]
354    fn apply_ramp_preserves_hue_in_shadow_band() {
355        // A pure-red pixel at lum ≈ 0.3*255 = 76.5 (within shadow band t≈0.30 → mid)
356        // With default ramp (hard snap): t=0.30 → value=0.50
357        // Expected scale = 0.50*255/76.5 ≈ 1.67 → r scales up
358        let width = 4; let height = 4;
359        let mut buf = vec![0u32; width * height];
360        let r_in = 255u32; let g_in = 0u32; let b_in = 0u32;
361        let _lum_in = 0.299 * r_in as f32; // ≈76.5 (kept for readability)
362        for px in buf.iter_mut() { *px = (r_in << 16) | (g_in << 8) | b_in; }
363        let zbuf: Vec<f32> = vec![]; // no zbuf
364        let ramp = ToneRamp::default();
365        apply_ramp(&mut buf, &zbuf, width, height, &ramp);
366        let p = buf[5];
367        let r = (p >> 16) & 0xFF;
368        let g = (p >>  8) & 0xFF;
369        let b =  p        & 0xFF;
370        // Hue: must stay pure red (g=b=0)
371        assert_eq!(g, 0, "hue must remain red");
372        assert_eq!(b, 0, "hue must remain red");
373        // Lum after: 0.50 * 255 = 127.5 → r ≈ 127.5/0.299 ≈ 426... clamped to 255
374        // Actually: scale = 0.50*255/76.5 = 1.667, r_out = 255*1.667 = 425 → clamped to 255
375        assert!(r > 0, "red channel should be non-zero");
376    }
377
378    #[test]
379    fn apply_ramp_skips_background_with_zbuf() {
380        // Background pixels (zbuf = +inf) must not be touched.
381        let width = 2; let height = 2;
382        let mut buf = vec![0x808080u32; width * height]; // grey background
383        let zbuf = vec![f32::INFINITY; width * height];  // all background
384        let ramp = ToneRamp::default();
385        apply_ramp(&mut buf, &zbuf, width, height, &ramp);
386        for px in &buf {
387            assert_eq!(*px, 0x808080, "background pixels must be unchanged");
388        }
389    }
390
391    #[test]
392    fn default_toon_config_has_3_band_ramp() {
393        let cfg = make_toon_default();
394        assert_eq!(cfg.ramp.stops.len(), 4);
395        assert!(!cfg.ramp.smooth, "default is hard cel");
396        assert!(cfg.ramp.bezier.is_none(), "default has no bezier");
397    }
398}