Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Cache: an adjustment layer's footprint key stops reading its effect s…
…tack (#687).

The footprint — the adjustment layer's own source through its masks — was keyed by the
same routine as a processed layer, so the whole effects group, per-paint spans, the
comp-camera read and the time fold any time-dependent effect forces all entered its key.
Noise or Grain on a finishing adjustment therefore changed the key on every frame: the
footprint re-rendered, and the GPU compositor re-uploaded a full-frame buffer (127 MB at
4K RGBA f32) each time even though none of those bytes move.

key_any now takes an explicit effect scope: All for the processed layer, First(n) for a
layer input, and a new Footprint that hashes none of it — masks still re-key through their
structure and their evaluated values, shutter stays keyed while masks exist, and the
dependencies walk and shape/text wiggle time fold behave as before. A render test moves a
mask past its keyframes with Noise enabled and asserts the key is stable across frames,
changes with the mask, ignores effect-parameter edits, and that the cached buffer matches
the mask it should show.
  • Loading branch information
vbasky committed Oct 11, 2026
commit e2c33fd9380f8fd39c1894dd7e682c60cbe7b17f
51 changes: 37 additions & 14 deletions crates/render/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,11 @@ fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) {
}

/// Time-based layer content: does any part of the source/effects read the clock directly
/// (rather than through property values)?
fn time_dependent(layer: &Layer) -> bool {
if layer.switches.effects
/// (rather than through property values)? `with_effects`: false when the keyed content is an
/// adjustment layer's footprint (source through masks) — only its shape/text wiggles then count.
fn time_dependent(layer: &Layer, with_effects: bool) -> bool {
if with_effects
&& layer.switches.effects
&& let Some(fx) = layer.effects()
{
for g in fx.groups() {
Expand Down Expand Up @@ -498,7 +500,7 @@ pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo
if layer.switches.adjustment {
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())
Expand All @@ -522,20 +524,35 @@ fn has_expression(g: &PropGroup) -> bool {
/// 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> {
Some(derive(key_any(ctx, layer, scale, draft, blur, false, None)?, 0xf007_9417))
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`]).
/// 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: Noise or Grain on a finishing adjustment layer changes
/// every frame, and hashing it there re-keyed the footprint each frame, forcing the GPU to
/// re-render and re-upload a full-frame buffer the footprint never needed (#687).
#[derive(Clone, Copy, PartialEq)]
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,
}

/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: how far
/// the effect stack reaches into the key (see [`EffectScope`]).
#[allow(clippy::too_many_arguments)]
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option<usize>) -> Option<u64> {
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);
Expand Down Expand Up @@ -573,7 +590,8 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
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()) {
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);
Expand All @@ -587,9 +605,13 @@ 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.
if effects == EffectScope::Footprint && matches!(c, Node::Group(g) if g.match_id == "effects") {
continue;
}
// 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 (EffectScope::First(n), Node::Group(g)) = (effects, c)
&& g.match_id == "effects"
{
let mut first = g.clone();
Expand All @@ -612,22 +634,23 @@ 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
if effects != EffectScope::Footprint
&& layer.switches.effects
&& let Some(fx) = layer.effects()
{
let lt = layer.layer_time(ctx.time).seconds();
for g in fx.groups().filter(|g| g.enabled && effectcraft_effects::paint::is_paint(g)) {
effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h);
}
}
if time_dependent(layer) {
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())
Expand Down
19 changes: 14 additions & 5 deletions crates/render/src/cache/dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ use crate::EvalCtx;
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, 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<()> {
if effects == super::EffectScope::Footprint {
// A footprint runs no effects: nothing they read is in its pixels or keys.
return Some(());
}
let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) };
deps.effects(ctx, layer, effects)
}
Expand All @@ -19,13 +23,18 @@ struct Dependencies<'a> {
}

impl Dependencies<'_> {
fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option<usize>) -> Option<()> {
fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, scope: super::EffectScope) -> Option<()> {
let previous = self.other_times;
self.other_times |= super::reads_other_times(layer);
let limit = match scope {
super::EffectScope::All => usize::MAX,
super::EffectScope::First(n) => n,
super::EffectScope::Footprint => 0,
};
if layer.switches.effects
&& let Some(fx) = layer.effects()
{
for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) {
for g in fx.groups().take(limit).filter(|g| g.enabled) {
self.group(ctx, layer, g, 0)?;
}
}
Expand Down Expand Up @@ -78,7 +87,7 @@ impl Dependencies<'_> {
hash_debug(self.h, layer);
hash_values(self.h, ctx, layer, &layer.props);
hash_debug(self.h, &ctx.world_matrix(layer));
if super::time_dependent(layer) {
if super::time_dependent(layer, true) {
layer.layer_time(ctx.time).0.hash(self.h);
}
match layer.source {
Expand All @@ -102,7 +111,7 @@ impl Dependencies<'_> {
}
_ => {}
}
self.effects(ctx, layer, None)?;
self.effects(ctx, layer, super::EffectScope::All)?;
self.path.pop();
Some(())
}
Expand Down
45 changes: 45 additions & 0 deletions crates/render/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,51 @@ fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() {
assert!(crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false).is_none(), "cycles decline caching without recursion");
}

/// #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 match a fresh render.
#[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 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))),
];
adj.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(adj);
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 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)
};
// A moving mask re-keys (and repaints) the footprint; the clock alone must not.
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");
// Past the mask's last keyframe every frame is identical — the Noise on the layer must not
// re-key it frame after frame.
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));
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/amount").unwrap().value = Value::Scalar(90.0);
assert_eq!(key(&p, 0.0), key(&p, 0.0), "editing an effect parameter must not re-key the footprint");
}

#[test]
fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() {
let (mut p, cid, comp) = setup();
Expand Down