diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 5c45c9652..a0910b0fd 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -3,7 +3,7 @@ //! cyclic/over-budget graphs or expressions sampled at unknown times. use super::{KeyHasher, hash_debug, hash_values}; use crate::EvalCtx; -use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +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<()> { @@ -65,9 +65,11 @@ impl Dependencies<'_> { /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself /// (geometry, materials, switches, in/out), its animated values and transform at the time, - /// the mesh item, the comp's camera and lights at the time (the lights are off in draft 3D, - /// and the renderer setting changes the pixels), and the in/out gate (outside the range the - /// read is transparent). Comp size and render scale are in the owner's key already. + /// the mesh item, the comp's camera (with its depth of field) and its lights at the time + /// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's + /// layer time while it plays an animation clip, and the in/out gate (outside the range the + /// read is transparent). An Environment light's image can be any layer's render: with one on + /// the read is not cached. Comp size and render scale are in the owner's key already. fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { self.remaining = self.remaining.checked_sub(1)?; // Expressions sampled at other times: not cacheable, as in the ordinary path. @@ -80,11 +82,31 @@ impl Dependencies<'_> { if let Some(item) = layer.source.item() { hash_debug(self.h, &ctx.project.item(item)); } - // The solo render goes through the comp's camera and lights. + // The solo render goes through the comp's camera and lights. The camera's depth of + // field blurs it (focus, aperture, iris, highlights) — none of that is in the + // projection hashed above. hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::active_camera(ctx).dof); hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); hash_debug(self.h, &ctx.comp.draft_3d); hash_debug(self.h, &ctx.comp.renderer); + // An Environment light's image lights Advanced 3D models (its source can be any + // layer's render, or any environment layer) — too open-ended to hash: while one is + // on (and lighting: Draft 3D lights nothing), don't cache the read. + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D + && !ctx.comp.draft_3d + && ctx.comp.layers.iter().any(|l| { + l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment }) + }) + { + return None; + } + // A model with an animation clip selected is posed at its layer time (Animation Speed + // and Looping are static, hashed above): every time is its own entry. Without one the + // pose is the same at every time: keep the static key. + if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 { + layer.layer_time(ctx.time).0.hash(self.h); + } // Outside the layer's in/out range the read is transparent: fold the gate in, so a // static model renders once inside it and again when it leaves. layer.is_active_at(ctx.time).hash(self.h); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 8857a739c..eec90436c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1185,3 +1185,82 @@ fn an_instance_of_a_model_is_cached_and_follows_the_camera() { let (a, b) = (cover_x(&here), cover_x(&panned)); assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); } + +/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) — +/// none of it is in the projection matrix: changing any of it must change the copy's key, or +/// it would serve the un-blurred pixels. +#[test] +fn the_copy_key_covers_the_cameras_depth_of_field() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| { + let mut q = p.clone(); + let cam = &mut q.comp_mut(cid).unwrap().layers[0]; + for (path, v) in + [("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))] + { + cam.props.prop_mut(path).unwrap().value = v; + } + q + }; + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + let ka = key_at(&set_dof(&p, true, 500.0, 20.0)); + let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture + let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus + let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off + assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key"); +} + +/// A model with an animation clip selected is posed at its layer time: every time is its own +/// entry. Without one (the default) the pose is the same at every time: one key inside the +/// in/out range, as before. +#[test] +fn the_copy_key_covers_an_animated_models_layer_time() { + let (p, cid) = instance_scene(0.0); + let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0)); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times"); + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1); + assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time"); + // Two times and back: each is rendered fresh, none served another time's pixels. + let cache = crate::LayerCache::default(); + let first = render_models(&q, cid, t0, Some(&cache)); + let second = render_models(&q, cid, t1, Some(&cache)); + assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels"); + assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render"); + let again = render_models(&q, cid, t0, Some(&cache)); + assert_eq!(cache.stats().hits, 1, "the first time is served on the way back"); + assert_same(&again, &first, "the way back is the first time's pixels"); +} + +/// An Environment light's image lights Advanced 3D models — its source can be any layer's +/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels. +#[test] +fn an_environment_light_makes_the_copy_uncached() { + let (p, cid) = instance_scene(0.0); + let mut q = p.clone(); + let comp = q.comp(cid).unwrap().clone(); + let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None); + q.comp_mut(cid).unwrap().layers.insert(0, env); + let t = Tick::from_seconds_f64(1.0); + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false) + }; + assert!(key_at(&q).is_none(), "with an Environment light the read is not cached"); + let cache = crate::LayerCache::default(); + let a = render_models(&q, cid, t, Some(&cache)); + let b = render_models(&q, cid, t, Some(&cache)); + assert_same(&a, &b, "two fresh renders agree"); + assert_eq!(cache.stats().hits, 0, "nothing is served from the cache"); + // The light off (not lighting): the cache comes back. + q.comp_mut(cid).unwrap().layers[0].switches.video = false; + assert!(key_at(&q).is_some(), "the light off, the read is cached again"); +}