use crate::npr::composition::{bloom_toon, vignette};
use crate::npr::hatch::hatch_lines;
use crate::npr::motion::speed_line;
use crate::npr::outline::composite_outline;
use crate::npr::palette::palette_gradient;
use crate::npr::toon::{posterize_color, soft_toon_ramp, toon_ramp, two_tone};
use crate::npr::NprColor;
use glam::{Vec2, Vec3};
#[derive(Debug, Clone, Copy)]
pub struct NprColorContext {
pub sdf: f32,
pub normal: Vec3,
pub view: Vec3,
pub light: Vec3,
pub uv: Vec2,
pub time: f32,
}
impl NprColorContext {
#[inline]
#[must_use]
pub fn n_dot_l(&self) -> f32 {
self.normal.dot(self.light)
}
#[inline]
#[must_use]
pub fn n_dot_v(&self) -> f32 {
self.normal.dot(self.view)
}
}
#[derive(Debug, Clone)]
pub enum NprColorNode {
Constant(NprColor),
Toon {
shadow: NprColor,
light: NprColor,
bands: u32,
},
SoftToon {
shadow: NprColor,
light: NprColor,
bands: u32,
smoothness: f32,
},
TwoTone {
shadow: NprColor,
light: NprColor,
threshold: f32,
},
OutlineOver {
base: Box<Self>,
outline: NprColor,
alpha: f32,
},
Multiply {
a: Box<Self>,
b: Box<Self>,
},
Add {
a: Box<Self>,
b: Box<Self>,
},
Scale {
child: Box<Self>,
factor: f32,
},
Fresnel {
base: Box<Self>,
edge: NprColor,
power: f32,
},
Saturate {
child: Box<Self>,
factor: f32,
},
Bloom {
child: Box<Self>,
threshold: f32,
intensity: f32,
},
PosterizeColor {
child: Box<Self>,
levels: u32,
},
Vignette {
child: Box<Self>,
radius: f32,
softness: f32,
},
Palette3 {
source: PaletteSource,
c0: NprColor,
c1: NprColor,
c2: NprColor,
},
Hatch {
base: Box<Self>,
angle_rad: f32,
density: f32,
thickness: f32,
ink: NprColor,
},
Palette5 {
source: PaletteSource,
c0: NprColor,
c1: NprColor,
c2: NprColor,
c3: NprColor,
c4: NprColor,
},
Tonemap {
child: Box<Self>,
exposure: f32,
},
SpeedLine {
base: Box<Self>,
focus: Vec2,
count: u32,
thickness: f32,
ink: NprColor,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteSource {
NDotL,
NDotV,
Sdf,
UvY,
TimeCycle,
}
impl NprColorNode {
#[must_use]
#[allow(
clippy::disallowed_methods,
reason = "rendering helper: the result is a colour or shading weight and never feeds a distance, bound or mesh"
)]
pub fn eval(&self, ctx: &NprColorContext) -> NprColor {
match self {
Self::Constant(c) => *c,
Self::Toon {
shadow,
light,
bands,
} => {
let t = toon_ramp(ctx.n_dot_l(), *bands);
shadow.lerp(*light, t)
}
Self::SoftToon {
shadow,
light,
bands,
smoothness,
} => {
let t = soft_toon_ramp(ctx.n_dot_l(), *bands, *smoothness);
shadow.lerp(*light, t)
}
Self::TwoTone {
shadow,
light,
threshold,
} => two_tone(ctx.n_dot_l(), *shadow, *light, *threshold),
Self::OutlineOver {
base,
outline,
alpha,
} => {
let base_color = base.eval(ctx);
composite_outline(base_color, *outline, *alpha)
}
Self::Multiply { a, b } => a.eval(ctx) * b.eval(ctx),
Self::Add { a, b } => a.eval(ctx) + b.eval(ctx),
Self::Scale { child, factor } => child.eval(ctx) * *factor,
Self::Fresnel { base, edge, power } => {
let base_color = base.eval(ctx);
let ndv = ctx.n_dot_v();
let fresnel = (1.0 - ndv.clamp(0.0, 1.0)).max(0.0).powf(power.max(0.0));
base_color.lerp(*edge, fresnel.clamp(0.0, 1.0))
}
Self::Saturate { child, factor } => {
let color = child.eval(ctx);
let lum = color.dot(Vec3::new(0.2126, 0.7152, 0.0722));
Vec3::splat(lum).lerp(color, *factor)
}
Self::Bloom {
child,
threshold,
intensity,
} => bloom_toon(child.eval(ctx), *threshold, *intensity),
Self::PosterizeColor { child, levels } => {
posterize_color(child.eval(ctx), (*levels).max(2))
}
Self::Vignette {
child,
radius,
softness,
} => {
let mask = vignette(ctx.uv.x, ctx.uv.y, *radius, *softness);
child.eval(ctx) * mask
}
Self::Palette3 { source, c0, c1, c2 } => {
let t = palette_source_scalar(*source, ctx).clamp(0.0, 1.0);
let palette = [*c0, *c1, *c2];
palette_gradient(t, &palette)
}
Self::Hatch {
base,
angle_rad,
density,
thickness,
ink,
} => {
let base_color = base.eval(ctx);
let mask = hatch_lines(ctx.uv.x, ctx.uv.y, *angle_rad, *density, *thickness);
base_color.lerp(*ink, mask)
}
Self::Palette5 {
source,
c0,
c1,
c2,
c3,
c4,
} => {
let t = palette_source_scalar(*source, ctx).clamp(0.0, 1.0);
let palette = [*c0, *c1, *c2, *c3, *c4];
palette_gradient(t, &palette)
}
Self::Tonemap { child, exposure } => {
let color = child.eval(ctx);
let scaled = color * exposure.max(0.0);
scaled / (Vec3::ONE + scaled)
}
Self::SpeedLine {
base,
focus,
count,
thickness,
ink,
} => {
let base_color = base.eval(ctx);
let mask = speed_line(ctx.uv.x, ctx.uv.y, focus.x, focus.y, *count, *thickness);
base_color.lerp(*ink, mask)
}
}
}
#[must_use]
pub fn with_outline(self, outline: NprColor, alpha: f32) -> Self {
Self::OutlineOver {
base: Box::new(self),
outline,
alpha,
}
}
#[must_use]
pub fn multiply(self, other: Self) -> Self {
Self::Multiply {
a: Box::new(self),
b: Box::new(other),
}
}
#[must_use]
pub fn plus(self, other: Self) -> Self {
Self::Add {
a: Box::new(self),
b: Box::new(other),
}
}
#[must_use]
pub fn scale(self, factor: f32) -> Self {
Self::Scale {
child: Box::new(self),
factor,
}
}
#[must_use]
pub fn with_fresnel(self, edge: NprColor, power: f32) -> Self {
Self::Fresnel {
base: Box::new(self),
edge,
power,
}
}
#[must_use]
pub fn saturate(self, factor: f32) -> Self {
Self::Saturate {
child: Box::new(self),
factor,
}
}
#[must_use]
pub fn bloom(self, threshold: f32, intensity: f32) -> Self {
Self::Bloom {
child: Box::new(self),
threshold,
intensity,
}
}
#[must_use]
pub fn posterize(self, levels: u32) -> Self {
Self::PosterizeColor {
child: Box::new(self),
levels: levels.max(2),
}
}
#[must_use]
pub fn vignetted(self, radius: f32, softness: f32) -> Self {
Self::Vignette {
child: Box::new(self),
radius,
softness,
}
}
#[must_use]
pub fn with_hatch(self, angle_rad: f32, density: f32, thickness: f32, ink: NprColor) -> Self {
Self::Hatch {
base: Box::new(self),
angle_rad,
density,
thickness,
ink,
}
}
#[must_use]
pub fn tonemap_reinhard(self, exposure: f32) -> Self {
Self::Tonemap {
child: Box::new(self),
exposure,
}
}
#[must_use]
pub fn with_speed_lines(self, focus: Vec2, count: u32, thickness: f32, ink: NprColor) -> Self {
Self::SpeedLine {
base: Box::new(self),
focus,
count,
thickness,
ink,
}
}
}
#[must_use]
pub(crate) fn palette_source_scalar(source: PaletteSource, ctx: &NprColorContext) -> f32 {
match source {
PaletteSource::NDotL => ctx.n_dot_l().clamp(0.0, 1.0),
PaletteSource::NDotV => ctx.n_dot_v().clamp(0.0, 1.0),
PaletteSource::Sdf => ctx.sdf.abs().clamp(0.0, 1.0),
PaletteSource::UvY => ctx.uv.y.clamp(0.0, 1.0),
PaletteSource::TimeCycle => ctx.time - ctx.time.floor(),
}
}
#[allow(
clippy::disallowed_methods,
reason = "test code: the platform libm and fused mul_add serve as independent references"
)]
#[cfg(test)]
mod tests {
use super::*;
fn lit_context() -> NprColorContext {
NprColorContext {
sdf: 0.0,
normal: Vec3::new(0.0, 1.0, 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.5, 0.5),
time: 0.0,
}
}
fn dark_context() -> NprColorContext {
NprColorContext {
sdf: 0.0,
normal: Vec3::new(0.0, 1.0, 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, -1.0, 0.0),
uv: Vec2::new(0.5, 0.5),
time: 0.0,
}
}
#[test]
fn constant_returns_stored_color() {
let node = NprColorNode::Constant(Vec3::new(0.3, 0.6, 0.9));
let c = node.eval(&lit_context());
assert_eq!(c, Vec3::new(0.3, 0.6, 0.9));
}
#[test]
fn toon_lit_side_returns_light() {
let node = NprColorNode::Toon {
shadow: Vec3::ZERO,
light: Vec3::ONE,
bands: 3,
};
let c = node.eval(&lit_context());
assert!((c - Vec3::ONE).length() < 1e-3);
}
#[test]
fn toon_dark_side_returns_shadow() {
let node = NprColorNode::Toon {
shadow: Vec3::ZERO,
light: Vec3::ONE,
bands: 3,
};
let c = node.eval(&dark_context());
assert!((c - Vec3::ZERO).length() < 1e-3);
}
#[test]
fn two_tone_dispatches_by_threshold() {
let node = NprColorNode::TwoTone {
shadow: Vec3::ZERO,
light: Vec3::ONE,
threshold: 0.5,
};
assert_eq!(node.eval(&lit_context()), Vec3::ONE);
assert_eq!(node.eval(&dark_context()), Vec3::ZERO);
}
#[test]
fn outline_over_full_alpha_returns_outline() {
let base = NprColorNode::Constant(Vec3::new(0.5, 0.5, 0.5));
let node = base.with_outline(Vec3::ZERO, 1.0);
let c = node.eval(&lit_context());
assert_eq!(c, Vec3::ZERO);
}
#[test]
fn outline_over_zero_alpha_returns_base() {
let base = NprColorNode::Constant(Vec3::new(0.5, 0.5, 0.5));
let node = base.with_outline(Vec3::ONE, 0.0);
let c = node.eval(&lit_context());
assert_eq!(c, Vec3::new(0.5, 0.5, 0.5));
}
#[test]
fn multiply_combines_children() {
let a = NprColorNode::Constant(Vec3::new(0.5, 0.5, 0.5));
let b = NprColorNode::Constant(Vec3::new(0.4, 0.4, 0.4));
let c = a.multiply(b).eval(&lit_context());
assert!((c - Vec3::splat(0.2)).length() < 1e-4);
}
#[test]
fn add_sums_children() {
let a = NprColorNode::Constant(Vec3::new(0.3, 0.1, 0.0));
let b = NprColorNode::Constant(Vec3::new(0.2, 0.4, 0.5));
let c = a.plus(b).eval(&lit_context());
assert!((c - Vec3::new(0.5, 0.5, 0.5)).length() < 1e-4);
}
#[test]
fn scale_multiplies_by_factor() {
let node = NprColorNode::Constant(Vec3::new(0.4, 0.4, 0.4)).scale(0.5);
let c = node.eval(&lit_context());
assert!((c - Vec3::splat(0.2)).length() < 1e-4);
}
#[test]
fn fresnel_head_on_returns_base() {
let ctx = NprColorContext {
sdf: 0.0,
normal: Vec3::new(0.0, 0.0, 1.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.5, 0.5),
time: 0.0,
};
let base = NprColorNode::Constant(Vec3::new(0.5, 0.5, 0.5));
let node = base.with_fresnel(Vec3::ZERO, 2.0);
let c = node.eval(&ctx);
assert!((c - Vec3::splat(0.5)).length() < 1e-4);
}
#[test]
fn fresnel_grazing_returns_edge() {
let ctx = NprColorContext {
sdf: 0.0,
normal: Vec3::new(1.0, 0.0, 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.5, 0.5),
time: 0.0,
};
let base = NprColorNode::Constant(Vec3::ZERO);
let node = base.with_fresnel(Vec3::ONE, 2.0);
let c = node.eval(&ctx);
assert!((c - Vec3::ONE).length() < 1e-4);
}
#[test]
fn saturate_zero_returns_grey() {
let color = Vec3::new(1.0, 0.0, 0.0);
let node = NprColorNode::Constant(color).saturate(0.0);
let c = node.eval(&lit_context());
let expected = Vec3::splat(0.2126);
assert!(
(c - expected).length() < 1e-4,
"expected grey ~ {expected:?}, got {c:?}"
);
}
#[test]
fn saturate_one_returns_original() {
let color = Vec3::new(0.7, 0.4, 0.2);
let node = NprColorNode::Constant(color).saturate(1.0);
let c = node.eval(&lit_context());
assert!((c - color).length() < 1e-4);
}
#[test]
fn bloom_passes_bright_colors() {
let color = Vec3::new(0.9, 0.9, 0.9);
let node = NprColorNode::Constant(color).bloom(0.5, 0.5);
let c = node.eval(&lit_context());
assert!((c - color * 0.5).length() < 1e-4);
}
#[test]
fn bloom_rejects_dim_colors() {
let color = Vec3::new(0.3, 0.3, 0.3);
let node = NprColorNode::Constant(color).bloom(0.5, 1.0);
let c = node.eval(&lit_context());
assert!((c - Vec3::ZERO).length() < 1e-4);
}
#[test]
fn posterize_reduces_levels() {
let node = NprColorNode::Constant(Vec3::new(0.25, 0.5, 0.75)).posterize(2);
let c = node.eval(&lit_context());
for ch in [c.x, c.y, c.z] {
assert!(ch == 0.0 || ch == 1.0, "expected binary, got {ch}");
}
}
#[test]
fn vignette_at_center_returns_full_child() {
let child = NprColorNode::Constant(Vec3::new(0.5, 0.5, 0.5));
let node = child.vignetted(0.5, 0.3);
let c = node.eval(&lit_context()); assert!((c - Vec3::splat(0.5)).length() < 1e-4);
}
#[test]
fn vignette_at_corner_returns_dark() {
let child = NprColorNode::Constant(Vec3::ONE);
let node = child.vignetted(0.3, 0.2);
let ctx = NprColorContext {
sdf: 0.0,
normal: Vec3::new(0.0, 1.0, 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.0, 0.0),
time: 0.0,
};
let c = node.eval(&ctx);
assert!((c - Vec3::ZERO).length() < 1e-4);
}
#[test]
fn palette3_ndotl_ends_pick_endpoints() {
let node = NprColorNode::Palette3 {
source: PaletteSource::NDotL,
c0: Vec3::new(1.0, 0.0, 0.0),
c1: Vec3::new(0.0, 1.0, 0.0),
c2: Vec3::new(0.0, 0.0, 1.0),
};
assert!((node.eval(&lit_context()) - Vec3::new(0.0, 0.0, 1.0)).length() < 1e-3);
assert!((node.eval(&dark_context()) - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-3);
}
#[test]
fn hatch_off_line_returns_base() {
let base = NprColorNode::Constant(Vec3::ONE);
let node = base.with_hatch(0.0, 4.0, 0.02, Vec3::ZERO);
let ctx = NprColorContext {
sdf: 0.0,
normal: Vec3::new(0.0, 1.0, 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.5, 0.375),
time: 0.0,
};
let c = node.eval(&ctx);
assert!((c - Vec3::ONE).length() < 1e-4);
}
#[test]
fn palette5_endpoints_pick_c0_c4() {
let node = NprColorNode::Palette5 {
source: PaletteSource::NDotL,
c0: Vec3::new(1.0, 0.0, 0.0),
c1: Vec3::new(0.0, 1.0, 0.0),
c2: Vec3::new(0.0, 0.0, 1.0),
c3: Vec3::new(1.0, 1.0, 0.0),
c4: Vec3::new(1.0, 0.0, 1.0),
};
assert!((node.eval(&lit_context()) - Vec3::new(1.0, 0.0, 1.0)).length() < 1e-3);
assert!((node.eval(&dark_context()) - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-3);
}
#[test]
fn tonemap_reinhard_maps_hdr_into_unit() {
let child = NprColorNode::Constant(Vec3::splat(10.0));
let node = child.tonemap_reinhard(1.0);
let c = node.eval(&lit_context());
for ch in [c.x, c.y, c.z] {
assert!(ch < 1.0 && ch > 0.9, "expected close-to-1, got {ch}");
}
}
#[test]
fn tonemap_reinhard_black_stays_black() {
let child = NprColorNode::Constant(Vec3::ZERO);
let node = child.tonemap_reinhard(2.0);
let c = node.eval(&lit_context());
assert!((c - Vec3::ZERO).length() < 1e-6);
}
#[test]
fn speed_line_at_focus_returns_base() {
let base = NprColorNode::Constant(Vec3::ONE);
let node = base.with_speed_lines(Vec2::new(0.5, 0.5), 12, 0.05, Vec3::ZERO);
let c = node.eval(&lit_context()); assert!((c - Vec3::ONE).length() < 1e-4);
}
#[test]
fn hatch_on_line_returns_ink() {
let base = NprColorNode::Constant(Vec3::ONE);
let node = base.with_hatch(0.0, 4.0, 0.2, Vec3::ZERO);
let c = node.eval(&lit_context()); assert!((c - Vec3::ZERO).length() < 1e-4);
}
#[test]
fn palette3_uv_y_selects_by_uv() {
let node = NprColorNode::Palette3 {
source: PaletteSource::UvY,
c0: Vec3::new(1.0, 0.0, 0.0),
c1: Vec3::new(0.0, 1.0, 0.0),
c2: Vec3::new(0.0, 0.0, 1.0),
};
let ctx_top = NprColorContext {
sdf: 0.0,
normal: Vec3::new(0.0, 1.0, 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.5, 1.0),
time: 0.0,
};
assert!((node.eval(&ctx_top) - Vec3::new(0.0, 0.0, 1.0)).length() < 1e-3);
}
#[test]
fn posterize_clamps_low_levels() {
let a = NprColorNode::Constant(Vec3::new(0.6, 0.6, 0.6)).posterize(1);
let b = NprColorNode::Constant(Vec3::new(0.6, 0.6, 0.6)).posterize(2);
assert!((a.eval(&lit_context()) - b.eval(&lit_context())).length() < 1e-4);
}
#[test]
fn soft_toon_bounded() {
let node = NprColorNode::SoftToon {
shadow: Vec3::ZERO,
light: Vec3::ONE,
bands: 4,
smoothness: 0.05,
};
for i in 0..12 {
let a = i as f32 * std::f32::consts::TAU / 12.0;
let ctx = NprColorContext {
sdf: 0.0,
normal: Vec3::new(a.cos(), a.sin(), 0.0),
view: Vec3::new(0.0, 0.0, 1.0),
light: Vec3::new(0.0, 1.0, 0.0),
uv: Vec2::new(0.5, 0.5),
time: 0.0,
};
let c = node.eval(&ctx);
for ch in [c.x, c.y, c.z] {
assert!((0.0..=1.0).contains(&ch), "out of range at a={a}: {ch}");
}
}
}
}