instance3d: the copy's cache key covers DoF, animated models and env light

The referenced model's solo render goes through the comp camera: its
depth of field (focus, aperture, iris, highlights) blurs it, none of
which is in the projection matrix — the evaluated Dof is hashed in now
(CodeRabbit, review of #2410).

A model with an animation clip selected is posed at its layer time
(Animation Speed and Looping are static and hashed): the layer time is
folded into the key, so every time is its own entry. A model without a
clip keeps one key across times, as before.

An Environment light's image lights Advanced 3D models, and its source
can be any layer's render (or any environment layer) — too open-ended
to hash: while one is on, the read is not cached. Each is covered by a
render test.
This commit is contained in:
Minher0
2026-10-29 11:50:58 +00:00
parent 7f1ec94810
commit 93aed3bfd6
2 changed files with 106 additions and 5 deletions
+27 -5
View File
@@ -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<usize>) -> 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);
+79
View File
@@ -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");
}