Merge main into M13.6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 08:03:27 -04:00
co-authored by Claude Opus 5.5
23 changed files with 2063 additions and 271 deletions
+34 -1
View File
@@ -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<BezPath>, [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<BezPath>, [f32; 4])> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let paths: Vec<BezPath> = 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<BezPath> {
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<kurbo::Rect> {
+123
View File
@@ -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<f32> = depth
.iter()
.map(|&z| {
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
((dof.coc(z) * scale * 0.5) as f32).min(MAX_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<f32>) {
// 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<bool> {
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);
}
}
+9 -64
View File
@@ -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<f32>) {
@@ -56,68 +63,6 @@ pub fn resolve(t: &Target, k: u32) -> (Image, Vec<f32>) {
(img, depth)
}
/// Depth of field: a gather blur where each pixel collects the neighbours whose circle of
/// confusion (from their depth, in output pixels) reaches it; nearer in-focus surfaces are not
/// smeared by the blur of what is behind them.
pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image {
let (w, h) = (img.width as i64, img.height as i64);
let max_r = 48.0f32;
let radius: Vec<f32> = depth
.iter()
.map(|&z| {
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
((dof.coc(z) * scale * 0.5) as f32).min(max_r)
})
.collect();
let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b));
if rmax < 0.5 {
return img.clone();
}
// Disk samples (golden-angle spiral) over the largest radius.
const N: usize = 64;
let golden = std::f32::consts::PI * (3.0 - 5.0f32.sqrt());
let taps: Vec<(f32, f32, f32)> = (1..=N)
.map(|i| {
let r = (i as f32 / N as f32).sqrt() * rmax;
let a = i as f32 * golden;
(r * a.cos(), r * a.sin(), r)
})
.collect();
let mut out = Image::new(img.width, img.height);
out.data.par_chunks_mut(w as usize).enumerate().for_each(|(y, row)| {
for x in 0..w as usize {
let i = y * w as usize + x;
let (rp, zp) = (radius[i], depth[i]);
let cw = 1.0 / (std::f32::consts::PI * rp * rp).max(1.0);
let c = img.data[i];
let mut acc = [c[0] * cw, c[1] * cw, c[2] * cw, c[3] * cw];
let mut wsum = cw;
for &(dx, dy, d) in &taps {
let (sx, sy) = (x as i64 + dx.round() as i64, y as i64 + dy.round() as i64);
if sx < 0 || sy < 0 || sx >= w || sy >= h {
continue;
}
let j = (sy * w + sx) as usize;
// A sample behind this pixel blurs no further than this pixel's own circle.
let rq = if depth[j] > zp { radius[j].min(rp) } else { radius[j] };
let cov = (rq - d + 0.5).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
// Each tap stands for an annulus of area ≈ π·rmax²/N.
let wq = cov / (std::f32::consts::PI * rq * rq).max(1.0) * (std::f32::consts::PI * rmax * rmax / N as f32);
let q = img.data[j];
for k in 0..4 {
acc[k] += q[k] * wq;
}
wsum += wq;
}
row[x] = acc.map(|v| v / wsum);
}
});
out
}
/// Linear → sRGB-encoded premultiplied pixels (the working encoding of non-linear projects).
pub fn encode(img: &mut Image) {
img.data.par_iter_mut().for_each(|p| {
+86 -25
View File
@@ -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<u32> = 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<model::extrude::
}
/// Extruded meshes of a text or shape layer: (mesh, straight colour, mesh space → layer
/// space). Text extrudes each character in its own space, so per-character 3D animation
/// (position, rotation, scale in Z) carries over.
/// space). Fills and strokes are both extruded (a stroke as the region it covers, bevelled like
/// the fill), stacked in the layer's paint order (see [`model::extrude::extrude_stacked`]).
/// Text extrudes each character in its own space, so per-character 3D animation (position,
/// rotation, scale in Z) carries over.
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4)> = vec![];
// (paths, colour, mesh → layer, stacking level)
let mut groups: Vec<(Vec<kurbo::BezPath>, [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<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
}
Some((model::extrude::extrude(&outlines, p), color, m))
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);
+110
View File
@@ -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<i32> = 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, &params);
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);
}
+4 -4
View File
@@ -419,9 +419,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;
}
@@ -749,7 +749,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;
}
// Depth of field is left to the accelerator (its blur runs where the planes are drawn).