Files
effectcraft/crates/render/src/three_d/compose.rs
T
Brandon ThomasandClaude Opus 5.5 0c4e533460 M13.22: GPU compositing of Advanced 3D runs with blend modes, track mattes (2D and 3D), Preserve Transparency, and environment backgrounds (Classic and Advanced 3D skies)
render: three_d::adv::SplitRun (Renderer::split_adv_run) hands draw_run's split path to an
accelerator as data (main scene, special layers far to near, 3D mattes drawn solo);
Renderer::sky_draw / three_d::SkyDraw for Environment Light Background layers. gpu: the walk
draws skies (adv_sky kernel), resolves each special layer, hides it behind the nearer main
scene (occlude kernel) and composites it through the 2D path without readbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:26:06 -04:00

1049 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Classic 3D compositor for a run of consecutive 3D layers.
//!
//! Each layer is a textured plane: its processed buffer (source → masks → effects, DOF-blurred)
//! mapped through `projection · world`. Rendering is per output pixel (rows in parallel): every
//! plane covering the pixel is inverse-mapped through its homography (exact perspective-correct
//! texture coordinates, bilinear/bicubic sampling), its camera depth taken from the plane
//! equation, and the fragment shaded by the comp's lights. The pixel's fragments are then sorted
//! far→near (coplanar fragments keep layer-stack order) and blended with each layer's mode and
//! opacity. Sorting per pixel handles intersecting layers exactly, and blend modes and partial
//! transparency still compose in depth order, as in AE's Classic 3D renderer.
//!
//! Shadows are ray-cast against the shadow-casting planes (exact for planar layers, including
//! alpha-shaped and coloured shadows through Light Transmission); Shadow Diffusion softens them
//! with a penumbra that grows with the distance between caster and receiver.
use std::sync::Arc;
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, Mat4, Vec3, vec3};
use effectcraft_project::{Layer, LightKind, MatteKind};
use effectcraft_raster::{Image, Px, hash_noise};
use effectcraft_time::Tick;
use rayon::prelude::*;
use super::camera::{CameraState, NEAR, active_camera};
use super::light::{LightState, Material, lights_at, shade};
use crate::{EvalCtx, Renderer};
/// One position of a plane (several with motion blur).
#[derive(Clone, Copy, Debug)]
pub struct Geo {
/// Output pixel → homogeneous layer coordinates.
pub hinv: Mat3,
/// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`.
pub depth: [f64; 3],
/// Layer → world.
pub world: Mat4,
/// Unit world normal.
pub normal: Vec3,
/// Output pixel bounds [x0, y0, x1, y1) covered by the plane.
pub bbox: [i64; 4],
pub cam: CameraState,
}
/// A prepared 3D layer.
pub(crate) struct Item<'a> {
pub layer: &'a Layer,
/// Position in the layer stack (higher = above).
order: usize,
buf: Arc<Buf>,
bicubic: bool,
geos: Vec<Geo>,
mat: Material,
opacity: f32,
/// Drawn (false for "Casts Shadows: Only" and shadow-only casters outside the run).
draw: bool,
/// Screen-space track matte factor (output size).
matte: Option<Arc<Vec<f32>>>,
preserve: bool,
/// World → layer (for shadow rays) and the buffer's layer-space bounds.
winv: Mat4,
bounds: [f64; 4],
bbox: [i64; 4],
/// Depth of field left to an accelerator (`buf` is then unblurred and unpadded).
dof: Option<PlaneDof>,
}
/// Depth of field of a plane for an accelerator to apply ([`Plane3d::dof`]): pad the buffer by
/// `pad` transparent pixels, boost its highlights, then blur it at each of `levels` with
/// [`super::bokeh::kernel_spans`] and blend the levels by each pixel's `radius` (padded buffer
/// pixels, row-major) with [`super::bokeh::level_weight`] — what the CPU's depth of field does.
#[derive(Clone, Debug)]
pub struct PlaneDof {
pub pad: u32,
pub iris: super::bokeh::Iris,
pub highlight: super::bokeh::Highlight,
pub radius: Arc<Vec<f32>>,
pub levels: Vec<f32>,
}
impl Item<'_> {
#[inline]
fn texel(&self, u: f64, v: f64) -> Px {
let x = u * self.buf.scale + self.buf.offset[0];
let y = v * self.buf.scale + self.buf.offset[1];
if self.bicubic { self.buf.img.sample_bicubic(x, y) } else { self.buf.img.sample_bilinear(x, y) }
}
}
/// Build the plane geometry of a layer for a camera.
fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32), off: (f64, f64)) -> Option<Geo> {
// The output canvas may start at `off` (scaled comp pixels): region of interest / Extended
// Viewer frames.
let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1);
let full = proj * world;
let h = full.plane_to_mat3();
// Edge-on planes project to a line (singular homography): nothing to draw.
let col = |j: usize| (h.0[0][j].powi(2) + h.0[1][j].powi(2) + h.0[2][j].powi(2)).sqrt();
if h.determinant().abs() <= 1e-10 * col(0) * col(1) * col(2) {
return None;
}
let hinv = h.inverse()?;
let cv = cam.view * world;
let depth = [cv.0[2][0], cv.0[2][1], cv.0[2][3]];
let normal = world.apply_vec(vec3(1.0, 0.0, 0.0)).cross(world.apply_vec(vec3(0.0, 1.0, 0.0)));
if normal.length() < 1e-12 {
return None;
}
let normal = normal.normalize();
let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])];
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut behind = 0;
let mut full_frame = false;
for (u, v) in corners {
let z = depth[0] * u + depth[1] * v + depth[2];
if !cam.ortho && z < NEAR {
behind += 1;
full_frame = true;
continue;
}
let p = full.apply(vec3(u, v, 0.0));
x0 = x0.min(p.x);
y0 = y0.min(p.y);
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
if behind == 4 {
return None;
}
let (ow, oh) = (out.0 as i64, out.1 as i64);
let bbox = if full_frame {
[0, 0, ow, oh]
} else {
[((x0.floor() as i64) - 2).max(0), ((y0.floor() as i64) - 2).max(0), ((x1.ceil() as i64) + 2).min(ow), ((y1.ceil() as i64) + 2).min(oh)]
};
if bbox[0] >= bbox[2] || bbox[1] >= bbox[3] {
return None;
}
Some(Geo { hinv, depth, world, normal, bbox, cam: *cam })
}
/// Layer-space rectangle covered by a buffer.
fn buf_bounds(buf: &Buf) -> [f64; 4] {
let s = buf.scale.max(1e-9);
[-buf.offset[0] / s, -buf.offset[1] / s, (buf.img.width as f64 - buf.offset[0]) / s, (buf.img.height as f64 - buf.offset[1]) / s]
}
/// Inverse-map an output pixel centre onto a plane: (u, v, camera depth).
#[inline]
fn unproject(g: &Geo, sx: f64, sy: f64) -> Option<(f64, f64, f64)> {
let h = &g.hinv.0;
let qz = h[2][0] * sx + h[2][1] * sy + h[2][2];
if !g.cam.ortho && qz <= 0.0 {
return None;
}
if qz.abs() < 1e-300 {
return None;
}
let u = (h[0][0] * sx + h[0][1] * sy + h[0][2]) / qz;
let v = (h[1][0] * sx + h[1][1] * sy + h[1][2]) / qz;
let z = g.depth[0] * u + g.depth[1] * v + g.depth[2];
if !g.cam.ortho && z < NEAR {
return None;
}
Some((u, v, z))
}
/// The camera a renderer uses at a context time (view override only for the top-level comp).
pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState {
// Collapsed precomps see through the parent's camera.
if let Some(c) = r.collapse3d {
return c.cam;
}
match r.opts.view {
Some(v) if r.depth == 0 => v,
_ => active_camera(ctx),
}
}
/// The size of the comp the camera looks through: a collapsed precomp's layers are projected
/// into the outermost comp they collapse into (its centre and size), not their own comp's.
pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) {
let c = r.collapse3d.map_or(ctx.comp, |c| c.parent.comp);
(c.width as f64, c.height as f64)
}
/// Number of motion-blur sub-samples for a layer.
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) {
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
}
}
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
let buf = r.blend_layer_buf(ctx, layer)?;
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
}
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
if crate::text::per_char_3d(ctx, layer) {
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
.into_iter()
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m))
.collect();
}
prepare(r, ctx, layer, order, out, in_run).into_iter().collect()
}
/// Prepare a plane showing `buf`, whose space maps into the layer through `local`.
#[allow(clippy::too_many_arguments)]
fn prepare_with<'a>(
r: &Renderer,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
order: usize,
out: (u32, u32),
in_run: bool,
mut buf: Arc<Buf>,
local: Mat4,
) -> Option<Item<'a>> {
if buf.img.is_empty() {
return None;
}
let comp = view_size(r, ctx);
let s = r.opts.scale;
let cam = camera_for(r, ctx);
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
let world = outer * ctx.world_matrix(layer) * local;
// Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a
// layer facing the camera, progressive for a tilted one).
let mut plane_dof = None;
let mut bounds = buf_bounds(&buf);
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
if r.defer_dof {
// An accelerator blurs: hand it the radius map of the padded buffer.
if let Some((pd, padded_bounds)) = dof_plan(&cam, &dof, world, &buf) {
plane_dof = Some(pd);
bounds = padded_bounds;
}
} else {
buf = dof_blur(&cam, &dof, world, buf);
bounds = buf_bounds(&buf);
}
}
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
let geos: Vec<Geo> = if n <= 1 {
geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect()
} else {
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
let phase = ctx.comp.shutter_phase / 360.0;
(0..n)
.filter_map(|i| {
let f = phase + angle * i as f64 / (n - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let c = camera_for(r, &sub);
geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out, r.out_offset())
})
.collect()
};
if geos.is_empty() && in_run {
return None;
}
let mut bbox = [i64::MAX, i64::MAX, i64::MIN, i64::MIN];
for g in &geos {
bbox = [bbox[0].min(g.bbox[0]), bbox[1].min(g.bbox[1]), bbox[2].max(g.bbox[2]), bbox[3].max(g.bbox[3])];
}
let mat = Material::of(ctx, layer);
Some(Item {
layer,
order,
bicubic: matches!(r.sampling(layer), effectcraft_raster::Sampling::Bicubic),
buf,
geos,
opacity: ctx.opacity(layer) as f32 * r.opacity_mul(),
draw: in_run && mat.casts_shadows != 2,
mat,
matte: None,
preserve: layer.preserve_transparency,
winv: world.inverse().unwrap_or(Mat4::IDENTITY),
bounds,
bbox,
dof: plane_dof,
})
}
/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there.
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec<f32> {
dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale)
}
fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec<f32> {
let s = scale.max(1e-9);
let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0)));
// Camera depth is affine across a plane.
let z0 = at(0.5, 0.5);
let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0);
let mut out = vec![0.0f32; w * h];
out.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let z = z0 + zx * x as f64 + zy * y as f64;
if z <= super::camera::NEAR {
continue;
}
// coc · ½ / (zoom / z · sx) · scale
*o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32;
}
});
out
}
/// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`.
fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 {
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
(rmax * a).ceil() as u32 + 2
}
/// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded
/// buffer's layer-space bounds. `None` when nothing blurs.
fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> {
let radii = dof_radii(cam, dof, world, buf);
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
if rmax <= 0.3 {
return None;
}
let pad = dof_pad(dof, rmax);
let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize);
let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64];
let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale);
let levels = super::bokeh::blur_levels(&radius)?;
let s = buf.scale.max(1e-9);
let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s];
Some((PlaneDof { pad, iris: dof.iris, highlight: dof.highlight, radius: Arc::new(radius), levels }, bounds))
}
/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth.
fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<Buf>) -> Arc<Buf> {
let radii = dof_radii(cam, dof, world, &buf);
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
if rmax <= 0.3 {
return buf;
}
let pad = dof_pad(dof, rmax);
let mut padded = buf.img.padded(pad);
super::bokeh::boost_highlights(&mut padded, &dof.highlight);
let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale };
let pr = dof_radii(cam, dof, world, &pbuf);
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale })
}
/// Screen-space track matte factor for a layer, or None.
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
let tm = layer.track_matte?;
let m = ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id)?;
let mut img = Image::new(out.0, out.1);
if m.is_active_at(ctx.time) {
if m.is_3d() {
// Render the matte plane alone (its own matte is ignored).
let mut solo = m.clone();
solo.track_matte = None;
solo.preserve_transparency = false;
solo.blend_mode = BlendMode::Normal;
let its = prepare_all(r, ctx, &solo, 0, out, true);
if !its.is_empty() {
let lights = scene_lights(ctx);
composite(&mut img, &its, &lights, &[], m.id.0 as u32);
}
} else if let Some(mb) = r.layer_buf(ctx, m) {
r.place(ctx, m, &mb, &mut img, BlendMode::Normal, ctx.opacity(m) as f32);
}
}
Some(Arc::new(
img.data
.par_iter()
.map(|q| {
match tm.kind {
MatteKind::Alpha => q[3],
MatteKind::AlphaInverted => 1.0 - q[3],
MatteKind::Luma => effectcraft_color::luminance(q[0], q[1], q[2]),
MatteKind::LumaInverted => 1.0 - effectcraft_color::luminance(q[0], q[1], q[2]),
}
.clamp(0.0, 1.0)
})
.collect(),
))
}
/// Lights used for shading: none in Draft 3D (which also turns off shadows and depth of field).
fn scene_lights(ctx: &EvalCtx) -> Vec<LightState> {
if ctx.comp.draft_3d { vec![] } else { lights_at(ctx) }
}
/// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
// Environment background layers: an infinitely distant sky behind the rest of the run.
if run.iter().any(|l| l.environment_background) {
for l in run.iter().filter(|l| l.environment_background) {
draw_sky(r, ctx, l, canvas);
}
let rest: Vec<&Layer> = run.iter().copied().filter(|l| !l.environment_background).collect();
draw_run(r, ctx, &rest, canvas);
return;
}
// 3D adjustment layers act on everything below them in the stack (like 2D ones): split.
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
draw_run(r, ctx, &run[..k], canvas);
r.draw_layer(ctx, run[k], canvas, false);
draw_run(r, ctx, &run[k + 1..], canvas);
return;
}
if run.is_empty() {
return;
}
// Advanced 3D comps: meshes, physically based lights, shadow maps (a collapsed precomp's
// layers use the renderer of the comp they collapse into).
if super::adv::active(r, ctx) {
super::adv::draw_run(r, ctx, run, canvas);
return;
}
let out = (canvas.width, canvas.height);
let g = gather(r, ctx, run, out);
let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| r.opts.shadows() && i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32);
for l in run.iter().filter(|l| r.quality(l) == effectcraft_project::Quality::Wireframe) {
r.draw_layer(ctx, l, canvas, false);
}
}
/// The prepared planes of a run: drawn items (stack order), shadow casters outside the run, and
/// the lights.
struct Gathered<'a> {
items: Vec<Item<'a>>,
extra: Vec<Item<'a>>,
lights: Vec<LightState>,
}
fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, u32)) -> Gathered<'a> {
// 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));
let mut items: Vec<Item> = run
.par_iter()
.enumerate()
.filter(|(_, l)| ctx.opacity(l) > 0.0 && r.quality(l) != effectcraft_project::Quality::Wireframe)
.flat_map_iter(|(i, l)| {
// Stack order with room for a collapsed precomp's layers in between.
let i = i * 1024;
if let Some(item) = r.collapsed(ctx, l) {
return collapsed_items(r, ctx, l, item, i, out);
}
let mut its = prepare_all(r, ctx, l, i, out, true);
if !its.is_empty() {
let m = matte_for(r, ctx, l, out);
for it in &mut its {
it.matte = m.clone();
}
}
its
})
.collect();
// Shadow casters outside this run (other 3D layers of the comp).
let mut extra: Vec<Item> = vec![];
if lights.iter().any(|l| l.casts_shadows && l.kind != LightKind::Ambient) {
let in_run: Vec<_> = run.iter().map(|l| l.id).collect();
extra = ctx
.comp
.layers
.par_iter()
.filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment)
.filter(|l| Material::of(ctx, l).casts_shadows != 0)
.flat_map_iter(|l| prepare_all(r, ctx, l, 0, out, false))
.collect();
}
items.sort_by_key(|i| i.order);
Gathered { items, extra, lights }
}
/// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's
/// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they
/// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack
/// order. (Nested adjustment layers are skipped here.)
fn collapsed_items<'a>(
r: &Renderer<'a>,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
item: effectcraft_project::ItemId,
order: usize,
out: (u32, u32),
) -> Vec<Item<'a>> {
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
let nested: Vec<&'a Layer> = nctx
.comp
.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide))
.collect();
let mut items = Vec::new();
for (k, l) in nested.into_iter().enumerate() {
if nctx.opacity(l) <= 0.0 {
continue;
}
let mut its = prepare_all(&sub, &nctx, l, order + 1 + k.min(1022), out, true);
if !its.is_empty() {
let m = matte_for(&sub, &nctx, l, out);
for it in &mut its {
it.matte = m.clone();
}
}
items.extend(its);
}
items
}
/// Per-pixel depth-sorted compositing of prepared planes.
fn composite(canvas: &mut Image, items: &[Item], lights: &[LightState], casters: &[&Item], seed: u32) {
let w = canvas.width as usize;
canvas.rows_mut().for_each(|(y, row)| {
let yi = y as i64;
let active: Vec<&Item> = items.iter().filter(|it| it.draw && yi >= it.bbox[1] && yi < it.bbox[3]).collect();
if active.is_empty() {
return;
}
let mut frags: Vec<(f64, usize, Px, usize)> = Vec::with_capacity(active.len());
for x in 0..w {
let xi = x as i64;
frags.clear();
for (k, it) in active.iter().enumerate() {
if xi < it.bbox[0] || xi >= it.bbox[2] {
continue;
}
if let Some((z, px)) = fragment(it, x as f64 + 0.5, y as f64 + 0.5, lights, casters) {
let px = match &it.matte {
Some(m) => {
let f = m[y * w + x];
[px[0] * f, px[1] * f, px[2] * f, px[3] * f]
}
None => px,
};
if px[3] > 0.0 || px[0] != 0.0 || px[1] != 0.0 || px[2] != 0.0 {
frags.push((z, it.order, px, k));
}
}
}
if frags.is_empty() {
continue;
}
// Far → near; (near-)coplanar fragments in stack order (lower layer first).
for i in 1..frags.len() {
let mut j = i;
while j > 0 && before(&frags[j], &frags[j - 1]) {
frags.swap(j, j - 1);
j -= 1;
}
}
let mut d = row[x];
for (_, _, px, k) in &frags {
let it = active[*k];
let mut s = *px;
let op = it.opacity * if it.preserve { d[3] } else { 1.0 };
for c in s.iter_mut() {
*c *= op;
}
let mode = it.layer.blend_mode;
let n = if matches!(mode, BlendMode::Dissolve | BlendMode::DancingDissolve) {
hash_noise(x as u32, y as u32, seed ^ it.layer.id.0 as u32)
} else {
0.5
};
d = blend_pixel(mode, d, s, n);
}
row[x] = d;
}
});
}
#[inline]
fn before(a: &(f64, usize, Px, usize), b: &(f64, usize, Px, usize)) -> bool {
let tol = 1e-6 * a.0.abs().max(b.0.abs()).max(1.0);
if (a.0 - b.0).abs() <= tol { a.1 < b.1 } else { a.0 > b.0 }
}
/// Shaded premultiplied fragment of a plane at an output pixel centre, with its depth.
fn fragment(it: &Item, sx: f64, sy: f64, lights: &[LightState], casters: &[&Item]) -> Option<(f64, Px)> {
let n = it.geos.len();
let mut acc = [0.0f32; 4];
let mut depth = None;
for g in &it.geos {
let Some((u, v, z)) = unproject(g, sx, sy) else { continue };
let t = it.texel(u, v);
if t[3] <= 1e-6 {
continue;
}
depth.get_or_insert(z);
let px = shade_texel(it, g, u, v, t, lights, casters);
for c in 0..4 {
acc[c] += px[c];
}
}
let depth = depth?;
if n > 1 {
let k = 1.0 / n as f32;
for c in acc.iter_mut() {
*c *= k;
}
}
Some((depth, acc))
}
fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState], casters: &[&Item]) -> Px {
if lights.is_empty() || !it.mat.accepts_lights {
if it.mat.accepts_shadows == 2 {
return [0.0; 4];
}
return t;
}
let a = t[3];
let rgb = [t[0] / a, t[1] / a, t[2] / a];
let p = g.world.apply(vec3(u, v, 0.0));
let shadow = |li: usize, p: Vec3| -> [f32; 3] {
let l = &lights[li];
let mut tr = [1.0f32; 3];
for c in casters {
if std::ptr::eq(*c, it) || c.layer.id == it.layer.id {
continue;
}
let o = occlusion(c, p, l);
for k in 0..3 {
tr[k] *= o[k];
}
}
tr
};
let (lit, shadow_amt) = shade(rgb, p, g.normal, g.cam.to_viewer(p), &it.mat, lights, &shadow);
if it.mat.accepts_shadows == 2 {
return [0.0, 0.0, 0.0, a * shadow_amt];
}
[lit[0] * a, lit[1] * a, lit[2] * a, a]
}
/// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded).
fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
const FAR: f64 = 1.0e6;
let a = c.winv.apply(p);
let target = if l.kind == LightKind::Parallel { p - l.dir * FAR } else { l.pos };
let b = c.winv.apply(target);
if a.z == b.z || a.z.signum() == b.z.signum() {
return [1.0; 3];
}
let t = a.z / (a.z - b.z);
if t <= 1e-6 || t >= 1.0 - 1e-9 {
return [1.0; 3];
}
let hit = a + (b - a) * t;
// Penumbra radius (layer pixels) from Shadow Diffusion: grows with receiver–caster distance.
let seg = (target - p).length();
let d_pc = seg * t;
let d_cl = if l.kind == LightKind::Parallel { 1000.0 } else { (seg * (1.0 - t)).max(1.0) };
let r = l.shadow_diffusion * (d_pc / d_cl).min(4.0);
let bd = &c.bounds;
if hit.x < bd[0] - r || hit.y < bd[1] - r || hit.x > bd[2] + r || hit.y > bd[3] + r {
return [1.0; 3];
}
let mut px = [0.0f32; 4];
if r * c.buf.scale < 0.5 {
px = c.texel(hit.x, hit.y);
} else {
for j in -1..=1 {
for i in -1..=1 {
let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66);
for k in 0..4 {
px[k] += q[k] / 9.0;
}
}
}
}
let alpha = (px[3] * c.opacity).clamp(0.0, 1.0);
if alpha <= 0.0 {
return [1.0; 3];
}
let tr = c.mat.light_transmission;
let dark = l.shadow_darkness as f32;
let mut out = [1.0f32; 3];
for k in 0..3 {
let straight = if px[3] > 0.0 { (px[k] / px[3]).clamp(0.0, 1.0) } else { 0.0 };
let pass = (1.0 - alpha) + alpha * tr * straight;
out[k] = 1.0 - dark * (1.0 - pass);
}
out
}
// ------------------------------------------------------------------ accelerator export
/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane
/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and
/// ray-cast shadows of [`draw_run`] on another device.
#[derive(Clone, Debug)]
pub struct Run3d {
/// Drawn planes in stack order, then shadow casters from outside the run (`draw` false).
pub planes: Vec<Plane3d>,
/// Indices (into `planes`) of the shadow casters, in the CPU's order.
pub casters: Vec<usize>,
pub lights: Vec<LightState>,
/// Dissolve seed of the run.
pub seed: u32,
}
/// One plane of a [`Run3d`].
#[derive(Clone, Debug)]
pub struct Plane3d {
/// The layer buffer (blending space; depth of field already applied unless `dof` is set).
pub buf: Arc<Buf>,
/// Depth of field for the accelerator to apply to `buf` (`bounds` are then the padded
/// buffer's).
pub dof: Option<PlaneDof>,
pub bicubic: bool,
/// One per motion-blur sub-sample (equal weights).
pub geos: Vec<Geo>,
pub mat: Material,
pub opacity: f32,
pub draw: bool,
/// Screen-space track matte factor (output size, row-major).
pub matte: Option<Arc<Vec<f32>>>,
pub preserve: bool,
/// World → layer, and the (blurred) buffer's layer-space bounds.
pub winv: Mat4,
pub bounds: [f64; 4],
/// Output pixel bounds of all geometries.
pub bbox: [i64; 4],
/// Stack order (coplanar fragments draw lower orders first).
pub order: usize,
pub layer_id: u32,
pub mode: BlendMode,
}
/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU
/// (adjustment, wireframe or environment background layers, Advanced 3D).
pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option<Run3d> {
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) {
return None;
}
if super::adv::active(r, ctx) {
return None;
}
// Depth of field is left to the accelerator (its blur runs where the planes are drawn).
let rd = Renderer { defer_dof: true, ..*r };
let g = gather(&rd, ctx, run, out);
let planes: Vec<Plane3d> = g
.items
.into_iter()
.chain(g.extra)
.map(|it| Plane3d {
layer_id: it.layer.id.0 as u32,
mode: it.layer.blend_mode,
buf: it.buf,
dof: it.dof,
bicubic: it.bicubic,
geos: it.geos,
mat: it.mat,
opacity: it.opacity,
draw: it.draw,
matte: it.matte,
preserve: it.preserve,
winv: it.winv,
bounds: it.bounds,
bbox: it.bbox,
order: it.order,
})
.collect();
let casters = planes.iter().enumerate().filter(|(_, p)| r.opts.shadows() && p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect();
Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 })
}
// ------------------------------------------------------------------ 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
}
/// Rotation (radians) of the comp's first Environment light, which also turns its backdrop.
pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
ctx.comp
.layers
.iter()
.find(|l| matches!(l.source, effectcraft_project::LayerSource::Light { kind: LightKind::Environment }) && l.is_active_at(ctx.time))
.and_then(|l| l.props.sub("lightOptions").map(|g| ctx.f(l, g, "rotation", 0.0)))
.unwrap_or(0.0)
.to_radians() as f32
}
/// An Environment Light Background layer ready to draw (see [`draw_sky`]): the layer's
/// buffer in the blending space, its opacity and the camera's view rays. Also handed to an
/// accelerator's compositor (`Renderer::sky_draw`).
#[derive(Clone)]
pub struct SkyDraw {
pub buf: Arc<Buf>,
pub opacity: f32,
/// Camera basis: forward, right, down.
pub fwd: [f64; 3],
pub right: [f64; 3],
pub down: [f64; 3],
pub zoom: f64,
pub ortho: bool,
/// View size (comp pixels), region-of-interest offset (output pixels), output scale.
pub view: (f64, f64),
pub roi: (f64, f64),
pub scale: f64,
/// The Environment light's rotation (radians).
pub rotation: f32,
}
pub(crate) fn sky_draw(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<SkyDraw> {
let buf = r.blend_layer_buf(ctx, layer)?;
if buf.img.width == 0 || buf.img.height == 0 {
return None;
}
let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
if op <= 0.0 {
return None;
}
let cam = camera_for(r, ctx);
let v3 = |v: Vec3| [v.x, v.y, v.z];
Some(SkyDraw {
buf,
opacity: op,
fwd: v3(cam.forward()),
right: v3(cam.right()),
down: v3(cam.down()),
zoom: cam.zoom,
ortho: cam.ortho,
view: view_size(r, ctx),
roi: r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)),
scale: r.opts.scale.max(1e-9),
rotation: environment_rotation(ctx),
})
}
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
/// pixel's view direction through the camera, as an infinitely distant backdrop.
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
let Some(sky) = sky_draw(r, ctx, layer) else { return };
let img = &sky.buf.img;
let op = sky.opacity;
let v3 = |v: [f64; 3]| vec3(v[0], v[1], v[2]);
let (fwd, right, down) = (v3(sky.fwd), v3(sky.right), v3(sky.down));
let s = sky.scale;
let (cw, ch) = sky.view;
let (ox, oy) = sky.roi;
let rot = sky.rotation;
let (iw, ih) = (img.width as f64, img.height as f64);
canvas.rows_mut().for_each(|(y, row)| {
let cy = (y as f64 + 0.5 + oy) / s;
for (x, d) in row.iter_mut().enumerate() {
let cx = (x as f64 + 0.5 + ox) / s;
let dir = if sky.ortho { fwd } else { (fwd * sky.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot);
// Wrap horizontally across the seam.
let sx = (u as f64 * iw).clamp(0.5, iw - 0.5);
let sy = (v as f64 * ih).clamp(0.5, ih - 0.5);
let p = img.sample_bilinear(sx, sy);
let k = 1.0 - p[3] * op;
*d = [p[0] * op + d[0] * k, p[1] * op + d[1] * k, p[2] * op + d[2] * k, p[3] * op + d[3] * k];
}
});
}