Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
34 changes: 31 additions & 3 deletions crates/gpu/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,20 @@ struct Upload {
last_use: u64,
}

#[derive(Default)]
struct Uploads {
map: HashMap<usize, Upload>,
bytes: usize,
clock: u64,
/// Live textures dropped to stay under [`Self::budget`] (#687): every one is a full
/// re-upload on a later frame. Counted and warned once, never silently.
starves: u64,
budget: usize,
}

impl Default for Uploads {
fn default() -> Self {
Self { map: HashMap::new(), bytes: 0, clock: 0, starves: 0, budget: UPLOAD_BUDGET }
}
}

/// The device and everything compiled for it.
Expand Down Expand Up @@ -873,24 +882,43 @@ impl GpuContext {
}
u.bytes += bytes;
// Drop textures of freed buffers, then the least recently used over budget.
if u.bytes > UPLOAD_BUDGET {
if u.bytes > u.budget {
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
}
}
while u.bytes > UPLOAD_BUDGET {
while u.bytes > u.budget {
let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break };
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
if e.buf.strong_count() > 0 {
u.starves += 1;
if u.starves == 1 {
log::warn!(
"gpu {}: the {} MB upload budget cannot hold this project's layers; their textures now re-upload every frame (Help ▸ Reveal Logs)",
self.name,
u.budget >> 20
);
}
}
}
}
}
Some(img)
}

/// Size the layer-texture upload budget in bytes (default 1536 MB): an embedder on a
/// small-memory device lowers it so the working pool keeps room; over-budget evictions of
/// live layers are logged, never silent (#687).
pub fn set_upload_budget(&self, bytes: usize) {
if let Ok(mut u) = self.uploads.lock() {
u.budget = bytes.max(16 << 20);
}
}

/// Forget uploaded layer textures.
pub fn clear_uploads(&self) {
if let Ok(mut u) = self.uploads.lock() {
Expand Down
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