mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
M6.2: shape path operators (round corners, offset, twist, zig zag, wiggle, merge paths, gradient stroke)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
808cd490cd
commit
ba2fa1bddf
+315
-112
@@ -1,11 +1,18 @@
|
||||
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
|
||||
//! with the After Effects stacking semantics (operators and paint items act on the paths above
|
||||
//! them in the same group; items higher in the list draw on top).
|
||||
//! with the After Effects stacking semantics:
|
||||
//!
|
||||
//! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
|
||||
//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
|
||||
//! the paths of nested groups;
|
||||
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
|
||||
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
|
||||
//! - items higher in the list draw on top.
|
||||
|
||||
use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_keyframe::{Gradient, Value};
|
||||
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle};
|
||||
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops};
|
||||
use effectcraft_project::{Layer, Node, PropGroup};
|
||||
use effectcraft_raster::{Image, Mask};
|
||||
use rayon::prelude::*;
|
||||
@@ -14,22 +21,40 @@ use crate::eval::EvalCtx;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Paint {
|
||||
Fill { color: [f32; 4], rule: FillRule },
|
||||
Stroke { color: [f32; 4], style: StrokeStyle },
|
||||
Gradient { g: Gradient, radial: bool, start: [f64; 2], end: [f64; 2], rule: FillRule },
|
||||
Fill {
|
||||
color: [f32; 4],
|
||||
rule: FillRule,
|
||||
},
|
||||
Stroke {
|
||||
color: [f32; 4],
|
||||
style: StrokeStyle,
|
||||
},
|
||||
/// Gradient fill, or gradient stroke when `stroke` is set.
|
||||
Gradient {
|
||||
g: Gradient,
|
||||
radial: bool,
|
||||
start: [f64; 2],
|
||||
end: [f64; 2],
|
||||
rule: FillRule,
|
||||
stroke: Option<StrokeStyle>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A paint item. It references path *slots* rather than copies of the geometry: path operators
|
||||
/// further down the stack rewrite the slots in place, so (as in After Effects) a Trim Paths or
|
||||
/// Round Corners placed below a fill or stroke still changes what that paint draws.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Draw {
|
||||
paint: Paint,
|
||||
paths: Vec<BezPath>,
|
||||
/// Group-space → layer-space for gradients and stroke scaling.
|
||||
slots: Vec<usize>,
|
||||
/// Paint-group space → layer space, for gradients and stroke scaling.
|
||||
xf: Mat3,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
}
|
||||
|
||||
fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
|
||||
effectcraft_path::transform(paths, m)
|
||||
fn mat_path(path: &BezPath, m: &Mat3) -> BezPath {
|
||||
effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
|
||||
@@ -51,10 +76,55 @@ fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
|
||||
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
|
||||
}
|
||||
|
||||
/// Collect draws and accumulated paths of a contents group (in group space).
|
||||
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Vec<BezPath>) {
|
||||
fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
|
||||
let dash = g.sub("dashes").and_then(|d| {
|
||||
let dash = ctx.f(layer, d, "dash", 0.0);
|
||||
let gap = ctx.f(layer, d, "gap", 0.0);
|
||||
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
|
||||
});
|
||||
StrokeStyle {
|
||||
width: ctx.f(layer, g, "width", 2.0),
|
||||
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
|
||||
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
|
||||
miter: ctx.f(layer, g, "miter", 4.0),
|
||||
dash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend mode of a paint item (`blend` holds an index into the Blending Mode menu).
|
||||
fn blend_mode(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> BlendMode {
|
||||
BlendMode::ALL.get(ctx.e(layer, g, "blend") as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Apply a path operator to the live slots in place.
|
||||
fn apply(arena: &mut [BezPath], live: &[usize], f: impl FnOnce(&[BezPath]) -> Vec<BezPath>) {
|
||||
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
|
||||
for (&i, p) in live.iter().zip(f(&input)) {
|
||||
arena[i] = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a shape's path as a new slot, honouring its Path Direction toggle (1 = reversed).
|
||||
fn push_shape(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, p: BezPath, arena: &mut Vec<BezPath>, live: &mut Vec<usize>) {
|
||||
let p = if ctx.e(layer, g, "direction") == 1 { p.reverse_subpaths() } else { p };
|
||||
arena.push(p);
|
||||
live.push(arena.len() - 1);
|
||||
}
|
||||
|
||||
/// Every slot used by `live` or by `draws`, sorted and unique.
|
||||
fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = live.iter().copied().chain(draws.iter().flat_map(|d| d.slots.iter().copied())).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Collect the draws of a contents group and the path slots live at its end (in group space).
|
||||
/// `arena` holds every path slot; operators rewrite slots in place.
|
||||
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
|
||||
let mut draws: Vec<Draw> = Vec::new();
|
||||
let mut acc: Vec<BezPath> = Vec::new();
|
||||
let mut live: Vec<usize> = Vec::new();
|
||||
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
|
||||
for node in &contents.children {
|
||||
let Node::Group(g) = node else { continue };
|
||||
if !g.enabled {
|
||||
@@ -64,16 +134,17 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
||||
"rect" => {
|
||||
let size = ctx.v2(layer, g, "size", [100.0; 2]);
|
||||
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
|
||||
acc.push(effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)));
|
||||
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
|
||||
push_shape(ctx, layer, g, p, arena, &mut live);
|
||||
}
|
||||
"ellipse" => {
|
||||
let size = ctx.v2(layer, g, "size", [100.0; 2]);
|
||||
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
|
||||
acc.push(effectcraft_path::ellipse(size, pos));
|
||||
push_shape(ctx, layer, g, effectcraft_path::ellipse(size, pos), arena, &mut live);
|
||||
}
|
||||
"star" => {
|
||||
let star = ctx.e(layer, g, "type") == 0;
|
||||
acc.push(effectcraft_path::polystar(
|
||||
let p = effectcraft_path::polystar(
|
||||
star,
|
||||
ctx.f(layer, g, "points", 5.0),
|
||||
ctx.v2(layer, g, "position", [0.0; 2]),
|
||||
@@ -82,40 +153,43 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
||||
ctx.f(layer, g, "outerRadius", 100.0),
|
||||
ctx.f(layer, g, "innerRoundness", 0.0),
|
||||
ctx.f(layer, g, "outerRoundness", 0.0),
|
||||
));
|
||||
);
|
||||
push_shape(ctx, layer, g, p, arena, &mut live);
|
||||
}
|
||||
"path" => {
|
||||
if let Some(Value::Path(p)) = ctx.group_value(layer, g, "path") {
|
||||
acc.push(effectcraft_path::to_kurbo(&p));
|
||||
push_shape(ctx, layer, g, effectcraft_path::to_kurbo(&p), arena, &mut live);
|
||||
}
|
||||
}
|
||||
"fill" => {
|
||||
let mut c = ctx.color(layer, g, "color");
|
||||
c[3] = 1.0;
|
||||
draws.push(Draw { paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
|
||||
draws.push(Draw {
|
||||
paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) },
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
});
|
||||
}
|
||||
"stroke" => {
|
||||
let mut c = ctx.color(layer, g, "color");
|
||||
c[3] = 1.0;
|
||||
let dash = g.sub("dashes").and_then(|d| {
|
||||
let dash = ctx.f(layer, d, "dash", 0.0);
|
||||
let gap = ctx.f(layer, d, "gap", 0.0);
|
||||
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
|
||||
let style = stroke_style(ctx, layer, g);
|
||||
draws.push(Draw {
|
||||
paint: Paint::Stroke { color: c, style },
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
});
|
||||
let style = StrokeStyle {
|
||||
width: ctx.f(layer, g, "width", 2.0),
|
||||
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
|
||||
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
|
||||
miter: ctx.f(layer, g, "miter", 4.0),
|
||||
dash,
|
||||
};
|
||||
draws.push(Draw { paint: Paint::Stroke { color: c, style }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
|
||||
}
|
||||
"gfill" => {
|
||||
"gfill" | "gstroke" => {
|
||||
let gr = match ctx.group_value(layer, g, "colors") {
|
||||
Some(Value::Gradient(gr)) => gr,
|
||||
_ => Gradient::default(),
|
||||
};
|
||||
let stroke = (g.match_id == "gstroke").then(|| stroke_style(ctx, layer, g));
|
||||
draws.push(Draw {
|
||||
paint: Paint::Gradient {
|
||||
g: gr,
|
||||
@@ -123,34 +197,86 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
||||
start: ctx.v2(layer, g, "start", [0.0; 2]),
|
||||
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
|
||||
rule: rule(ctx, layer, g),
|
||||
stroke,
|
||||
},
|
||||
paths: acc.clone(),
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32,
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
});
|
||||
}
|
||||
"trim" => {
|
||||
let (s, e, o) = (ctx.f(layer, g, "start", 0.0), ctx.f(layer, g, "end", 100.0), ctx.f(layer, g, "offset", 0.0));
|
||||
acc = effectcraft_path::ops::trim(&acc, s, e, o);
|
||||
for d in &mut draws {
|
||||
// Paint items above the trim are not affected (AE semantics).
|
||||
let _ = d;
|
||||
// Trim Multiple Shapes: 0 = Simultaneously (each path by the same fractions),
|
||||
// 1 = Individually (one after another, as one continuous length).
|
||||
if ctx.e(layer, g, "mode") == 1 {
|
||||
apply(arena, &live, |p| ops::trim_individually(p, s, e, o));
|
||||
} else {
|
||||
apply(arena, &live, |p| ops::trim(p, s, e, o));
|
||||
}
|
||||
}
|
||||
"pucker" => {
|
||||
acc = effectcraft_path::ops::pucker_bloat(&acc, ctx.f(layer, g, "amount", 0.0));
|
||||
let a = ctx.f(layer, g, "amount", 0.0);
|
||||
apply(arena, &live, |p| ops::pucker_bloat(p, a));
|
||||
}
|
||||
"round" => {
|
||||
let r = ctx.f(layer, g, "radius", 10.0);
|
||||
apply(arena, &live, |p| ops::round_corners(p, r));
|
||||
}
|
||||
"offset" => {
|
||||
let amount = ctx.f(layer, g, "amount", 10.0);
|
||||
let join = [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize];
|
||||
let miter = ctx.f(layer, g, "miter", 4.0);
|
||||
let copies = ctx.f(layer, g, "copies", 1.0);
|
||||
let copy_offset = ctx.f(layer, g, "copyOffset", 0.0);
|
||||
apply(arena, &live, |p| ops::offset(p, amount, join, miter, copies, copy_offset));
|
||||
}
|
||||
"twist" => {
|
||||
let angle = ctx.f(layer, g, "angle", 0.0);
|
||||
let center = ctx.v2(layer, g, "center", [0.0; 2]);
|
||||
apply(arena, &live, |p| ops::twist(p, angle, center));
|
||||
}
|
||||
"zigzag" => {
|
||||
let size = ctx.f(layer, g, "size", 10.0);
|
||||
let ridges = ctx.f(layer, g, "ridges", 5.0);
|
||||
let smooth = ctx.e(layer, g, "points") == 1;
|
||||
apply(arena, &live, |p| ops::zigzag(p, size, ridges, smooth));
|
||||
}
|
||||
"wiggle" => {
|
||||
let w = ops::WiggleParams {
|
||||
size: ctx.f(layer, g, "size", 10.0),
|
||||
detail: ctx.f(layer, g, "detail", 10.0),
|
||||
smooth: ctx.e(layer, g, "points") == 1,
|
||||
speed: ctx.f(layer, g, "speed", 2.0),
|
||||
correlation: ctx.f(layer, g, "correlation", 50.0),
|
||||
phase_deg: ctx.f(layer, g, "phase", 0.0),
|
||||
seed: ctx.f(layer, g, "seed", 0.0),
|
||||
};
|
||||
let t = layer.layer_time(ctx.time).seconds();
|
||||
apply(arena, &live, |p| ops::wiggle(p, &w, t));
|
||||
}
|
||||
"merge" => {
|
||||
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
|
||||
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
|
||||
arena.push(ops::merge(&input, mode));
|
||||
live = vec![arena.len() - 1];
|
||||
// As in After Effects, fills and strokes above a Merge Paths in its group are not
|
||||
// rendered: the merged path is painted by the paint items below it.
|
||||
draws.clear();
|
||||
}
|
||||
"group" => {
|
||||
let Some(inner) = g.sub("contents") else { continue };
|
||||
let (cd, cp) = collect(ctx, layer, inner);
|
||||
let (cd, cl) = collect(ctx, layer, inner, arena);
|
||||
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
|
||||
for i in used_slots(&cl, &cd) {
|
||||
arena[i] = mat_path(&arena[i], &m);
|
||||
}
|
||||
for mut d in cd {
|
||||
d.paths = mat_paths(&d.paths, &m);
|
||||
d.xf = m * d.xf;
|
||||
d.opacity *= op;
|
||||
draws.push(d);
|
||||
}
|
||||
acc.extend(mat_paths(&cp, &m));
|
||||
live.extend(cl);
|
||||
}
|
||||
"repeater" => {
|
||||
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
|
||||
@@ -171,114 +297,191 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
||||
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
|
||||
};
|
||||
let base_draws = std::mem::take(&mut draws);
|
||||
let base_acc = std::mem::take(&mut acc);
|
||||
let base_live = std::mem::take(&mut live);
|
||||
let base_slots = used_slots(&base_live, &base_draws);
|
||||
// Each copy gets its own transformed slots, so operators below the repeater act on
|
||||
// the repeated geometry (copy order: original first).
|
||||
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let m = step(i as f64 + offset);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for &s in &base_slots {
|
||||
arena.push(mat_path(&arena[s], &m));
|
||||
map.insert(s, arena.len() - 1);
|
||||
}
|
||||
live.extend(base_live.iter().map(|s| map[s]));
|
||||
copy_maps.push(map);
|
||||
}
|
||||
let above = ctx.e(layer, g, "composite") == 1;
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
if !above {
|
||||
order.reverse();
|
||||
}
|
||||
for i in order {
|
||||
let k = i as f64 + offset;
|
||||
let m = step(k);
|
||||
let m = step(i as f64 + offset);
|
||||
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
|
||||
let opk = so + (eo - so) * t;
|
||||
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
|
||||
for d in &base_draws {
|
||||
let mut d = d.clone();
|
||||
d.paths = mat_paths(&d.paths, &m);
|
||||
d.slots = d.slots.iter().map(|s| copy_maps[i][s]).collect();
|
||||
d.xf = m * d.xf;
|
||||
d.opacity *= opk * partial;
|
||||
draws.push(d);
|
||||
}
|
||||
acc.extend(mat_paths(&base_acc, &m));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(draws, acc)
|
||||
(draws, live)
|
||||
}
|
||||
|
||||
fn composite_coverage(img: &mut Image, cov: &Mask, color: [f32; 4], opacity: f32) {
|
||||
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
|
||||
let a = c * opacity * color[3];
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct PxRect {
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
w: u32,
|
||||
h: u32,
|
||||
}
|
||||
|
||||
/// Composite `cov`-weighted colour (`color_at(x, y)` in buffer pixels, straight alpha) into the
|
||||
/// `r` sub-rectangle of a premultiplied image (`cov` is `r.w × r.h`).
|
||||
fn paint(img: &mut Image, r: PxRect, cov: &Mask, opacity: f32, blend: BlendMode, color_at: impl Fn(usize, usize) -> [f32; 4] + Sync) {
|
||||
let iw = img.width as usize;
|
||||
let (x0, y0, rw) = (r.x0 as usize, r.y0 as usize, r.w as usize);
|
||||
let row = |y: usize, out: &mut [[f32; 4]], crow: &[f32]| {
|
||||
for (dx, (px, &c)) in out[x0..x0 + rw].iter_mut().zip(crow).enumerate() {
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let x = x0 + dx;
|
||||
let col = color_at(x, y);
|
||||
let a = c * opacity * col[3];
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let src = [col[0] * a, col[1] * a, col[2] * a, a];
|
||||
if blend == BlendMode::Normal {
|
||||
let k = 1.0 - a;
|
||||
*px = [src[0] + px[0] * k, src[1] + px[1] * k, src[2] + px[2] * k, a + px[3] * k];
|
||||
} else {
|
||||
// Deterministic per-pixel noise for the dissolve modes.
|
||||
let h = ((x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761);
|
||||
*px = blend_pixel(blend, *px, src, (h >> 8) as f32 / (1u32 << 24) as f32);
|
||||
}
|
||||
}
|
||||
let k = 1.0 - a;
|
||||
px[0] = color[0] * a + px[0] * k;
|
||||
px[1] = color[1] * a + px[1] * k;
|
||||
px[2] = color[2] * a + px[2] * k;
|
||||
px[3] = a + px[3] * k;
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
|
||||
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
|
||||
let (draws, _) = collect(ctx, layer, contents);
|
||||
let mut bounds: Option<kurbo::Rect> = None;
|
||||
for d in &draws {
|
||||
if let Some(b) = effectcraft_path::bounds(&d.paths) {
|
||||
let grow = match &d.paint {
|
||||
Paint::Stroke { style, .. } => style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let b = b.inflate(grow, grow);
|
||||
bounds = Some(bounds.map_or(b, |a| a.union(b)));
|
||||
};
|
||||
let rows = img.data.chunks_mut(iw).skip(y0).take(r.h as usize);
|
||||
if r.w as usize * r.h as usize >= 64 * 1024 {
|
||||
let rows: Vec<&mut [[f32; 4]]> = rows.collect();
|
||||
rows.into_par_iter().zip(cov.data.par_chunks(rw)).enumerate().for_each(|(dy, (out, crow))| row(y0 + dy, out, crow));
|
||||
} else {
|
||||
for (dy, (out, crow)) in rows.zip(cov.data.chunks(rw)).enumerate() {
|
||||
row(y0 + dy, out, crow);
|
||||
}
|
||||
}
|
||||
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
|
||||
}
|
||||
|
||||
/// Coverage of one draw inside its pixel rectangle.
|
||||
fn draw_coverage(d: &Draw, paths: &[BezPath], m: &Mat3, r: PxRect) -> Mask {
|
||||
// Rasterise into a small mask: shift the buffer matrix so the rectangle starts at 0,0.
|
||||
let sub = Mat3::translate(vec2(-(r.x0 as f64), -(r.y0 as f64))) * *m;
|
||||
let stroke = |style: &StrokeStyle| {
|
||||
// Strokes scale with their group transform.
|
||||
let mut st = style.clone();
|
||||
st.width *= d.xf.mean_scale();
|
||||
effectcraft_path::stroke_coverage(paths, &st, &sub, r.w, r.h)
|
||||
};
|
||||
match &d.paint {
|
||||
Paint::Fill { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
|
||||
Paint::Stroke { style, .. } => stroke(style),
|
||||
Paint::Gradient { stroke: Some(style), .. } => stroke(style),
|
||||
Paint::Gradient { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-space bounds of what a draw can touch (stroke width, miters and anti-aliasing included).
|
||||
fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
|
||||
let b = effectcraft_path::bounds(paths)?;
|
||||
let stroke = match &d.paint {
|
||||
Paint::Stroke { style, .. } => Some(style),
|
||||
Paint::Gradient { stroke: Some(style), .. } => Some(style),
|
||||
_ => None,
|
||||
};
|
||||
let grow = stroke.map_or(1.0, |style| style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0);
|
||||
Some(b.inflate(grow, grow))
|
||||
}
|
||||
|
||||
/// Maximum number of coverage pixels rasterised concurrently (bounds peak memory).
|
||||
const BATCH_PIXELS: usize = 8 << 20;
|
||||
|
||||
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
|
||||
///
|
||||
/// Each draw rasterises and composites only its own pixel rectangle; coverage masks of
|
||||
/// consecutive draws are rasterised in parallel, then composited in stacking order.
|
||||
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
|
||||
let mut arena = Vec::new();
|
||||
let (draws, _) = collect(ctx, layer, contents, &mut arena);
|
||||
// Bottom-most draw first.
|
||||
let resolved: Vec<(&Draw, Vec<BezPath>, kurbo::Rect)> = 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();
|
||||
let b = draw_bounds(d, &paths)?;
|
||||
Some((d, paths, b))
|
||||
})
|
||||
.collect();
|
||||
let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
|
||||
// Keep buffers sane for runaway geometry.
|
||||
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
|
||||
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
|
||||
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
|
||||
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
|
||||
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
|
||||
let px_rect = |r: kurbo::Rect| -> Option<PxRect> {
|
||||
let x0 = (r.x0 * s + offset[0]).floor().max(0.0);
|
||||
let y0 = (r.y0 * s + offset[1]).floor().max(0.0);
|
||||
let x1 = (r.x1 * s + offset[0]).ceil().min(w as f64);
|
||||
let y1 = (r.y1 * s + offset[1]).ceil().min(h as f64);
|
||||
(x1 > x0 && y1 > y0).then_some(PxRect { x0: x0 as u32, y0: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 })
|
||||
};
|
||||
let jobs: Vec<(&Draw, &[BezPath], PxRect)> = resolved.iter().filter_map(|(d, p, b)| Some((*d, p.as_slice(), px_rect(*b)?))).collect();
|
||||
let mut img = Image::new(w, h);
|
||||
for d in draws.iter().rev() {
|
||||
if d.opacity <= 0.0 || d.paths.is_empty() {
|
||||
continue;
|
||||
let mut i = 0;
|
||||
while i < jobs.len() {
|
||||
// Next batch: as many draws as fit the pixel budget (at least one).
|
||||
let mut j = i;
|
||||
let mut px = 0usize;
|
||||
while j < jobs.len() && (j == i || px + jobs[j].2.w as usize * jobs[j].2.h as usize <= BATCH_PIXELS) {
|
||||
px += jobs[j].2.w as usize * jobs[j].2.h as usize;
|
||||
j += 1;
|
||||
}
|
||||
match &d.paint {
|
||||
Paint::Fill { color, rule } => {
|
||||
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
|
||||
composite_coverage(&mut img, &cov, *color, d.opacity);
|
||||
}
|
||||
Paint::Stroke { color, style } => {
|
||||
// Strokes scale with their group transform.
|
||||
let mut st = style.clone();
|
||||
st.width *= d.xf.mean_scale();
|
||||
let cov = effectcraft_path::stroke_coverage(&d.paths, &st, &m, w, h);
|
||||
composite_coverage(&mut img, &cov, *color, d.opacity);
|
||||
}
|
||||
Paint::Gradient { g, radial, start, end, rule } => {
|
||||
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
|
||||
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
|
||||
let (sx, sy) = (start[0], start[1]);
|
||||
let (dx, dy) = (end[0] - sx, end[1] - sy);
|
||||
let len2 = (dx * dx + dy * dy).max(1e-9);
|
||||
let op = d.opacity;
|
||||
img.data.par_chunks_mut(w as usize).zip(cov.data.par_chunks(w as usize)).enumerate().for_each(|(y, (row, crow))| {
|
||||
for x in 0..row.len() {
|
||||
let c = crow[x];
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let covs: Vec<Mask> = jobs[i..j].par_iter().map(|(d, paths, r)| draw_coverage(d, paths, &m, *r)).collect();
|
||||
for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) {
|
||||
match &d.paint {
|
||||
Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color),
|
||||
Paint::Gradient { g, radial, start, end, .. } => {
|
||||
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
|
||||
let (sx, sy) = (start[0], start[1]);
|
||||
let (dx, dy) = (end[0] - sx, end[1] - sy);
|
||||
let len2 = (dx * dx + dy * dy).max(1e-9);
|
||||
paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| {
|
||||
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let (vx, vy) = (p.x - sx, p.y - sy);
|
||||
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
|
||||
let col = g.sample(t);
|
||||
let a = c * op * col[3];
|
||||
let k = 1.0 - a;
|
||||
let px = &mut row[x];
|
||||
px[0] = col[0] * a + px[0] * k;
|
||||
px[1] = col[1] * a + px[1] * k;
|
||||
px[2] = col[2] * a + px[2] * k;
|
||||
px[3] = a + px[3] * k;
|
||||
}
|
||||
});
|
||||
g.sample(t)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
Buf { img, offset, scale: s }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Render-level tests for shape-layer path operators and paint items.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Gradient, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, PropGroup};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::render_frame;
|
||||
|
||||
/// A 200×100 comp with one shape layer (origin at the comp centre, 100,50) holding `items` at the
|
||||
/// top level of its contents.
|
||||
fn shape_comp(items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
|
||||
shape_comp_sized(200, 100, items)
|
||||
}
|
||||
|
||||
fn shape_comp_sized(w: u32, h: u32, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (w, h), None);
|
||||
let mut next = p.next_id;
|
||||
let items = items(&mut Ids(&mut next));
|
||||
p.next_id = next;
|
||||
let contents = l.props.sub_mut("contents").unwrap();
|
||||
for i in items {
|
||||
contents.children.push(i.into());
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn frame(p: &Project, cid: ItemId) -> Image {
|
||||
render_frame(p, cid, Tick::ZERO, 1.0)
|
||||
}
|
||||
|
||||
fn alpha(img: &Image, x: i64, y: i64) -> f32 {
|
||||
img.get(x, y)[3]
|
||||
}
|
||||
|
||||
fn coverage(img: &Image) -> f32 {
|
||||
img.data.iter().map(|px| px[3]).sum()
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, path: &str, v: Value) {
|
||||
g.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
|
||||
}
|
||||
|
||||
fn op(ids: &mut Ids, kind: &str, params: &[(&str, Value)]) -> PropGroup {
|
||||
let mut g = build::shape_simple_op(ids, kind).unwrap();
|
||||
for (k, v) in params {
|
||||
set(&mut g, k, v.clone());
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f64; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
|
||||
#[test]
|
||||
fn merge_add_feeds_fill_below_and_drops_paint_above() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
|
||||
build::shape_rect(ids, [40.0, 40.0], [20.0, 0.0], 0.0),
|
||||
build::shape_fill(ids, RED),
|
||||
op(ids, "merge", &[("mode", Value::Enum(1))]),
|
||||
build::shape_fill(ids, GREEN),
|
||||
]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let c = img.get(100, 50);
|
||||
assert!(c[1] > 0.99 && c[0] < 0.01, "merged path painted by the fill below only: {c:?}");
|
||||
assert!(img.get(65, 50)[1] > 0.99 && img.get(135, 50)[1] > 0.99);
|
||||
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_subtract_and_intersect() {
|
||||
let sub = |mode: u32| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [80.0, 60.0], [0.0, 0.0], 0.0),
|
||||
build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0),
|
||||
op(ids, "merge", &[("mode", Value::Enum(mode))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
frame(&p, cid)
|
||||
};
|
||||
let s = sub(2);
|
||||
assert!(alpha(&s, 100, 50) < 0.01, "hole");
|
||||
assert!(alpha(&s, 70, 50) > 0.99);
|
||||
assert!((coverage(&s) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
|
||||
let i = sub(3);
|
||||
assert!(alpha(&i, 100, 50) > 0.99);
|
||||
assert!(alpha(&i, 70, 50) < 0.01);
|
||||
assert!((coverage(&i) - 400.0).abs() < 10.0);
|
||||
let x = sub(4);
|
||||
assert!((coverage(&x) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_paths_grow_and_shrink_fill() {
|
||||
let run = |amount: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "offset", &[("amount", Value::Scalar(amount))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
frame(&p, cid)
|
||||
};
|
||||
let g = run(10.0);
|
||||
assert!((coverage(&g) - 60.0 * 60.0).abs() < 20.0, "{}", coverage(&g));
|
||||
assert!(alpha(&g, 100 + 25, 50) > 0.99);
|
||||
let s = run(-10.0);
|
||||
assert!((coverage(&s) - 20.0 * 20.0).abs() < 10.0, "{}", coverage(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_below_repeater_act_on_copies() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut rep = build::shape_repeater(ids, 3.0, [30.0, 0.0]);
|
||||
set(&mut rep, "transform/position", Value::Vec2([30.0, 0.0]));
|
||||
vec![build::shape_rect(ids, [20.0, 20.0], [-30.0, 0.0], 0.0), rep, op(ids, "offset", &[("amount", Value::Scalar(2.0))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
// Three 24×24 squares (offset by 2) at x = -30, 0, 30.
|
||||
for cx in [70, 100, 130] {
|
||||
assert!(alpha(&img, cx, 50) > 0.99, "copy at {cx}");
|
||||
assert!(alpha(&img, cx + 11, 50) > 0.99, "offset applied to copy at {cx}");
|
||||
}
|
||||
assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeater_then_merge_unites_copies() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [20.0, 20.0], [-10.0, 0.0], 0.0),
|
||||
build::shape_repeater(ids, 2.0, [10.0, 0.0]),
|
||||
op(ids, "merge", &[("mode", Value::Enum(4))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
// Two squares overlapping by 10 px: exclude leaves the two 10×20 ends.
|
||||
assert!(alpha(&img, 95, 50) < 0.01);
|
||||
assert!(alpha(&img, 85, 50) > 0.99 && alpha(&img, 105, 50) > 0.99);
|
||||
assert!((coverage(&img) - 400.0).abs() < 10.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_below_stroke_trims_it() {
|
||||
let run = |end: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut t = build::shape_trim(ids, 0.0, end, 0.0);
|
||||
set(&mut t, "end", Value::Scalar(end));
|
||||
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 4.0), t]
|
||||
});
|
||||
coverage(&frame(&p, cid))
|
||||
};
|
||||
let full = run(100.0);
|
||||
let half = run(50.0);
|
||||
assert!(full > 900.0);
|
||||
assert!((half / full - 0.5).abs() < 0.05, "{half} / {full}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_individually_vs_simultaneously() {
|
||||
let run = |mode: u32| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let items = vec![build::shape_rect(ids, [40.0, 40.0], [-40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
|
||||
let a = build::shape_group(ids, "A", items);
|
||||
let items = vec![build::shape_rect(ids, [40.0, 40.0], [40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
|
||||
let b = build::shape_group(ids, "B", items);
|
||||
let mut t = build::shape_trim(ids, 0.0, 50.0, 0.0);
|
||||
set(&mut t, "mode", Value::Enum(mode));
|
||||
vec![a, b, t]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let left: f32 = (0..100).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
|
||||
let right: f32 = (100..200).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
|
||||
(left, right)
|
||||
};
|
||||
let (l, r) = run(0);
|
||||
assert!((l - r).abs() < 0.1 * l, "simultaneously: both half drawn {l} {r}");
|
||||
let (l, r) = run(1);
|
||||
assert!(l > 250.0 && r < 1.0, "individually: first path complete, second not started {l} {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stroke_paints_gradient_along_stroke() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
|
||||
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_gradient_stroke(ids, false, [-30.0, 0.0], [30.0, 0.0], g, 6.0)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let l = img.get(70, 50);
|
||||
let r = img.get(130, 50);
|
||||
assert!(l[3] > 0.99 && r[3] > 0.99, "{l:?} {r:?}");
|
||||
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
|
||||
assert!(alpha(&img, 100, 50) < 0.01, "stroke only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_blend_mode_multiply() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut top = build::shape_fill(ids, [0.5, 0.5, 0.5, 1.0]);
|
||||
set(
|
||||
&mut top,
|
||||
"blend",
|
||||
Value::Enum(effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32),
|
||||
);
|
||||
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), top, build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0])]
|
||||
});
|
||||
let c = frame(&p, cid).get(100, 50);
|
||||
assert!((c[0] - 0.5).abs() < 0.01 && c[1] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_path_direction_cuts_hole_under_non_zero() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut inner = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
|
||||
set(&mut inner, "direction", Value::Enum(1));
|
||||
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), inner, op(ids, "merge", &[]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 100, 50) < 0.01);
|
||||
assert!(alpha(&img, 75, 50) > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_clip_corner_pixels() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "round", &[("radius", Value::Scalar(20.0))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 81, 31) < 0.01, "corner rounded off");
|
||||
assert!(alpha(&img, 100, 50) > 0.99);
|
||||
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_paths_vary_over_time_deterministically() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]), op(ids, "wiggle", &[("size", Value::Scalar(8.0))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let a = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
|
||||
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
|
||||
let c = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0);
|
||||
assert_eq!(a.data, b.data);
|
||||
assert_ne!(a.data, c.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_and_twist_render() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0),
|
||||
op(ids, "zigzag", &[("size", Value::Scalar(5.0)), ("ridges", Value::Scalar(4.0))]),
|
||||
op(ids, "twist", &[("angle", Value::Scalar(45.0))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 100, 50) > 0.99);
|
||||
let cov = coverage(&img);
|
||||
assert!(cov > 2000.0 && cov < 4600.0, "{cov}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeater_draws_match_per_copy_bounds() {
|
||||
// Many small copies spread over a large buffer: each must rasterise correctly in its own
|
||||
// sub-rectangle.
|
||||
let (p, cid) = shape_comp_sized(400, 400, |ids| {
|
||||
let mut rep = build::shape_repeater(ids, 12.0, [0.0, 0.0]);
|
||||
set(&mut rep, "transform/rotation", Value::Scalar(30.0));
|
||||
let items = vec![build::shape_rect(ids, [10.0, 30.0], [0.0, -150.0], 3.0), build::shape_fill(ids, RED)];
|
||||
let g = build::shape_group(ids, "Tick", items);
|
||||
vec![g, rep]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
for k in 0..12 {
|
||||
let a = (k as f64 * 30.0).to_radians();
|
||||
let (x, y) = (200.0 + 150.0 * a.sin(), 200.0 - 150.0 * a.cos());
|
||||
assert!(alpha(&img, x.round() as i64, y.round() as i64) > 0.99, "copy {k}");
|
||||
}
|
||||
assert!(alpha(&img, 200, 200) < 0.01);
|
||||
let expect = 12.0 * (300.0 - (4.0 - std::f32::consts::PI) * 9.0);
|
||||
assert!((coverage(&img) - expect).abs() < 40.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// Performance probe: 60-copy repeater of a small rounded rect at 1920×1080.
|
||||
/// Run with `cargo test -p effectcraft-render --release -- --ignored --nocapture shape_perf`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn shape_perf_repeater_60_copies_1080p() {
|
||||
let (p, cid) = shape_comp_sized(1920, 1080, |ids| {
|
||||
let mut rep = build::shape_repeater(ids, 60.0, [0.0, 0.0]);
|
||||
set(&mut rep, "transform/rotation", Value::Scalar(6.0));
|
||||
let items = vec![
|
||||
build::shape_rect(ids, [12.0, 60.0], [0.0, -420.0], 4.0),
|
||||
build::shape_fill(ids, [1.0, 0.8, 0.2, 1.0]),
|
||||
build::shape_stroke(ids, [1.0; 4], 2.0),
|
||||
];
|
||||
let g = build::shape_group(ids, "Tick", items);
|
||||
vec![g, rep]
|
||||
});
|
||||
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let n = 10;
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: 60-copy repeater at 1920x1080: {ms:.2} ms/frame");
|
||||
let ItemKind::Comp(comp) = &p.item(cid).unwrap().kind else { unreachable!() };
|
||||
let layer = &comp.layers[0];
|
||||
let ctx = crate::eval::EvalCtx::new(&p, cid, comp, Tick::ZERO);
|
||||
let contents = layer.props.sub("contents").unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = super::render(&ctx, layer, contents, 1.0);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: shapes::render alone: {ms:.2} ms");
|
||||
let mut arena = Vec::new();
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
arena.clear();
|
||||
let _ = super::collect(&ctx, layer, contents, &mut arena);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: collect alone: {ms:.2} ms");
|
||||
let (empty, ecid) = shape_comp_sized(1920, 1080, |_| vec![]);
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = render_frame(&empty, ecid, Tick::ZERO, 1.0);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
|
||||
}
|
||||
Reference in New Issue
Block a user