diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index b6f2e4076..cf1a84e82 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -527,7 +527,7 @@ pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo if layer.switches.adjustment || history_unkeyable(layer, Some(effects)) { return None; } - let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?; + let base = key_any(ctx, layer, scale, draft, blur, true, EffectScope::First(effects))?; let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3); effects.hash(&mut h); Some(h.finish()) @@ -588,25 +588,39 @@ fn has_expression(g: &PropGroup) -> bool { }) } +/// How far the effect stack reaches into a cache key. [`EffectScope::Footprint`] keys an +/// adjustment layer's footprint — its source through its masks, without any effects — so the +/// stack must not reach the key at all (#687). +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum EffectScope { + /// The whole stack (the processed layer). + All, + /// Only the first `n` effects (a layer's input, see [`input_key`]). + First(usize), + /// None of it (an adjustment layer's footprint). + Footprint, +} + /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // The footprint is the source through the masks, without the effects: a time-dependent // effect (Noise on a finishing adjustment layer) must not re-key it every frame (on the GPU, - // a full-frame re-upload per frame). - Some(derive(key_any(ctx, layer, scale, draft, blur, false, Some(0))?, 0xf007_9417)) + // a full-frame re-upload per frame). Shutter, paint, the comp scene and effect dependencies + // stay out of this key too; a mask still does. + Some(derive(key_any(ctx, layer, scale, draft, blur, false, EffectScope::Footprint)?, 0xf007_9417)) } fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } - key_any(ctx, layer, scale, draft, blur, footage, None) + key_any(ctx, layer, scale, draft, blur, footage, EffectScope::All) } -/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only -/// the first that many effects (a layer's input, see [`input_key`]). -fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option) -> Option { +/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: how far +/// the effect stack reaches into the key (see [`EffectScope`]). +fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: EffectScope) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h); ctx.comp.enable_frame_blending.hash(&mut h); @@ -651,8 +665,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); // Mask motion blur and effects that read the shutter depend on whether the layer is motion - // blurred in this render and on the comp's shutter. - if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) { + // blurred in this render and on the comp's shutter. A footprint has no effects, so only its + // masks put the shutter in the key. + if layer.masks().is_some_and(|m| !m.children.is_empty()) || (effects != EffectScope::Footprint && layer.effects().is_some_and(|fx| !fx.children.is_empty())) + { blur.hash(&mut h); ctx.comp.enable_motion_blur.hash(&mut h); ctx.comp.shutter_angle.to_bits().hash(&mut h); @@ -666,23 +682,29 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo { continue; } + // A footprint is its source through its masks: the effect stack is not in it at all. // A layer's input runs only its first effects: the later ones (a Puppet whose pins are // dragged, #212) don't change it, so it stays cached. - if let (Some(n), Node::Group(g)) = (effects, c) + if let Node::Group(g) = c && g.match_id == "effects" { - let mut first = g.clone(); - let mut seen = 0; - first.children.retain(|e| match e { - Node::Prop(_) => true, - Node::Group(_) => { - seen += 1; - seen <= n - } - }); - hash_debug(&mut h, &first); - hash_values(&mut h, ctx, layer, &first); - continue; + if effects == EffectScope::Footprint { + continue; + } + if let EffectScope::First(n) = effects { + let mut first = g.clone(); + let mut seen = 0; + first.children.retain(|e| match e { + Node::Prop(_) => true, + Node::Group(_) => { + seen += 1; + seen <= n + } + }); + hash_debug(&mut h, &first); + hash_values(&mut h, ctx, layer, &first); + continue; + } } // Static structure: values, keyframes, expressions, enabled flags, effect ids, modes. hash_debug(&mut h, c); @@ -691,7 +713,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo } } // Paint strokes appear and disappear with their Duration spans (not property values). - if layer.switches.effects + // A footprint does not include them. + if effects != EffectScope::Footprint + && layer.switches.effects && let Some(fx) = layer.effects() { let lt = layer.layer_time(ctx.time).seconds(); @@ -704,14 +728,14 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo if mask_motion_blur(ctx, layer, blur) { layer.layer_time(ctx.time).0.hash(&mut h); } - if time_dependent(layer, effects != Some(0)) { + if time_dependent(layer, effects != EffectScope::Footprint) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. layer.start_time.0.hash(&mut h); layer.stretch.to_bits().hash(&mut h); } // Effects that see the comp camera or lights (#397) change when they or the layer move. - if reads_comp_scene(layer) { + if effects != EffectScope::Footprint && reads_comp_scene(layer) { hash_debug(&mut h, &crate::comp_scene(ctx, layer)); } Some(h.finish()) diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 1a550f9c7..2019d858a 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -6,9 +6,15 @@ use crate::EvalCtx; use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup}; use std::hash::Hash; -pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { +pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: super::EffectScope) -> Option<()> { + // A footprint runs no effects: nothing they read is in its pixels or its key. + let limit = match effects { + super::EffectScope::Footprint => return Some(()), + super::EffectScope::All => None, + super::EffectScope::First(n) => Some(n), + }; let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer, None) }; - deps.effects(ctx, layer, effects) + deps.effects(ctx, layer, limit) } struct Dependencies<'a> { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 488f38ec7..9ab94ebfb 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -903,6 +903,54 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { } } +/// #687: an adjustment layer's footprint (its source through its masks) must not be keyed by +/// its effect stack. Noise on a finishing adjustment changes every frame, and letting that +/// reach the key re-rendered and re-uploaded a full-frame buffer every frame. A moving mask +/// must still re-key it, and the cached buffer must follow the mask. +#[test] +fn adjustment_footprint_key_ignores_the_effect_stack() { + let (mut p, cid, comp) = setup(); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + adj.switches.adjustment = true; + add_effect_200(&mut p, &mut adj, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]); + let key = |p: &Project, t: f64| { + let c = p.comp(cid).unwrap(); + crate::cache::footprint_key(&crate::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(t)), &c.layers[0], 1.0, false, false).unwrap() + }; + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + m.prop_mut("path").unwrap().keys = vec![ + Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([50.0, 50.0], 40.0, 40.0))), + Keyframe::new(Tick::from_seconds_f64(1.0), Value::Path(ShapePath::rect([150.0, 50.0], 40.0, 40.0))), + ]; + // Effects alone must not put the shutter in the footprint key. Masks, added below, may. + p.comp_mut(cid).unwrap().layers.push(adj.clone()); + let bare = key(&p, 0.0); + p.comp_mut(cid).unwrap().shutter_angle = 90.0; + assert_eq!(bare, key(&p, 0.0), "the comp shutter must not re-key a footprint"); + p.comp_mut(cid).unwrap().shutter_angle = 180.0; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("masks").unwrap().children.push(m.into()); + let foot = |p: &Project, t: f64| { + let c = p.comp(cid).unwrap(); + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + let cache = crate::LayerCache::default(); + r.cache = Some(&cache); + let layer = c.layers[0].clone(); + let b = r.adjustment_footprint(&crate::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(t)), &layer).unwrap(); + b.img.get(50, 50) + }; + assert_ne!(key(&p, 0.0), key(&p, 0.5), "an animated mask path must re-key the footprint"); + assert_ne!(key(&p, 0.0), key(&p, 1.0)); + assert_ne!(foot(&p, 0.0), foot(&p, 1.0), "the moved mask must reach the cached footprint"); + assert_eq!(key(&p, 1.0), key(&p, 1.5), "a per-frame effect on the adjustment re-keyed its footprint"); + assert_eq!(key(&p, 1.0), key(&p, 3.5), "the footprint key followed the clock past the mask"); + assert_eq!(foot(&p, 1.0), foot(&p, 3.5)); + let before = key(&p, 0.0); + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/amount").unwrap().value = Value::Scalar(90.0); + assert_eq!(before, key(&p, 0.0), "editing an effect parameter must not re-key the footprint"); +} + /// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs /// were cut off at their own bounding box: the effect computed over the comp-sized layer rect /// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the