mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
Merge branch 'main' into worktree-agent-a15cecfec57d5290d
# Conflicts: # crates/effects/src/ocio.rs # crates/engine/src/commands/stubs.rs # docs/parity.md
This commit is contained in:
@@ -1051,13 +1051,51 @@ impl<'a> Renderer<'a> {
|
||||
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
}
|
||||
|
||||
/// Motion-blur sub-samples for a layer (1 = no motion blur).
|
||||
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize {
|
||||
if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
|
||||
if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
|
||||
} else {
|
||||
1
|
||||
/// Whether a layer is motion blurred.
|
||||
fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||||
self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
|
||||
}
|
||||
|
||||
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
|
||||
/// layer gets about one sample per output pixel its corners travel during the shutter,
|
||||
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
|
||||
/// does not move within the shutter is drawn once.
|
||||
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize {
|
||||
if !self.mb_on(ctx, layer) {
|
||||
return 1;
|
||||
}
|
||||
let travel = self.shutter_travel(ctx, layer, buf);
|
||||
if travel < 1e-3 {
|
||||
return 1;
|
||||
}
|
||||
if self.opts.draft {
|
||||
return 4;
|
||||
}
|
||||
adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit)
|
||||
}
|
||||
|
||||
/// Largest distance (output pixels) a corner of the layer buffer moves during the shutter,
|
||||
/// measured along the path at a few points.
|
||||
fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
|
||||
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)];
|
||||
const PROBES: usize = 8;
|
||||
let mut prev: Option<Vec<effectcraft_geom::Vec2>> = None;
|
||||
let mut travel = vec![0.0f64; corners.len()];
|
||||
for i in 0..PROBES {
|
||||
let f = phase + angle * i as f64 / (PROBES - 1) as f64;
|
||||
let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf);
|
||||
let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect();
|
||||
if let Some(p) = &prev {
|
||||
for (k, (a, b)) in p.iter().zip(&pts).enumerate() {
|
||||
travel[k] += (b.x - a.x).hypot(b.y - a.y);
|
||||
}
|
||||
}
|
||||
prev = Some(pts);
|
||||
}
|
||||
travel.into_iter().fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's
|
||||
@@ -1067,7 +1105,7 @@ impl<'a> Renderer<'a> {
|
||||
/// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank
|
||||
/// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise.
|
||||
fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region {
|
||||
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer) > 1 {
|
||||
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer, buf) > 1 {
|
||||
return Region::Full;
|
||||
}
|
||||
let m = self.buf_matrix(ctx, layer, buf);
|
||||
@@ -1102,7 +1140,7 @@ impl<'a> Renderer<'a> {
|
||||
/// How a processed layer buffer lands in the output: one buffer → output matrix, or one per
|
||||
/// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed.
|
||||
pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement {
|
||||
let samples = self.mb_samples(ctx, layer);
|
||||
let samples = self.mb_samples(ctx, layer, buf);
|
||||
let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32);
|
||||
if samples <= 1 {
|
||||
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
|
||||
@@ -1475,6 +1513,14 @@ pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image {
|
||||
}
|
||||
|
||||
/// Convenience: render one frame of a comp with no footage source.
|
||||
/// Adaptive motion-blur sample count for a layer whose corners travel `travel` output pixels
|
||||
/// during the shutter: one sample per pixel, clamped to Samples Per Frame … Adaptive Sample Limit.
|
||||
pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize {
|
||||
let lo = per_frame.clamp(2, 64) as f64;
|
||||
let hi = (limit.clamp(16, 256) as f64).max(lo);
|
||||
travel.ceil().clamp(lo, hi) as usize
|
||||
}
|
||||
|
||||
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
|
||||
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,17 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
|
||||
let path = effectcraft_path::to_kurbo(&sp);
|
||||
let (w, h) = (buf.img.width, buf.img.height);
|
||||
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
|
||||
let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero);
|
||||
let exp = ctx.f(layer, g, "expansion", 0.0);
|
||||
if exp.abs() > 0.01 {
|
||||
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
|
||||
// Variable-width feather (Mask Feather tool points): expansion and the per-point feather
|
||||
// are applied together from the signed distance to the path.
|
||||
let variable = !sp.feather.is_empty();
|
||||
let mut cov = if variable {
|
||||
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
|
||||
} else {
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
|
||||
};
|
||||
if exp.abs() > 0.01 && !variable {
|
||||
let ring = effectcraft_path::stroke_coverage(
|
||||
std::slice::from_ref(&path),
|
||||
&StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() },
|
||||
@@ -71,7 +79,6 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
|
||||
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
|
||||
if feather[0] > 0.0 || feather[1] > 0.0 {
|
||||
let img = cov.to_image();
|
||||
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
|
||||
let b = if linear {
|
||||
// One box pass: a straight ramp `feather` pixels wide across the edge.
|
||||
let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 });
|
||||
|
||||
@@ -12,7 +12,7 @@ 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, ops};
|
||||
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke};
|
||||
use effectcraft_project::{Layer, Node, PropGroup};
|
||||
use effectcraft_raster::{Image, Mask};
|
||||
use rayon::prelude::*;
|
||||
@@ -37,6 +37,8 @@ enum Paint {
|
||||
end: [f64; 2],
|
||||
rule: FillRule,
|
||||
stroke: Option<StrokeStyle>,
|
||||
/// Radial gradients: Highlight Length (−100…100 %) and Highlight Angle (degrees).
|
||||
highlight: (f64, f64),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -102,7 +104,35 @@ 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)))
|
||||
if dash <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut pat = vec![dash, if gap > 0.0 { gap } else { dash }];
|
||||
// Dash 2 / Gap 2, Dash 3 / Gap 3 (added with the Dashes "+" button).
|
||||
for (dm, gm) in effectcraft_project::build::EXTRA_DASHES {
|
||||
if d.get(dm).is_none() {
|
||||
break;
|
||||
}
|
||||
let dv = ctx.f(layer, d, dm, 0.0);
|
||||
pat.push(dv);
|
||||
pat.push(if d.get(gm).is_some() { ctx.f(layer, d, gm, 0.0) } else { dv });
|
||||
}
|
||||
Some((pat, ctx.f(layer, d, "offset", 0.0)))
|
||||
});
|
||||
let taper = g.sub("taper").map(|t| varstroke::Taper {
|
||||
percent: ctx.e(layer, t, "units") == 1,
|
||||
start_len: ctx.f(layer, t, "startLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
|
||||
end_len: ctx.f(layer, t, "endLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
|
||||
start_width: ctx.f(layer, t, "startWidth", 100.0) / 100.0,
|
||||
end_width: ctx.f(layer, t, "endWidth", 100.0) / 100.0,
|
||||
start_ease: ctx.f(layer, t, "startEase", 0.0) / 100.0,
|
||||
end_ease: ctx.f(layer, t, "endEase", 0.0) / 100.0,
|
||||
});
|
||||
let wave = g.sub("wave").map(|w| varstroke::Wave {
|
||||
amount: ctx.f(layer, w, "amount", 0.0) / 100.0,
|
||||
cycles: (ctx.e(layer, w, "units") == 1).then(|| ctx.f(layer, w, "cycles", 10.0)),
|
||||
wavelength: ctx.f(layer, w, "wavelength", 20.0),
|
||||
phase: ctx.f(layer, w, "phase", 0.0),
|
||||
});
|
||||
StrokeStyle {
|
||||
width: ctx.f(layer, g, "width", 2.0),
|
||||
@@ -110,6 +140,8 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
|
||||
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,
|
||||
taper: taper.filter(|t| t.is_active()),
|
||||
wave: wave.filter(|w| w.is_active()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
|
||||
rule: rule(ctx, layer, g),
|
||||
stroke,
|
||||
highlight: (ctx.f(layer, g, "highlightLength", 0.0), ctx.f(layer, g, "highlightAngle", 0.0)),
|
||||
},
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
@@ -359,6 +392,54 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
(draws, live)
|
||||
}
|
||||
|
||||
/// Gradient position of a point: linear along start→end, radial by distance from the start over
|
||||
/// the radius |end − start|. A radial Highlight shifts the focal point towards
|
||||
/// `highlight_length` % of the radius at `highlight_angle` from the start→end axis (a focal
|
||||
/// two-point gradient: t is measured from the focal point to the circle along the ray).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct GradientRamp {
|
||||
radial: bool,
|
||||
s: [f64; 2],
|
||||
d: [f64; 2],
|
||||
len2: f64,
|
||||
focal: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl GradientRamp {
|
||||
pub(crate) fn new(radial: bool, start: [f64; 2], end: [f64; 2], highlight: (f64, f64)) -> Self {
|
||||
let d = [end[0] - start[0], end[1] - start[1]];
|
||||
let len2 = (d[0] * d[0] + d[1] * d[1]).max(1e-9);
|
||||
let (hl, ha) = highlight;
|
||||
let focal = (radial && hl.abs() > 1e-6).then(|| {
|
||||
let r = len2.sqrt();
|
||||
let a = d[1].atan2(d[0]) + ha.to_radians();
|
||||
let dist = r * (hl / 100.0).clamp(-0.99, 0.99);
|
||||
[start[0] + dist * a.cos(), start[1] + dist * a.sin()]
|
||||
});
|
||||
GradientRamp { radial, s: start, d, len2, focal }
|
||||
}
|
||||
|
||||
pub(crate) fn t(&self, x: f64, y: f64) -> f64 {
|
||||
let (vx, vy) = (x - self.s[0], y - self.s[1]);
|
||||
if !self.radial {
|
||||
return (vx * self.d[0] + vy * self.d[1]) / self.len2;
|
||||
}
|
||||
let Some(f) = self.focal else { return ((vx * vx + vy * vy) / self.len2).sqrt() };
|
||||
// Ray from the focal point through p meets the circle (centre s, radius r) at λ.
|
||||
let (px, py) = (x - f[0], y - f[1]);
|
||||
let dist = (px * px + py * py).sqrt();
|
||||
if dist < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let (ux, uy) = (px / dist, py / dist);
|
||||
let (cx, cy) = (f[0] - self.s[0], f[1] - self.s[1]);
|
||||
let b = ux * cx + uy * cy;
|
||||
let c = cx * cx + cy * cy - self.len2;
|
||||
let lambda = -b + (b * b - c).max(0.0).sqrt();
|
||||
if lambda <= 1e-12 { 1.0 } else { dist / lambda }
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct PxRect {
|
||||
@@ -486,16 +567,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
|
||||
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, .. } => {
|
||||
Paint::Gradient { g, radial, start, end, highlight, .. } => {
|
||||
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 ramp = GradientRamp::new(*radial, *start, *end, *highlight);
|
||||
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 };
|
||||
g.sample(t)
|
||||
g.sample(ramp.t(p.x, p.y))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,3 +340,87 @@ fn shape_perf_repeater_60_copies_1080p() {
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
|
||||
}
|
||||
|
||||
fn line_path(ids: &mut Ids, from: [f64; 2], to: [f64; 2]) -> PropGroup {
|
||||
let sp = effectcraft_keyframe::ShapePath {
|
||||
vertices: vec![from, to],
|
||||
in_tangents: vec![[0.0; 2]; 2],
|
||||
out_tangents: vec![[0.0; 2]; 2],
|
||||
closed: false,
|
||||
feather: Vec::new(),
|
||||
};
|
||||
build::shape_path(ids, sp)
|
||||
}
|
||||
|
||||
fn column_alpha(img: &Image, x: i64) -> f32 {
|
||||
(0..img.height as i64).map(|y| alpha(img, x, y)).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_taper_narrows_the_ends() {
|
||||
// A horizontal line from x = 20 to 180 (layer −80…80), 20 px wide, tapered over 40 px at both ends.
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut st = build::shape_stroke(ids, RED, 20.0);
|
||||
set(&mut st, "taper/startLength", Value::Scalar(40.0));
|
||||
set(&mut st, "taper/endLength", Value::Scalar(40.0));
|
||||
set(&mut st, "taper/startWidth", Value::Scalar(0.0));
|
||||
set(&mut st, "taper/endWidth", Value::Scalar(50.0));
|
||||
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let near_start = column_alpha(&img, 30); // 10 px along → 25 % of 20 px
|
||||
let mid = column_alpha(&img, 100);
|
||||
let near_end = column_alpha(&img, 169); // 10 px before the end → (50 % + 50 % × 25 %) of 20 px
|
||||
assert!((near_start - 5.0).abs() < 1.0, "{near_start}");
|
||||
assert!((mid - 20.0).abs() < 0.6, "{mid}");
|
||||
assert!((near_end - 12.5).abs() < 1.0, "{near_end}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_wave_and_multi_dashes() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut st = build::shape_stroke(ids, RED, 10.0);
|
||||
set(&mut st, "wave/amount", Value::Scalar(100.0));
|
||||
set(&mut st, "wave/wavelength", Value::Scalar(40.0));
|
||||
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(column_alpha(&img, 40) < 0.5, "trough (20 px along): {}", column_alpha(&img, 40));
|
||||
assert!(column_alpha(&img, 60) > 9.0, "crest (40 px along): {}", column_alpha(&img, 60));
|
||||
|
||||
// Dash 20, Gap 10, Dash 2 = 5, Gap 2 = 15: on 0–20, 30–35, 50–70…
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut st = build::shape_stroke(ids, RED, 10.0);
|
||||
set(&mut st, "dashes/dash", Value::Scalar(20.0));
|
||||
set(&mut st, "dashes/gap", Value::Scalar(10.0));
|
||||
let d = st.sub_mut("dashes").unwrap();
|
||||
d.children.insert(2, ids.prop("gap2", "Gap 2", Value::Scalar(15.0)).into());
|
||||
d.children.insert(2, ids.prop("dash2", "Dash 2", Value::Scalar(5.0)).into());
|
||||
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let on = |s: i64| column_alpha(&img, 20 + s) > 9.0;
|
||||
assert!(on(10) && !on(25) && on(32) && !on(40) && on(60), "{:?}", (0..80).map(|s| on(s) as u8).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_gradient_highlight_moves_the_focal_point() {
|
||||
let build_with = |hl: f64| {
|
||||
shape_comp_sized(200, 200, move |ids| {
|
||||
let mut gf = build::shape_gradient_fill(ids, true, [0.0, 0.0], [80.0, 0.0], Gradient::default());
|
||||
set(&mut gf, "highlightLength", Value::Scalar(hl));
|
||||
set(&mut gf, "highlightAngle", Value::Scalar(90.0));
|
||||
vec![build::shape_rect(ids, [200.0, 200.0], [0.0, 0.0], 0.0), gf]
|
||||
})
|
||||
};
|
||||
let (p0, c0) = build_with(0.0);
|
||||
let (p1, c1) = build_with(50.0);
|
||||
let (a, b) = (frame(&p0, c0), frame(&p1, c1));
|
||||
// Without a highlight the centre is white; a 50 % highlight at 90° (down the +y axis, 90° from
|
||||
// the start→end axis) puts the white focus 40 px below the centre and darkens the centre.
|
||||
assert!(a.get(100, 100)[0] > 0.98);
|
||||
assert!(b.get(100, 140)[0] > 0.97, "focal point: {:?}", b.get(100, 140));
|
||||
assert!(b.get(100, 100)[0] < 0.8, "{:?}", b.get(100, 100));
|
||||
// The outer circle is unchanged: points just outside it are black in both.
|
||||
assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,33 @@ pub struct CharXf {
|
||||
pub char_range: u32,
|
||||
pub char_align: u32,
|
||||
pub blur: [f64; 2],
|
||||
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
|
||||
pub axes: [([u8; 4], f64); 4],
|
||||
pub n_axes: u8,
|
||||
}
|
||||
|
||||
impl CharXf {
|
||||
/// The variable-font axis offsets that move the outline.
|
||||
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
|
||||
self.axes[..self.n_axes as usize]
|
||||
.iter()
|
||||
.filter(|(_, d)| d.abs() > 1e-6)
|
||||
.map(|(t, d)| (String::from_utf8_lossy(t).trim_end().to_string(), *d as f32))
|
||||
.collect()
|
||||
}
|
||||
fn add_axis(&mut self, tag: [u8; 4], d: f64) {
|
||||
if let Some(a) = self.axes[..self.n_axes as usize].iter_mut().find(|a| a.0 == tag) {
|
||||
a.1 += d;
|
||||
} else if (self.n_axes as usize) < self.axes.len() {
|
||||
self.axes[self.n_axes as usize] = (tag, d);
|
||||
self.n_axes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
|
||||
pub const AXIS_PREFIX: &str = "axis_";
|
||||
|
||||
impl Default for CharXf {
|
||||
fn default() -> Self {
|
||||
CharXf {
|
||||
@@ -86,6 +111,8 @@ impl Default for CharXf {
|
||||
char_range: 0,
|
||||
char_align: 1,
|
||||
blur: [0.0; 2],
|
||||
axes: [([0; 4], 0.0); 4],
|
||||
n_axes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +272,17 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
||||
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
|
||||
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
|
||||
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
|
||||
let axes: Vec<([u8; 4], f64)> = props
|
||||
.props()
|
||||
.filter_map(|p| {
|
||||
let t = p.match_id.strip_prefix(AXIS_PREFIX)?;
|
||||
let mut tag = [b' '; 4];
|
||||
for (i, c) in t.bytes().take(4).enumerate() {
|
||||
tag[i] = c;
|
||||
}
|
||||
Some((tag, ctx.value(layer, p).as_f64()))
|
||||
})
|
||||
.collect();
|
||||
for (gi, k3) in sel.iter().enumerate() {
|
||||
let k = k3[0];
|
||||
if k3.iter().all(|x| *x == 0.0) {
|
||||
@@ -342,6 +380,9 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
||||
c.blur[0] += (b[0] * k3[0]).abs();
|
||||
c.blur[1] += (b[1] * k3[1]).abs();
|
||||
}
|
||||
for (tag, v) in &axes {
|
||||
c.add_axis(*tag, v * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -404,6 +445,11 @@ pub struct PlacedGlyph {
|
||||
pub stroke_width: f64,
|
||||
/// The character's style fills it (Character panel fill on).
|
||||
pub apply_fill: bool,
|
||||
/// Index of the character in the text.
|
||||
pub char_index: usize,
|
||||
/// Where the pivot sits in the unanimated layout (layer space): `m · (p − rest)` carries a
|
||||
/// point of the static layout to where the animation puts this character.
|
||||
pub rest: [f64; 2],
|
||||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
@@ -463,6 +509,30 @@ fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeas
|
||||
Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0)))
|
||||
}
|
||||
|
||||
/// For every caret position `0..=chars` of the layer's text, the map from the static layout
|
||||
/// (layer space, what [`TextLayout::caret`] returns) to where animators and Path Options put
|
||||
/// the characters: the caret follows the character before it (or the first one).
|
||||
pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec<Mat3> {
|
||||
let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] };
|
||||
let mut by_char: Vec<Option<Mat3>> = vec![None; chars + 1];
|
||||
for g in &geom.glyphs {
|
||||
if let Some(slot) = by_char.get_mut(g.char_index) {
|
||||
*slot = Some(g.m2() * Mat3::translate(effectcraft_geom::vec2(-g.rest[0], -g.rest[1])));
|
||||
}
|
||||
}
|
||||
let first = by_char.iter().flatten().next().copied().unwrap_or(Mat3::IDENTITY);
|
||||
let mut out = Vec::with_capacity(chars + 1);
|
||||
let mut last = first;
|
||||
for ci in 0..=chars {
|
||||
// Caret ci sits after character ci − 1.
|
||||
if let Some(Some(m)) = ci.checked_sub(1).map(|p| by_char[p]) {
|
||||
last = m;
|
||||
}
|
||||
out.push(if ci == 0 { by_char[0].unwrap_or(first) } else { last });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lay out and animate a text layer.
|
||||
pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
let doc = source_text(ctx, layer)?;
|
||||
@@ -612,7 +682,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
};
|
||||
(kurbo::Affine::translate((dx, 0.0)) * p, g.advance)
|
||||
}
|
||||
None => (g.path.clone(), g.advance),
|
||||
// Variable Font Axes: the outline redrawn at the animated design-space position.
|
||||
None => match x.axis_deltas() {
|
||||
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance),
|
||||
_ => (g.path.clone(), g.advance),
|
||||
},
|
||||
};
|
||||
if outline.elements().is_empty() {
|
||||
continue;
|
||||
@@ -642,7 +716,18 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
|
||||
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
|
||||
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
|
||||
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill });
|
||||
let rest = [pivot[0] - (o[0] - g.origin.x), pivot[1] - (o[1] - g.origin.y)];
|
||||
glyphs.push(PlacedGlyph {
|
||||
local,
|
||||
m,
|
||||
xf: x,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
|
||||
apply_fill: st.apply_fill,
|
||||
char_index: g.char_index,
|
||||
rest,
|
||||
});
|
||||
}
|
||||
Some(TextGeom { doc, glyphs, fill_stroke, blend })
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ fn anchor_grouping_line_rotates_around_line_centre() {
|
||||
fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) {
|
||||
let mut next = p.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed };
|
||||
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() };
|
||||
let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
let masks = l.props.group_mut("masks").unwrap();
|
||||
@@ -416,3 +416,39 @@ fn keyframed_selector_animates() {
|
||||
assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_follows_animators_and_path_text() {
|
||||
let (mut p, cid, comp) = setup(400, 400);
|
||||
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
|
||||
let a = animator(&mut p, &mut l, &["position"], &["range"]);
|
||||
set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0]));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = ctx(&p, cid, 0.0);
|
||||
let ly = &p.comp(cid).unwrap().layers[0];
|
||||
let lay = layout_doc(&source_text(&c, ly).unwrap());
|
||||
let maps = caret_maps(&c, ly, lay.chars);
|
||||
assert_eq!(maps.len(), 5);
|
||||
for (ci, m) in maps.iter().enumerate() {
|
||||
let (top, _) = lay.caret(ci);
|
||||
let q = m.apply(vec2(top.x, top.y));
|
||||
assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}");
|
||||
}
|
||||
// On a vertical path (x = 100, running down) the caret turns with the characters and sits
|
||||
// on the path.
|
||||
let (mut p, cid, comp) = setup(400, 400);
|
||||
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
|
||||
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
|
||||
set(&mut l, "text/pathOptions/path", Value::Enum(1));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = ctx(&p, cid, 0.0);
|
||||
let ly = &p.comp(cid).unwrap().layers[0];
|
||||
let lay = layout_doc(&source_text(&c, ly).unwrap());
|
||||
let maps = caret_maps(&c, ly, lay.chars);
|
||||
let (t, b) = lay.caret(2);
|
||||
let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y)));
|
||||
assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}");
|
||||
assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}");
|
||||
let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::<Vec<_>>();
|
||||
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ fn depth_of_field_blurs_out_of_focus_surfaces() {
|
||||
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
|
||||
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
|
||||
};
|
||||
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 };
|
||||
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
|
||||
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
|
||||
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
|
||||
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Classic 3D depth of field: bokeh-shaped gather blur.
|
||||
//!
|
||||
//! The camera's Iris Shape (Fast Rectangle, or a regular polygon from Triangle to Decagon),
|
||||
//! Iris Rotation, Roundness and Aspect Ratio define the blur kernel; Iris Diffraction Fringe
|
||||
//! brightens its rim. Highlight Gain / Threshold / Saturation boost the bright parts of the layer
|
||||
//! before blurring so they bloom into visible bokeh shapes. A layer whose depth varies across it
|
||||
//! (tilted towards or away from the camera) is blurred progressively: the buffer is blurred at a
|
||||
//! few kernel sizes and every pixel blends the two that bracket its own circle of confusion.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Camera iris (Camera Options ▸ Iris …).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Iris {
|
||||
/// 0 = Fast Rectangle, else the number of sides (3–10).
|
||||
pub sides: u32,
|
||||
/// Degrees.
|
||||
pub rotation: f64,
|
||||
/// −1…1: 1 = circular, negative pinches the sides inwards.
|
||||
pub roundness: f64,
|
||||
/// Width / height of the aperture.
|
||||
pub aspect: f64,
|
||||
/// Rim brightness: 0 = none, 1 = the rim is twice as bright (Iris Diffraction Fringe / 100).
|
||||
pub fringe: f64,
|
||||
}
|
||||
|
||||
impl Default for Iris {
|
||||
fn default() -> Self {
|
||||
Iris { sides: 0, rotation: 0.0, roundness: 0.0, aspect: 1.0, fringe: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlight boost before the blur (Camera Options ▸ Highlight …), all 0–1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Highlight {
|
||||
pub gain: f64,
|
||||
pub threshold: f64,
|
||||
pub saturation: f64,
|
||||
}
|
||||
|
||||
impl Iris {
|
||||
/// Iris Shape popup index → sides (0 Fast Rectangle, 1 Triangle … 8 Decagon).
|
||||
pub fn sides_of_shape(shape: u32) -> u32 {
|
||||
if shape == 0 { 0 } else { (shape + 2).min(10) }
|
||||
}
|
||||
|
||||
/// Normalised boundary distance (1 = circumscribed circle) of the iris at angle `a`.
|
||||
fn boundary(&self, a: f64) -> f64 {
|
||||
let n = self.sides.max(3) as f64;
|
||||
let seg = std::f64::consts::TAU / n;
|
||||
let rel = (a - self.rotation.to_radians()).rem_euclid(seg) - seg * 0.5;
|
||||
let poly = (std::f64::consts::PI / n).cos() / rel.cos();
|
||||
let r = self.roundness.clamp(-1.0, 1.0);
|
||||
// Positive: towards the circumscribed circle; negative: the sides bow inwards (the
|
||||
// vertices stay put, the edge midpoints move in).
|
||||
if r >= 0.0 { poly + (1.0 - poly) * r } else { (poly + (1.0 - poly) * r * 2.0).max(0.05) }
|
||||
}
|
||||
|
||||
/// Whether the kernel offset (dx, dy) lies inside an iris of radius `r`.
|
||||
fn inside(&self, r: f64, dx: f64, dy: f64) -> bool {
|
||||
let a = self.aspect.clamp(0.01, 100.0).sqrt();
|
||||
let (u, v) = (dx / a, dy * a);
|
||||
let d = u.hypot(v);
|
||||
d < 1e-9 || d <= self.boundary(v.atan2(u)) * r
|
||||
}
|
||||
|
||||
/// The kernel as horizontal spans: for every row offset `dy`, the (fractional) x extent
|
||||
/// `[x0, x1]` of the iris of radius `r` at that height. The iris is convex for roundness ≥ 0;
|
||||
/// a pinched iris keeps its outermost extent per row.
|
||||
pub fn spans(&self, r: f64) -> Vec<(i32, f64, f64)> {
|
||||
let a = self.aspect.clamp(0.01, 100.0).sqrt();
|
||||
let (ext_x, ext_y) = (r * a + 1.0, (r / a).ceil() as i32 + 1);
|
||||
let mut out = Vec::new();
|
||||
for dy in -ext_y..=ext_y {
|
||||
let y = dy as f64;
|
||||
// Coarse scan, then bisection of both ends.
|
||||
let step = 0.5;
|
||||
let mut first = None;
|
||||
let mut last = None;
|
||||
let mut x = -ext_x;
|
||||
while x <= ext_x {
|
||||
if self.inside(r, x, y) {
|
||||
first.get_or_insert(x);
|
||||
last = Some(x);
|
||||
}
|
||||
x += step;
|
||||
}
|
||||
let (Some(f), Some(l)) = (first, last) else { continue };
|
||||
let refine = |inside_x: f64, outside_x: f64| {
|
||||
let (mut i, mut o) = (inside_x, outside_x);
|
||||
for _ in 0..12 {
|
||||
let m = (i + o) * 0.5;
|
||||
if self.inside(r, m, y) { i = m } else { o = m }
|
||||
}
|
||||
(i + o) * 0.5
|
||||
};
|
||||
out.push((dy, refine(f, f - step), refine(l, l + step)));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix sums of each row (per channel): `p[x]` = sum of pixels `0..x`.
|
||||
fn row_prefix(img: &Image) -> Vec<Vec<[f32; 4]>> {
|
||||
let w = img.width as usize;
|
||||
img.data
|
||||
.par_chunks(w.max(1))
|
||||
.map(|row| {
|
||||
let mut acc = [0.0f32; 4];
|
||||
let mut v = Vec::with_capacity(w + 1);
|
||||
v.push(acc);
|
||||
for p in row {
|
||||
for c in 0..4 {
|
||||
acc[c] += p[c];
|
||||
}
|
||||
v.push(acc);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Continuous prefix: sum over [0, t) with linear interpolation inside a pixel.
|
||||
#[inline]
|
||||
fn prefix_at(p: &[[f32; 4]], t: f64) -> [f32; 4] {
|
||||
let n = p.len() - 1;
|
||||
if t <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
if t >= n as f64 {
|
||||
return p[n];
|
||||
}
|
||||
let i = t.floor() as usize;
|
||||
let f = (t - i as f64) as f32;
|
||||
let (a, b) = (p[i], p[i + 1]);
|
||||
[a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f]
|
||||
}
|
||||
|
||||
/// Gather with span kernels: `(weight, spans)` pairs summed, then normalised by `norm`.
|
||||
fn span_gather(img: &Image, kernels: &[(f32, Vec<(i32, f64, f64)>)], norm: f32) -> Image {
|
||||
let pre = row_prefix(img);
|
||||
let (w, h) = (img.width as usize, img.height as i64);
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
let inv = 1.0 / norm;
|
||||
out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let mut acc = [0.0f32; 4];
|
||||
for (wt, spans) in kernels {
|
||||
for &(dy, x0, x1) in spans {
|
||||
let sy = y as i64 + dy as i64;
|
||||
if sy < 0 || sy >= h {
|
||||
continue;
|
||||
}
|
||||
let pr = &pre[sy as usize];
|
||||
let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5));
|
||||
for c in 0..4 {
|
||||
acc[c] += (hi[c] - lo[c]) * wt;
|
||||
}
|
||||
}
|
||||
}
|
||||
*o = acc.map(|v| v * inv);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Boost the highlights of a premultiplied image in place.
|
||||
pub fn boost_highlights(img: &mut Image, h: &Highlight) {
|
||||
if h.gain <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let thr = h.threshold.clamp(0.0, 1.0) as f32;
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
if p[3] <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let a = p[3];
|
||||
let (r, g, b) = (p[0] / a, p[1] / a, p[2] / a);
|
||||
let l = effectcraft_color::luminance(r, g, b);
|
||||
if l < thr || (thr >= 1.0 && l < 1.0) {
|
||||
return;
|
||||
}
|
||||
let over = if thr < 1.0 { ((l - thr) / (1.0 - thr)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let k = 1.0 + h.gain as f32 * 4.0 * over.max(0.25);
|
||||
let sat = 1.0 + h.saturation as f32;
|
||||
let c = [r, g, b].map(|c| (l + (c - l) * sat).max(0.0) * k);
|
||||
*p = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
});
|
||||
}
|
||||
|
||||
/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels
|
||||
/// beyond the image are transparent.
|
||||
pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
|
||||
if r < 0.3 {
|
||||
return img.clone();
|
||||
}
|
||||
if iris.sides == 0 {
|
||||
// Fast Rectangle: a separable box of the iris' width and height.
|
||||
let a = iris.aspect.clamp(0.01, 100.0).sqrt();
|
||||
let rx = (r * a).round().max(0.0) as usize;
|
||||
let ry = (r / a).round().max(0.0) as usize;
|
||||
return effectcraft_raster::box_blur(img, rx, ry, 1, false);
|
||||
}
|
||||
// The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The
|
||||
// diffraction fringe adds the rim: the full iris minus a shrunken one.
|
||||
let outer = iris.spans(r);
|
||||
let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::<f32>();
|
||||
let mut kernels = vec![(1.0f32, outer)];
|
||||
if iris.fringe > 0.0 {
|
||||
let rim = (r * 0.15).max(1.0);
|
||||
let inner = if r - rim > 0.5 { iris.spans(r - rim) } else { Vec::new() };
|
||||
let f = iris.fringe as f32;
|
||||
kernels[0].0 = 1.0 + f;
|
||||
kernels.push((-f, inner));
|
||||
}
|
||||
let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum();
|
||||
if norm <= 0.0 {
|
||||
return img.clone();
|
||||
}
|
||||
span_gather(img, &kernels, norm)
|
||||
}
|
||||
|
||||
/// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to
|
||||
/// eight radii spanning the range and each pixel interpolates between the two levels around
|
||||
/// its own radius.
|
||||
pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image {
|
||||
let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r)));
|
||||
if !lo.is_finite() || hi < 0.3 {
|
||||
return img.clone();
|
||||
}
|
||||
if hi - lo < 0.75 {
|
||||
return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64);
|
||||
}
|
||||
let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8);
|
||||
let levels: Vec<f32> = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect();
|
||||
let blurred: Vec<Image> = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect();
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let r = radius[i];
|
||||
let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
|
||||
let k = (f.floor() as usize).min(n - 2);
|
||||
let t = f - k as f32;
|
||||
let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]);
|
||||
*o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A single bright point in the middle of a transparent image.
|
||||
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
|
||||
}
|
||||
|
||||
fn lit(img: &Image, x: i32, y: i32) -> bool {
|
||||
let c = img.width as i32 / 2;
|
||||
img.data[((c + y) * img.width as i32 + c + x) as usize][3] > 1e-4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_shapes() {
|
||||
// A hexagon with a vertex on +x (rotation 0): the point at 0.97 r on +x is inside, the
|
||||
// point at 0.97 r at 30° (an edge midpoint at cos 30° = 0.866 r) is outside.
|
||||
let hex = Iris { sides: 6, ..Default::default() };
|
||||
let b = bokeh_blur(&dot(81), &hex, 20.0);
|
||||
assert!(lit(&b, 19, 0));
|
||||
let (x, y) = ((19.4f64 * 30f64.to_radians().cos()).round() as i32, (19.4f64 * 30f64.to_radians().sin()).round() as i32);
|
||||
assert!(!lit(&b, x, y), "edge midpoint beyond the apothem");
|
||||
// Rotating by 30° swaps them.
|
||||
let rot = Iris { rotation: 30.0, ..hex };
|
||||
let b = bokeh_blur(&dot(81), &rot, 20.0);
|
||||
assert!(!lit(&b, 19, 0) && lit(&b, x, y));
|
||||
// Full roundness: a circle.
|
||||
let round = Iris { roundness: 1.0, ..hex };
|
||||
let b = bokeh_blur(&dot(81), &round, 20.0);
|
||||
assert!(lit(&b, 19, 0) && lit(&b, x, y));
|
||||
// Triangle: far less area than a decagon of the same radius.
|
||||
let area = |sides| {
|
||||
let b = bokeh_blur(&dot(81), &Iris { sides, ..Default::default() }, 20.0);
|
||||
b.data.iter().filter(|p| p[3] > 1e-4).count()
|
||||
};
|
||||
assert!(area(3) * 2 < area(10), "{} vs {}", area(3), area(10));
|
||||
// Aspect ratio 4 makes it twice as wide and half as tall.
|
||||
let wide = Iris { sides: 8, aspect: 4.0, ..Default::default() };
|
||||
let b = bokeh_blur(&dot(121), &wide, 15.0);
|
||||
assert!(lit(&b, 28, 0) && !lit(&b, 0, 10));
|
||||
// Energy is preserved.
|
||||
let sum: f32 = b.data.iter().map(|p| p[3]).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-3, "{sum}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diffraction_fringe_brightens_the_rim() {
|
||||
let plain = bokeh_blur(&dot(81), &Iris { sides: 8, ..Default::default() }, 20.0);
|
||||
let fringe = bokeh_blur(&dot(81), &Iris { sides: 8, fringe: 2.0, ..Default::default() }, 20.0);
|
||||
let at = |img: &Image, x: i32| img.data[(40 * 81 + 40 + x) as usize][3];
|
||||
assert!((at(&plain, 0) - at(&plain, 17)).abs() < 1e-6, "flat disc");
|
||||
assert!(at(&fringe, 18) > at(&fringe, 0) * 1.5, "{} vs {}", at(&fringe, 18), at(&fringe, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_boost_only_above_threshold() {
|
||||
let mut img = Image::new(2, 1);
|
||||
img.data = vec![[0.9, 0.9, 0.9, 1.0], [0.3, 0.3, 0.3, 1.0]];
|
||||
boost_highlights(&mut img, &Highlight { gain: 0.5, threshold: 0.8, saturation: 0.0 });
|
||||
assert!(img.data[0][0] > 1.2 && img.data[1][0] == 0.3);
|
||||
let mut c = Image::new(1, 1);
|
||||
c.data = vec![[1.0, 0.8, 0.8, 1.0]];
|
||||
boost_highlights(&mut c, &Highlight { gain: 0.1, threshold: 0.5, saturation: 1.0 });
|
||||
assert!(c.data[0][0] - c.data[0][1] > 0.3, "more saturated: {:?}", c.data[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_blur_follows_the_radius_map() {
|
||||
// A vertical line, blurred 0 px at the top rising to 8 px at the bottom.
|
||||
let (w, h) = (41u32, 41u32);
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h as usize {
|
||||
img.data[y * w as usize + 20] = [1.0; 4];
|
||||
}
|
||||
let radius: Vec<f32> = (0..(w * h) as usize).map(|i| (i / w as usize) as f32 / 40.0 * 8.0).collect();
|
||||
let out = progressive_blur(&img, &Iris { sides: 6, roundness: 1.0, ..Default::default() }, &radius);
|
||||
let spread = |y: usize| (0..w as usize).filter(|&x| out.data[y * w as usize + x][3] > 0.03).count();
|
||||
assert!(spread(1) <= 3, "top stays sharp: {}", spread(1));
|
||||
assert!(spread(39) >= 13, "bottom is wide: {}", spread(39));
|
||||
assert!(spread(20) > spread(1) && spread(20) < spread(39));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ pub struct Dof {
|
||||
pub aperture: f64,
|
||||
/// Blur Level (1.0 = 100%).
|
||||
pub blur_level: f64,
|
||||
/// Iris Shape, Rotation, Roundness, Aspect Ratio and Diffraction Fringe.
|
||||
#[serde(default)]
|
||||
pub iris: super::bokeh::Iris,
|
||||
/// Highlight Gain, Threshold and Saturation.
|
||||
#[serde(default)]
|
||||
pub highlight: super::bokeh::Highlight,
|
||||
}
|
||||
|
||||
impl Dof {
|
||||
@@ -294,6 +300,18 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState {
|
||||
focus: ctx.f(cam, g, "focusDistance", zoom),
|
||||
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
|
||||
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
|
||||
iris: super::bokeh::Iris {
|
||||
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
|
||||
rotation: ctx.f(cam, g, "irisRotation", 0.0),
|
||||
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
|
||||
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
|
||||
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
|
||||
},
|
||||
highlight: super::bokeh::Highlight {
|
||||
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
|
||||
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
},
|
||||
});
|
||||
CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof }
|
||||
}
|
||||
|
||||
@@ -206,26 +206,10 @@ fn prepare_with<'a>(
|
||||
let cam = camera_for(r, ctx);
|
||||
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
|
||||
let world = outer * ctx.world_matrix(layer) * local;
|
||||
// Depth of field: blur the layer by its circle of confusion at its centre.
|
||||
// Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a
|
||||
// layer facing the camera, progressive for a tilted one).
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
let b = buf_bounds(&buf);
|
||||
let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0));
|
||||
let z = cam.depth(centre);
|
||||
let coc = dof.coc(z);
|
||||
// Comp pixels on screen per layer pixel at that depth.
|
||||
let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
|
||||
let radius = coc * 0.5 / k * buf.scale;
|
||||
if radius > 0.3 {
|
||||
let sigma = radius / 1.5;
|
||||
let pad = (sigma * 3.0).ceil() as u32 + 1;
|
||||
let padded = buf.img.padded(pad);
|
||||
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
|
||||
buf = Arc::new(Buf {
|
||||
img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false),
|
||||
offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64],
|
||||
scale: buf.scale,
|
||||
});
|
||||
}
|
||||
buf = dof_blur(&cam, &dof, world, buf);
|
||||
}
|
||||
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
|
||||
let geos: Vec<Geo> = if n <= 1 {
|
||||
@@ -269,6 +253,47 @@ fn prepare_with<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from
|
||||
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there.
|
||||
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec<f32> {
|
||||
let s = buf.scale.max(1e-9);
|
||||
let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
|
||||
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
|
||||
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0)));
|
||||
// Camera depth is affine across a plane.
|
||||
let z0 = at(0.5, 0.5);
|
||||
let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0);
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
out.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let z = z0 + zx * x as f64 + zy * y as f64;
|
||||
if z <= super::camera::NEAR {
|
||||
continue;
|
||||
}
|
||||
// coc · ½ / (zoom / z · sx) · scale
|
||||
*o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth.
|
||||
fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<Buf>) -> Arc<Buf> {
|
||||
let radii = dof_radii(cam, dof, world, &buf);
|
||||
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
|
||||
if rmax <= 0.3 {
|
||||
return buf;
|
||||
}
|
||||
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
|
||||
let pad = (rmax * a).ceil() as u32 + 2;
|
||||
let mut padded = buf.img.padded(pad);
|
||||
super::bokeh::boost_highlights(&mut padded, &dof.highlight);
|
||||
let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale };
|
||||
let pr = dof_radii(cam, dof, world, &pbuf);
|
||||
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
|
||||
Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale })
|
||||
}
|
||||
|
||||
/// Screen-space track matte factor for a layer, or None.
|
||||
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
|
||||
let tm = layer.track_matte?;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! groups, as in After Effects).
|
||||
|
||||
pub mod adv;
|
||||
pub mod bokeh;
|
||||
pub mod camera;
|
||||
pub(crate) mod compose;
|
||||
pub mod light;
|
||||
|
||||
@@ -423,6 +423,63 @@ fn depth_of_field_blurs_out_of_focus_layers() {
|
||||
assert!(b.get(66, 50)[3] > 0.01);
|
||||
}
|
||||
|
||||
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
|
||||
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
|
||||
(0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilted_layer_blurs_progressively_with_depth() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(80.0));
|
||||
set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]);
|
||||
set(&mut s, "transform/rotationY", Value::Scalar(60.0));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let img = render(&p, cid);
|
||||
// The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of
|
||||
// and behind it: soft.
|
||||
let xs: Vec<i64> = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect();
|
||||
let (x0, x1) = (xs[0], *xs.last().unwrap());
|
||||
let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64;
|
||||
let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9)));
|
||||
assert!(centre <= 2, "centre {centre}");
|
||||
assert!(left >= 4 && right >= 4, "ends {left} {right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_shape_and_highlights_shape_the_bokeh() {
|
||||
// A small bright square far out of focus: its blur takes the iris' shape.
|
||||
let spot = |shape: u32, gain: f64| {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(120.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
|
||||
set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape));
|
||||
set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain));
|
||||
set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]);
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
render(&p, cid)
|
||||
};
|
||||
let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count();
|
||||
let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0));
|
||||
assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec));
|
||||
// Fast Rectangle: a box.
|
||||
let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x)));
|
||||
let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64;
|
||||
assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent);
|
||||
// Highlight Gain brightens the blurred highlight.
|
||||
let lit = spot(8, 100.0);
|
||||
let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::<f32>();
|
||||
assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user