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);