mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
553a51316c
commit
07da394ccb
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user