Merge pull request #1956 from ded-achtung/g5-near-camera-1388

#1388: 3D text and shapes rasterise only the part the frame shows
This commit is contained in:
FredApps
2026-11-09 23:21:49 +01:00
committed by GitHub
10 changed files with 584 additions and 16 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Opti
r.reads = Some(&log);
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
let out = if post {
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
r.layer_content(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
} else {
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
};
+26
View File
@@ -2070,3 +2070,29 @@ fn bench_evaluation() {
println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})");
}
}
/// Viewport clipping must never affect layer-space expression reads (#1388).
#[test]
fn sample_image_reads_3d_shape_behind_camera_before_and_after_effects() {
let mut p = Project::default();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (100, 100), None);
let mut next = p.next_id;
let mut ids = build::Ids(&mut next);
let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let group = build::shape_group(&mut ids, "Box", vec![rect, fill]);
layer.props.sub_mut("contents").unwrap().children.push(group.into());
p.next_id = next;
layer.switches.three_d = true;
layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, -300.0]);
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.comp_mut(cid).unwrap().layers.push(layer);
let comp = p.comp(cid).unwrap();
let layer = &comp.layers[0];
let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO);
for post in [false, true] {
let pixel = crate::sample::sample_image(ctx, cid, layer, [0.0, 0.0], [0.5, 0.5], post, 0.0).unwrap();
assert!(pixel[0] > 0.99 && pixel[3] > 0.99, "postEffect={post}: {pixel:?}");
}
}
+1 -1
View File
@@ -374,7 +374,7 @@ impl<'a> Renderer<'a> {
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
// Cached, so an accelerator sees the same buffer (and uploads it once) every frame.
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
let key = self.mask_gate_key(ctx, layer, key);
let key = self.mask_gate_key(ctx, layer, self.clipped_key(ctx, layer, key));
if let Some((b, _)) = self.cached(key) {
return Some(b);
}
+2
View File
@@ -102,6 +102,8 @@ pub struct Renderer<'a> {
through: [Option<Through<'a>>; MAX_THROUGH],
/// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred.
through_lost: bool,
/// Layer-space consumers must not use the viewport raster optimization.
pub(crate) whole_raster: bool,
}
#[cfg(test)]
+91 -8
View File
@@ -141,6 +141,7 @@ impl<'a> Renderer<'a> {
top: true,
through: [None; MAX_THROUGH],
through_lost: false,
whole_raster: false,
}
}
@@ -276,6 +277,8 @@ impl<'a> Renderer<'a> {
top: false,
through: [None; MAX_THROUGH],
through_lost: false,
// Effects and nested renders need whole layer-space pixels.
whole_raster: true,
..*self
}
}
@@ -416,8 +419,9 @@ impl<'a> Renderer<'a> {
}
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
let prev = ctx.time - ctx.comp.frame_rate.frame_duration();
let now = cache::layer_key(ctx, layer, scale, draft, blur);
now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur)
let before = ctx.at(prev);
let now = self.clipped_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
now.is_some() && now == self.clipped_key(&before, layer, cache::layer_key(&before, layer, scale, draft, blur))
}
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
@@ -451,14 +455,73 @@ impl<'a> Renderer<'a> {
// a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390:
// rounded up to 2^¼, unscaled text was drawn larger and resampled, softer).
let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
// Keep the buffer within about 16 million pixels.
// Keep the buffer within about 16 million pixels (of the part the output shows, when
// only that is rasterised: see `raster_clip`).
if let Some(b) = content_bounds(ctx, layer) {
let b = match self.raster_clip(ctx, layer) {
Some(c) => {
let r = kurbo::Rect::new(b[0], b[1], b[2], b[3]).intersect(c);
[r.x0, r.y0, r.x1, r.y1]
}
None => b,
};
let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s;
k = k.min((16.0e6 / area).sqrt().max(1.0));
}
s * k
}
/// The layer-space part of a 3D text or shape layer the output shows, when only that part
/// needs rasterising (#1388: text close to the camera was rasterised whole at up to 16
/// million pixels, a quarter gigabyte per frame worker, though the frame showed a fraction
/// of it; behind the camera it was rasterised and not drawn). `None` rasterises it whole:
/// whenever pixels outside the frame can matter — effects, layer styles, masks, motion blur,
/// depth of field, lights (shadows and transmission), Advanced 3D, per-character 3D, a
/// collapsed 2D transform — or the visible part can't be bounded (see
/// [`three_d::compose::visible_clip`]).
pub(crate) fn raster_clip(&self, ctx: &EvalCtx, layer: &Layer) -> Option<kurbo::Rect> {
if self.whole_raster || !layer.is_3d() || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) || self.outer.is_some() {
return None;
}
if (layer.switches.effects && layer.effects().is_some_and(|fx| !fx.children.is_empty()))
|| layer.masks().is_some_and(|m| !m.children.is_empty())
|| styles::active(ctx, layer)
|| ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. }))
|| self.is_adv3d(ctx)
|| (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer)))
{
return None;
}
let v = three_d::compose::visible_clip(self, ctx, layer)?;
if v.area() <= 0.0 {
// Behind the camera.
return Some(v);
}
let b = content_bounds(ctx, layer).map(|b| kurbo::Rect::new(b[0], b[1], b[2], b[3]))?;
if v.x0 <= b.x0 && v.y0 <= b.y0 && v.x1 >= b.x1 && v.y1 >= b.y1 {
return None;
}
// Whole cells of a layer-space grid whose cell is about a quarter of the part that shows,
// rounded down to a power of two: a moving camera keeps one raster (and its cache entry)
// while that part stays within the same cells, and a dolly changes the grid only at each
// doubling. The cells cover at most about 2.25 times the part that shows. Worth it only
// when it leaves out at least half of the content.
let c = 2f64.powf(((v.width().max(v.height()) / 4.0).max(1e-6)).log2().floor());
let snap = |v: f64, up: bool| if up { (v / c).ceil() * c } else { (v / c).floor() * c };
// (Not cut to the content bounds: a stroke or a glyph's padding reaches past them.)
let cells = kurbo::Rect::new(snap(v.x0, false), snap(v.y0, false), snap(v.x1, true), snap(v.y1, true));
(cells.is_finite() && cells.intersect(b).area() <= b.area() * 0.5).then_some(cells)
}
/// A layer content cache key that also names the part rasterised ([`Self::raster_clip`]).
pub(crate) fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
let k = key?;
Some(match self.raster_clip(ctx, layer) {
Some(c) => raster_clip_key(k, c),
None => k,
})
}
/// Draw a Wireframe-quality layer: its bounds as a one-pixel outline.
pub(crate) fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) {
@@ -910,9 +973,12 @@ impl<'a> Renderer<'a> {
{
log.mark_unkeyed();
}
Some(self.authored(text::render(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer))))
Some(self.authored(text::render_in(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer))))
}
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
LayerSource::Shape => layer
.props
.sub("contents")
.map(|c| self.authored(shapes::render_in(ctx, layer, c, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))),
_ => None,
}
}
@@ -1149,7 +1215,13 @@ impl<'a> Renderer<'a> {
/// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers
/// without pixels.
pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
self.source(ctx, layer)
Renderer { whole_raster: true, ..*self }.source(ctx, layer)
}
/// Layer-space pixels after masks and effects, including content outside the viewport.
/// Expression sampleImage reads this rather than the frame-oriented clipped buffer.
pub fn layer_content(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
Renderer { whole_raster: true, ..*self }.content_buf(ctx, layer)
}
/// A 3D model or primitive layer rendered on its own, through the comp's camera: what an
@@ -1189,7 +1261,7 @@ impl<'a> Renderer<'a> {
mut timing: Option<&mut LayerTiming>,
) -> Option<(Arc<Buf>, Option<u64>, Arc<ReadFiles>)> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
let key = self.content_key(ctx, layer, key);
let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key));
if let Some((b, files)) = self.cached(key) {
if let Some(t) = timing.as_deref_mut() {
t.cached = true;
@@ -1211,8 +1283,13 @@ impl<'a> Renderer<'a> {
/// what effect `effects` sees. Served from / stored in the layer cache under its own key,
/// so scrubbing reuses frames rendered for earlier output frames.
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
// The Layer panel, analysis tools and effect inputs read the whole layer, not the viewport.
Renderer { whole_raster: true, ..*self }.whole_layer_input(ctx, layer, effects)
}
fn whole_layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
let key = self.content_key(ctx, layer, key);
let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key));
if let Some((b, _)) = self.cached(key) {
return Some(b);
}
@@ -1229,3 +1306,9 @@ impl<'a> Renderer<'a> {
Some(self.keep(key, Arc::new(buf?), &files).0)
}
}
/// Snap cells can be finer than 1/16 unit: preserve every coordinate bit so visible
/// clips cannot collide with another cell or the empty behind-camera raster.
pub(crate) fn raster_clip_key(key: u64, clip: kurbo::Rect) -> u64 {
[clip.x0, clip.y0, clip.x1, clip.y1].iter().fold(cache::derive(key, 0xc11b), |key, coordinate| cache::derive(key, coordinate.to_bits()))
}
+7 -3
View File
@@ -686,6 +686,12 @@ const BATCH_PIXELS: usize = 8 << 20;
/// 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 {
render_in(ctx, layer, contents, s, None)
}
/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows,
/// see `Renderer::raster_clip`).
pub fn render_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64, clip: Option<kurbo::Rect>) -> Buf {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
// Bottom-most draw first.
@@ -702,9 +708,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
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 Some((w, h, offset)) = crate::text::raster_window(b, s, clip) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
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);
+1
View File
@@ -22,6 +22,7 @@ mod motion_tile;
mod nested;
mod paint;
mod proxy_cache;
mod raster_clip;
mod remap;
mod roi;
mod stretch_limits;
+360
View File
@@ -0,0 +1,360 @@
//! #1388: 3D text and shape layers rasterise only the part of the layer the output shows. Where
//! the whole raster fits its pixel budget the frame is identical to rasterising the whole layer;
//! close to the camera the raster stays small instead of growing to ~16 million pixels.
use effectcraft_color::Label;
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
const W: u32 = 400;
const H: u32 = 300;
/// The default camera's distance to the z = 0 plane.
fn zoom() -> f64 {
W as f64 * 50.0 / 36.0
}
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
/// A 3D text layer at `pos`, turned `rot_y` degrees about Y.
fn text3(p: &mut Project, comp: &Comp, pos: [f64; 3], rot_y: f64) -> Layer {
let mut l = build::layer(p, comp, "Text", LayerSource::Text, (W, H), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA TEXT".into(), size: 60.0, ..Default::default() }));
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
set(&mut l, "transform/rotationY", Value::Scalar(rot_y));
l
}
/// A 3D shape layer (a 300×120 rectangle with a stroke) at `pos`.
fn shape3(p: &mut Project, comp: &Comp, pos: [f64; 3]) -> Layer {
let mut l = build::layer(p, comp, "Shape", LayerSource::Shape, (W, H), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let r = build::shape_rect(&mut ids, [300.0, 120.0], [0.0, 0.0], 0.0);
let f = build::shape_fill(&mut ids, [0.9, 0.4, 0.1, 1.0]);
let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 6.0);
let g = build::shape_group(&mut ids, "Box", vec![r, f, s]);
l.props.sub_mut("contents").unwrap().children.push(g.into());
}
p.next_id = next;
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
l
}
fn scene(l: impl FnOnce(&mut Project, &Comp) -> Layer) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let layer = l(&mut p, &comp);
p.comp_mut(cid).unwrap().layers.push(layer);
(p, cid)
}
fn frame(p: &Project, cid: ItemId, whole: bool) -> effectcraft_raster::Image {
let mut r = Renderer::new(p, &NoFootage, RenderOpts::default());
r.whole_raster = whole;
r.comp_frame(cid, Tick::ZERO)
}
/// Pixels of the layer's processed buffer.
fn raster_px(p: &Project, cid: ItemId, whole: bool) -> u64 {
let mut r = Renderer::new(p, &NoFootage, RenderOpts::default());
r.whole_raster = whole;
let c = p.comp(cid).unwrap();
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let b = r.layer_buf(&ctx, &c.layers[0]).unwrap();
b.img.width as u64 * b.img.height as u64
}
#[test]
fn the_visible_part_renders_the_same_frame_as_the_whole_layer() {
let cx = W as f64 / 2.0;
let cy = H as f64 / 2.0;
let cases: Vec<(&str, (Project, ItemId))> = vec![
("far", scene(|p, c| text3(p, c, [cx, cy, 300.0], 0.0))),
("in the frame plane", scene(|p, c| text3(p, c, [cx, cy, 0.0], 0.0))),
("past the frame edge", scene(|p, c| text3(p, c, [60.0, cy, -200.0], 0.0))),
("turned", scene(|p, c| text3(p, c, [cx, cy, -150.0], 55.0))),
("close to the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 40.0], 0.0))),
("partly behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 30.0], 80.0))),
("behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() - 200.0], 0.0))),
("shape", scene(|p, c| shape3(p, c, [cx + 150.0, cy, -250.0]))),
];
for (label, (p, cid)) in &cases {
let clipped = frame(p, *cid, false);
let whole = frame(p, *cid, true);
assert_eq!(clipped.data, whole.data, "{label}");
}
}
/// Close to the camera only part of the text shows: its raster is a fraction of the whole one
/// (millions of pixels), and the frame still shows text.
#[test]
fn text_close_to_the_camera_rasterises_what_shows() {
let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let clipped = raster_px(&p, cid, false);
let whole = raster_px(&p, cid, true);
assert!(whole > 5_000_000, "the whole raster is huge ({whole} px)");
eprintln!("near camera: {clipped} px rasterised, {whole} px whole");
assert!(clipped * 10 < whole, "{clipped} px of {whole}");
let img = frame(&p, cid, false);
let covered = img.data.iter().filter(|px| px[3] > 0.5).count();
assert!(covered > (W * H / 20) as usize, "text shows ({covered} px)");
// Behind the camera there is nothing to rasterise.
let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0], 0.0));
assert!(raster_px(&p, cid, false) <= 1);
}
/// The #1388 case at full size: 200 pt text in a 1920 × 1080 comp, close to the camera. Rasterised
/// whole it fills the ~16 million pixel cap; clipped, it stays within a few frames' worth of pixels
/// (and the text still shows).
#[test]
fn hd_text_close_to_the_camera_stays_within_a_few_frames() {
let mut p = Project::default();
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (1920, 1080), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA".into(), size: 200.0, ..Default::default() }));
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0 + 60.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let (clipped, whole) = (raster_px(&p, cid, false), raster_px(&p, cid, true));
eprintln!("HD near camera: {clipped} px rasterised, {whole} px whole");
assert!(whole > 12_000_000, "{whole}");
assert!(clipped <= 4 * 1920 * 1080, "{clipped} px");
assert!(frame(&p, cid, false).data.iter().any(|px| px[3] > 0.5), "the text shows");
}
/// A slow move of the layer against the camera (a camera move does the same) keeps the part
/// rasterised within the same grid cells for several frames: they reuse the cached raster instead
/// of drawing the text again each frame.
#[test]
fn a_slow_move_reuses_the_clipped_raster() {
let cache = crate::LayerCache::default();
let at = |x: f64| {
let (p, cid) = scene(|p, c| text3(p, c, [x, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, Tick::ZERO);
};
at(W as f64 / 2.0);
let misses = cache.stats().misses;
// 16 frames, a quarter of a layer pixel apart (here about three output pixels).
for i in 1..=16 {
at(W as f64 / 2.0 + i as f64 * 0.25);
}
let rasters = cache.stats().misses - misses;
assert!(rasters <= 2, "{rasters} new rasters over 16 frames");
}
/// One keyframed motion: (seconds, value) pairs.
type Keys<'a> = &'a [(f64, [f64; 3])];
fn keyed(l: &mut Layer, path: &str, keys: Keys) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).keys =
keys.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Vec3(*v))).collect();
}
/// A camera keyed through `pos` / `poi`, and the text keyed through `text_pos` (and turning about
/// Y through `text_rot`): every frame of a second, rendered with the clipped raster and a layer
/// cache shared across the frames, matches rasterising the whole layer without a cache (or differs
/// by an anti-aliasing sub-sample or two on a few edge pixels) — except where the whole raster is held at
/// its pixel cap (softer than the clipped one, which isn't): there the text shows and the clipped
/// raster stays small.
fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f64)]) {
let (mut p, cid, comp) = setup();
let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, 0.0], 0.0);
if !text_pos.is_empty() {
keyed(&mut text, "transform/position", text_pos);
}
if !text_rot.is_empty() {
text.props.prop_mut("transform/rotationY").unwrap().keys =
text_rot.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Scalar(*v))).collect();
}
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (W, H), None);
set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom()));
keyed(&mut cam, "transform/position", pos);
keyed(&mut cam, "transform/poi", poi);
p.comp_mut(cid).unwrap().layers = vec![cam, text].into();
let cache = crate::LayerCache::default();
let (mut exact, mut near, mut capped) = (0, 0, 0);
for f in 0..30 {
let t = Tick::from_seconds_f64(f as f64 / 30.0);
let mut clipped = Renderer::new(&p, &NoFootage, RenderOpts::default());
clipped.cache = Some(&cache);
let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default());
whole.whole_raster = true;
let ctx = whole.eval_ctx(cid, t).unwrap();
let layer = &p.comp(cid).unwrap().layers[1];
let a = clipped.comp_frame(cid, t);
if clipped.raster_scale(&ctx, layer) == whole.raster_scale(&ctx, layer) {
let b = whole.comp_frame(cid, t);
if a.data == b.data {
exact += 1;
} else {
// The clipped raster places the glyphs at other coordinates (smaller ones: the whole
// raster can be thousands of pixels wide), and tiny-skia transforms them in f32: its
// 4 × 4 anti-aliasing may land a sub-sample or two differently on a few edge pixels.
let (n, worst) = a.data.iter().zip(&b.data).fold((0usize, 0.0f32), |(n, w), (p, q)| {
let d = (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0f32, f32::max);
(n + usize::from(d > 0.0), w.max(d))
});
assert!(worst <= 2.0 / 16.0 + 1e-4 && n * 1000 <= (W * H) as usize, "{label}: frame {f}: {n} px differ, by up to {worst}");
near += 1;
}
} else {
// Held at the cap whole: the clipped raster is sharper, and small.
let buf = clipped.layer_buf(&ctx, layer).unwrap();
assert!(buf.img.width as u64 * buf.img.height as u64 <= 16_500_000, "{label}: frame {f}");
let shows = |img: &effectcraft_raster::Image| img.data.iter().filter(|px| px[3] > 0.5).count();
let (sa, sb) = (shows(&a), shows(&whole.comp_frame(cid, t)));
// The same text shows (or none, the camera past it); coverage within a few percent.
assert!(sa.abs_diff(sb) * 50 <= sb.max(1) + 50, "{label}: frame {f}: {sa} px of text, {sb} rasterised whole");
capped += 1;
}
}
eprintln!("{label}: {exact} frames identical, {near} within anti-aliasing noise, {capped} sharper than the capped whole raster");
assert!(exact + near >= 10, "{label}: {exact} identical, {near} close, {capped} at the cap");
}
#[test]
fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() {
let (cx, cy, z) = (W as f64 / 2.0, H as f64 / 2.0, -zoom());
let still = [(0.0, [cx, cy, 0.0])];
// In towards the text, through it and past it; and back out.
moving("dolly in through the text", &[(0.0, [cx, cy, 2.0 * z]), (1.0, [cx, cy, 150.0])], &[(0.0, [cx, cy, 400.0]), (1.0, [cx, cy, 700.0])], &[], &[]);
moving("dolly out", &[(0.0, [cx, cy, -60.0]), (1.0, [cx, cy, 3.0 * z])], &still, &[], &[]);
// Sideways and up and down close to the text, so it leaves the frame on both sides.
moving(
"pan x",
&[(0.0, [cx - 500.0, cy, -120.0]), (1.0, [cx + 500.0, cy, -120.0])],
&[(0.0, [cx - 500.0, cy, 0.0]), (1.0, [cx + 500.0, cy, 0.0])],
&[],
&[],
);
moving("pan y", &[(0.0, [cx, cy - 300.0, -90.0]), (1.0, [cx, cy + 300.0, -90.0])], &[(0.0, [cx, cy - 300.0, 0.0]), (1.0, [cx, cy + 300.0, 0.0])], &[], &[]);
// Around the text, close, looking at it.
let orbit: Vec<(f64, [f64; 3])> = (0..=8)
.map(|i| {
let a = i as f64 / 8.0 * std::f64::consts::TAU * 0.4 - 0.8;
(i as f64 / 8.0, [cx + 150.0 * a.sin(), cy, -150.0 * a.cos()])
})
.collect();
moving("orbit", &orbit, &still, &[], &[]);
// The text moves instead: towards the camera, past it, and across.
moving(
"text towards and past the camera",
&[(0.0, [cx, cy, z])],
&still,
&[(0.0, [cx + 200.0, cy - 80.0, 400.0]), (1.0, [cx - 150.0, cy + 60.0, z - 100.0])],
&[],
);
// Turning close to the camera.
moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]);
}
/// Reads of a layer in its own space get the whole layer, not the part the frame shows: an effect
/// taking another layer as a parameter (Set Matte, stretched to fit, from hidden text close to the
/// camera), and the source the motion tracker analyses.
#[test]
fn layer_space_reads_get_the_whole_layer() {
let (mut p, cid, comp) = setup();
let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0);
text.switches.video = false;
let text_id = text.id;
let sid =
p.add_item("Solid", Label::Red, None, ItemKind::Solid(effectcraft_project::Solid { color: [0.2, 0.6, 1.0], width: W, height: H, pixel_aspect: 1.0 }));
let mut solid = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (W, H), None);
let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0));
solid.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers = vec![text, solid].into();
assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text");
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let layer = &p.comp(cid).unwrap().layers[0];
let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default());
whole.whole_raster = true;
assert_eq!(r.layer_source(&ctx, layer).unwrap().img, whole.layer_source(&ctx, layer).unwrap().img, "the tracker's source");
}
/// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole.
#[test]
fn layers_with_effects_rasterise_whole() {
let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into());
assert_eq!(raster_px(&p, cid, false), raster_px(&p, cid, true));
}
/// The Layer panel and analysis tools consume layer-space inputs, even with no effects.
#[test]
fn layer_panel_input_reads_the_whole_layer_behind_the_camera() {
let (p, cid) = scene(|p, c| shape3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0]));
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let layer = &p.comp(cid).unwrap().layers[0];
let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default());
whole.whole_raster = true;
let expected = whole.layer_input(&ctx, layer, 0).unwrap();
assert!(expected.img.data.iter().any(|pixel| pixel[3] > 0.5));
assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img, expected.img);
}
#[test]
fn fine_clip_cells_have_distinct_cache_keys() {
let a = kurbo::Rect::new(0.0, 0.0, 1.0 / 128.0, 1.0 / 128.0);
let b = kurbo::Rect::new(1.0 / 128.0, 0.0, 2.0 / 128.0, 1.0 / 128.0);
let key = |clip| crate::pipeline::raster_clip_key(42, clip);
// All three clips rounded to identical coordinates in the old 1/16-unit key.
assert_ne!(key(a), key(b));
assert_ne!(key(a), key(kurbo::Rect::ZERO));
assert_ne!(key(b), key(kurbo::Rect::ZERO));
}
/// Text animator Blur reads beyond the fixed anti-aliasing padding of a clipped window.
#[test]
fn animated_text_blur_keeps_offscreen_glyph_pixels_while_panning() {
let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let props = build::text_anim_props(&mut ids, "blur", false);
let mut animator = build::text_animator(&mut ids, "Blur", props);
animator.prop_mut("properties/blur").unwrap().value = Value::Vec2([40.0, 40.0]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.push(animator.into());
let opts = RenderOpts { scale: 0.125, ..Default::default() };
let cache = crate::LayerCache::default();
for dx in [-10.0, 0.0, 10.0] {
set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3([W as f64 / 2.0 + dx, H as f64 / 2.0, -zoom() + 40.0]));
let mut r = Renderer::new(&p, &NoFootage, opts);
r.cache = Some(&cache);
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let layer = &p.comp(cid).unwrap().layers[0];
assert!(r.raster_clip(&ctx, layer).is_none(), "animator blur needs off-window pixels");
let mut whole = Renderer::new(&p, &NoFootage, opts);
whole.whole_raster = true;
assert_eq!(r.comp_frame(cid, Tick::ZERO).data, whole.comp_frame(cid, Tick::ZERO).data);
}
}
+49 -3
View File
@@ -509,6 +509,16 @@ pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d"))
}
/// Animator blur can read glyph pixels beyond a viewport raster window. Conservatively
/// keep the whole raster even when the current selector happens to select no characters.
pub(crate) fn animator_blur(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.props.group("text/animators").is_some_and(|anims| {
anims.groups().filter(|anim| anim.enabled).any(|anim| {
anim.sub("properties").and_then(|props| props.get("blur")).is_some_and(|blur| ctx.value(layer, blur).as_vec2().iter().any(|value| *value != 0.0))
})
})
}
/// One drawn character.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
@@ -1122,8 +1132,46 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
}
}
/// Buffer pixels kept around a clip (see [`raster_window`]).
const CLIP_PAD: f64 = 24.0;
/// The buffer for content bounds `b` (layer space) at scale `s`: (width, height, layer → buffer
/// offset), two pixels of padding around it. With `clip`, only the pixels of that grid inside
/// `clip`, padded by [`CLIP_PAD`]: a whole-pixel window of the whole buffer's grid. Its pixels are
/// the whole buffer's, except that a glyph cut by the window is rasterised from the window's edge:
/// tiny-skia's 4 × 4 anti-aliasing then lands a sub-sample differently on a few edge pixels, up to
/// a dozen rows in from that edge — inside the padding, out of the frame. `None` when `clip`
/// misses the content.
pub(crate) fn raster_window(b: kurbo::Rect, s: f64, clip: Option<kurbo::Rect>) -> Option<(u32, u32, [f64; 2])> {
let (full_w, full_h) = ((b.width() * s).ceil() + 4.0, (b.height() * s).ceil() + 4.0);
let full = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let (x0, y0, x1, y1) = match clip {
None => (0.0, 0.0, full_w, full_h),
// Nothing shows (behind the camera).
Some(c) if c.area() <= 0.0 => return None,
Some(c) => (
(c.x0 * s + full[0] - CLIP_PAD).floor().max(0.0),
(c.y0 * s + full[1] - CLIP_PAD).floor().max(0.0),
(c.x1 * s + full[0] + CLIP_PAD).ceil().min(full_w),
(c.y1 * s + full[1] + CLIP_PAD).ceil().min(full_h),
),
};
if !(x1 > x0 && y1 > y0) {
return None;
}
let w = ((x1 - x0) as u32).clamp(1, 16384);
let h = ((y1 - y0) as u32).clamp(1, 16384);
Some((w, h, [full[0] - x0, full[1] - y0]))
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
render_in(ctx, layer, s, None)
}
/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows,
/// see `Renderer::raster_clip`).
pub fn render_in(ctx: &EvalCtx, layer: &Layer, s: f64, clip: Option<kurbo::Rect>) -> Buf {
let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s };
let Some(geom) = text_geom(ctx, layer) else { return empty() };
let mut bounds: Option<kurbo::Rect> = None;
@@ -1137,9 +1185,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
}
let Some(b) = bounds else { return empty() };
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 Some((w, h, offset)) = raster_window(b, s, clip) else { return empty() };
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, None);
+46
View File
@@ -185,6 +185,52 @@ pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) {
(c.width as f64, c.height as f64)
}
/// The layer-space rectangle of a 3D layer that this render's output can show: the output frame
/// (padded by two pixels) projected back onto the layer's plane, the way [`geo`] draws it. `None`
/// when that can't be bounded: an orthographic camera, depth of field (its blur reads past the
/// frame), motion blur (each sub-sample sees another part), or a frame corner whose ray misses the
/// plane in front of the camera (a plane seen towards its horizon). An empty rectangle when the
/// whole content is behind the camera.
pub(crate) fn visible_clip(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<kurbo::Rect> {
let cam = camera_for(r, ctx);
if cam.ortho || cam.dof.is_some() || mb_samples(r, ctx, layer) > 1 {
return None;
}
let comp = view_size(r, ctx);
let s = r.opts.scale;
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
let world = outer * ctx.world_matrix(layer);
let (off, w, h) = match r.roi_offset() {
Some((x, y, w, h)) => ((x, y), w as f64, h as f64),
None => ((0.0, 0.0), (comp.0 * s).round(), (comp.1 * s).round()),
};
let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(s, s, 1.0)) * cam.projection(comp.0, comp.1);
let hinv = (proj * world).plane_to_mat3().inverse()?;
let cv = cam.view * world;
let depth = |u: f64, v: f64| cv.0[2][0] * u + cv.0[2][1] * v + cv.0[2][3];
if let Some(b) = crate::content_bounds(ctx, layer)
&& [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].iter().all(|&(u, v)| depth(u, v) < NEAR)
{
return Some(kurbo::Rect::ZERO);
}
const PAD: f64 = 2.0;
let mut rect: Option<kurbo::Rect> = None;
for (sx, sy) in [(-PAD, -PAD), (w + PAD, -PAD), (w + PAD, h + PAD), (-PAD, h + PAD)] {
let m = &hinv.0;
let qz = m[2][0] * sx + m[2][1] * sy + m[2][2];
if qz <= 1e-300 {
return None;
}
let (u, v) = ((m[0][0] * sx + m[0][1] * sy + m[0][2]) / qz, (m[1][0] * sx + m[1][1] * sy + m[1][2]) / qz);
if !u.is_finite() || !v.is_finite() || depth(u, v) < NEAR {
return None;
}
let p = kurbo::Rect::new(u, v, u, v);
rect = Some(rect.map_or(p, |r| r.union(p)));
}
rect
}
/// Number of motion-blur sub-samples for a layer.
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) {