effectcraft/crates/render/src/three_d/compose.rs

495 lines
18 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(crate) struct Geo {
/// Output pixel → homogeneous layer coordinates.
hinv: Mat3,
/// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`.
depth: [f64; 3],
world: Mat4,
normal: Vec3,
/// Output pixel bounds [x0, y0, x1, y1) covered by the plane.
bbox: [i64; 4],
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<Vec<f32>>,
preserve: bool,
/// World → layer (for shadow rays) and the buffer's layer-space bounds.
winv: Mat4,
bounds: [f64; 4],
bbox: [i64; 4],
}
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, buf: &Buf, comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option<Geo> {
let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1);
let full = proj * world;
let h = full.plane_to_mat3();
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 b = buf_bounds(buf);
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 {
match r.opts.view {
Some(v) if r.depth == 0 => v,
_ => active_camera(ctx),
}
}
/// 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 && layer.switches.motion_blur {
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 mut buf = r.layer_buf(ctx, layer)?;
if buf.img.is_empty() {
return None;
}
let comp = (ctx.comp.width as f64, ctx.comp.height as f64);
let s = r.opts.scale;
let cam = camera_for(r, ctx);
let world = ctx.world_matrix(layer);
// Depth of field: blur the layer by its circle of confusion at its centre.
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
let b = buf_bounds(&buf);
let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0));
let z = cam.depth(centre);
let coc = dof.coc(z);
// Comp pixels on screen per layer pixel at that depth.
let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
let radius = coc * 0.5 / k * buf.scale;
if radius > 0.3 {
let sigma = radius / 1.5;
let pad = (sigma * 3.0).ceil() as u32 + 1;
let padded = buf.img.padded(pad);
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
buf = Arc::new(Buf {
img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false),
offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64],
scale: buf.scale,
});
}
}
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
let geos: Vec<Geo> = if n <= 1 {
geo(&cam, world, &buf, comp, s, out).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, sub.world_matrix(layer), &buf, comp, s, out)
})
.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);
let bounds = buf_bounds(&buf);
Some(Item {
layer,
order,
bicubic: matches!(r.sampling(layer), effectcraft_raster::Sampling::Bicubic),
buf,
geos,
opacity: ctx.opacity(layer) as f32,
draw: in_run && mat.casts_shadows != 2,
mat,
matte: None,
preserve: layer.preserve_transparency,
winv: world.inverse().unwrap_or(Mat4::IDENTITY),
bounds,
bbox,
})
}
/// Screen-space track matte factor for a layer, or None.
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<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;
if let Some(it) = prepare(r, ctx, &solo, 0, out, true) {
let lights = scene_lights(ctx);
composite(&mut img, &[it], &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(
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) {
// 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);
draw_run(r, ctx, &run[k + 1..], canvas);
return;
}
if run.is_empty() {
return;
}
let out = (canvas.width, canvas.height);
let lights = scene_lights(ctx);
let mut items: Vec<Item> = run
.par_iter()
.enumerate()
.filter(|(_, l)| ctx.opacity(l) > 0.0)
.filter_map(|(i, l)| {
let mut it = prepare(r, ctx, l, i, out, true)?;
it.matte = matte_for(r, ctx, l, out);
Some(it)
})
.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)
.filter_map(|l| prepare(r, ctx, l, 0, out, false))
.collect();
}
items.sort_by_key(|i| i.order);
let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
composite(canvas, &items, &lights, &casters, run[0].id.0 as u32);
}
/// 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
}