mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 06:52:21 -06:00
Merge main into M13.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user