Merge pull request #701 from vbasky/fix/687-adjustment-footprint-key

Cache/GPU: adjustment footprint ignores the effect stack; upload starvation is reported (#687)
This commit is contained in:
BFlat
2026-10-30 14:39:17 +07:00
committed by GitHub
3 changed files with 105 additions and 27 deletions
+49 -25
View File
@@ -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<u64> {
// 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<u64> {
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<usize>) -> Option<u64> {
/// `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<u64> {
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())
+8 -2
View File
@@ -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<usize>) -> 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> {
+48
View File
@@ -929,6 +929,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