From 07da394ccb55c0360089d73561b3d8230a642ccc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 07:47:25 -0400 Subject: [PATCH] M13.8: Advanced 3D iris depth of field, collapsed precomps, extruded strokes Depth of field in Advanced 3D comps uses the camera's iris (Fast Rectangle, Triangle to Decagon, rotation, roundness, aspect, diffraction fringe) and highlight gain/threshold/ saturation through the Classic 3D bokeh kernel. Collapsed precomps in an Advanced 3D comp add their nested layers as real geometry (meshes, extrusions, cards) lit by the parent; 2D collapsed precomps draw nested 3D layers with the parent's renderer. Text and shape strokes extrude as bevelled meshes stacked in paint order (model extrude_stacked). Co-Authored-By: Claude Opus 5.5 --- crates/model/src/extrude.rs | 16 ++++ crates/model/src/tests.rs | 13 +++ crates/render/src/shapes.rs | 35 ++++++- crates/render/src/three_d/adv/dof.rs | 123 +++++++++++++++++++++++++ crates/render/src/three_d/adv/mod.rs | 73 ++------------- crates/render/src/three_d/adv/scene.rs | 111 +++++++++++++++++----- crates/render/src/three_d/adv/tests.rs | 110 ++++++++++++++++++++++ crates/render/src/three_d/compose.rs | 8 +- 8 files changed, 395 insertions(+), 94 deletions(-) create mode 100644 crates/render/src/three_d/adv/dof.rs diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 344c67cb8..058be7620 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -299,3 +299,19 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive { } prim } + +/// Gap (pixels) between stacked extrusions of one layer: paint drawn later (a stroke over its +/// fill, an upper shape group) sits this much nearer the camera per level, so coplanar front +/// caps never fight in the depth buffer and the stacking order of the 2D layer is kept. +pub const STACK_GAP: f64 = 0.02; + +/// [`extrude`] at stacking level `level` (0 = bottom paint of the layer): the mesh moves +/// `level × STACK_GAP` towards the camera (−z). +pub fn extrude_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> Primitive { + let mut prim = extrude(outlines, p); + let dz = (level as f64 * STACK_GAP) as f32; + for v in &mut prim.positions { + v[2] -= dz; + } + prim +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 1a3b13221..032ca47d7 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -360,3 +360,16 @@ fn gltf_cameras_and_punctual_lights() { let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0)); assert!((d.y + 1.0).abs() < 1e-6, "{d:?}"); } + +#[test] +fn stacked_extrusions_move_towards_the_camera() { + let square = Outline { outer: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], holes: vec![] }; + let p = ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 0.0, hole_bevel: 1.0, depth: 10.0 }; + let base = extrude(std::slice::from_ref(&square), &p); + let up = super::extrude::extrude_stacked(std::slice::from_ref(&square), &p, 3); + assert_eq!(base.indices, up.indices); + for (a, b) in base.positions.iter().zip(&up.positions) { + assert!((a[2] - b[2] - 3.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6); + assert_eq!((a[0], a[1]), (b[0], b[1])); + } +} diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f03c3c120..6bd8686bf 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -586,7 +586,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf 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. +/// fills: the colour half way along), without strokes (see [`extrusion_outlines`]). pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec, [f32; 4])> { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); @@ -606,6 +606,39 @@ pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec< .collect() } +/// The painted regions of a shape layer for Advanced 3D extrusion, bottom paint first: fills +/// (gradient fills: the colour half way along) and strokes, each stroke turned into the region +/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved). +pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec, [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 paths: Vec = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect(); + if paths.is_empty() { + return None; + } + let (c, region) = match &d.paint { + Paint::Fill { color, .. } => (*color, paths), + Paint::Gradient { g, stroke: None, .. } => (g.sample(0.5), paths), + Paint::Stroke { color, style } => (*color, vec![stroke_region(&paths, style)?]), + Paint::Gradient { g, stroke: Some(style), .. } => (g.sample(0.5), vec![stroke_region(&paths, style)?]), + }; + Some((region, [c[0], c[1], c[2], c[3] * d.opacity])) + }) + .collect() +} + +/// The filled region a stroke covers: its outline with self-overlaps resolved (non-zero). +pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option { + let o = effectcraft_path::stroke_outline(paths, style, 1.0)?; + let r = effectcraft_path::boolean::normalize(&o, FillRule::NonZero); + (!r.elements().is_empty()).then_some(r) +} + /// 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 { diff --git a/crates/render/src/three_d/adv/dof.rs b/crates/render/src/three_d/adv/dof.rs new file mode 100644 index 000000000..a35b2dae5 --- /dev/null +++ b/crates/render/src/three_d/adv/dof.rs @@ -0,0 +1,123 @@ +//! Advanced 3D depth of field with the camera's iris and highlight options (the same Camera +//! Options as Classic 3D: Iris Shape from Fast Rectangle to Decagon, Iris Rotation, Roundness, +//! Aspect Ratio, Diffraction Fringe, Highlight Gain / Threshold / Saturation). +//! +//! Every pixel's blur radius comes from its camera depth (the circle of confusion of the +//! resolved scene, in output pixels). The image is blurred with the Classic 3D bokeh kernel +//! ([`crate::three_d::bokeh`]) at a few radii spanning the range and each pixel blends the two +//! levels around its own radius, so an out-of-focus point spreads into exactly the iris shape +//! a Classic 3D layer shows, and in-focus pixels stay sharp. Highlights are boosted before the +//! blur (only where the image is blurred) so bright points bloom into bokeh. + +use rayon::prelude::*; + +use crate::Image; +use crate::three_d::bokeh::{boost_highlights, progressive_blur}; +use crate::three_d::camera::Dof; + +/// Largest blur radius (output pixels). +const MAX_RADIUS: f32 = 48.0; + +/// Depth of field of a resolved Advanced 3D image (premultiplied) with camera depth per pixel +/// (∞ where nothing was drawn: blurred as far away). +pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image { + let radius: Vec = 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_RADIUS) + }) + .collect(); + let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b)); + if rmax < 0.5 { + return img.clone(); + } + let mut src = img.clone(); + if dof.highlight.gain > 0.0 { + let mut boosted = img.clone(); + boost_highlights(&mut boosted, &dof.highlight); + src.data.par_iter_mut().zip(boosted.data.par_iter()).zip(radius.par_iter()).for_each(|((d, b), &r)| { + let k = (r - 0.5).clamp(0.0, 1.0); + for c in 0..4 { + d[c] += (b[c] - d[c]) * k; + } + }); + } + progressive_blur(&src, &dof.iris, &radius) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::three_d::bokeh::{Highlight, Iris, bokeh_blur}; + + fn dot(size: u32) -> Image { + let mut img = Image::new(size, size); + let c = (size / 2) as usize; + img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0]; + img + } + + /// Out of focus everywhere with a blur radius of `r` output pixels. + fn dof(r: f64, iris: Iris) -> (Dof, Vec) { + // coc = aperture · (z − focus) / z; radius = coc / 2. + let (focus, z) = (500.0, 2000.0); + let aperture = 2.0 * r * z / (z - focus); + (Dof { focus, aperture, blur_level: 1.0, iris, highlight: Highlight::default() }, vec![z as f32; 81 * 81]) + } + + fn lit(img: &Image) -> Vec { + let m = img.data.iter().fold(0.0f32, |a, p| a.max(p[3])); + img.data.iter().map(|p| p[3] > m * 0.2).collect() + } + + #[test] + fn iris_shapes_match_the_classic_kernel() { + for (sides, rotation, roundness, aspect) in + [(3u32, 0.0, 0.0, 1.0), (5, 20.0, 0.0, 1.0), (6, 0.0, 0.0, 1.0), (10, 0.0, 0.5, 1.0), (4, 45.0, 0.0, 2.0), (0, 0.0, 0.0, 1.0)] + { + let iris = Iris { sides, rotation, roundness, aspect, fringe: 0.0 }; + let (d, depth) = dof(16.0, iris); + let ours = lit(&depth_of_field(&dot(81), &depth, &d, 1.0)); + let classic = lit(&bokeh_blur(&dot(81), &iris, 16.0)); + let (mut same, mut any) = (0, 0); + for (a, b) in ours.iter().zip(&classic) { + if *a || *b { + any += 1; + if a == b { + same += 1; + } + } + } + let iou = same as f64 / any as f64; + assert!(iou > 0.8, "{sides} sides, rotation {rotation}: overlap {iou:.2}"); + } + } + + #[test] + fn triangle_is_not_a_disc() { + let iris = Iris { sides: 3, ..Default::default() }; + let (d, depth) = dof(16.0, iris); + let out = lit(&depth_of_field(&dot(81), &depth, &d, 1.0)); + // One side flat (half the radius from the centre), the opposite a vertex. + let at = |x: usize| out[40 * 81 + x]; + assert_ne!(at(40 - 12), at(40 + 12)); + } + + #[test] + fn fringe_brightens_the_rim_and_highlights_bloom() { + let mut iris = Iris { sides: 8, ..Default::default() }; + let (mut d, depth) = dof(16.0, iris); + let plain = depth_of_field(&dot(81), &depth, &d, 1.0); + iris.fringe = 1.0; + d.iris = iris; + let rimmed = depth_of_field(&dot(81), &depth, &d, 1.0); + let ratio = |im: &Image| im.data[40 * 81 + 40 + 14][3] / im.data[40 * 81 + 40 + 2][3]; + assert!(ratio(&rimmed) > ratio(&plain) * 1.3, "{} vs {}", ratio(&rimmed), ratio(&plain)); + // Highlight gain above the threshold brightens the bloom. + d.iris.fringe = 0.0; + d.highlight = Highlight { gain: 0.5, threshold: 0.5, saturation: 0.0 }; + let bright = depth_of_field(&dot(81), &depth, &d, 1.0); + assert!(bright.data[40 * 81 + 40][0] > plain.data[40 * 81 + 40][0] * 1.5); + } +} diff --git a/crates/render/src/three_d/adv/mod.rs b/crates/render/src/three_d/adv/mod.rs index f8690b8f4..4bd3fd04d 100644 --- a/crates/render/src/three_d/adv/mod.rs +++ b/crates/render/src/three_d/adv/mod.rs @@ -6,9 +6,10 @@ //! 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 +//! circle of confusion, shaped by the camera's iris: [`dof`]) 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 dof; pub mod raster; pub mod scene; pub mod shade; @@ -16,13 +17,19 @@ pub mod shade; use effectcraft_project::Layer; use rayon::prelude::*; +pub use dof::depth_of_field; 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}; +/// Whether a comp draws its 3D layers with the Advanced 3D renderer: its own setting, or for a +/// collapsed precomp's layers, the setting of the outermost comp they collapse into. +pub(crate) fn active(r: &Renderer, ctx: &EvalCtx) -> bool { + r.collapse3d.map_or(ctx.comp, |c| c.parent.comp).renderer == effectcraft_project::Renderer::Advanced3D +} + /// 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) { @@ -56,68 +63,6 @@ pub fn resolve(t: &Target, k: u32) -> (Image, Vec) { (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 = 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| { diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index ca124a415..5ec3e8c2b 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -212,6 +212,9 @@ pub(crate) struct Builder { /// Per triangle: transparent and its sort depth. tri_depth: Vec<(bool, f32)>, cam: CameraState, + /// World of the collapsed precomp layers the current layers are drawn through (identity at + /// the top level). + outer: Mat4, } impl Builder { @@ -451,7 +454,7 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { } _ => return, }; - let world = ctx.world_matrix(layer) * model_to_layer(unit); + let world = b.outer * ctx.world_matrix(layer) * model_to_layer(unit); // Materials of the model (one default when it has none). let mut mats: Vec = vec![]; let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)]; @@ -532,34 +535,55 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option Vec<(Primitive, [f32; 4], Mat4)> { - let mut groups: Vec<(Vec, [f32; 4], Mat4)> = vec![]; + // (paths, colour, mesh → layer, stacking level) + let mut groups: Vec<(Vec, [f32; 4], Mat4, usize)> = 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)); + // Fill & Stroke order: all strokes over all fills (2), all fills over all strokes + // (1), else the character style's Stroke Over Fill. + let stroke_on_top = match tg.fill_stroke { + 1 => false, + 2 => true, + _ => tg.doc.stroke_over_fill, + }; + for g in &tg.glyphs { + let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; + if g.apply_fill && g.fill[3] * op > 0.0 { + let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op]; + groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top))); + } + if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { + let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { + let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; + groups.push((vec![region], c, g.m, usize::from(stroke_on_top))); + } + } } } } 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 = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c))| (ps, c, Mat4::IDENTITY, k)).collect(); } } _ => {} } groups .into_iter() - .filter_map(|(paths, color, m)| { + .filter_map(|(paths, color, m, level)| { let cs: Vec> = 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)) + Some((model::extrude::extrude_stacked(&outlines, p, level), color, m)) }) .collect() } @@ -567,7 +591,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude 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 world = b.outer * 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) }; @@ -610,7 +634,7 @@ fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) { 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); + let world = b.outer * 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); @@ -804,6 +828,50 @@ 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 } } +/// Add one layer of a run: a model or primitive mesh, an extruded text/shape layer, the nested +/// layers of a collapsed precomp, or a textured card. +fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Builder) { + if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { + return; + } + if let Some(item) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, item, b); + } else if layer.source.is_model() { + model_layer(r, ctx, layer, b); + } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) + && let Some(p) = extrusion_params(ctx, layer) + { + extruded_layer(r, ctx, layer, &p, b); + } else { + card_layer(r, ctx, layer, b); + } +} + +/// A collapsed precomp layer: its nested layers join this scene as real Advanced 3D geometry +/// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they +/// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp +/// layer's plane; nested lights and cameras are ignored (the parent's light the scene). +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { + let op = ctx.opacity(layer) as f32 * r.opacity_mul(); + let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let saved = b.outer; + b.outer = saved * ctx.world_matrix(layer); + 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)); + for l in nctx.comp.layers.iter().rev() { + if !l.is_active_at(t) + || l.is_light() + || matches!(l.source, LayerSource::Camera) + || (any_solo && !l.switches.solo) + || (!r.opts.guides && l.switches.guide) + { + continue; + } + emit_layer(&sub, &nctx, l, b); + } + b.outer = saved; +} + /// Build the scene of a run of 3D layers. pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene { build_at(r, ctx, None, run, out) @@ -828,24 +896,17 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: tex_keys: HashMap::new(), tri_depth: vec![], cam, + outer: r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world), }; for layer in run { let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base }; - 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); - } + emit_layer(r, ctx, layer, &mut b); } b.sort_triangles(); - // Lights, environment, shadows (Draft 3D: unlit, like After Effects). + // Lights, environment, shadows (Draft 3D: unlit, like After Effects). A collapsed + // precomp's layers are lit by the outermost comp's lights. + let parent = r.collapse3d.map(|c| c.parent); + let ctx = parent.as_ref().unwrap_or(ctx); if !ctx.comp.draft_3d { let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; @@ -866,7 +927,7 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: } // Camera matrices. let s = r.opts.scale; - let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64); + let (cw, ch) = crate::three_d::compose::view_size(r, ctx); 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); diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 4e8b9c3b9..67eb9ed68 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -425,3 +425,113 @@ fn track_mattes_in_advanced_3d() { let img = render(&p, cid); assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99); } + +#[test] +fn collapsed_precomps_join_the_advanced_3d_scene() { + // A cube in a nested (Classic 3D) comp, collapsed into an Advanced 3D comp: the cube is + // real geometry lit by the parent's light, not a flat card of the nested comp (which, being + // Classic 3D, draws no meshes at all). + let (mut p, cid) = project(160, 120); + let mut nc = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + nc.renderer = R3::Classic3D; + let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into())); + add(&mut p, nid, 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])); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {}); + add(&mut p, cid, LayerSource::Comp { item: nid }, |l| { + l.switches.three_d = true; + l.switches.collapse = true; + }); + let shades = |img: &crate::Image| { + let mut s: Vec = img.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect(); + s.sort(); + s.dedup(); + s + }; + let img = render(&p, cid); + assert!(img.data[60 * 160 + 80][3] > 0.99, "the nested cube covers the centre"); + assert!(shades(&img).len() >= 3, "faces lit by the parent's light: {:?}", shades(&img)); + // The scene holds the cube's triangles (a card would be two). + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO); + let run: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| l.is_3d() && !l.is_light()).collect(); + let s = scene_of(&r, &ctx, &run, (160, 120)); + assert!(s.indices.len() / 3 >= 12, "{} triangles", s.indices.len() / 3); + // A 2D collapsed precomp draws its 3D layers through the parent's Advanced 3D renderer too. + p.comp_mut(cid).unwrap().layers[0].switches.three_d = false; + let flat = render(&p, cid); + assert!(flat.data[60 * 160 + 80][3] > 0.99); + assert!(shades(&flat).len() >= 3, "{:?}", shades(&flat)); + // Collapse off: the nested comp renders on its own (Classic 3D: no meshes). + p.comp_mut(cid).unwrap().layers[0].switches.collapse = false; + p.comp_mut(cid).unwrap().layers[0].switches.three_d = true; + assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0)); +} + +#[test] +fn extruded_strokes_render_as_bevelled_meshes() { + let (mut p, cid) = project(200, 120); + add(&mut p, cid, LayerSource::Text, |l| { + l.switches.three_d = true; + let doc = effectcraft_keyframe::TextDoc { + text: "I".into(), + size: 80.0, + fill: [1.0, 1.0, 1.0, 1.0], + apply_fill: true, + stroke: [1.0, 0.0, 0.0, 1.0], + apply_stroke: true, + stroke_width: 8.0, + stroke_over_fill: false, + ..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(20.0)); + set(l, "geometryOptions/bevelStyle", Value::Enum(1)); + }); + let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let l = &ctx.comp.layers[0]; + let params = scene::extrusion_params(&ctx, l).unwrap(); + let meshes = scene::extruded_meshes(&ctx, l, ¶ms); + assert_eq!(meshes.len(), 2, "fill and stroke"); + let width = |m: &effectcraft_model::Primitive| { + let (a, b) = m.positions.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), q| (a.min(q[0]), b.max(q[0]))); + b - a + }; + let (fill, stroke) = (&meshes[0], &meshes[1]); + assert_eq!(fill.1, [1.0, 1.0, 1.0, 1.0]); + assert_eq!(stroke.1, [1.0, 0.0, 0.0, 1.0]); + // The stroke reaches half its width beyond the fill on each side, and is extruded too. + assert!((width(&stroke.0) - width(&fill.0) - 8.0).abs() < 1.0, "{} vs {}", width(&stroke.0), width(&fill.0)); + assert!(stroke.0.positions.iter().any(|q| q[2] > 19.0)); + // Fill Over Stroke: the fill's front cap sits in front of the stroke's. + let front = |m: &effectcraft_model::Primitive| m.positions.iter().fold(f32::INFINITY, |a, q| a.min(q[2])); + assert!(front(&fill.0) < front(&stroke.0)); + // Rendered (unlit: base colours): a red rim around the white face. + let img = render(&p, cid); + let red = img.data.iter().filter(|q| q[3] > 0.99 && q[0] > 0.9 && q[1] < 0.1).count(); + let white = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9).count(); + assert!(red > 50 && white > 50, "red {red}, white {white}"); +} + +#[test] +fn stroke_regions_are_rings() { + // A closed path's stroke covers a ring (the inside stays open), so an extruded stroke is a + // hollow frame around its fill. + let sq = effectcraft_path::rect([100.0, 100.0], [0.0, 0.0], 0.0); + let st = effectcraft_path::StrokeStyle { width: 10.0, ..Default::default() }; + let r = crate::shapes::stroke_region(std::slice::from_ref(&sq), &st).unwrap(); + let a = effectcraft_path::boolean::filled_area(&r, effectcraft_path::FillRule::NonZero); + assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 20.0, "{a}"); + let outlines = effectcraft_model::extrude::group_contours(scene::contours(&r, 0.25)); + assert_eq!(outlines.len(), 1); + assert_eq!(outlines[0].holes.len(), 1); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 1f5470b36..5836f9340 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -367,9 +367,9 @@ 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() { + // 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; } @@ -694,7 +694,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) { return None; } - if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { + if super::adv::active(r, ctx) { return None; } let g = gather(r, ctx, run, out);