Files
effectcraft/crates/render/src/three_d/light.rs
T
Brandon ThomasandClaude Opus 5.5 dec091e575 M7.5: advanced 3D renderer
Advanced 3D comps render their 3D runs as one scene of world-space triangles: model and
primitive layers (meshes, skinning, animation clips by layer time), extruded text/shape
layers, and textured cards for every other layer with their Material Options. Shading is
physically based in linear light: Cook-Torrance GGX + Lambert for parallel/spot/point lights
(AE falloff and cone), ambient lights, image-based light from an Environment light's layer
(equirectangular; cosine irradiance map + roughness mip chain + split-sum BRDF fit), and
PCF shadow maps (spot/parallel/point cube faces). 2x2 SSAA, depth of field as a gather blur
by circle of confusion, back to the working encoding.

The software rasteriser (depth buffer, top-left rule, perspective-correct, opaque visibility
then sorted transparent blending) is the reference and CI path; effectcraft-gpu implements
Accelerator::raster_3d with a wgpu render pipeline (advanced3d.wgsl) and matches it within
tolerance. Environment layers are hidden from both renderers.

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

254 lines
9.4 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.
//! Lights and Classic 3D shading.
//!
//! Shading model (per light, Blinn-Phong on the layer plane, lit from both sides):
//! `colour = texture · (ambient_mat · Σ ambient + diffuse_mat · Σ diffuse) + specular`, where a
//! light's diffuse term is `colour · intensity · max(N·L, 0) · falloff · cone · shadow`, light from
//! behind the layer passes through by Light Transmission, and the specular highlight
//! `(N·H)^shininess` takes the light's colour mixed towards the layer's colour by Metal.
//! A comp without lights shows 3D layers unlit.
use effectcraft_geom::{Vec3, vec3};
use effectcraft_project::{Layer, LightKind};
use super::camera::layer_frame;
use crate::EvalCtx;
/// An evaluated light.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LightState {
pub kind: LightKind,
/// World position (point/spot; parallel lights use it only for the shadow source).
pub pos: Vec3,
/// Unit direction the light travels (parallel/spot).
pub dir: Vec3,
/// Linear colour × intensity (1.0 = 100%).
pub color: [f32; 3],
/// Spot cone half-angle (radians) and feather (0..1).
pub cone_half: f64,
pub feather: f64,
/// Falloff: 0 None, 1 Smooth, 2 Inverse Square Clamped.
pub falloff: u32,
pub radius: f64,
pub falloff_distance: f64,
pub casts_shadows: bool,
/// 0..1.
pub shadow_darkness: f64,
/// Pixels.
pub shadow_diffusion: f64,
}
impl LightState {
/// Evaluate a light layer.
pub fn from_layer(ctx: &EvalCtx, layer: &Layer) -> Option<LightState> {
let effectcraft_project::LayerSource::Light { kind } = layer.source else { return None };
let g = layer.props.sub("lightOptions")?;
let intensity = ctx.f(layer, g, "intensity", 100.0) / 100.0;
let c = ctx.color(layer, g, "color");
let color = [(c[0] as f64 * intensity) as f32, (c[1] as f64 * intensity) as f32, (c[2] as f64 * intensity) as f32];
let (pos, fwd, _) = layer_frame(ctx, layer);
// Spot/parallel lights aim at their point of interest (auto-orient), then rotate.
let dir = fwd;
Some(LightState {
kind,
pos,
dir,
color,
cone_half: (ctx.f(layer, g, "coneAngle", 90.0).clamp(0.0, 180.0) / 2.0).to_radians(),
feather: ctx.f(layer, g, "coneFeather", 50.0).clamp(0.0, 100.0) / 100.0,
falloff: ctx.e(layer, g, "falloff"),
radius: ctx.f(layer, g, "radius", 500.0).max(0.0),
falloff_distance: ctx.f(layer, g, "falloffDistance", 500.0).max(0.0),
casts_shadows: ctx.b(layer, g, "castsShadows"),
shadow_darkness: ctx.f(layer, g, "shadowDarkness", 100.0).clamp(0.0, 100.0) / 100.0,
shadow_diffusion: ctx.f(layer, g, "shadowDiffusion", 0.0).max(0.0),
})
}
/// Unit vector from a point towards the light, and the distance (∞ for parallel lights).
pub fn to_light(&self, p: Vec3) -> (Vec3, f64) {
match self.kind {
LightKind::Parallel => (-self.dir, f64::INFINITY),
_ => {
let d = self.pos - p;
let len = d.length();
if len < 1e-9 { (vec3(0.0, 0.0, -1.0), 0.0) } else { (d / len, len) }
}
}
}
/// Distance attenuation × spot cone factor at `p` (0..1).
pub fn attenuation(&self, p: Vec3) -> f64 {
let (l, dist) = self.to_light(p);
let mut k = match self.falloff {
1 if dist.is_finite() => {
// Smooth: full within Radius, smooth to zero over Falloff Distance.
if dist <= self.radius {
1.0
} else if self.falloff_distance <= 0.0 {
0.0
} else {
let t = ((dist - self.radius) / self.falloff_distance).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
}
}
2 if dist.is_finite() => {
// Inverse square, clamped to 1 inside Radius.
if dist <= self.radius || self.radius <= 0.0 && dist <= 1.0 { 1.0 } else { (self.radius.max(1.0) / dist).powi(2) }
}
_ => 1.0,
};
if self.kind == LightKind::Spot {
let cos_a = (-l).dot(self.dir).clamp(-1.0, 1.0);
let a = cos_a.acos();
let outer = self.cone_half;
let inner = outer * (1.0 - self.feather);
k *= if a <= inner {
1.0
} else if a >= outer {
0.0
} else {
let t = (a - inner) / (outer - inner).max(1e-9);
1.0 - t * t * (3.0 - 2.0 * t)
};
}
k
}
}
/// All lights switched on and active at the context time.
pub fn lights_at(ctx: &EvalCtx) -> Vec<LightState> {
// Environment lights only light Advanced 3D comps (image-based light).
ctx.comp
.layers
.iter()
.filter(|l| {
l.is_light()
&& l.switches.video
&& l.is_active_at(ctx.time)
&& l.source != (effectcraft_project::LayerSource::Light { kind: LightKind::Environment })
})
.filter_map(|l| LightState::from_layer(ctx, l))
.collect()
}
/// Material Options of a 3D layer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Material {
/// 0 Off, 1 On, 2 Only.
pub casts_shadows: u32,
/// 0..1.
pub light_transmission: f32,
/// 0 Off, 1 On, 2 Only.
pub accepts_shadows: u32,
pub accepts_lights: bool,
pub ambient: f32,
pub diffuse: f32,
pub specular: f32,
/// 0..1.
pub shininess: f32,
pub metal: f32,
}
impl Default for Material {
fn default() -> Self {
Material {
casts_shadows: 0,
light_transmission: 0.0,
accepts_shadows: 1,
accepts_lights: true,
ambient: 1.0,
diffuse: 0.5,
specular: 0.5,
shininess: 0.05,
metal: 1.0,
}
}
}
impl Material {
pub fn of(ctx: &EvalCtx, layer: &Layer) -> Material {
let Some(g) = layer.props.sub("materialOptions") else { return Material::default() };
let d = Material::default();
let pct = |m: &str, def: f32| (ctx.f(layer, g, m, def as f64 * 100.0) / 100.0) as f32;
Material {
casts_shadows: g.get("castsShadows").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(d.casts_shadows),
light_transmission: pct("lightTransmission", d.light_transmission).clamp(0.0, 1.0),
accepts_shadows: g.get("acceptsShadows").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(d.accepts_shadows),
accepts_lights: g.get("acceptsLights").map(|p| ctx.value(layer, p).as_bool()).unwrap_or(true),
ambient: pct("ambient", d.ambient),
diffuse: pct("diffuse", d.diffuse),
specular: pct("specularIntensity", d.specular),
shininess: pct("specularShininess", d.shininess).clamp(0.0, 1.0),
metal: pct("metal", d.metal).clamp(0.0, 1.0),
}
}
/// Blinn-Phong exponent for Specular Shininess (0% → 1, 100% → 256).
pub fn exponent(&self) -> f64 {
256f64.powf(self.shininess as f64).max(1.0)
}
}
/// Shade one straight-alpha texel at world point `p` with unit normal `n`.
///
/// `to_viewer` is the unit direction towards the viewer; `shadow(light_index, p)` returns the
/// RGB light transmittance (1 = unshadowed). Returns the lit straight RGB colour and the summed
/// shadow amount (0..1, used by "Accepts Shadows: Only").
pub fn shade(
rgb: [f32; 3],
p: Vec3,
n: Vec3,
to_viewer: Vec3,
mat: &Material,
lights: &[LightState],
shadow: &dyn Fn(usize, Vec3) -> [f32; 3],
) -> ([f32; 3], f32) {
if lights.is_empty() || !mat.accepts_lights {
return (rgb, 0.0);
}
let n = if n.dot(to_viewer) < 0.0 { -n } else { n };
let mut amb = [0.0f32; 3];
let mut diff = [0.0f32; 3];
let mut spec = [0.0f32; 3];
let mut shadow_amt = 0.0f32;
let exp = mat.exponent();
for (i, l) in lights.iter().enumerate() {
if l.kind == LightKind::Ambient {
for c in 0..3 {
amb[c] += l.color[c];
}
continue;
}
let k = l.attenuation(p);
if k <= 0.0 {
continue;
}
let (lv, _) = l.to_light(p);
let ndl = n.dot(lv);
let front = ndl > 0.0;
let lambert = if front { ndl } else { -ndl * mat.light_transmission as f64 };
if lambert <= 0.0 {
continue;
}
let sh = if l.casts_shadows && mat.accepts_shadows != 0 { shadow(i, p) } else { [1.0; 3] };
shadow_amt = shadow_amt.max(1.0 - (sh[0] + sh[1] + sh[2]) / 3.0);
let kd = (lambert * k) as f32;
for c in 0..3 {
diff[c] += l.color[c] * kd * sh[c];
}
if front && mat.specular > 0.0 {
let h = (lv + to_viewer).normalize();
let s = (n.dot(h).max(0.0).powf(exp) * k) as f32;
for c in 0..3 {
// Metal: highlight colour from the light (0%) to the layer colour (100%).
let tint = 1.0 + (rgb[c] - 1.0) * mat.metal;
spec[c] += l.color[c] * s * sh[c] * tint;
}
}
}
let mut out = [0.0f32; 3];
for c in 0..3 {
out[c] = rgb[c] * (mat.ambient * amb[c] + mat.diffuse * diff[c]) + mat.specular * spec[c];
}
(out, shadow_amt)
}