instance3d: a dedicated 3D Layer Instance effect, and the copy is cached

CC Composite stays After Effects-pure: its Layer parameter is gone, so
parameter indices match AE again (scripts and expressions that address
parameters by index). The model reference lives in its own effect,
ec.3d.instance3d (3D Channel, CPU, one layer parameter), which replaces
the layer's pixels with the referenced layer's render — alpha included.

A referenced model's solo render is now hashed into the owner's cache key
instead of declining the entry (Bflat, review of #2410): the layer, its
animated values and transform, the mesh item, the comp camera and lights,
draft 3D, the renderer setting and the in/out gate. Covered by a render
test: the copy is cached, served on the second render, and re-rendered
when the camera pans or the model leaves its in/out range.
This commit is contained in:
Minher0
2026-10-29 10:18:17 +00:00
parent 36a50a6366
commit 5b2f305f72
11 changed files with 210 additions and 77 deletions
+32 -4
View File
@@ -63,17 +63,45 @@ impl Dependencies<'_> {
Some(())
}
/// 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.
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.
if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) {
return None;
}
hash_debug(self.h, layer);
hash_values(self.h, ctx, layer, &layer.props);
hash_debug(self.h, &ctx.world_matrix(layer));
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.
hash_debug(self.h, &ctx.camera());
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);
// 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);
Some(())
}
fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> {
let id = (ctx.comp_id, layer.id);
if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) {
return None;
}
// A referenced 3D model or primitive layer renders through the comp's camera and lights
// (an effect layer parameter reads it that way — Create 3D Layer Instance's CC
// Composite): those move its pixels without a trace in its own properties, so the owner
// keeps no cache entry for it.
// (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what
// moves its pixels, so the owner keeps a cache entry that changes when any of it does.
if layer.source.is_model() {
return None;
self.soloed_model(ctx, layer)?;
return Some(());
}
self.remaining = self.remaining.checked_sub(1)?;
if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) {
+1 -1
View File
@@ -74,7 +74,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
let sub = self.r.nested();
// A 3D model or primitive layer has no 2D source of its own: an effect referencing one
// reads it as After Effects does — the layer rendered on its own through the comp's
// camera (Create 3D Layer Instance's CC Composite), in the layer's effect space (the
// camera (Create 3D Layer Instance's effect), in the layer's effect space (the
// comp, centred on the layer's anchor).
if other.source.is_model() {
let buf = sub.solo_3d(self.ctx, other);
+112
View File
@@ -1073,3 +1073,115 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() {
assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer");
assert_eq!(cache.stats().entries, 1);
}
// ------------------------------------------------------------------------------ 3D layer instances
/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a
/// data URI), parsed in memory: a FootageSource that knows one model item.
const TRIANGLE: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
]
}"#;
struct SoloModels;
impl crate::FootageSource for SoloModels {
fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<crate::Image>> {
None
}
fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option<std::sync::Arc<effectcraft_model::Model>> {
effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new)
}
}
/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the
/// copy: a solid with a 3D Layer Instance effect referencing the model.
fn instance_scene(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
// Models are Advanced 3D (layer.newModel switches a Classic comp over).
p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D;
let mid = p.add_item(
"Triangle",
Label::Red,
None,
ItemKind::Footage(effectcraft_project::Footage {
path: "Triangle.gltf".into(),
kind: effectcraft_project::FootageKind::Model,
frame_rate: FrameRate::FPS_30,
codec: "glTF".into(),
..Default::default()
}),
);
let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None);
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
for path in ["transform/position", "transform/poi"] {
let pr = cam.props.prop_mut(path).unwrap();
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]);
p.comp_mut(cid).unwrap().layers = vec![cam, owner, model];
(p, cid)
}
fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, t)
}
/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not.
fn cover_x(img: &crate::Image) -> f32 {
let (mut m, mut sx) = (0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let a = img.get(x as i64, y as i64)[3];
m += a;
sx += a * x as f32;
}
}
assert!(m > 1.0, "the triangle is drawn");
sx / m
}
/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the
/// referenced model's solo render depends on — the camera, and the model's in/out gate.
#[test]
fn an_instance_of_a_model_is_cached_and_follows_the_camera() {
let t = Tick::from_seconds_f64(1.0);
let (p, cid) = instance_scene(0.0);
let (q, _) = instance_scene(40.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_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key");
// The same layer at a later time, outside its in/out range: the read turns transparent.
let mut out = p.clone();
out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5);
assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key");
// Two renders of one time: the second is served from the cache.
let cache = crate::LayerCache::default();
let here = render_models(&p, cid, t, Some(&cache));
let again = render_models(&p, cid, t, Some(&cache));
assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one");
assert!(cache.stats().hits > 0, "the copy was served from the cache");
// Panning the camera: the copy (and the model) move with it.
let panned = render_models(&q, cid, t, Some(&cache));
assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved");
let (a, b) = (cover_x(&here), cover_x(&panned));
assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}");
}