Merge main into IO-2/SHP-5/OUT-2/PRV-2 work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:39:47 -04:00
co-authored by Claude Opus 5.5
104 changed files with 18573 additions and 73 deletions
+2
View File
@@ -30,6 +30,7 @@ use effectcraft_raster::Image;
const VERSION: &str = "v1";
const MAGIC: &[u8; 4] = b"ECDC";
/// Pending writes before new ones are dropped.
#[cfg(not(target_arch = "wasm32"))]
const QUEUE_LIMIT: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -86,6 +87,7 @@ struct Index {
clock: u64,
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
enum Job {
Write(Kind, u128, Vec<u8>),
}
+58 -3
View File
@@ -40,6 +40,16 @@ pub trait FootageSource: Send + Sync {
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
/// Auxiliary 3D channels of `item` at source time `t` (multi-layer OpenEXR: depth, IDs,
/// Cryptomatte, any named channel), with `scale` relative to the file's pixels. `None` when
/// the footage has none.
fn aux(&self, _item: ItemId, _footage: &Footage, _t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
None
}
/// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D).
fn model(&self, _item: ItemId, _footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
None
}
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
/// a cache ignore it.
fn set_cache_budget(&self, _bytes: usize) {}
@@ -118,6 +128,29 @@ impl EffectHost for FxHost<'_, '_, '_> {
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
}
fn aux(&self) -> Option<Arc<effectcraft_raster::AuxChannels>> {
match &self.layer.source {
LayerSource::Footage { item } => {
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?;
// Aux pixels per layer pixel (the layer is the footage at its nominal size).
if f.width > 0 && a.width > 0 {
let mut a = (*a).clone();
a.scale = a.width as f64 / f.width as f64;
return Some(Arc::new(a));
}
Some(a)
}
LayerSource::Comp { item } => {
if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) {
return None;
}
self.r.nested().comp_aux(*item, self.ctx.source_time(self.layer)).map(Arc::new)
}
_ => None,
}
}
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
@@ -185,6 +218,12 @@ pub trait Accelerator: Send + Sync {
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
/// every effect when set (8/16 bpc). `None` = not handled.
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf>;
/// Rasterise and shade an Advanced 3D scene at its raster size (depth buffer, PBR, image
/// based light, shadow maps), with the CPU rasteriser's semantics
/// ([`three_d::adv::raster::render`]). `None` = not handled (the CPU renders it).
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
None
}
}
#[derive(Clone, Copy, Debug)]
@@ -543,8 +582,15 @@ impl<'a> Renderer<'a> {
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let mask_shapes = masks::shapes(ctx, layer);
let host = FxHost { r: self, ctx, layer, index: Default::default() };
let env =
EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64(), effect_index: 0 };
let env = EffectEnv {
masks: &mask_shapes,
host: Some(&host),
comp_time: ctx.time.seconds(),
frame_rate: ctx.comp.frame_rate.as_f64(),
effect_index: 0,
working_space: self.pipe.space,
working_linear: self.pipe.linear,
};
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
.groups()
@@ -1195,6 +1241,13 @@ impl<'a> Renderer<'a> {
self.depth
}
/// The auxiliary 3D channels of comp `comp_id` at comp time `t` (depth, layer IDs, normals,
/// UVs, Cryptomatte; see `three_d::compose::aux_pass`), at the renderer's output scale.
pub fn comp_aux(&self, comp_id: ItemId, t: Tick) -> Option<effectcraft_raster::AuxChannels> {
let ctx = self.eval_ctx(comp_id, t)?;
Some(three_d::compose::aux_pass(self, &ctx))
}
/// Evaluation context of comp `comp_id` at comp time `t`.
pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option<EvalCtx<'a>> {
Some(self.ctx(comp_id, self.project.comp(comp_id)?, t))
@@ -1208,7 +1261,7 @@ impl<'a> Renderer<'a> {
comp.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.filter(|l| l.is_active_at(t) && l.has_video() && !l.environment && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect()
}
@@ -1315,6 +1368,8 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
#[cfg(test)]
mod tests_audio_fx;
#[cfg(test)]
mod tests_aux;
#[cfg(test)]
mod tests_collapse;
#[cfg(test)]
mod tests_color;
+21
View File
@@ -508,6 +508,27 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
#[cfg(test)]
mod tests;
/// The filled outlines of a shape layer in layer space with their straight colours (gradient
/// fills: the colour half way along), for Advanced 3D extrusion. Strokes are not extruded.
pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let c = match &d.paint {
Paint::Fill { color, .. } => *color,
Paint::Gradient { g, stroke: None, .. } => g.sample(0.5),
_ => return None,
};
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
(!paths.is_empty()).then_some((paths, [c[0], c[1], c[2], c[3] * d.opacity]))
})
.collect()
}
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
/// rasterising.
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {
+11 -1
View File
@@ -175,7 +175,17 @@ fn blocks_match_one_long_mix() {
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
(x, -x)
}
for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] {
for id in [
"ec.audio.reverb",
"ec.audio.basstreble",
"ec.audio.flangechorus",
"ec.audio.modulator",
"ec.audio.delay",
"ec.audio.parametriceq",
"ec.audio.compressor",
"ec.audio.gate",
"ec.audio.distortion",
] {
let vals: Vec<(&str, Value)> = match id {
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],
+98
View File
@@ -0,0 +1,98 @@
//! Auxiliary 3D channels: the comp's depth / ID / Cryptomatte pass and the 3D Channel effects on
//! a precomp of a 3D comp.
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn zoom() -> f64 {
W as f64 * 50.0 / 36.0
}
fn solid3(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], pos: [f64; 3]) -> Layer {
let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: 60, height: 60, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, name, LayerSource::Solid { item: sid }, (60, 60), None);
l.name = name.to_string();
l.switches.three_d = true;
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos);
l
}
/// Inner 3D comp: "Near" (red, z 0, left) above "Far" (green, z 500, right). Outer comp: the
/// precomp layer, optionally with an effect.
fn scene(effect: Option<(&str, Vec<(&str, Value)>)>) -> (Project, ItemId, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let near = solid3(&mut p, &inner, "Near", [1.0, 0.0, 0.0], [60.0, 50.0, 0.0]);
let far = solid3(&mut p, &inner, "Far", [0.0, 1.0, 0.0], [140.0, 50.0, 500.0]);
p.comp_mut(iid).unwrap().layers.push(near);
p.comp_mut(iid).unwrap().layers.push(far);
let outer = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (W, H), None);
if let Some((id, vals)) = effect {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v.clone();
}
pre.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(oid).unwrap().layers.push(pre);
(p, iid, oid)
}
#[test]
fn comp_aux_pass_has_depth_ids_and_cryptomatte() {
let (p, iid, _) = scene(None);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
assert_eq!((a.width, a.height), (W, H));
let at = |name: &str, x: usize, y: usize| a.get(name).unwrap()[y * W as usize + x];
// Near plane at the camera's zoom distance, the far one 500 px behind it.
assert!((at("Z", 60, 50) as f64 - zoom()).abs() < 1e-2, "{}", at("Z", 60, 50));
assert!((at("Z", 114, 50) as f64 - (zoom() + 500.0)).abs() < 1e-2, "{}", at("Z", 114, 50));
assert_eq!(at("Z", 5, 5), BACKGROUND_DEPTH);
assert_eq!((at("ObjectID", 60, 50), at("ObjectID", 114, 50), at("ObjectID", 5, 5)), (1.0, 2.0, 0.0));
assert_eq!(at("Coverage", 60, 50), 1.0);
// Planes face the camera.
assert!((at("N.Z", 60, 50) + 1.0).abs() < 1e-5);
// Cryptomatte rank 0 = the layer under the pixel, full coverage; manifest lists names.
assert_eq!(at("CryptoObject00.R", 60, 50).to_bits(), crypto_hash("Near"));
assert_eq!(at("CryptoObject00.G", 60, 50), 1.0);
assert_eq!(a.manifest_name("CryptoObject", crypto_hash("Far")), Some("Far"));
}
#[test]
fn three_d_channel_effects_work_on_a_precomp_of_a_3d_comp() {
let near = (60, 50);
let far = (114, 50);
let alpha = |p: &Project, oid: ItemId, (x, y): (i64, i64)| render_frame(p, oid, Tick::ZERO, 1.0).get(x, y)[3];
let (p, _, oid) = scene(Some(("ec.3d.idmatte", vec![("idSelection", Value::Scalar(2.0))])));
assert_eq!(alpha(&p, oid, near), 0.0);
assert!(alpha(&p, oid, far) > 0.99);
let (p, _, oid) = scene(Some(("ec.3d.depthmatte", vec![("depth", Value::Scalar(zoom() + 250.0))])));
assert_eq!(alpha(&p, oid, near), 0.0);
assert!(alpha(&p, oid, far) > 0.99);
let (p, _, oid) = scene(Some(("ec.3d.cryptomatte", vec![("selection", Value::Str("Near".into())), ("display", Value::Enum(1))])));
assert!(alpha(&p, oid, near) > 0.99);
assert_eq!(alpha(&p, oid, far), 0.0);
// Half resolution: the pass follows the render scale.
let (p, iid, _) = scene(None);
let r = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() });
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
assert_eq!((a.width, a.height, a.scale), (W / 2, H / 2, 0.5));
assert_eq!(a.get("ObjectID").unwrap()[25 * 100 + 30], 1.0);
}
+165
View File
@@ -0,0 +1,165 @@
//! The Advanced 3D renderer (Composition Settings ▸ 3D Renderer ▸ Advanced 3D).
//!
//! A run of 3D layers becomes one [`Scene`] of world-space triangles (models, primitives,
//! extruded text and shapes, and textured cards for every other layer), lit physically
//! ([`shade`]) by the comp's lights, an Environment light's image and shadow maps. The scene
//! is rasterised with a depth buffer on the GPU when an [`crate::Accelerator`] implements
//! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2
//! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's
//! circle of confusion) and the conversion back to the working encoding run on the CPU for
//! both paths, and the result is composited over the layers below the run.
pub mod raster;
pub mod scene;
pub mod shade;
use effectcraft_project::Layer;
use rayon::prelude::*;
pub use raster::Target;
pub use scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo, Vertex};
use super::camera::Dof;
use super::compose::camera_for;
use crate::{EvalCtx, Image, Renderer};
/// Box-filter a supersampled target down by `k`: (premultiplied colour, mean depth of the
/// covered subsamples, ∞ where none).
pub fn resolve(t: &Target, k: u32) -> (Image, Vec<f32>) {
let (w, h) = (t.width / k, t.height / k);
let mut img = Image::new(w, h);
let mut depth = vec![f32::INFINITY; (w * h) as usize];
let n = (k * k) as f32;
img.data.par_chunks_mut(w as usize).zip(depth.par_chunks_mut(w as usize)).enumerate().for_each(|(y, (row, drow))| {
for x in 0..w as usize {
let mut acc = [0.0f32; 4];
let (mut dz, mut dn) = (0.0f32, 0.0f32);
for j in 0..k as usize {
for i in 0..k as usize {
let s = (y * k as usize + j) * t.width as usize + x * k as usize + i;
let c = t.color[s];
for q in 0..4 {
acc[q] += c[q];
}
if t.depth[s].is_finite() {
dz += t.depth[s];
dn += 1.0;
}
}
}
row[x] = acc.map(|v| v / n);
if dn > 0.0 {
drow[x] = dz / dn;
}
}
});
(img, depth)
}
/// Depth of field: a gather blur where each pixel collects the neighbours whose circle of
/// confusion (from their depth, in output pixels) reaches it; nearer in-focus surfaces are not
/// smeared by the blur of what is behind them.
pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image {
let (w, h) = (img.width as i64, img.height as i64);
let max_r = 48.0f32;
let radius: Vec<f32> = depth
.iter()
.map(|&z| {
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
((dof.coc(z) * scale * 0.5) as f32).min(max_r)
})
.collect();
let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b));
if rmax < 0.5 {
return img.clone();
}
// Disk samples (golden-angle spiral) over the largest radius.
const N: usize = 64;
let golden = std::f32::consts::PI * (3.0 - 5.0f32.sqrt());
let taps: Vec<(f32, f32, f32)> = (1..=N)
.map(|i| {
let r = (i as f32 / N as f32).sqrt() * rmax;
let a = i as f32 * golden;
(r * a.cos(), r * a.sin(), r)
})
.collect();
let mut out = Image::new(img.width, img.height);
out.data.par_chunks_mut(w as usize).enumerate().for_each(|(y, row)| {
for x in 0..w as usize {
let i = y * w as usize + x;
let (rp, zp) = (radius[i], depth[i]);
let cw = 1.0 / (std::f32::consts::PI * rp * rp).max(1.0);
let c = img.data[i];
let mut acc = [c[0] * cw, c[1] * cw, c[2] * cw, c[3] * cw];
let mut wsum = cw;
for &(dx, dy, d) in &taps {
let (sx, sy) = (x as i64 + dx.round() as i64, y as i64 + dy.round() as i64);
if sx < 0 || sy < 0 || sx >= w || sy >= h {
continue;
}
let j = (sy * w + sx) as usize;
// A sample behind this pixel blurs no further than this pixel's own circle.
let rq = if depth[j] > zp { radius[j].min(rp) } else { radius[j] };
let cov = (rq - d + 0.5).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
// Each tap stands for an annulus of area ≈ π·rmax²/N.
let wq = cov / (std::f32::consts::PI * rq * rq).max(1.0) * (std::f32::consts::PI * rmax * rmax / N as f32);
let q = img.data[j];
for k in 0..4 {
acc[k] += q[k] * wq;
}
wsum += wq;
}
row[x] = acc.map(|v| v / wsum);
}
});
out
}
/// Linear → sRGB-encoded premultiplied pixels (the working encoding of non-linear projects).
pub fn encode(img: &mut Image) {
img.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a > 1e-6 {
for c in 0..3 {
p[c] = shade::linear_to_srgb(p[c] / a) * a;
}
}
});
}
/// Render a scene on the CPU, resolved: (premultiplied image at output size, depth).
pub fn render_cpu(s: &Scene) -> (Image, Vec<f32>) {
resolve(&raster::render(s), s.ssaa.max(1))
}
/// Build the scene of a run (for tests and tools).
pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
scene::build(r, ctx, run, out)
}
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
if s.indices.is_empty() {
return;
}
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
let cam = camera_for(r, ctx);
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
}
if !s.linear_io {
encode(&mut img);
}
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
let k = 1.0 - s[3];
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
});
}
#[cfg(test)]
mod tests;
+492
View File
@@ -0,0 +1,492 @@
//! The software rasteriser of the Advanced 3D renderer (the CPU reference and the fallback
//! when no GPU is available) and the shadow-map rasteriser shared by both paths.
//!
//! Triangles are clipped against the depth range (`0 ≤ z ≤ w`, as GPUs do), set up in screen
//! space and scanned per band of rows in parallel with edge functions (pixel centres, top-left
//! fill rule) and perspective-correct barycentrics. Opaque triangles resolve visibility first
//! (reversed depth: larger ndc z is nearer) and each pixel is shaded once; transparent triangles
//! (already sorted back to front) are then shaded per fragment and blended over, depth-tested
//! against the opaque surfaces.
use rayon::prelude::*;
use super::scene::{Scene, ShadowMap, Vertex};
use super::shade::{Frag, sample, shade};
/// A rendered target at raster size.
pub struct Target {
pub width: u32,
pub height: u32,
/// Premultiplied linear RGBA.
pub color: Vec<[f32; 4]>,
/// Camera-space depth of the nearest opaque surface (∞ where empty).
pub depth: Vec<f32>,
}
/// A clipped triangle in screen space; `w` holds, per corner, its weights over the original
/// triangle's three vertices.
#[derive(Clone, Copy)]
struct STri {
p: [[f64; 2]; 3],
z: [f32; 3],
inv_w: [f32; 3],
w: [[f32; 3]; 3],
tri: u32,
front: bool,
bbox: [i32; 4],
}
#[inline]
fn mat_mul(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 4] {
let mut o = [0.0; 4];
for r in 0..4 {
o[r] = m[r][0] * p[0] + m[r][1] * p[1] + m[r][2] * p[2] + m[r][3];
}
o
}
/// Clip a polygon (clip coords + original weights) against `0 ≤ z ≤ w`.
fn clip_poly(poly: Vec<([f32; 4], [f32; 3])>) -> Vec<([f32; 4], [f32; 3])> {
let mut p = poly;
for plane in 0..2 {
if p.is_empty() {
break;
}
let d = |c: &[f32; 4]| if plane == 0 { c[2] } else { c[3] - c[2] };
let mut out = Vec::with_capacity(p.len() + 2);
for i in 0..p.len() {
let (a, b) = (p[i], p[(i + 1) % p.len()]);
let (da, db) = (d(&a.0), d(&b.0));
if da >= 0.0 {
out.push(a);
}
if (da >= 0.0) != (db >= 0.0) {
let t = da / (da - db);
let mut c = [0.0; 4];
let mut w = [0.0; 3];
for k in 0..4 {
c[k] = a.0[k] + (b.0[k] - a.0[k]) * t;
}
for k in 0..3 {
w[k] = a.1[k] + (b.1[k] - a.1[k]) * t;
}
out.push((c, w));
}
}
p = out;
}
p
}
/// Set up the screen triangles of index range `range`.
fn setup(s: &Scene, clip: &[[f32; 4]], range: std::ops::Range<usize>) -> Vec<STri> {
let (w, h) = (s.width as f64, s.height as f64);
let mut out = vec![];
for t in range.step_by(3) {
let ids = [s.indices[t] as usize, s.indices[t + 1] as usize, s.indices[t + 2] as usize];
let mat = s.vertices[ids[0]].material as usize;
if !s.materials.get(mat).is_some_and(|m| m.visible) {
continue;
}
let poly = vec![(clip[ids[0]], [1.0, 0.0, 0.0]), (clip[ids[1]], [0.0, 1.0, 0.0]), (clip[ids[2]], [0.0, 0.0, 1.0])];
let poly = clip_poly(poly);
if poly.len() < 3 {
continue;
}
let scr: Vec<([f64; 2], f32, f32, [f32; 3])> = poly
.iter()
.map(|(c, wt)| {
let iw = 1.0 / c[3];
let (nx, ny) = (c[0] * iw, c[1] * iw);
([(nx as f64 + 1.0) * 0.5 * w, (1.0 - ny as f64) * 0.5 * h], c[2] * iw, iw, *wt)
})
.collect();
// Facing from the polygon's screen-space winding (y down: negative = front).
let mut area = 0.0;
for i in 0..scr.len() {
let (a, b) = (scr[i].0, scr[(i + 1) % scr.len()].0);
area += a[0] * b[1] - b[0] * a[1];
}
if area == 0.0 || !area.is_finite() {
continue;
}
let front = area < 0.0;
for k in 1..scr.len() - 1 {
let c = [scr[0], scr[k], scr[k + 1]];
let mut x0 = f64::INFINITY;
let mut y0 = f64::INFINITY;
let mut x1 = f64::NEG_INFINITY;
let mut y1 = f64::NEG_INFINITY;
for v in &c {
x0 = x0.min(v.0[0]);
y0 = y0.min(v.0[1]);
x1 = x1.max(v.0[0]);
y1 = y1.max(v.0[1]);
}
let bbox = [
(x0.floor() as i64).max(0) as i32,
(y0.floor() as i64).max(0) as i32,
(x1.ceil() as i64).min(s.width as i64) as i32,
(y1.ceil() as i64).min(s.height as i64) as i32,
];
if bbox[0] >= bbox[2] || bbox[1] >= bbox[3] {
continue;
}
out.push(STri {
p: [c[0].0, c[1].0, c[2].0],
z: [c[0].1, c[1].1, c[2].1],
inv_w: [c[0].2, c[1].2, c[2].2],
w: [c[0].3, c[1].3, c[2].3],
tri: (t / 3) as u32,
front,
bbox,
});
}
}
out
}
/// Coverage of a pixel centre: (ndc z, perspective-correct weights over the screen corners).
#[inline]
fn cover(t: &STri, px: f64, py: f64) -> Option<(f32, [f32; 3])> {
let [a, b, c] = t.p;
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
// Orient so the area is positive.
let (b, c, swap) = if area < 0.0 { (c, b, true) } else { (b, c, false) };
let area = area.abs();
let edge = |p: [f64; 2], q: [f64; 2]| -> f64 { (q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) };
let top_left = |p: [f64; 2], q: [f64; 2]| -> bool {
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
dy < 0.0 || (dy == 0.0 && dx > 0.0)
};
let e0 = edge(b, c); // opposite a
let e1 = edge(c, a); // opposite b
let e2 = edge(a, b); // opposite c
let inside = |e: f64, p: [f64; 2], q: [f64; 2]| e > 0.0 || (e == 0.0 && top_left(p, q));
if !(inside(e0, b, c) && inside(e1, c, a) && inside(e2, a, b)) {
return None;
}
let (l0, l1, l2) = ((e0 / area) as f32, (e1 / area) as f32, (e2 / area) as f32);
// Back to the stored corner order.
let l = if swap { [l0, l2, l1] } else { [l0, l1, l2] };
let z = l[0] * t.z[0] + l[1] * t.z[1] + l[2] * t.z[2];
let q = [l[0] * t.inv_w[0], l[1] * t.inv_w[1], l[2] * t.inv_w[2]];
let sum = q[0] + q[1] + q[2];
if sum <= 0.0 {
return None;
}
Some((z, [q[0] / sum, q[1] / sum, q[2] / sum]))
}
/// Interpolate the original triangle's vertex attributes at screen-corner weights `l`.
fn frag(s: &Scene, t: &STri, l: [f32; 3]) -> Frag {
let base = t.tri as usize * 3;
let v: [&Vertex; 3] = [0, 1, 2].map(|k| &s.vertices[s.indices[base + k] as usize]);
// Weights over the original vertices.
let mut wv = [0.0f32; 3];
for c in 0..3 {
for k in 0..3 {
wv[k] += l[c] * t.w[c][k];
}
}
let mut f = Frag { pos: [0.0; 3], normal: [0.0; 3], uv: [0.0; 2], tangent: [0.0; 4], material: v[0].material, front: t.front };
for k in 0..3 {
let x = wv[k];
for i in 0..3 {
f.pos[i] += v[k].pos[i] * x;
f.normal[i] += v[k].normal[i] * x;
}
for i in 0..2 {
f.uv[i] += v[k].uv[i] * x;
}
for i in 0..4 {
f.tangent[i] += v[k].tangent[i] * x;
}
}
f.tangent[3] = v[0].tangent[3];
f
}
const BAND: i32 = 16;
/// Rasterise and shade the scene.
pub fn render(s: &Scene) -> Target {
let (w, h) = (s.width as usize, s.height as usize);
let clip: Vec<[f32; 4]> = s.vertices.iter().map(|v| mat_mul(&s.clip, v.pos)).collect();
let opaque = setup(s, &clip, 0..s.opaque_count as usize);
let trans = setup(s, &clip, s.opaque_count as usize..s.indices.len());
let bands = (h as i32 + BAND - 1) / BAND;
let bin = |ts: &[STri]| -> Vec<Vec<u32>> {
let mut b = vec![vec![]; bands.max(0) as usize];
for (i, t) in ts.iter().enumerate() {
for band in (t.bbox[1] / BAND)..((t.bbox[3] + BAND - 1) / BAND).min(bands) {
b[band as usize].push(i as u32);
}
}
b
};
let ob = bin(&opaque);
let tb = bin(&trans);
let view = s.view;
let cam_z = |p: [f32; 3]| view[2][0] * p[0] + view[2][1] * p[1] + view[2][2] * p[2] + view[2][3];
let mut color = vec![[0.0f32; 4]; w * h];
let mut depth = vec![f32::INFINITY; w * h];
color.par_chunks_mut(w * BAND as usize).zip(depth.par_chunks_mut(w * BAND as usize)).enumerate().for_each(|(band, (crow, drow))| {
let y0 = band as i32 * BAND;
let rows = crow.len() / w;
// Visibility: (screen tri, ndc z, weights).
let mut vis: Vec<(u32, f32, [f32; 3])> = vec![(u32::MAX, 0.0, [0.0; 3]); rows * w];
for &ti in &ob[band] {
let t = &opaque[ti as usize];
for y in t.bbox[1].max(y0)..t.bbox[3].min(y0 + rows as i32) {
let r = (y - y0) as usize * w;
for x in t.bbox[0]..t.bbox[2] {
if let Some((z, l)) = cover(t, x as f64 + 0.5, y as f64 + 0.5) {
let v = &mut vis[r + x as usize];
if z > v.1 && z <= 1.0 {
*v = (ti, z, l);
}
}
}
}
}
// A discarded opaque fragment (alpha test, back face) lets the next nearer-than-nothing
// surface through only on a second look; keep it simple: resolve again without it.
for i in 0..rows * w {
let (ti, _, l) = vis[i];
if ti == u32::MAX {
continue;
}
let t = &opaque[ti as usize];
let f = frag(s, t, l);
match shade(s, &f) {
Some(c) => {
crow[i] = c;
drow[i] = cam_z(f.pos);
}
None => {
// Find the nearest surviving fragment under this pixel.
let (x, y) = ((i % w) as f64 + 0.5, (y0 as usize + i / w) as f64 + 0.5);
let mut best: Option<(f32, [f32; 4], f32)> = None;
for &tj in &ob[band] {
if tj == ti {
continue;
}
let t2 = &opaque[tj as usize];
if (x as i32) < t2.bbox[0] || (x as i32) >= t2.bbox[2] || (y as i32) < t2.bbox[1] || (y as i32) >= t2.bbox[3] {
continue;
}
if let Some((z, l2)) = cover(t2, x, y)
&& best.is_none_or(|b| z > b.0)
{
let f2 = frag(s, t2, l2);
if let Some(c) = shade(s, &f2) {
best = Some((z, c, cam_z(f2.pos)));
}
}
}
if let Some((z, c, d)) = best {
crow[i] = c;
drow[i] = d;
vis[i].1 = z;
} else {
vis[i].1 = 0.0;
}
}
}
}
// Transparent fragments in order, over the opaque result.
for &ti in &tb[band] {
let t = &trans[ti as usize];
for y in t.bbox[1].max(y0)..t.bbox[3].min(y0 + rows as i32) {
let r = (y - y0) as usize * w;
for x in t.bbox[0]..t.bbox[2] {
let i = r + x as usize;
if let Some((z, l)) = cover(t, x as f64 + 0.5, y as f64 + 0.5)
&& z > vis[i].1
&& z <= 1.0
&& let Some(c) = shade(s, &frag(s, t, l))
{
let d = crow[i];
let k = 1.0 - c[3];
crow[i] = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k];
}
}
}
}
});
Target { width: s.width, height: s.height, color, depth }
}
// ---------------------------------------------------------------- shadow maps
fn basis(eye: [f64; 3], fwd: [f64; 3], down: [f64; 3]) -> [[f32; 4]; 3] {
use effectcraft_geom::vec3;
let m = crate::three_d::camera::basis_view(vec3(eye[0], eye[1], eye[2]), vec3(fwd[0], fwd[1], fwd[2]), vec3(down[0], down[1], down[2]));
[0, 1, 2].map(|r| m.0[r].map(|v| v as f32))
}
/// Rasterise the shadow-casting triangles' light-space depth into `m`'s texels.
fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec<f32> {
let n = m.size as usize;
let mut d = vec![f32::INFINITY; n * n];
let c = n as f32 * 0.5;
const NEAR: f32 = 1.0;
for &t in casters {
let ids = [s.indices[3 * t] as usize, s.indices[3 * t + 1] as usize, s.indices[3 * t + 2] as usize];
let mat = &s.materials[s.vertices[ids[0]].material as usize];
let alpha_tex = (mat.alpha_mode != 0 && mat.tex_base >= 0).then(|| &s.textures[mat.tex_base as usize]);
// Light space, clipped against the near plane (perspective).
let q: Vec<([f32; 3], [f32; 2])> = ids
.iter()
.map(|&i| {
let p = s.vertices[i].pos;
let v = &m.view;
([0, 1, 2].map(|r| v[r][0] * p[0] + v[r][1] * p[1] + v[r][2] * p[2] + v[r][3]), s.vertices[i].uv)
})
.collect();
let mut poly = q;
if !m.ortho {
let mut out = vec![];
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
let (da, db) = (a.0[2] - NEAR, b.0[2] - NEAR);
if da >= 0.0 {
out.push(a);
}
if (da >= 0.0) != (db >= 0.0) {
let k = da / (da - db);
let lerp = |x: f32, y: f32| x + (y - x) * k;
out.push(([lerp(a.0[0], b.0[0]), lerp(a.0[1], b.0[1]), lerp(a.0[2], b.0[2])], [lerp(a.1[0], b.1[0]), lerp(a.1[1], b.1[1])]));
}
}
poly = out;
}
if poly.len() < 3 {
continue;
}
// Screen: (x, y, 1/z or 1, depth) per corner.
let scr: Vec<([f64; 2], f32, f32, [f32; 2])> = poly
.iter()
.map(|(p, uv)| {
if m.ortho {
([(p[0] * m.focal + c) as f64, (p[1] * m.focal + c) as f64], 1.0, p[2], *uv)
} else {
let iz = 1.0 / p[2];
([(p[0] * m.focal * iz + c) as f64, (p[1] * m.focal * iz + c) as f64], iz, p[2], *uv)
}
})
.collect();
for k in 1..scr.len() - 1 {
let tri = [scr[0], scr[k], scr[k + 1]];
let (a, b, cc) = (tri[0].0, tri[1].0, tri[2].0);
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
if area.abs() < 1e-12 {
continue;
}
let x0 = a[0].min(b[0]).min(cc[0]).floor().max(0.0) as usize;
let x1 = (a[0].max(b[0]).max(cc[0]).ceil().max(0.0) as usize).min(n);
let y0 = a[1].min(b[1]).min(cc[1]).floor().max(0.0) as usize;
let y1 = (a[1].max(b[1]).max(cc[1]).ceil().max(0.0) as usize).min(n);
for y in y0..y1 {
for x in x0..x1 {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let e0 = ((cc[0] - b[0]) * (py - b[1]) - (cc[1] - b[1]) * (px - b[0])) / area;
let e1 = ((a[0] - cc[0]) * (py - cc[1]) - (a[1] - cc[1]) * (px - cc[0])) / area;
let e2 = 1.0 - e0 - e1;
if e0 < 0.0 || e1 < 0.0 || e2 < 0.0 {
continue;
}
let l = [e0 as f32, e1 as f32, e2 as f32];
let (z, uv) = if m.ortho {
(l[0] * tri[0].2 + l[1] * tri[1].2 + l[2] * tri[2].2, [0, 1].map(|i| l[0] * tri[0].3[i] + l[1] * tri[1].3[i] + l[2] * tri[2].3[i]))
} else {
let iz = l[0] * tri[0].1 + l[1] * tri[1].1 + l[2] * tri[2].1;
let z = 1.0 / iz;
(z, [0, 1].map(|i| (l[0] * tri[0].3[i] * tri[0].1 + l[1] * tri[1].3[i] * tri[1].1 + l[2] * tri[2].3[i] * tri[2].1) * z))
};
let px = &mut d[y * n + x];
if z >= *px {
continue;
}
if let Some(t) = alpha_tex
&& sample(t, &s.texels, uv[0], uv[1])[3] * mat.base[3] * mat.opacity < 0.5
{
continue;
}
*px = z;
}
}
}
}
d
}
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
let casters: Vec<usize> = (0..s.indices.len() / 3)
.filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows))
.collect();
if casters.is_empty() {
for l in &mut s.lights {
l.shadow = -1;
}
return;
}
// Bounding sphere of the scene (parallel lights' orthographic frustum).
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for v in &s.vertices {
for k in 0..3 {
lo[k] = lo[k].min(v.pos[k] as f64);
hi[k] = hi[k].max(v.pos[k] as f64);
}
}
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5);
let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0;
let size = if draft { 512 } else { 1024 };
let mut maps: Vec<ShadowMap> = vec![];
for li in 0..s.lights.len() {
if s.lights[li].shadow != 0 {
s.lights[li].shadow = -1;
continue;
}
let l = s.lights[li];
let first = maps.len() as i32;
let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0);
let pos = l.pos.map(|v| v as f64);
let dir = l.dir.map(|v| v as f64);
let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
match l.kind {
0 => {
let eye = [centre[0] - dir[0] * radius * 2.0, centre[1] - dir[1] * radius * 2.0, centre[2] - dir[2] * radius * 2.0];
let focal = (size as f64 * 0.5 / radius) as f32;
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft });
}
1 => {
let half = (l.cos_outer.clamp(-1.0, 1.0).acos() as f64 + 0.05).min(1.45);
let focal = (size as f64 * 0.5 / half.tan()) as f32;
maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft });
}
_ => {
let fs = size / 2;
for (f, d) in [
([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
] {
maps.push(ShadowMap { view: basis(pos, f, d), focal: fs as f32 * 0.5, ortho: false, size: fs, offset: 0, bias: 1.5, radius: soft });
}
}
}
s.lights[li].shadow = first;
}
let texels: Vec<Vec<f32>> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect();
for (m, t) in maps.iter_mut().zip(texels) {
m.offset = s.shadow_texels.len() as u32;
s.shadow_texels.extend(t);
}
s.shadows = maps;
}
+875
View File
@@ -0,0 +1,875 @@
//! The Advanced 3D scene: everything a rasteriser needs, flattened into world-space triangles
//! and plain arrays (identical input for the CPU rasteriser and the GPU pipeline).
//!
//! - Model and primitive layers: their meshes (glTF/OBJ models are +Y up in model units and
//! are mapped into the layer by `Model Scale` with Y and Z flipped, a 180° turn about X).
//! - Extruded text and shape layers (Geometry Options ▸ Extrusion Depth > 0): bevelled meshes.
//! - Every other 3D layer (footage, solids, comps, flat text and shapes): a textured card of
//! its processed buffer, lit with its Material Options.
//! - Lights from the comp's light layers, the Environment light's image (prefiltered for image
//! based lighting), and shadow maps of the shadow-casting lights.
//!
//! Colours are linear light; textures and layer buffers are linearised from the sRGB curve
//! unless the project already blends in linear light.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_model::{self as model, Primitive};
use effectcraft_project::{ItemKind, Layer, LayerSource, LightKind, PrimitiveKind};
use super::shade::srgb_to_linear;
use crate::three_d::camera::{CameraState, NEAR};
use crate::three_d::compose::camera_for;
use crate::three_d::light::{LightState, Material as ClassicMaterial};
use crate::{EvalCtx, Renderer};
/// One vertex in world space.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[repr(C)]
pub struct Vertex {
pub pos: [f32; 3],
pub normal: [f32; 3],
pub uv: [f32; 2],
pub tangent: [f32; 4],
pub material: u32,
}
/// A material, resolved for shading.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Material {
/// Linear RGBA factor.
pub base: [f32; 4],
pub metallic: f32,
pub roughness: f32,
pub emissive: [f32; 3],
pub normal_scale: f32,
pub occlusion_strength: f32,
/// 0 opaque, 1 mask, 2 blend.
pub alpha_mode: u32,
pub alpha_cutoff: f32,
/// Layer opacity (multiplies alpha).
pub opacity: f32,
/// Texture indices (−1 = none).
pub tex_base: i32,
pub tex_mr: i32,
pub tex_normal: i32,
pub tex_occlusion: i32,
pub tex_emissive: i32,
pub double_sided: bool,
pub unlit: bool,
pub accepts_lights: bool,
pub receives_shadows: bool,
pub casts_shadows: bool,
/// Drawn (false for "Casts Shadows: Only").
pub visible: bool,
/// Material Options weights (cards: Diffuse, Specular Intensity / 50%, Ambient).
pub diffuse_k: f32,
pub specular_k: f32,
pub ambient_k: f32,
}
impl Default for Material {
fn default() -> Self {
Material {
base: [1.0; 4],
metallic: 0.0,
roughness: 0.5,
emissive: [0.0; 3],
normal_scale: 1.0,
occlusion_strength: 1.0,
alpha_mode: 0,
alpha_cutoff: 0.5,
opacity: 1.0,
tex_base: -1,
tex_mr: -1,
tex_normal: -1,
tex_occlusion: -1,
tex_emissive: -1,
double_sided: false,
unlit: false,
accepts_lights: true,
receives_shadows: true,
casts_shadows: false,
visible: true,
diffuse_k: 1.0,
specular_k: 1.0,
ambient_k: 1.0,
}
}
}
impl Material {
/// Drawn in the transparent (sorted, blended) pass.
pub fn transparent(&self) -> bool {
self.alpha_mode == 2 || self.opacity < 0.999
}
}
/// A texture in [`Scene::texels`]: linear straight RGBA.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct TexInfo {
pub offset: u32,
pub width: u32,
pub height: u32,
/// 0 repeat, 1 clamp, 2 mirror.
pub wrap_u: u32,
pub wrap_v: u32,
}
/// A light (ambient lights are summed into [`Scene::ambient`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Light {
/// 0 parallel, 1 spot, 2 point.
pub kind: u32,
pub pos: [f32; 3],
/// Direction the light travels.
pub dir: [f32; 3],
/// Linear colour × intensity.
pub color: [f32; 3],
pub cos_inner: f32,
pub cos_outer: f32,
/// 0 none, 1 smooth, 2 inverse square clamped.
pub falloff: u32,
pub radius: f32,
pub falloff_distance: f32,
/// First shadow map (point lights use six), −1 = none.
pub shadow: i32,
pub shadow_darkness: f32,
/// Shadow Diffusion (pixels).
pub shadow_diffusion: f32,
}
/// A depth map seen from a light: `view` maps world → light space (+z forward); pixels
/// `u = focal·x/z + size/2` (perspective) or `focal·x + size/2` (orthographic); texels store
/// the light-space depth of the nearest caster.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShadowMap {
pub view: [[f32; 4]; 3],
pub focal: f32,
pub ortho: bool,
pub size: u32,
pub offset: u32,
/// Depth bias in texels (scaled by the texel footprint at the receiver).
pub bias: f32,
/// PCF spread (texels).
pub radius: f32,
}
/// The environment image: radiance mip levels `radiance..radiance + mips` and an irradiance
/// map, in [`Scene::textures`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EnvInfo {
pub radiance: u32,
pub mips: u32,
pub irradiance: u32,
pub intensity: f32,
/// Radians about the vertical axis.
pub rotation: f32,
}
#[derive(Clone, Debug, Default)]
pub struct Scene {
/// Raster size (output × `ssaa`).
pub width: u32,
pub height: u32,
pub ssaa: u32,
/// World → clip (row-major; reversed depth: ndc z = near / camera z).
pub clip: [[f32; 4]; 4],
/// World → camera (row-major; camera z = depth for depth of field).
pub view: [[f32; 4]; 4],
pub eye: [f32; 3],
pub cam_fwd: [f32; 3],
pub ortho: bool,
pub vertices: Vec<Vertex>,
/// Triangles: the first `opaque_count` indices are opaque, the rest transparent, sorted
/// back to front.
pub indices: Vec<u32>,
pub opaque_count: u32,
pub materials: Vec<Material>,
pub textures: Vec<TexInfo>,
pub texels: Vec<[f32; 4]>,
pub lights: Vec<Light>,
pub shadows: Vec<ShadowMap>,
pub shadow_texels: Vec<f32>,
pub env: Option<EnvInfo>,
pub ambient: [f32; 3],
/// Shading inputs and outputs are linear (no sRGB conversion needed).
pub linear_io: bool,
}
/// Row-major f32 copy of a matrix.
pub fn m32(m: &Mat4) -> [[f32; 4]; 4] {
m.0.map(|r| r.map(|v| v as f32))
}
/// Builds a [`Scene`]: texture uploads deduplicated by source.
pub(crate) struct Builder {
pub s: Scene,
tex_keys: HashMap<(usize, bool), i32>,
/// Per triangle: transparent and its sort depth.
tri_depth: Vec<(bool, f32)>,
cam: CameraState,
}
impl Builder {
fn texture_from(&mut self, w: u32, h: u32, wrap: [u32; 2], data: impl Iterator<Item = [f32; 4]>) -> i32 {
let offset = self.s.texels.len() as u32;
self.s.texels.extend(data);
self.s.textures.push(TexInfo { offset, width: w, height: h, wrap_u: wrap[0], wrap_v: wrap[1] });
(self.s.textures.len() - 1) as i32
}
/// Upload a model texture (`srgb`: colour data to linearise).
fn model_texture(&mut self, t: &Arc<model::Texture>, srgb: bool) -> i32 {
let key = (Arc::as_ptr(t) as usize, srgb);
if let Some(&i) = self.tex_keys.get(&key) {
return i;
}
let lin = srgb && !self.s.linear_io;
let wrap = t.wrap.map(|w| match w {
model::Wrap::Repeat => 0,
model::Wrap::Clamp => 1,
model::Wrap::Mirror => 2,
});
let i = self.texture_from(
t.width,
t.height,
wrap,
t.data.iter().map(|p| if lin { [srgb_to_linear(p[0]), srgb_to_linear(p[1]), srgb_to_linear(p[2]), p[3]] } else { *p }),
);
self.tex_keys.insert(key, i);
i
}
/// Upload a premultiplied layer buffer as a linear straight-alpha texture (clamped edges).
fn buf_texture(&mut self, b: &Buf) -> (i32, bool) {
let lin = !self.s.linear_io;
let mut opaque = true;
let data: Vec<[f32; 4]> = b
.img
.data
.iter()
.map(|p| {
let a = p[3];
if a < 0.999 {
opaque = false;
}
if a <= 1e-6 {
return [0.0; 4];
}
let s = [p[0] / a, p[1] / a, p[2] / a];
if lin { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), a] } else { [s[0], s[1], s[2], a] }
})
.collect();
(self.texture_from(b.img.width, b.img.height, [1, 1], data.into_iter()), opaque)
}
fn material(&mut self, m: Material) -> u32 {
self.s.materials.push(m);
(self.s.materials.len() - 1) as u32
}
/// Append a primitive with per-vertex matrices (model/layer space → world).
fn emit(&mut self, p: &Primitive, mat: u32, xf: &dyn Fn(usize) -> Mat4) {
if p.positions.is_empty() || p.indices.len() < 3 {
return;
}
let base = self.s.vertices.len() as u32;
let transparent = self.s.materials[mat as usize].transparent();
let uniform = xf(usize::MAX);
let normal_of = |m: &Mat4| -> (Mat4, f64) {
let a = &m.0;
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
let inv = Mat4([[a[0][0], a[0][1], a[0][2], 0.0], [a[1][0], a[1][1], a[1][2], 0.0], [a[2][0], a[2][1], a[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]])
.inverse()
.unwrap_or(Mat4::IDENTITY);
(inv.transpose(), det)
};
let (un, udet) = normal_of(&uniform);
let mut flip_count = 0usize;
for k in 0..p.positions.len() {
let (m, n, det) = if p.joints.is_empty() {
(uniform, un, udet)
} else {
let m = xf(k);
let (n, d) = normal_of(&m);
(m, n, d)
};
if det < 0.0 {
flip_count += 1;
}
let q = p.positions[k];
let w = m.apply(vec3(q[0] as f64, q[1] as f64, q[2] as f64));
let nn = p.normals.get(k).copied().unwrap_or([0.0, 0.0, 1.0]);
let wn = n.apply_vec(vec3(nn[0] as f64, nn[1] as f64, nn[2] as f64)).normalize();
let t = p.tangents.get(k).copied().unwrap_or([1.0, 0.0, 0.0, 1.0]);
let wt = m.apply_vec(vec3(t[0] as f64, t[1] as f64, t[2] as f64)).normalize();
self.s.vertices.push(Vertex {
pos: [w.x as f32, w.y as f32, w.z as f32],
normal: [wn.x as f32, wn.y as f32, wn.z as f32],
uv: p.uvs.get(k).copied().unwrap_or([0.0, 0.0]),
tangent: [wt.x as f32, wt.y as f32, wt.z as f32, if det < 0.0 { -t[3] } else { t[3] }],
material: mat,
});
}
// Mirroring transforms reverse the winding.
let flip = flip_count * 2 > p.positions.len();
for t in p.indices.chunks_exact(3) {
let (a, b, c) = (t[0] + base, t[1] + base, t[2] + base);
if flip {
self.s.indices.extend([a, c, b])
} else {
self.s.indices.extend([a, b, c])
}
let z = [a, b, c].iter().map(|&i| self.cam.depth(Vec3::from(self.s.vertices[i as usize].pos.map(|v| v as f64)))).sum::<f64>() / 3.0;
self.tri_depth.push((transparent, z as f32));
}
}
/// Opaque triangles first, then transparent ones back to front.
fn sort_triangles(&mut self) {
let tris: Vec<[u32; 3]> = self.s.indices.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect();
let mut order: Vec<usize> = (0..tris.len()).collect();
order.sort_by(|&a, &b| {
let (ta, za) = self.tri_depth[a];
let (tb, zb) = self.tri_depth[b];
ta.cmp(&tb).then_with(|| if ta { zb.total_cmp(&za) } else { std::cmp::Ordering::Equal })
});
self.s.opaque_count = 3 * self.tri_depth.iter().filter(|t| !t.0).count() as u32;
self.s.indices = order.into_iter().flat_map(|i| tris[i]).collect();
}
}
/// The Material Options of a card (classic properties).
fn card_material(ctx: &EvalCtx, layer: &Layer) -> Material {
let c = ClassicMaterial::of(ctx, layer);
Material {
roughness: (1.0 - c.shininess).clamp(0.05, 1.0),
double_sided: true,
accepts_lights: c.accepts_lights,
receives_shadows: c.accepts_shadows != 0,
casts_shadows: c.casts_shadows != 0,
visible: c.casts_shadows != 2,
diffuse_k: c.diffuse,
specular_k: c.specular * 2.0,
ambient_k: c.ambient,
..Material::default()
}
}
/// Shadow/light switches and the primitive material of a model or primitive layer.
fn model_options(ctx: &EvalCtx, layer: &Layer, lin: bool) -> (Material, Option<model::Material>) {
let g = layer.props.sub("materialOptions");
let e = |m: &str, d: u32| g.and_then(|g| g.get(m)).map_or(d, |p| ctx.value(layer, p).as_enum());
let b = |m: &str| g.and_then(|g| g.get(m)).is_none_or(|p| ctx.value(layer, p).as_bool());
let casts = e("castsShadows", 0);
let opts = Material {
accepts_lights: b("acceptsLights"),
receives_shadows: e("acceptsShadows", 1) != 0,
casts_shadows: casts != 0,
visible: casts != 2,
..Material::default()
};
let prim = match (layer.source.clone(), g) {
(LayerSource::Primitive { .. }, Some(g)) => {
let c = ctx.color(layer, g, "baseColor");
let em = ctx.color(layer, g, "emissive");
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
let mut m = model::Material::plain(
[l(c[0]), l(c[1]), l(c[2]), 1.0],
(ctx.f(layer, g, "metallic", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
(ctx.f(layer, g, "roughness", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
);
m.emissive = [l(em[0]), l(em[1]), l(em[2])];
Some(m)
}
_ => None,
};
(opts, prim)
}
fn prim_kind(k: PrimitiveKind) -> model::prim::PrimitiveKind {
use model::prim::PrimitiveKind as M;
match k {
PrimitiveKind::Cube => M::Cube,
PrimitiveKind::Sphere => M::Sphere,
PrimitiveKind::Plane => M::Plane,
PrimitiveKind::Torus => M::Torus,
PrimitiveKind::Cone => M::Cone,
PrimitiveKind::Cylinder => M::Cylinder,
}
}
/// The primitive model of a primitive layer at the context time.
pub fn primitive_model(ctx: &EvalCtx, layer: &Layer, kind: PrimitiveKind, mat: model::Material) -> model::Model {
let g = layer.props.sub("geometryOptions");
let f = |m: &str, d: f64| g.map_or(d, |g| ctx.f(layer, g, m, d));
let pr = model::prim::PrimParams {
size: [f("width", 300.0) as f32, f("height", 300.0) as f32, f("depth", 300.0) as f32],
radius: f("radius", 150.0) as f32,
tube: f("tubeRadius", 50.0) as f32,
height: f("height", 300.0) as f32,
segments: f("segments", 48.0).round().clamp(3.0, 512.0) as u32,
rings: f("rings", 24.0).round().clamp(2.0, 512.0) as u32,
};
model::prim::model(prim_kind(kind), &pr, mat)
}
/// Model space → layer space: model units to pixels, Y up → Y down (a 180° turn about X).
pub fn model_to_layer(unit_scale: f64) -> Mat4 {
Mat4::scale(vec3(unit_scale, -unit_scale, -unit_scale))
}
fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
let lin = b.s.linear_io;
let (opts, prim_mat) = model_options(ctx, layer, lin);
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let geo = layer.props.sub("geometryOptions");
let (mdl, unit, clip, t): (Arc<model::Model>, f64, Option<usize>, f64) = match &layer.source {
LayerSource::Primitive { kind } => (Arc::new(primitive_model(ctx, layer, *kind, prim_mat.unwrap_or_default())), 1.0, None, 0.0),
LayerSource::Model { item } => {
let Some(it) = r.project.item(*item) else { return };
let ItemKind::Footage(f) = &it.kind else { return };
let Some(m) = r.footage.model(*item, f) else { return };
let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0));
let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize;
let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len());
let speed = geo.map_or(100.0, |g| ctx.f(layer, g, "animationSpeed", 100.0)) / 100.0;
let mut t = layer.layer_time(ctx.time).seconds() * speed;
if let Some(c) = clip {
let d = m.animations[c].duration();
let looping = geo.is_none_or(|g| ctx.b(layer, g, "loopAnimation"));
if looping && d > 0.0 {
t = t.rem_euclid(d);
}
}
(m, unit, clip, t)
}
_ => return,
};
let world = ctx.world_matrix(layer) * model_to_layer(unit);
// Materials of the model (one default when it has none).
let mut mats: Vec<u32> = vec![];
let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)];
let src: &[model::Material] = if mdl.materials.is_empty() { &defaults } else { &mdl.materials };
for mm in src {
let tex = |b: &mut Builder, t: Option<model::TexRef>, srgb: bool| -> i32 {
t.and_then(|t| mdl.textures.get(t.texture)).map_or(-1, |t| b.model_texture(t, srgb))
};
let m = Material {
base: mm.base_color,
metallic: mm.metallic,
roughness: mm.roughness,
emissive: mm.emissive,
normal_scale: mm.normal_scale,
occlusion_strength: mm.occlusion_strength,
alpha_mode: match mm.alpha_mode {
model::AlphaMode::Opaque => 0,
model::AlphaMode::Mask => 1,
model::AlphaMode::Blend => 2,
},
alpha_cutoff: mm.alpha_cutoff,
opacity,
tex_base: tex(b, mm.base_color_tex, true),
tex_mr: tex(b, mm.metallic_roughness_tex, false),
tex_normal: tex(b, mm.normal_tex, false),
tex_occlusion: tex(b, mm.occlusion_tex, false),
tex_emissive: tex(b, mm.emissive_tex, true),
double_sided: mm.double_sided,
unlit: mm.unlit,
..opts
};
mats.push(b.material(m));
}
for inst in mdl.instances(clip, t) {
for p in &inst.mesh.primitives {
let mat = mats[p.material.unwrap_or(0).min(mats.len() - 1)];
b.emit(p, mat, &|k| if k == usize::MAX { world * inst.world } else { world * model::vertex_matrix(&inst, p, k) });
}
}
}
/// Closed polylines of a path (flattened to `tol` pixels).
pub fn contours(path: &kurbo::BezPath, tol: f64) -> Vec<Vec<[f64; 2]>> {
let mut out: Vec<Vec<[f64; 2]>> = vec![];
let mut cur: Vec<[f64; 2]> = vec![];
kurbo::flatten(path, tol, |el| match el {
kurbo::PathEl::MoveTo(p) => {
if cur.len() >= 3 {
out.push(std::mem::take(&mut cur));
}
cur = vec![[p.x, p.y]];
}
kurbo::PathEl::LineTo(p) => cur.push([p.x, p.y]),
kurbo::PathEl::ClosePath => {
if cur.len() >= 3 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
}
_ => {}
});
if cur.len() >= 3 {
out.push(cur);
}
out
}
/// Geometry Options of a text/shape layer when it is extruded.
pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::ExtrudeParams> {
let g = layer.props.sub("geometryOptions")?;
let depth = ctx.f(layer, g, "extrusionDepth", 0.0);
(depth > 0.0).then(|| model::extrude::ExtrudeParams {
bevel: model::extrude::BevelStyle::from_index(ctx.e(layer, g, "bevelStyle")),
bevel_depth: ctx.f(layer, g, "bevelDepth", 2.0).max(0.0),
hole_bevel: (ctx.f(layer, g, "holeBevelDepth", 100.0) / 100.0).clamp(0.0, 1.0),
depth,
})
}
/// Extruded meshes of a text or shape layer: (mesh, straight colour, mesh space → layer
/// space). Text extrudes each character in its own space, so per-character 3D animation
/// (position, rotation, scale in Z) carries over.
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4)> = vec![];
match layer.source {
LayerSource::Text => {
if let Some(tg) = crate::text::text_geom(ctx, layer) {
for g in tg.glyphs.iter().filter(|g| g.apply_fill && g.fill[3] > 0.0) {
groups.push((vec![g.local.clone()], g.fill, g.m));
}
}
}
LayerSource::Shape => {
if let Some(c) = layer.props.sub("contents") {
groups = crate::shapes::fill_outlines(ctx, layer, c).into_iter().map(|(ps, c)| (ps, c, Mat4::IDENTITY)).collect();
}
}
_ => {}
}
groups
.into_iter()
.filter_map(|(paths, color, m)| {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
}
Some((model::extrude::extrude(&outlines, p), color, m))
})
.collect()
}
fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) {
let base = card_material(ctx, layer);
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
let lin = b.s.linear_io;
for (mesh, c, local) in extruded_meshes(ctx, layer, p) {
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
let m = b.material(Material {
base: [l(c[0]), l(c[1]), l(c[2]), c[3]],
alpha_mode: if c[3] < 0.999 { 2 } else { 0 },
opacity,
double_sided: false,
..base
});
let xf = world * local;
b.emit(&mesh, m, &|_| xf);
}
}
fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) {
if buf.img.width == 0 || buf.img.height == 0 {
return;
}
let (tex, opaque) = b.buf_texture(buf);
mat.tex_base = tex;
mat.alpha_mode = if opaque { 0 } else { 2 };
let m = b.material(mat);
let s = buf.scale.max(1e-9);
let (x0, y0) = (-buf.offset[0] / s, -buf.offset[1] / s);
let (x1, y1) = ((buf.img.width as f64 - buf.offset[0]) / s, (buf.img.height as f64 - buf.offset[1]) / s);
// Faces the camera (−z) in layer space; uv (0,0) at the buffer's top-left texel corner.
let p = Primitive {
positions: vec![[x0 as f32, y0 as f32, 0.0], [x1 as f32, y0 as f32, 0.0], [x1 as f32, y1 as f32, 0.0], [x0 as f32, y1 as f32, 0.0]],
normals: vec![[0.0, 0.0, -1.0]; 4],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
tangents: vec![[1.0, 0.0, 0.0, 1.0]; 4],
indices: vec![0, 2, 1, 0, 3, 2],
material: Some(0),
..Default::default()
};
b.emit(&p, m, &|_| world);
}
fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
let mut mat = card_material(ctx, layer);
mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
if crate::text::per_char_3d(ctx, layer) {
for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) {
let buf = r.to_blend(Arc::new(buf), None);
card(b, &buf, world * m, mat);
}
return;
}
if let Some(buf) = r.blend_layer_buf(ctx, layer) {
card(b, &buf, world, mat);
}
}
/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour,
/// rotation)).
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64>, [f32; 3], f32)>) {
let mut out = vec![];
let mut ambient = [0.0f32; 3];
let mut env = None;
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
for layer in ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)) {
let LayerSource::Light { kind } = layer.source else { continue };
let Some(g) = layer.props.sub("lightOptions") else { continue };
let k = (ctx.f(layer, g, "intensity", 100.0) / 100.0) as f32;
let c = ctx.color(layer, g, "color");
let color = [l(c[0]) * k, l(c[1]) * k, l(c[2]) * k];
match kind {
LightKind::Ambient => {
for i in 0..3 {
ambient[i] += color[i];
}
}
LightKind::Environment => {
if env.is_none() {
let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer());
let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32;
env = Some((src, color, rot));
}
}
_ => {
let Some(st) = LightState::from_layer(ctx, layer) else { continue };
let f = |v: Vec3| [v.x as f32, v.y as f32, v.z as f32];
out.push(Light {
kind: match kind {
LightKind::Parallel => 0,
LightKind::Spot => 1,
_ => 2,
},
pos: f(st.pos),
dir: f(st.dir.normalize()),
color,
cos_inner: (st.cone_half * (1.0 - st.feather)).cos() as f32,
cos_outer: st.cone_half.cos() as f32,
falloff: st.falloff,
radius: st.radius as f32,
falloff_distance: st.falloff_distance as f32,
// Marked; `raster::shadow_maps` assigns the maps.
shadow: if st.casts_shadows { 0 } else { -1 },
shadow_darkness: st.shadow_darkness as f32,
shadow_diffusion: st.shadow_diffusion as f32,
});
}
}
}
(out, ambient, env)
}
/// Box-downsample a straight RGBA image by 2 (odd sizes round up).
fn half(w: u32, h: u32, d: &[[f32; 4]]) -> (u32, u32, Vec<[f32; 4]>) {
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
let mut out = vec![[0.0f32; 4]; (nw * nh) as usize];
for y in 0..nh {
for x in 0..nw {
let mut acc = [0.0f32; 4];
let mut n = 0.0;
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let (sx, sy) = (2 * x + dx, 2 * y + dy);
if sx < w && sy < h {
let p = d[(sy * w + sx) as usize];
for k in 0..4 {
acc[k] += p[k];
}
n += 1.0;
}
}
out[(y * nw + x) as usize] = acc.map(|v| v / n);
}
}
(nw, nh, out)
}
/// Cosine-weighted irradiance (`w`×`h` equirectangular) of an equirectangular radiance image:
/// each texel is the cosine-weighted mean radiance over the hemisphere around its direction
/// (a constant environment gives the same constant).
pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec<[f32; 4]> {
use super::shade::{dot, equirect_dir};
// Source directions and solid-angle weights (∝ sin θ).
let dirs: Vec<([f32; 3], f32, [f32; 3])> = (0..sh)
.flat_map(|y| (0..sw).map(move |x| (x, y)))
.map(|(x, y)| {
let (u, v) = ((x as f32 + 0.5) / sw as f32, (y as f32 + 0.5) / sh as f32);
let p = src[(y * sw + x) as usize];
(equirect_dir(u, v, 0.0), (v * std::f32::consts::PI).sin(), [p[0], p[1], p[2]])
})
.collect();
let mut out = vec![[0.0f32; 4]; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let n = equirect_dir((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32, 0.0);
let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32);
for (d, sa, c) in &dirs {
let k = dot(n, *d).max(0.0) * sa;
if k > 0.0 {
for i in 0..3 {
acc[i] += c[i] * k;
}
wsum += k;
}
}
let inv = if wsum > 0.0 { 1.0 / wsum } else { 0.0 };
out[(y * w + x) as usize] = [acc[0] * inv, acc[1] * inv, acc[2] * inv, 1.0];
}
}
out
}
/// Prefilter an environment image into the scene's textures.
pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) {
// Radiance level 0 at most 1024 wide.
let (mut w, mut h, mut d) = (w, h, data);
while w > 1024 {
(w, h, d) = half(w, h, &d);
}
let first = b.s.textures.len() as u32;
let mut levels = 0;
let mut small: Option<(u32, u32, Vec<[f32; 4]>)> = None;
loop {
b.texture_from(w, h, [0, 1], d.iter().copied());
levels += 1;
if w <= 64 && small.is_none() {
small = Some((w, h, d.clone()));
}
if w <= 8 || h <= 4 {
break;
}
(w, h, d) = half(w, h, &d);
}
let (sw, sh, sd) = small.unwrap_or((w, h, d));
let irr = irradiance_map(sw, sh, &sd, 32, 16);
let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {
let tint = intensity.map(|c| c / k);
for t in first..=ii {
let ti = b.s.textures[t as usize];
for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {
for c in 0..3 {
p[c] *= tint[c];
}
}
}
}
b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation });
}
/// The environment layer's pixels as linear straight RGBA.
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {
let layer = match src {
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,
};
let buf = r.blend_layer_buf(ctx, layer)?;
let img = &buf.img;
let data = img
.data
.iter()
.map(|p| {
let a = p[3];
if a <= 1e-6 {
return [0.0, 0.0, 0.0, 1.0];
}
let s = [p[0] / a, p[1] / a, p[2] / a];
if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }
})
.collect();
Some((img.width, img.height, data))
}
/// Supersampling factor: 2×2 unless draft or very large.
fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 }
}
/// Build the scene of a run of 3D layers.
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
let cam = camera_for(r, ctx);
let lin = r.pipe.linear || r.pipe.linear_blend;
let ssaa = ssaa_for(r, out);
let mut b = Builder {
s: Scene { width: out.0 * ssaa, height: out.1 * ssaa, ssaa, linear_io: lin, ..Scene::default() },
tex_keys: HashMap::new(),
tri_depth: vec![],
cam,
};
for layer in run {
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
continue;
}
if layer.source.is_model() {
model_layer(r, ctx, layer, &mut b);
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
&& let Some(p) = extrusion_params(ctx, layer)
{
extruded_layer(r, ctx, layer, &p, &mut b);
} else {
card_layer(r, ctx, layer, &mut b);
}
}
b.sort_triangles();
// Lights, environment, shadows (Draft 3D: unlit, like After Effects).
if !ctx.comp.draft_3d {
let (ls, amb, env) = lights(ctx, lin);
b.s.lights = ls;
b.s.ambient = amb;
if let Some((src, k, rot)) = env
&& let Some((w, h, d)) = env_pixels(r, ctx, src, lin)
{
add_environment(&mut b, w, h, d, k, rot);
}
super::raster::shadow_maps(&mut b.s, r.opts.draft);
}
// Camera matrices.
let s = r.opts.scale;
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
let (ow, oh) = (out.0 as f64, out.1 as f64);
let (a, bb) = (2.0 * s * cam.zoom / ow, s * cw / ow - 1.0);
let (c, d) = (2.0 * s * cam.zoom / oh, s * ch / oh - 1.0);
let proj = if cam.ortho {
let (mut zmin, mut zmax) = (f64::INFINITY, f64::NEG_INFINITY);
for v in &b.s.vertices {
let z = cam.depth(Vec3::from(v.pos.map(|x| x as f64)));
zmin = zmin.min(z);
zmax = zmax.max(z);
}
if !zmin.is_finite() {
(zmin, zmax) = (-1.0, 1.0);
}
let pad = (zmax - zmin).abs() * 0.01 + 1.0;
let (zmin, zmax) = (zmin - pad, zmax + pad);
let zr = zmax - zmin;
Mat4([[a, 0.0, 0.0, bb], [0.0, -c, 0.0, -d], [0.0, 0.0, -1.0 / zr, zmax / zr], [0.0, 0.0, 0.0, 1.0]])
} else {
Mat4([[a, 0.0, bb, 0.0], [0.0, -c, -d, 0.0], [0.0, 0.0, 0.0, NEAR], [0.0, 0.0, 1.0, 0.0]])
};
b.s.clip = m32(&(proj * cam.view));
b.s.view = m32(&cam.view);
let f = cam.forward();
b.s.cam_fwd = [f.x as f32, f.y as f32, f.z as f32];
b.s.eye = [cam.eye.x as f32, cam.eye.y as f32, cam.eye.z as f32];
b.s.ortho = cam.ortho;
b.s
}
+410
View File
@@ -0,0 +1,410 @@
//! 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])
}
+314
View File
@@ -0,0 +1,314 @@
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build};
use effectcraft_time::{FrameRate, Tick};
use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};
use super::shade::{self, PI};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer};
// ---------------------------------------------------------------- shading
#[test]
fn brdf_matches_cook_torrance_formula() {
// Reference: f = kd·c/π + D·G·F/(4·NV·NL), returned × NL × π.
let n = [0.0, 0.0, 1.0];
let v = shade::norm([0.3, 0.1, 1.0]);
let l = shade::norm([-0.4, 0.2, 1.0]);
let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32);
let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l);
let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]);
let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h));
let a2 = (rough * rough).powi(2);
let d = a2 / (PI * ((nh * nh) * (a2 - 1.0) + 1.0).powi(2));
let k = (rough + 1.0).powi(2) / 8.0;
let g = nv / (nv * (1.0 - k) + k) * nl / (nl * (1.0 - k) + k);
for c in 0..3 {
let f0 = 0.04 + (base[c] - 0.04) * metal;
let f = f0 + (1.0 - f0) * (1.0 - vh).powi(5);
let expect = ((1.0 - f) * (1.0 - metal) * base[c] / PI + d * g * f / (4.0 * nv * nl)) * nl * PI;
assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]);
}
// No light from behind the surface.
assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
// A white rough dielectric lit head-on reflects about its base colour (π folded in).
let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n);
assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}");
}
#[test]
fn ggx_distribution_is_normalised() {
// ∫ D(h)·(n·h) dω = 1 over the hemisphere.
for &rough in &[0.2f32, 0.5, 0.9] {
let a = rough * rough;
let n = 4000;
let mut sum = 0.0f64;
for i in 0..n {
let th = (i as f64 + 0.5) / n as f64 * std::f64::consts::FRAC_PI_2;
let c = th.cos() as f32;
sum += shade::d_ggx(c, a) as f64 * c as f64 * th.sin() * std::f64::consts::FRAC_PI_2 / n as f64 * 2.0 * std::f64::consts::PI;
}
assert!((sum - 1.0).abs() < 0.02, "roughness {rough}: {sum}");
}
}
#[test]
fn constant_environment_has_constant_irradiance() {
let src = vec![[0.25, 0.5, 0.75, 1.0]; 64 * 32];
let irr = irradiance_map(64, 32, &src, 32, 16);
for p in &irr {
for c in 0..3 {
assert!((p[c] - src[0][c]).abs() < 1e-4, "{p:?}");
}
}
// And a sky brighter than the ground lights upward-facing normals more.
let sky: Vec<[f32; 4]> = (0..32).flat_map(|y| (0..64).map(move |_| if y < 16 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] })).collect();
let irr = irradiance_map(64, 32, &sky, 32, 16);
assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]);
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
let d = shade::equirect_dir(u, v, 0.3);
let (u2, v2) = shade::equirect_uv(d, 0.3);
assert!((u - u2).abs() < 1e-4 && (v - v2).abs() < 1e-4);
}
// Up (−Y in AE space) is the top row.
assert!(shade::equirect_uv([0.0, -1.0, 0.0], 0.0).1 < 1e-3);
let (a, b) = shade::env_brdf(1.0, 0.0);
assert!(a + b > 0.9 && a + b <= 1.05);
let (a, b) = shade::env_brdf(0.1, 1.0);
assert!(a >= 0.0 && b > -0.01 && a + b < 1.0);
}
// ---------------------------------------------------------------- rasteriser
/// A scene of axis-aligned quads (z = camera depth) seen by an orthographic identity camera
/// mapping world x/y straight to raster pixels.
fn quad_scene(w: u32, h: u32, quads: &[([f32; 4], f32, [f32; 4])]) -> Scene {
let mut s = Scene { width: w, height: h, ssaa: 1, linear_io: true, ..Scene::default() };
// clip: x → 2x/w − 1, y → 1 − 2y/h, z: depth 0..1000 → 1..0 (nearer = larger).
s.clip = [[2.0 / w as f32, 0.0, 0.0, -1.0], [0.0, -2.0 / h as f32, 0.0, 1.0], [0.0, 0.0, -0.001, 1.0], [0.0, 0.0, 0.0, 1.0]];
s.view = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
s.ortho = true;
s.cam_fwd = [0.0, 0.0, 1.0];
for (rect, z, color) in quads {
let m = s.materials.len() as u32;
s.materials.push(Material { base: *color, unlit: true, double_sided: true, alpha_mode: if color[3] < 1.0 { 2 } else { 0 }, ..Material::default() });
let b = s.vertices.len() as u32;
for (x, y) in [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])] {
s.vertices.push(Vertex { pos: [x, y, *z], normal: [0.0, 0.0, -1.0], uv: [0.0; 2], tangent: [1.0, 0.0, 0.0, 1.0], material: m });
}
s.indices.extend([b, b + 2, b + 1, b, b + 3, b + 2]);
}
// Opaque first, transparent (in given order) after.
let tris: Vec<[u32; 3]> = s.indices.chunks(3).map(|t| [t[0], t[1], t[2]]).collect();
let (o, t): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| !s.materials[s.vertices[t[0] as usize].material as usize].transparent());
s.opaque_count = 3 * o.len() as u32;
s.indices = o.into_iter().chain(t).flatten().collect();
s
}
#[test]
fn depth_buffer_keeps_the_nearest_surface() {
let red = [1.0, 0.0, 0.0, 1.0];
let blue = [0.0, 0.0, 1.0, 1.0];
// Blue is listed first but is farther away.
let s = quad_scene(40, 30, &[([0.0, 0.0, 30.0, 20.0], 500.0, blue), ([10.0, 10.0, 40.0, 30.0], 100.0, red)]);
let t = raster::render(&s);
let px = |x: usize, y: usize| t.color[y * 40 + x];
assert_eq!(px(5, 5), blue);
assert_eq!(px(15, 15), red, "overlap: the nearer quad wins");
assert_eq!(px(35, 25), red);
assert_eq!(px(35, 5), [0.0; 4]);
assert_eq!(t.depth[15 * 40 + 15], 100.0);
assert!(t.depth[5 * 40 + 35].is_infinite());
}
#[test]
fn coverage_is_exact_for_pixel_aligned_quads() {
// Shared diagonal edges are filled once (top-left rule): exact pixel counts.
let s = quad_scene(32, 32, &[([4.0, 4.0, 20.0, 12.0], 10.0, [1.0, 1.0, 1.0, 0.5])]);
let t = raster::render(&s);
let covered = t.color.iter().filter(|c| c[3] > 0.0).count();
assert_eq!(covered, 16 * 8);
// Blended once (no double coverage along the diagonal).
assert!(t.color.iter().all(|c| c[3] == 0.0 || (c[3] - 0.5).abs() < 1e-6));
// Two transparent layers composite in order.
let s = quad_scene(8, 8, &[([0.0, 0.0, 8.0, 8.0], 100.0, [1.0, 0.0, 0.0, 0.5]), ([0.0, 0.0, 8.0, 8.0], 50.0, [0.0, 0.0, 1.0, 0.5])]);
let c = raster::render(&s).color[0];
assert!((c[0] - 0.25).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6 && (c[3] - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn textures_sample_bilinear_with_wrap_modes() {
let t = TexInfo { offset: 0, width: 2, height: 1, wrap_u: 0, wrap_v: 1 };
let texels = vec![[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]];
assert_eq!(shade::sample(&t, &texels, 0.25, 0.5)[0], 0.0);
assert_eq!(shade::sample(&t, &texels, 0.5, 0.5)[0], 0.5);
// Repeat wraps the right edge back to texel 0.
assert!((shade::sample(&t, &texels, 1.0, 0.5)[0] - 0.5).abs() < 1e-6);
let c = TexInfo { wrap_u: 1, ..t };
assert_eq!(shade::sample(&c, &texels, 1.0, 0.5)[0], 1.0);
}
// ---------------------------------------------------------------- full renderer
fn project(w: u32, h: u32) -> (Project, ItemId) {
let mut p = Project::default();
let mut c = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
let id = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
(p, id)
}
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
let comp = p.comp(cid).unwrap().clone();
let mut l = build::layer(p, &comp, "L", src, (comp.width, comp.height), None);
edit(&mut l);
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v;
}
fn render(p: &Project, cid: ItemId) -> crate::Image {
Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO)
}
#[test]
fn lit_cube_renders_with_depth_and_shading() {
let (mut p, cid) = project(160, 120);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(60.0));
set(l, "geometryOptions/height", Value::Scalar(60.0));
set(l, "geometryOptions/depth", Value::Scalar(60.0));
set(l, "transform/rotationY", Value::Scalar(35.0));
set(l, "transform/rotationX", Value::Scalar(-25.0));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
let img = render(&p, cid);
let centre = img.data[60 * 160 + 80];
assert!(centre[3] > 0.99, "cube covers the centre: {centre:?}");
assert_eq!(img.data[2 * 160 + 2][3], 0.0, "corner is empty");
// No lights: unlit (base colour) — add a light and faces get different shades.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {});
let lit = render(&p, cid);
let mut shades: Vec<i32> = lit.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect();
shades.sort();
shades.dedup();
assert!(shades.len() >= 3, "faces are shaded differently: {shades:?}");
// Classic 3D draws no meshes.
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0));
}
#[test]
fn shadows_darken_the_receiver() {
let (mut p, cid) = project(200, 150);
// A floor plane facing the camera, a sphere in front of it, a spot light behind the camera.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([100.0, 75.0, -300.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let img = render(&p, cid);
// The sphere casts only (invisible); its shadow lands on the plane behind it.
let mid = img.data[75 * 200 + 100];
let side = img.data[75 * 200 + 20];
assert!(mid[3] > 0.99 && side[3] > 0.99);
assert!(mid[0] < side[0] * 0.5, "shadowed centre {mid:?} vs lit side {side:?}");
// Without shadow casting the centre is lit.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
assert!(img.data[75 * 200 + 100][0] > side[0] * 0.8);
}
#[test]
fn cards_and_environment_light() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
let c = unlit.data[20 * 120 + 30];
assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");
assert_eq!(unlit.data[80 * 120 + 110][3], 0.0);
// An Environment light sourcing a white environment layer brightens the card.
let env = p.add_item(
"E",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
l.environment = true;
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
let lit = render(&p, cid);
let c2 = lit.data[20 * 120 + 30];
assert!(c2[0] > 0.3, "{c2:?}");
// The environment layer itself is not drawn.
assert_eq!(lit.data[80 * 120 + 110][3], 0.0);
}
#[test]
fn depth_of_field_blurs_out_of_focus_surfaces() {
let mut img = Image::new(64, 64);
for y in 0..64 {
for x in 0..64 {
let v = if (x / 4 + y / 4) % 2 == 0 { 1.0 } else { 0.0 };
img.data[y * 64 + x] = [v, v, v, 1.0];
}
}
let var = |im: &Image| {
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
};
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 };
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
assert!(var(&blurred) < var(&img) * 0.3, "out of focus: blurred");
}
#[test]
fn extruded_text_renders_as_a_mesh() {
let (mut p, cid) = project(200, 100);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() };
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
assert!(!meshes.is_empty());
assert!(meshes.iter().all(|(m, _, _)| m.positions.iter().any(|q| (q[2] - 30.0).abs() < 1e-3)));
let img = render(&p, cid);
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
+185
View File
@@ -323,6 +323,12 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
if run.is_empty() {
return;
}
// Advanced 3D comps: meshes, physically based lights, shadow maps (collapsed precomps keep
// the parent's Classic 3D planes).
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
super::adv::draw_run(r, ctx, run, canvas);
return;
}
let out = (canvas.width, canvas.height);
// A collapsed precomp's 3D layers are lit by the outer comp's lights.
let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent));
@@ -583,3 +589,182 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
}
out
}
// ------------------------------------------------------------------ auxiliary render pass
/// One surface seen through an output pixel (aux pass).
#[derive(Clone, Copy)]
struct AuxFrag {
z: f32,
a: f32,
obj: f32,
mat: f32,
n: [f32; 3],
uv: [f32; 2],
obj_hash: u32,
mat_hash: u32,
}
/// The comp's auxiliary channels at the context time, at output resolution: camera-space depth
/// (`Z`), `ObjectID` (layer number), `MaterialID` (source item id), `Coverage`, camera-space
/// normals (`N.X/Y/Z`), texture coordinates (`UV.U/V`) and Cryptomatte `CryptoObject` (layer
/// names) / `CryptoMaterial` (source names) with two ranks each, plus their manifests.
///
/// Layers are gathered in stack order (top first); a run of 3D layers contributes its planes
/// sorted near → far at each pixel, a 2D layer its coverage on the plane of the comp. The
/// front-most surface with ≥ 50 % opacity (else the strongest one) provides depth, IDs, normal
/// and UV; Cryptomatte coverage is each surface's visible share (`α · Π(1 − α_front)`).
pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxChannels {
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash};
let s = r.opts.scale;
let (w, h) = (((ctx.comp.width as f64 * s).round() as u32).max(1), ((ctx.comp.height as f64 * s).round() as u32).max(1));
let out = (w, h);
let (wu, n) = (w as usize, w as usize * h as usize);
let cam = camera_for(r, ctx);
let visible: Vec<&Layer> = r.visible_layers(ctx).into_iter().filter(|l| !l.switches.adjustment).collect();
let number = |l: &Layer| ctx.comp.layers.iter().position(|x| x.id == l.id).map_or(0.0, |i| i as f32 + 1.0);
let source_name = |l: &Layer| l.source.item().and_then(|i| r.project.item(i)).map(|it| it.name.clone()).unwrap_or_else(|| l.name.clone());
let source_id = |l: &Layer| l.source.item().map_or(0.0, |i| i.0 as f32);
let mut manifest_obj: Vec<(String, u32)> = vec![];
let mut manifest_mat: Vec<(String, u32)> = vec![];
for l in &visible {
let (on, mn) = (l.name.clone(), source_name(l));
if !manifest_obj.iter().any(|(x, _)| *x == on) {
manifest_obj.push((on.clone(), crypto_hash(&on)));
}
if !manifest_mat.iter().any(|(x, _)| *x == mn) {
manifest_mat.push((mn.clone(), crypto_hash(&mn)));
}
}
// Groups in stack order: runs of 3D layers, single 2D layers.
let mut groups: Vec<&[&Layer]> = vec![];
let mut i = 0;
while i < visible.len() {
let mut j = i + 1;
if visible[i].is_3d() {
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
}
groups.push(&visible[i..j]);
i = j;
}
let mut frags: Vec<Vec<AuxFrag>> = vec![Vec::new(); n];
let plane_z = cam.depth(vec3(ctx.comp.width as f64 * 0.5, ctx.comp.height as f64 * 0.5, 0.0)) as f32;
for g in groups.iter().rev() {
if g[0].is_3d() {
let items: Vec<Item> = g.iter().enumerate().flat_map(|(k, l)| prepare_all(r, ctx, l, k, out, true)).collect();
let meta: Vec<(f32, f32, u32, u32, [f32; 3], [f64; 2])> = items
.iter()
.map(|it| {
let nrm = it.geos.first().map_or(vec3(0.0, 0.0, -1.0), |gm| cam.view.apply_vec(gm.normal));
let mut nv = [nrm.x as f32, nrm.y as f32, nrm.z as f32];
if nv[2] > 0.0 {
nv = nv.map(|v| -v);
}
let (sw, sh) = crate::source_size(r.project, it.layer);
let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] };
(number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size)
})
.collect();
frags.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
let yi = y as i64;
let mut local: Vec<AuxFrag> = Vec::new();
for (x, cell) in row.iter_mut().enumerate() {
let xi = x as i64;
local.clear();
for (it, m) in items.iter().zip(&meta) {
if xi < it.bbox[0] || xi >= it.bbox[2] || yi < it.bbox[1] || yi >= it.bbox[3] {
continue;
}
let Some(gm) = it.geos.first() else { continue };
let Some((u, v, z)) = unproject(gm, x as f64 + 0.5, y as f64 + 0.5) else { continue };
let a = it.texel(u, v)[3] * it.opacity;
if a > 1e-4 {
let uv = [(u / m.5[0]) as f32, (v / m.5[1]) as f32];
local.push(AuxFrag { z: z as f32, a: a.min(1.0), obj: m.0, mat: m.1, n: m.4, uv, obj_hash: m.2, mat_hash: m.3 });
}
}
local.sort_by(|p, q| p.z.total_cmp(&q.z));
cell.extend_from_slice(&local);
}
});
} else {
let l = g[0];
let mut img = Image::new(w, h);
if let Some(b) = r.layer_buf(ctx, l) {
r.place(ctx, l, &b, &mut img, BlendMode::Normal, ctx.opacity(l) as f32);
}
let (obj, mat, oh, mh) = (number(l), source_id(l), crypto_hash(&l.name), crypto_hash(&source_name(l)));
frags.par_chunks_mut(wu).zip(img.data.par_chunks(wu)).enumerate().for_each(|(y, (row, px))| {
for (x, (cell, p)) in row.iter_mut().zip(px).enumerate() {
if p[3] > 1e-4 {
let uv = [(x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32];
cell.push(AuxFrag { z: plane_z, a: p[3].min(1.0), obj, mat, n: [0.0, 0.0, -1.0], uv, obj_hash: oh, mat_hash: mh });
}
}
});
}
}
// Resolve per pixel.
const NAMES: [&str; 9] = ["Z", "ObjectID", "MaterialID", "Coverage", "N.X", "N.Y", "N.Z", "UV.U", "UV.V"];
const CRYPTO: usize = 16;
let resolved: Vec<([f32; 9], [f32; CRYPTO])> = frags
.par_iter()
.map(|fs| {
let mut o = [BACKGROUND_DEPTH, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let mut crypto = [0.0f32; CRYPTO];
if fs.is_empty() {
return (o, crypto);
}
let mut t = 1.0f32;
let mut obj: Vec<(u32, f32)> = vec![];
let mut mat: Vec<(u32, f32)> = vec![];
let mut best: Option<(usize, f32)> = None;
let mut front: Option<usize> = None;
for (k, f) in fs.iter().enumerate() {
let c = f.a * t;
if front.is_none() && f.a >= 0.5 {
front = Some(k);
}
if best.is_none_or(|(_, bc)| c > bc) {
best = Some((k, c));
}
for (list, hsh) in [(&mut obj, f.obj_hash), (&mut mat, f.mat_hash)] {
match list.iter_mut().find(|(x, _)| *x == hsh) {
Some(e) => e.1 += c,
None => list.push((hsh, c)),
}
}
t *= 1.0 - f.a;
if t <= 1e-5 {
break;
}
}
let f = fs[front.or(best.map(|b| b.0)).unwrap_or(0)];
o = [f.z, f.obj, f.mat, 1.0 - t, f.n[0], f.n[1], f.n[2], f.uv[0], f.uv[1]];
for (base, list) in [(0usize, &mut obj), (8usize, &mut mat)] {
list.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
for (rank, (hsh, c)) in list.iter().take(4).enumerate() {
crypto[base + rank * 2] = crypto_float(*hsh);
crypto[base + rank * 2 + 1] = *c;
}
}
(o, crypto)
})
.collect();
let mut aux = effectcraft_raster::AuxChannels::new(w, h, s);
for (k, name) in NAMES.iter().enumerate() {
aux.channels.push((name.to_string(), resolved.iter().map(|r| r.0[k]).collect()));
}
for (base, layer) in [(0usize, "CryptoObject"), (8usize, "CryptoMaterial")] {
for rank_pair in 0..2 {
for (c, ch) in ["R", "G", "B", "A"].iter().enumerate() {
let k = base + rank_pair * 4 + c;
aux.channels.push((format!("{layer}{rank_pair:02}.{ch}"), resolved.iter().map(|r| r.1[k]).collect()));
}
}
}
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
aux
}
+12 -1
View File
@@ -117,7 +117,18 @@ impl LightState {
/// All lights switched on and active at the context time.
pub fn lights_at(ctx: &EvalCtx) -> Vec<LightState> {
ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)).filter_map(|l| LightState::from_layer(ctx, l)).collect()
// 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.
+1
View File
@@ -5,6 +5,7 @@
//! consecutive 3D layers is handed to [`compose::draw_run`] as a group (2D layers break 3D
//! groups, as in After Effects).
pub mod adv;
pub mod camera;
pub(crate) mod compose;
pub mod light;