mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
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>
411 lines
15 KiB
Rust
411 lines
15 KiB
Rust
//! 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])
|
||
}
|