#[derive(Debug, Clone, PartialEq)]
pub struct ToneStop {
pub t: f32,
pub value: f32,
}
#[derive(Debug, Clone)]
pub struct ToneRamp {
pub stops: Vec<ToneStop>,
pub smooth: bool,
pub bezier: Option<[f32; 2]>,
}
impl Default for ToneRamp {
fn default() -> Self {
Self {
stops: vec![
ToneStop { t: 0.00, value: 0.08 },
ToneStop { t: 0.25, value: 0.50 },
ToneStop { t: 0.60, value: 1.00 },
ToneStop { t: 1.00, value: 1.00 },
],
smooth: false,
bezier: None,
}
}
}
#[inline]
fn bezier_remap(y1: f32, y2: f32, t: f32) -> f32 {
let mt = 1.0 - t;
3.0 * t * mt * mt * y1 + 3.0 * t * t * mt * y2 + t * t * t
}
pub fn sample_ramp(ramp: &ToneRamp, t_in: f32) -> f32 {
let t = t_in.clamp(0.0, 1.0);
let t = match ramp.bezier {
Some([y1, y2]) => bezier_remap(y1, y2, t).clamp(0.0, 1.0),
None => t,
};
let stops = &ramp.stops;
if stops.is_empty() { return t; }
if t <= stops[0].t { return stops[0].value; }
let last = stops.len() - 1;
if t >= stops[last].t { return stops[last].value; }
for i in 0..last {
if t < stops[i + 1].t {
if ramp.smooth {
let span = stops[i + 1].t - stops[i].t;
let f = if span > 1e-6 { (t - stops[i].t) / span } else { 1.0 };
return stops[i].value + f * (stops[i + 1].value - stops[i].value);
} else {
return stops[i].value; }
}
}
stops[last].value
}
pub fn apply_ramp(
buf: &mut Vec<u32>,
zbuf: &[f32],
width: usize,
height: usize,
ramp: &ToneRamp,
) {
let n = width * height;
if buf.len() < n || ramp.stops.is_empty() { return; }
let use_zbuf = zbuf.len() >= n;
#[inline]
fn shade(p: u32, ramp: &ToneRamp) -> u32 {
let r = ((p >> 16) & 0xFF) as f32;
let g = ((p >> 8) & 0xFF) as f32;
let b = ( p & 0xFF) as f32;
let lum = 0.299 * r + 0.587 * g + 0.114 * b;
if lum < 0.001 { return p; }
let new_val = sample_ramp(ramp, lum / 255.0);
let scale = (new_val * 255.0 / lum).clamp(0.0, 8.0);
(((r * scale).min(255.0) as u32) << 16)
| (((g * scale).min(255.0) as u32) << 8)
| ((b * scale).min(255.0) as u32)
}
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
const ROWS: usize = 32;
let band = ROWS * width;
if use_zbuf {
buf[..n]
.par_chunks_mut(band)
.zip(zbuf[..n].par_chunks(band))
.for_each(|(bb, zz)| {
for (px, z) in bb.iter_mut().zip(zz) {
if z.is_finite() {
*px = shade(*px, ramp);
}
}
});
} else {
buf[..n].par_chunks_mut(band).for_each(|bb| {
for px in bb.iter_mut() {
*px = shade(*px, ramp);
}
});
}
return;
}
#[cfg(target_arch = "wasm32")]
for i in 0..n {
if use_zbuf && !zbuf[i].is_finite() { continue; }
buf[i] = shade(buf[i], ramp);
}
}
pub fn draw_outlines(
buf: &mut Vec<u32>,
zbuf: &[f32],
width: usize,
height: usize,
thickness: f32,
color: u32,
threshold: f32,
) {
if zbuf.len() < width * height || buf.len() < width * height {
return;
}
let t = thickness.clamp(0.5, 6.0);
let t_i = t.ceil() as i32;
let t2 = t * t;
for y in t_i..(height as i32 - t_i) {
for x in t_i..(width as i32 - t_i) {
let idx = y as usize * width + x as usize;
let z = zbuf[idx];
if !z.is_finite() { continue; }
let zn = zbuf[(y - 1) as usize * width + x as usize];
let zs = zbuf[(y + 1) as usize * width + x as usize];
let zw = zbuf[y as usize * width + (x - 1) as usize];
let ze = zbuf[y as usize * width + (x + 1) as usize];
let dmax = (z - zn).abs()
.max((z - zs).abs())
.max((z - zw).abs())
.max((z - ze).abs());
if dmax < threshold { continue; }
for dy in -t_i..=t_i {
for dx in -t_i..=t_i {
let dist2 = (dx as f32) * (dx as f32) + (dy as f32) * (dy as f32);
if dist2 > t2 { continue; }
let nx = x + dx;
let ny = y + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
let ni = ny as usize * width + nx as usize;
let cov = (t2 - dist2).sqrt() / t.max(1.0);
let cov = cov.clamp(0.0, 1.0);
if cov >= 0.999 {
buf[ni] = color;
} else {
let dst = buf[ni];
let dr = ((dst >> 16) & 0xFF) as f32;
let dg = ((dst >> 8) & 0xFF) as f32;
let db = ( dst & 0xFF) as f32;
let ir = ((color >> 16) & 0xFF) as f32;
let ig = ((color >> 8) & 0xFF) as f32;
let ib = ( color & 0xFF) as f32;
let r = (ir * cov + dr * (1.0 - cov)) as u32;
let g = (ig * cov + dg * (1.0 - cov)) as u32;
let b = (ib * cov + db * (1.0 - cov)) as u32;
buf[ni] = (r << 16) | (g << 8) | b;
}
}
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct ToonConfig {
pub ramp: ToneRamp,
pub outline_px: f32,
pub outline_thresh: f32,
pub outline_color: u32,
}
impl Default for ToonConfig {
fn default() -> Self {
Self {
ramp: ToneRamp::default(),
outline_px: 0.0,
outline_thresh: 0.05,
outline_color: 0x00_00_00,
}
}
}
pub fn apply(
cfg: &ToonConfig,
buf: &mut Vec<u32>,
zbuf: &[f32],
width: usize,
height: usize,
) {
apply_ramp(buf, zbuf, width, height, &cfg.ramp);
if cfg.outline_px > 0.0 {
draw_outlines(buf, zbuf, width, height,
cfg.outline_px, cfg.outline_color, cfg.outline_thresh);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_toon_default() -> ToonConfig { ToonConfig::default() }
#[test]
fn sample_ramp_hard_snap_3band() {
let ramp = ToneRamp::default();
let v = sample_ramp(&ramp, 0.10);
assert!((v - 0.08).abs() < 1e-4, "shadow band: {v}");
let v = sample_ramp(&ramp, 0.40);
assert!((v - 0.50).abs() < 1e-4, "mid band: {v}");
let v = sample_ramp(&ramp, 0.80);
assert!((v - 1.00).abs() < 1e-4, "lit band: {v}");
}
#[test]
fn sample_ramp_smooth_lerps() {
let mut ramp = ToneRamp::default();
ramp.smooth = true;
let v = sample_ramp(&ramp, 0.125);
assert!((v - 0.29).abs() < 0.01, "smooth lerp: {v}");
}
#[test]
fn bezier_identity_at_1third_2third() {
let ramp = ToneRamp {
stops: vec![ToneStop { t: 0.0, value: 0.0 }, ToneStop { t: 1.0, value: 1.0 }],
smooth: true,
bezier: Some([1.0 / 3.0, 2.0 / 3.0]),
};
for &t in &[0.0f32, 0.25, 0.5, 0.75, 1.0] {
let v = sample_ramp(&ramp, t);
assert!((v - t).abs() < 1e-4, "identity at t={t}: got {v}");
}
}
#[test]
fn apply_ramp_preserves_hue_in_shadow_band() {
let width = 4; let height = 4;
let mut buf = vec![0u32; width * height];
let r_in = 255u32; let g_in = 0u32; let b_in = 0u32;
let _lum_in = 0.299 * r_in as f32; for px in buf.iter_mut() { *px = (r_in << 16) | (g_in << 8) | b_in; }
let zbuf: Vec<f32> = vec![]; let ramp = ToneRamp::default();
apply_ramp(&mut buf, &zbuf, width, height, &ramp);
let p = buf[5];
let r = (p >> 16) & 0xFF;
let g = (p >> 8) & 0xFF;
let b = p & 0xFF;
assert_eq!(g, 0, "hue must remain red");
assert_eq!(b, 0, "hue must remain red");
assert!(r > 0, "red channel should be non-zero");
}
#[test]
fn apply_ramp_skips_background_with_zbuf() {
let width = 2; let height = 2;
let mut buf = vec![0x808080u32; width * height]; let zbuf = vec![f32::INFINITY; width * height]; let ramp = ToneRamp::default();
apply_ramp(&mut buf, &zbuf, width, height, &ramp);
for px in &buf {
assert_eq!(*px, 0x808080, "background pixels must be unchanged");
}
}
#[test]
fn default_toon_config_has_3_band_ramp() {
let cfg = make_toon_default();
assert_eq!(cfg.ramp.stops.len(), 4);
assert!(!cfg.ramp.smooth, "default is hard cel");
assert!(cfg.ramp.bezier.is_none(), "default has no bezier");
}
}