1#[derive(Debug, Clone, PartialEq)]
31pub struct ToneStop {
32 pub t: f32,
33 pub value: f32,
34}
35
36#[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 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#[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
77pub 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 if t <= stops[0].t { return stops[0].value; }
91
92 let last = stops.len() - 1;
93 if t >= stops[last].t { return stops[last].value; }
95
96 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; }
106 }
107 }
108 stops[last].value
109}
110
111pub 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
179pub 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#[derive(Debug, Clone)]
263pub struct ToonConfig {
264 pub ramp: ToneRamp,
267 pub outline_px: f32,
269 pub outline_thresh: f32,
271 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
286pub 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#[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 let v = sample_ramp(&ramp, 0.10);
320 assert!((v - 0.08).abs() < 1e-4, "shadow band: {v}");
321 let v = sample_ramp(&ramp, 0.40);
323 assert!((v - 0.50).abs() < 1e-4, "mid band: {v}");
324 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 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 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 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; for px in buf.iter_mut() { *px = (r_in << 16) | (g_in << 8) | b_in; }
363 let zbuf: Vec<f32> = vec![]; 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 assert_eq!(g, 0, "hue must remain red");
372 assert_eq!(b, 0, "hue must remain red");
373 assert!(r > 0, "red channel should be non-zero");
376 }
377
378 #[test]
379 fn apply_ramp_skips_background_with_zbuf() {
380 let width = 2; let height = 2;
382 let mut buf = vec![0x808080u32; width * height]; let zbuf = vec![f32::INFINITY; width * height]; 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}