Files
effectcraft/crates/render/src/three_d/adv/shade.rs
T
Brandon ThomasandClaude Opus 5.5 79ad7e1843 M7.6: extrusion + primitives
Extruded text keeps per-character 3D: each glyph's outline is extruded in its own space and
placed by its animated matrix; shape layers extrude their fill outlines (gradients use their
mid colour). Shadow-map bias scales with the texel footprint at the receiver and the PCF
reach (no acne on curved casters), identically on CPU and GPU. Docs: Advanced 3D in
architecture.md, parity table updated (3D, Text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:36:59 -04:00

411 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Physically based shading for the Advanced 3D renderer (CPU reference; `crates/gpu`'s
//! `advanced3d.wgsl` repeats it step for step).
//!
//! - Direct light: Cook–Torrance microfacet specular with the GGX/Trowbridge–Reitz normal
//! distribution, the Smith–Schlick-GGX geometry term (`k = (r + 1)² / 8`) and Schlick's
//! Fresnel (`F0` = 0.04 for dielectrics, the base colour for metals), plus a Lambertian
//! diffuse lobe weighted by `(1 − F)(1 − metallic)` (Karis 2013, "Real Shading in Unreal
//! Engine 4"; the glTF 2.0 metallic-roughness model). Light colours are the irradiance on a
//! surface facing the light (a white 100% light shows a white diffuse surface at its base
//! colour), i.e. π is folded into the light intensity.
//! - Image-based light: a cosine-convolved irradiance map for diffuse, a box-filtered mip
//! chain of the equirectangular environment sampled by roughness for specular, and the
//! analytic split-sum environment BRDF fit (Karis 2014, "Physically Based Shading on
//! Mobile").
//! - Shadows: depth maps per light, percentage-closer filtered.
use super::scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo};
pub const PI: f32 = std::f32::consts::PI;
#[inline]
pub fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
pub fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
#[inline]
pub fn norm(a: [f32; 3]) -> [f32; 3] {
let l = dot(a, a).sqrt();
if l > 1e-20 { [a[0] / l, a[1] / l, a[2] / l] } else { [0.0, 0.0, -1.0] }
}
#[inline]
fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn scale(a: [f32; 3], k: f32) -> [f32; 3] {
[a[0] * k, a[1] * k, a[2] * k]
}
#[inline]
fn mix(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
/// sRGB-encoded → linear light.
#[inline]
pub fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
/// Linear light → sRGB-encoded (over-range values continue the curve).
#[inline]
pub fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
}
/// GGX / Trowbridge–Reitz normal distribution, `a = roughness²`.
#[inline]
pub fn d_ggx(n_h: f32, a: f32) -> f32 {
let a2 = a * a;
let d = n_h * n_h * (a2 - 1.0) + 1.0;
a2 / (PI * d * d).max(1e-12)
}
/// Smith–Schlick-GGX geometry term for direct light (`k = (r + 1)² / 8`).
#[inline]
pub fn g_smith(n_v: f32, n_l: f32, rough: f32) -> f32 {
let k = (rough + 1.0) * (rough + 1.0) / 8.0;
let g1 = |x: f32| x / (x * (1.0 - k) + k);
g1(n_v) * g1(n_l)
}
/// Schlick's Fresnel approximation.
#[inline]
pub fn f_schlick(f0: [f32; 3], v_h: f32) -> [f32; 3] {
let k = (1.0 - v_h).clamp(0.0, 1.0).powi(5);
[f0[0] + (1.0 - f0[0]) * k, f0[1] + (1.0 - f0[1]) * k, f0[2] + (1.0 - f0[2]) * k]
}
/// Cook–Torrance BRDF × N·L × π for one light: (diffuse colour factor, specular colour).
/// Returned as the reflected fraction of the light's irradiance.
pub fn brdf(base: [f32; 3], metallic: f32, rough: f32, spec_k: f32, diff_k: f32, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] {
let n_l = dot(n, l);
if n_l <= 0.0 {
return [0.0; 3];
}
let n_v = dot(n, v).max(1e-4);
let h = norm(add(v, l));
let n_h = dot(n, h).max(0.0);
let v_h = dot(v, h).max(0.0);
let r = rough.clamp(0.03, 1.0);
let f0 = [mix(0.04 * spec_k, base[0], metallic), mix(0.04 * spec_k, base[1], metallic), mix(0.04 * spec_k, base[2], metallic)];
let f = f_schlick(f0, v_h);
let d = d_ggx(n_h, r * r);
let g = g_smith(n_v, n_l, r);
let spec = d * g / (4.0 * n_v * n_l).max(1e-4);
let mut out = [0.0; 3];
for c in 0..3 {
let kd = (1.0 - f[c]) * (1.0 - metallic);
out[c] = (kd * base[c] / PI * diff_k + f[c] * spec) * n_l * PI;
}
out
}
/// Split-sum environment BRDF (scale, bias) for `F0·A + B` (analytic fit).
#[inline]
pub fn env_brdf(n_v: f32, rough: f32) -> (f32, f32) {
let c0 = [-1.0, -0.0275, -0.572, 0.022];
let c1 = [1.0, 0.0425, 1.04, -0.04];
let r = [rough * c0[0] + c1[0], rough * c0[1] + c1[1], rough * c0[2] + c1[2], rough * c0[3] + c1[3]];
let a004 = (r[0] * r[0]).min((-9.28 * n_v).exp2()) * r[0] + r[1];
(-1.04 * a004 + r[2], 1.04 * a004 + r[3])
}
/// Wrap a texel index.
#[inline]
fn wrap(i: i64, n: i64, mode: u32) -> i64 {
match mode {
1 => i.clamp(0, n - 1),
2 => {
let m = i.rem_euclid(2 * n);
if m >= n { 2 * n - 1 - m } else { m }
}
_ => i.rem_euclid(n),
}
}
/// Bilinear texture sample at (u, v) (0..1 across the texture, texel centres at +0.5).
pub fn sample(t: &TexInfo, texels: &[[f32; 4]], u: f32, v: f32) -> [f32; 4] {
let (w, h) = (t.width as i64, t.height as i64);
if w == 0 || h == 0 {
return [1.0; 4];
}
let x = u * w as f32 - 0.5;
let y = v * h as f32 - 0.5;
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let (x0, y0) = (x0 as i64, y0 as i64);
let get = |xi: i64, yi: i64| -> [f32; 4] {
let xi = wrap(xi, w, t.wrap_u);
let yi = wrap(yi, h, t.wrap_v);
texels.get(t.offset as usize + (yi * w + xi) as usize).copied().unwrap_or([0.0; 4])
};
let (a, b, c, d) = (get(x0, y0), get(x0 + 1, y0), get(x0, y0 + 1), get(x0 + 1, y0 + 1));
let mut o = [0.0; 4];
for k in 0..4 {
let top = a[k] + (b[k] - a[k]) * fx;
let bot = c[k] + (d[k] - c[k]) * fx;
o[k] = top + (bot - top) * fy;
}
o
}
/// Equirectangular coordinates of a world direction (AE world: +Y down; the environment's up
/// is −Y), rotated about the vertical axis by `rot` radians.
#[inline]
pub fn equirect_uv(d: [f32; 3], rot: f32) -> (f32, f32) {
let u = 0.5 + (d[0].atan2(d[2]) + rot) / (2.0 * PI);
let v = (-d[1]).clamp(-1.0, 1.0).acos() / PI;
(u - u.floor(), v)
}
/// Inverse of [`equirect_uv`] (texel centre → direction).
pub fn equirect_dir(u: f32, v: f32, rot: f32) -> [f32; 3] {
let phi = (u - 0.5) * 2.0 * PI - rot;
let th = v * PI;
[th.sin() * phi.sin(), -th.cos(), th.sin() * phi.cos()]
}
/// Diffuse (irradiance) and roughness-filtered specular radiance of the environment.
pub fn env_light(s: &Scene, e: &EnvInfo, n: [f32; 3], r: [f32; 3], rough: f32) -> ([f32; 3], [f32; 3]) {
let (u, v) = equirect_uv(n, e.rotation);
let irr = s.textures.get(e.irradiance as usize).map_or([0.0; 4], |t| sample(t, &s.texels, u, v));
let (u, v) = equirect_uv(r, e.rotation);
let lv = rough.clamp(0.0, 1.0) * (e.mips.saturating_sub(1)) as f32;
let l0 = lv.floor() as u32;
let l1 = (l0 + 1).min(e.mips.saturating_sub(1));
let f = lv - l0 as f32;
let a = s.textures.get((e.radiance + l0) as usize).map_or([0.0; 4], |t| sample(t, &s.texels, u, v));
let b = s.textures.get((e.radiance + l1) as usize).map_or([0.0; 4], |t| sample(t, &s.texels, u, v));
let spec = [mix(a[0], b[0], f), mix(a[1], b[1], f), mix(a[2], b[2], f)];
(scale([irr[0], irr[1], irr[2]], e.intensity), scale(spec, e.intensity))
}
/// Light-space position (u, v in shadow-map pixels, depth) of a world point.
#[inline]
pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> {
let v = &m.view;
let q = [
v[0][0] * p[0] + v[0][1] * p[1] + v[0][2] * p[2] + v[0][3],
v[1][0] * p[0] + v[1][1] * p[1] + v[1][2] * p[2] + v[1][3],
v[2][0] * p[0] + v[2][1] * p[1] + v[2][2] * p[2] + v[2][3],
];
let c = m.size as f32 * 0.5;
if m.ortho {
Some((q[0] * m.focal + c, q[1] * m.focal + c, q[2]))
} else if q[2] > 1e-3 {
Some((q[0] * m.focal / q[2] + c, q[1] * m.focal / q[2] + c, q[2]))
} else {
None
}
}
/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid
/// spread over `radius` texels.
pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 {
let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 };
let size = m.size as i64;
// Slope-scaled bias (world units are pixels).
// Texel footprint (world units) × PCF reach, steeper for grazing light.
let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal };
let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
let step = m.radius / 2.0;
let mut lit = 0.0;
for j in -2..=2 {
for i in -2..=2 {
let x = (u + i as f32 * step).floor() as i64;
let y = (v + j as f32 * step).floor() as i64;
if x < 0 || y < 0 || x >= size || y >= size {
lit += 1.0;
continue;
}
let d = texels[m.offset as usize + (y * size + x) as usize];
if z - bias <= d {
lit += 1.0;
}
}
}
lit / 25.0
}
/// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness.
pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
if l.shadow < 0 {
return 1.0;
}
let first = l.shadow as usize;
let m = if l.kind == 2 {
// Point light: the cube face of the major axis of the light → point direction.
let d = sub(p, l.pos);
let (ax, ay, az) = (d[0].abs(), d[1].abs(), d[2].abs());
let face = if ax >= ay && ax >= az {
if d[0] > 0.0 { 0 } else { 1 }
} else if ay >= az {
if d[1] > 0.0 { 2 } else { 3 }
} else if d[2] > 0.0 {
4
} else {
5
};
s.shadows.get(first + face)
} else {
s.shadows.get(first)
};
let Some(m) = m else { return 1.0 };
let f = shadow_factor(m, &s.shadow_texels, p, n_l);
1.0 - l.shadow_darkness * (1.0 - f)
}
/// Distance attenuation × spot cone of a light at `p` (AE falloff modes).
pub fn attenuation(l: &Light, p: [f32; 3], to_l: [f32; 3], dist: f32) -> f32 {
let mut k = match l.falloff {
1 if l.kind != 0 => {
if dist <= l.radius {
1.0
} else if l.falloff_distance <= 0.0 {
0.0
} else {
let t = ((dist - l.radius) / l.falloff_distance).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
}
}
2 if l.kind != 0 => {
if dist <= l.radius || (l.radius <= 0.0 && dist <= 1.0) {
1.0
} else {
(l.radius.max(1.0) / dist).powi(2)
}
}
_ => 1.0,
};
let _ = p;
if l.kind == 1 {
let cos_a = dot(scale(to_l, -1.0), l.dir).clamp(-1.0, 1.0);
k *= if cos_a >= l.cos_inner {
1.0
} else if cos_a <= l.cos_outer {
0.0
} else {
// Smoothstep across the feather in angle.
let a = cos_a.acos();
let (ai, ao) = (l.cos_inner.acos(), l.cos_outer.acos());
let t = ((a - ai) / (ao - ai).max(1e-6)).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
};
}
k
}
/// Inputs of one fragment.
#[derive(Clone, Copy, Debug)]
pub struct Frag {
pub pos: [f32; 3],
/// Interpolated (unnormalised) vertex normal.
pub normal: [f32; 3],
pub uv: [f32; 2],
pub tangent: [f32; 4],
pub material: u32,
pub front: bool,
}
/// Shade a fragment: premultiplied linear RGBA, or `None` when discarded (alpha test, back
/// face of a single-sided material).
pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
let m: &Material = s.materials.get(f.material as usize)?;
if !f.front && !m.double_sided {
return None;
}
let tex = |i: i32| -> Option<[f32; 4]> { (i >= 0).then(|| s.textures.get(i as usize).map(|t| sample(t, &s.texels, f.uv[0], f.uv[1]))).flatten() };
let mut base = m.base;
if let Some(t) = tex(m.tex_base) {
for k in 0..4 {
base[k] *= t[k];
}
}
let alpha = base[3] * m.opacity;
if m.alpha_mode == 1 && alpha < m.alpha_cutoff {
return None;
}
if alpha <= 1e-5 && m.alpha_mode != 0 {
return None;
}
let alpha = if m.alpha_mode == 0 { m.opacity } else { alpha };
let rgb = [base[0], base[1], base[2]];
let mut emissive = m.emissive;
if let Some(t) = tex(m.tex_emissive) {
emissive = [emissive[0] * t[0], emissive[1] * t[1], emissive[2] * t[2]];
}
if m.unlit || !m.accepts_lights || (s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]) {
let c = add(rgb, emissive);
return Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha]);
}
let (mut metallic, mut rough) = (m.metallic, m.roughness);
if let Some(t) = tex(m.tex_mr) {
rough *= t[1];
metallic *= t[2];
}
let mut n = norm(f.normal);
if !f.front {
n = scale(n, -1.0);
}
if let Some(t) = tex(m.tex_normal) {
let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0];
let tg = [f.tangent[0], f.tangent[1], f.tangent[2]];
// Gram-Schmidt against the shading normal.
let tg = norm(sub(tg, scale(n, dot(n, tg))));
let sign = if f.front { f.tangent[3] } else { -f.tangent[3] };
let bt = scale(cross(n, tg), sign);
n = norm(add(add(scale(tg, tn[0]), scale(bt, tn[1])), scale(n, tn[2])));
}
let ao = match tex(m.tex_occlusion) {
Some(t) => 1.0 + m.occlusion_strength * (t[0] - 1.0),
None => 1.0,
};
let v = if s.ortho { scale(s.cam_fwd, -1.0) } else { norm(sub(s.eye, f.pos)) };
let mut out = [0.0f32; 3];
for l in &s.lights {
let (to_l, dist) = if l.kind == 0 {
(scale(l.dir, -1.0), f32::INFINITY)
} else {
let d = sub(l.pos, f.pos);
let len = dot(d, d).sqrt();
if len < 1e-6 { ([0.0, 0.0, -1.0], 0.0) } else { (scale(d, 1.0 / len), len) }
};
let k = attenuation(l, f.pos, to_l, dist);
if k <= 0.0 {
continue;
}
let n_l = dot(n, to_l);
if n_l <= 0.0 {
continue;
}
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 };
let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l);
for c in 0..3 {
out[c] += b[c] * l.color[c] * k * sh;
}
}
for c in 0..3 {
out[c] += s.ambient[c] * rgb[c] * m.ambient_k * ao;
}
if let Some(e) = &s.env {
let n_v = dot(n, v).clamp(1e-4, 1.0);
let r = sub(scale(n, 2.0 * dot(n, v)), v);
let (irr, spec) = env_light(s, e, n, r, rough);
let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)];
let (a, b) = env_brdf(n_v, rough);
for c in 0..3 {
let fs = f0[c] * a + b;
out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao;
}
}
let c = add(out, emissive);
Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha])
}