diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7229aa5ad..dccf29e6c 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -37,9 +37,16 @@ const EXT_ENTRIES: &[&str] = &["classic3d"]; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; -/// Uploaded layer buffers kept on the GPU by default (bytes; [`GpuContext::set_upload_budget`]). +/// Uploaded layer buffers kept on the GPU by default (bytes; [`GpuContext::set_upload_budget`], +/// overridable through `EFFECTCRAFT_GPU_UPLOAD_BUDGET_MB`). const UPLOAD_BUDGET: usize = 1536 << 20; +/// `EFFECTCRAFT_GPU_UPLOAD_BUDGET_MB`'s value in megabytes, if it is a positive integer. +fn parse_upload_budget_mb(v: &str) -> Option { + let v = v.trim(); + v.parse::().ok().filter(|mb| *mb > 0) +} + /// Working textures kept for reuse once their last image is dropped (bytes). const POOL_BUDGET: usize = 768 << 20; @@ -338,11 +345,19 @@ struct Upload { last_use: u64, } -#[derive(Default)] struct Uploads { map: HashMap, bytes: usize, clock: u64, + /// Live textures dropped to stay under [`GpuContext::upload_budget`] (#687): every one is a + /// full re-upload on a later frame. Counted and warned once, never silently. + starves: u64, +} + +impl Default for Uploads { + fn default() -> Self { + Self { map: HashMap::new(), bytes: 0, clock: 0, starves: 0 } + } } /// The device and everything compiled for it. @@ -365,7 +380,6 @@ pub struct GpuContext { dummy_buf: wgpu::Buffer, uploads: Mutex, upload_budget: std::sync::atomic::AtomicUsize, - upload_full_warned: std::sync::atomic::AtomicBool, pool: Arc>, /// CPU ↔ GPU traffic (diagnostics: `bench --gpu`). pub(crate) transfers: Transfers, @@ -695,7 +709,7 @@ impl GpuContext { init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; - Ok(GpuContext { + let ctx = GpuContext { device, queue, name, @@ -712,7 +726,6 @@ impl GpuContext { dummy_buf, uploads: Mutex::new(Uploads::default()), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), - upload_full_warned: Default::default(), pool: Default::default(), staging: Default::default(), transfers: Default::default(), @@ -723,7 +736,13 @@ impl GpuContext { particle_states: Default::default(), deferred: None, readbacks, - }) + }; + // An embedder sizes the upload cache through the environment (the same channel the + // face landmarkers use); unset keeps the default. + if let Some(mb) = std::env::var("EFFECTCRAFT_GPU_UPLOAD_BUDGET_MB").ok().and_then(|v| parse_upload_budget_mb(&v)) { + ctx.set_upload_budget(mb.saturating_mul(1 << 20)); + } + Ok(ctx) } /// Whether this context has observed a readback timeout or device failure. @@ -953,9 +972,9 @@ impl GpuContext { (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } - /// Bytes of uploaded layer textures the context keeps (default 1536 MB). An application that - /// knows the device's memory can raise it (static layers of a big frame upload once) or lower - /// it (a small GPU); the least recently used textures go first. + /// 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) { self.upload_budget.store(bytes.max(1 << 20), std::sync::atomic::Ordering::Relaxed); } @@ -1035,17 +1054,20 @@ impl GpuContext { u.bytes -= e.bytes; } } - if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { - log::warn!( - target: "effectcraft_gpu", - "GPU upload cache full ({} MB): evicting layer textures, which upload again when next used; a frame whose static layers exceed it re-uploads them every frame", - budget >> 20 - ); - } while u.bytes > 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, + budget >> 20 + ); + } + } } } } @@ -1473,6 +1495,21 @@ pub(crate) fn mip_levels(w: u32, h: u32) -> u32 { u32::BITS - w.max(h).max(1).leading_zeros() } +#[cfg(test)] +mod env_tests { + use super::parse_upload_budget_mb; + + #[test] + fn upload_budget_env_parses_only_positive_integers() { + for v in ["1024", " 512 ", "2048"] { + assert_eq!(parse_upload_budget_mb(v).map(|mb| mb << 20), Some(v.trim().parse::().unwrap() << 20)); + } + for v in ["", "0", "-8", "64.5", "512mb", "x".repeat(64).as_str()] { + assert_eq!(parse_upload_budget_mb(v), None, "{v:?}"); + } + } +} + #[cfg(test)] mod adapter_name_tests { use super::{match_adapter_name, wanted_adapter_name}; diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..b802e06eb 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -422,10 +422,10 @@ 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)? -/// `effects: false` ignores the effects (a key of the source and masks only). -fn time_dependent(layer: &Layer, effects: bool) -> bool { - if 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() { @@ -500,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()) @@ -527,20 +527,35 @@ pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur // 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)) + 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`]). +/// 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) -> Option { +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); @@ -578,7 +593,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); @@ -592,9 +608,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(); @@ -617,7 +637,8 @@ 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(); @@ -625,14 +646,14 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).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 483b19da3..a35436695 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -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) -> 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) } @@ -19,13 +23,18 @@ struct Dependencies<'a> { } impl Dependencies<'_> { - fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option) -> 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)?; } } @@ -107,7 +116,7 @@ impl Dependencies<'_> { } _ => {} } - self.effects(ctx, layer, None)?; + self.effects(ctx, layer, super::EffectScope::All)?; self.path.pop(); Some(()) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..a4caff585 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -899,6 +899,52 @@ 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)); + 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"); +} + #[test] fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { let (mut p, cid, comp) = setup();