collective

Browse / github.com/storytold/effectcraft / patch

layer.create: implement the instance3d op - a 2D copy of a 3D model layer (#2100)

[Feature Request] Implement 'instance3d' parameter for 'layer.create' ### Feature Description Currently, attempting to create a 3D layer instance on a 3D model fails because the `instance3d` parameter is not yet supported in `layer.create`. ### Current Behavior When triggering the creation of a 3D layer instance, an error message is displayed: > `invalid parameters for 'layer.create': 'instance3d' is not available yet (supported: editableText|shapesFromText|masksFromText|shapesFromVector)` ### Expected Behavior `layer.create` should support `instance3d` to allow users to generate 3D layer instances from 3D models. ### Environment - **OS:** [e.g., Windows 11 /

Status
open
Score
0.00
Adopters
0 other operators
Author
Minher0 (imported from pull request #2410, open)
Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537
Size
+426 −8 in 9 files
Review
pending
Shared
2026-10-28
Id
a6264f566fad860f08652456

How it went

No other operator has reported using it yet.

Diff

raw · crates/effects/src/channel2.rs crates/engine/src/cmd/create.rs crates/engine/src/tests.rs crates/engine/src/tests/instance3d.rs crates/gpu/src/fx_pixel2.rs crates/render/src/cache/dependencies.rs crates/render/src/fx_host.rs crates/render/src/pipeline.rs docs/effects.md

diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs--- a/crates/effects/src/channel2.rs+++ b/crates/effects/src/channel2.rs@@ -171,7 +171,9 @@ const CC_COMPOSITE_MODES: [&str; 16] = [     "Stencil Alpha", ]; -/// Composites the original stack input over/under/with the result of preceding effects.+/// Composites the original stack input over/under/with the result of preceding effects. With a+/// layer chosen (Create 3D Layer Instance's solid), that layer takes the original's place — a 2D+/// copy of it, rendered through the comp's camera when it is a 3D model layer. fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {     let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);     let m = ctx.params.e("compositeOriginal");@@ -193,11 +195,24 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {         15 => BlendMode::StencilAlpha,         _ => BlendMode::Normal,     };-    let original = ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale));+    // The chosen layer takes the original's place (a 2D copy of it); the layer's own original+    // otherwise, as before.+    let referenced = ctx.layer_param("layer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, false));+    // A chosen layer in front replaces the layer's pixels, alpha included: the copy of a 3D+    // layer is transparent where the model isn't. The other modes composite with them.+    let replace = referenced.is_some() && m == 0;+    let original =+        referenced.or_else(|| ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale)));     b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {         let result = *px;         let orig = original.as_ref().and_then(|o| o.data.get(i)).copied().unwrap_or(result).map(|v| v * op);-        let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) };+        let mut o = if replace {+            orig+        } else if m == 1 {+            blend_pixel(BlendMode::Normal, orig, result, 0.5)+        } else {+            blend_pixel(mode, result, orig, 0.5)+        };         o[3] = o[3].clamp(0.0, 1.0);         if rgb_only {             let (c, _) = unpremul(o);@@ -323,6 +338,7 @@ pub fn specs() -> Vec<EffectSpec> {             "ec.channel.cccomposite",             "CC Composite",             vec![+                layer_param("layer", "Layer"),                 p("opacity", "Opacity", num(100.0), pct()),                 p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)),                 p("rgbOnly", "RGB Only", Value::Bool(false), ParamUi::Checkbox),@@ -423,6 +439,39 @@ pub(crate) mod tests {         assert!((add.img.get(0, 0)[0] - 0.6).abs() < 1e-5);     } +    #[test]+    fn cc_composite_layer_takes_the_originals_place() {+        // Create 3D Layer Instance's solid: the chosen layer takes the original's place. In+        // Front replaces the pixels — alpha included, the copy of a 3D layer is transparent+        // where the model isn't — and the other modes composite with them.+        let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]);+        let model = |write: bool| {+            let mut img = Image::new(4, 4);+            if write {+                img.set(1, 1, [1.0, 0.0, 0.0, 1.0]);+            }+            img+        };+        let out = with_host(model(true), |env| run_fx("ec.channel.cccomposite", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env));+        assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the chosen layer's pixel");+        assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the chosen layer isn't");+        // Half opacity keeps the copy's shape at half strength.+        let out =+            with_host(model(true), |env| run_fx("ec.channel.cccomposite", &[("layer", Value::Layer(Some(7))), ("opacity", num(50.0))], solid(), 0.0, env));+        let p = out.img.get(1, 1);+        assert!((p[0] - 0.5).abs() < 1e-6 && (p[3] - 0.5).abs() < 1e-6, "{p:?}");+        assert_eq!(out.img.get(3, 3), [0.0; 4]);+        // In Back: the layer's own pixels over the chosen layer.+        let out = with_host(model(true), |env| {+            run_fx("ec.channel.cccomposite", &[("layer", Value::Layer(Some(7))), ("compositeOriginal", Value::Enum(1))], solid(), 0.0, env)+        });+        assert_eq!(out.img.get(1, 1), [1.0, 1.0, 1.0, 1.0]);+        assert_eq!(out.img.get(3, 3), [1.0, 1.0, 1.0, 1.0]);+        // No layer chosen: the layer's own original, as before.+        let out = with_host(model(true), |env| run_fx("ec.channel.cccomposite", &[], Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]), 0.0, env));+        assert_eq!(out.img.get(0, 0), [0.5, 0.5, 0.5, 1.0]);+    }+     #[test]     fn compound_arithmetic_copy_self_identity_and_add() {         let img = sample();diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs--- a/crates/engine/src/cmd/create.rs+++ b/crates/engine/src/cmd/create.rs@@ -1,14 +1,17 @@ //! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,-//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).+//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer+//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to).  use effectcraft_color::Label;+use effectcraft_keyframe::Value as KV;+use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ItemKind, LayerSource, Solid}; use serde_json::{Value, json};  use super::{CommandSpec, bad, has_layers, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; -const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";+const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";  fn create(s: &mut Session, p: &Value) -> Result<Value> {     let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;@@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {     let time = s.time();     let mut created = vec![];     let mut skipped = vec![];+    // All the selected models in one edit: one undo (or redo) removes (or restores) every+    // instance at once, as one command in the History panel.+    if op == "instance3d" {+        // After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take+        // no masks or effects, so a comp-sized solid goes at the top of the layer stack+        // with CC Composite referencing the model — a 2D copy of its render, through the+        // comp's camera, that 2D effects can be applied to. The model is switched off,+        // as After Effects does (back on to keep its shadow).+        let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();+        let mut eligible = vec![];+        for lid in &lids {+            match comp.layer(*lid) {+                Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())),+                Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)),+                None => {}+            }+        }+        if !eligible.is_empty() {+            let ids = s.edit("Create 3D Layer Instance", None, |proj, st| {+                let mut ids = vec![];+                for (lid, src) in &eligible {+                    let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name));+                    let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));+                    let sid = proj.add_item(+                        &name,+                        src.label,+                        Some(folder),+                        ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),+                    );+                    let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);+                    // A soloed source keeps its Solo switch with video off, and any_solo hides+                    // the non-soloed copy: the copy takes the Solo over to stay visible.+                    l.switches.solo = src.switches.solo;+                    // CC Composite referencing the 3D layer: the solid shows its render (the 2D+                    // copy).+                    let fx = effectcraft_effects::find("ec.channel.cccomposite").ok_or_else(|| bad("layer.create", "the CC Composite effect is missing"))?;+                    let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]);+                    if let Some(pr) = g.get_mut("layer") {+                        pr.value = KV::Layer(Some(lid.0));+                    }+                    if let Some(fxg) = l.props.sub_mut("effects") {+                        fxg.children.push(g.into());+                    }+                    // The model is switched off (After Effects turns the referenced layer off);+                    // the copies go at the top of the stack, selected.+                    let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;+                    if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) {+                        m.switches.video = false;+                    }+                    let id = l.id;+                    c.layers.insert(0, l);+                    ids.push(id);+                }+                st.selected_layers = ids.clone();+                Ok(ids)+            })?;+            created.extend(ids.iter().map(|i| i.0));+        }+        if created.is_empty() && !skipped.is_empty() {+            return Err(bad("layer.create", skipped.join("; ")));+        }+        return Ok(json!({"layers": created, "skipped": skipped}));+    }     for lid in lids {         let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();         let Some(src) = comp.layer(lid).cloned() else { continue };@@ -198,5 +264,5 @@ fn insert_above( }  pub fn specs() -> Vec<CommandSpec> {-    vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]+    vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)] }diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -15,6 +15,7 @@ mod frame_export; mod fuzz_open; mod history; mod import_project;+mod instance3d; mod key_metadata_order; mod key_time; mod keylight;diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/tests/instance3d.rs@@ -0,0 +1,257 @@+//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack+//! with CC Composite referencing the selected 3D model layer — a 2D copy of its render that 2D+//! effects can be applied to (behaviour, pixels, undo/redo, serde).++use std::sync::Arc;++use effectcraft_keyframe::Value as KV;+use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project};+use effectcraft_render::{FootageSource, RenderOpts};+use effectcraft_time::{FrameRate, Tick};+use serde_json::json;++use crate::{Importer, Session};++fn session() -> Session {+    let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };+    s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();+    s+}++fn cid(s: &Session) -> ItemId {+    s.active_comp_id().unwrap()+}++fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {+    s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()+}++fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {+    img.data[(y * img.width + x) as usize]+}++fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool {+    a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))+}++struct DiskModels;++impl FootageSource for DiskModels {+    fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {+        None+    }+    fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {+        let bytes = std::fs::read(&f.path).ok()?;+        effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)+    }+}++struct ModelImporter;++impl Importer for ModelImporter {+    fn probe(&self, path: &str) -> Result<Footage, String> {+        effectcraft_model::format_of(path).ok_or("not a model")?;+        Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })+    }+}++/// A red triangle in the Z = 0 plane (buffer embedded as a data URI).+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}+  ]+}"#;++fn triangle_model(s: &mut Session) -> u64 {+    let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("Triangle.gltf");+    std::fs::write(&path, TRIANGLE).unwrap();+    s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap()+}++#[test]+fn instance3d_creates_a_2d_copy_of_the_model() {+    let mut s = session();+    let model = triangle_model(&mut s);+    // The model renders through the comp's camera.+    let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());+    assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180));+    assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4));+    // Create 3D Layer Instance.+    s.execute("layer.select", json!({"layers": [model]})).unwrap();+    let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();+    let solid = r["layers"][0].as_u64().unwrap();+    let comp = cid(&s);+    let l = layer_in(&s, comp, solid);+    assert_eq!(l.name, "Triangle Instance");+    assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack");+    // CC Composite referencing the 3D layer.+    let g = l.effects().unwrap().groups().next().unwrap().clone();+    assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.channel.cccomposite"), "{:?}", g.kind);+    assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));+    // The model is switched off, the copy selected.+    assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does");+    assert_eq!(s.state.selected_layers, vec![LayerId(solid)]);+    // The comp renders the same through the copy: a 2D copy of the 3D layer, transparent+    // around it.+    let after = s.render(comp, Tick::ZERO, RenderOpts::default());+    assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));+    assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4));+    // Undo removes the copy and switches the model back on; redo returns it.+    s.execute("edit.undo", json!({})).unwrap();+    assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none());+    assert!(layer_in(&s, comp, model).switches.video);+    s.execute("edit.redo", json!({})).unwrap();+    assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some());+    assert!(!layer_in(&s, comp, model).switches.video);+    // Serde round-trips the layer parameter.+    let j = serde_json::to_string(&*s.project).unwrap();+    let back: Project = serde_json::from_str(&j).unwrap();+    assert_eq!(back, *s.project);+    // 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue+    // recolours the triangle.+    s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap();+    s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();+    let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default());+    let p = px(&with_fx, 320, 180);+    assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}");+}++#[test]+fn instance3d_also_takes_3d_primitives() {+    let mut s = session();+    let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap();+    let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());+    let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap();+    let solid = r["layers"][0].as_u64().unwrap();+    let l = layer_in(&s, cid(&s), solid);+    assert_eq!(l.name, "Cube Instance");+    assert!(!layer_in(&s, cid(&s), prim).switches.video);+    let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default());+    assert!(close(&before, &after, 2e-2), "the copy shows the cube");+}++#[test]+fn instance3d_needs_a_3d_layer() {+    let mut s = session();+    let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();+    let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]}));+    assert!(err.is_err());+    let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap();+    assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());+    // A 3D-switched text layer is not a model layer either (it has 2D pixels of its own).+    s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap();+    assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());+}++#[test]+fn instance3d_creates_every_selected_copy_in_one_undo() {+    let mut s = session();+    let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();+    let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();+    let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();+    let copies = r["layers"].as_array().unwrap();+    assert_eq!(copies.len(), 2);+    let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());+    let comp = cid(&s);+    assert_ne!(ia, ib);+    assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");+    // Both sources are off, both copies selected, above their sources in the stack.+    assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);+    let mut sel = s.state.selected_layers.clone();+    let mut want = vec![LayerId(ia), LayerId(ib)];+    sel.sort_by_key(|l| l.0);+    want.sort_by_key(|l| l.0);+    assert_eq!(sel, want);+    let c = s.active_comp().unwrap();+    let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();+    assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");+    // One undo removes both copies and switches both models back on; one redo returns them.+    s.execute("edit.undo", json!({})).unwrap();+    let c = s.active_comp().unwrap();+    assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");+    assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);+    s.execute("edit.redo", json!({})).unwrap();+    let c = s.active_comp().unwrap();+    assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");+    assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);+}++#[test]+fn instance3d_in_a_mixed_selection_only_takes_the_models() {+    let mut s = session();+    let model = triangle_model(&mut s);+    let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();+    let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();+    let copies = r["layers"].as_array().unwrap();+    assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");+    let copy = copies[0].as_u64().unwrap();+    let comp = cid(&s);+    assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");+    assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");+    let skipped = r["skipped"].as_array().unwrap();+    assert_eq!(skipped.len(), 1);+    assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");+    // The copy references the model, not the solid.+    let l = layer_in(&s, comp, copy);+    let g = l.effects().unwrap().groups().next().unwrap().clone();+    assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));+}++#[test]+fn instance3d_shows_nothing_outside_the_source_layer_range() {+    let mut s = session();+    let model = triangle_model(&mut s);+    // The model's range ends at 1 s; the comp runs to 2 s.+    let comp = cid(&s);+    s.edit("trim", None, |p, _| {+        p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0);+        Ok(())+    })+    .unwrap();+    s.execute("layer.select", json!({"layers": [model]})).unwrap();+    let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();+    let _copy = r["layers"][0].as_u64().unwrap();+    // Inside the model's range the copy shows it, transparent around it.+    let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default());+    assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180));+    // After its out point the source has nothing to read: the copy is transparent.+    let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default());+    assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180));+}++#[test]+fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() {+    let mut s = session();+    let model = triangle_model(&mut s);+    let comp = cid(&s);+    // Solo the model, then create the instance: any_solo hides non-soloed layers and the+    // source's video goes off, so the copy has to take the Solo over.+    s.edit("solo", None, |p, _| {+        p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true;+        Ok(())+    })+    .unwrap();+    let before = s.render(comp, Tick::ZERO, RenderOpts::default());+    assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180));+    s.execute("layer.select", json!({"layers": [model]})).unwrap();+    let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();+    let solid = r["layers"][0].as_u64().unwrap();+    assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch");+    assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off");+    let after = s.render(comp, Tick::ZERO, RenderOpts::default());+    assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180));+    assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));+}diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs--- a/crates/gpu/src/fx_pixel2.rs+++ b/crates/gpu/src/fx_pixel2.rs@@ -247,6 +247,12 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {             p.f[0] = pr.color("highlightColor");         }         "ec.channel.cccomposite" => {+            // A chosen layer (Create 3D Layer Instance's solid) runs on the CPU: its pixels — a+            // referenced 3D model rendered through the comp's camera — replace the layer's own+            // original, alpha included.+            if ctx.layer_param("layer", true).is_some() {+                return None;+            }             if let Some(source) = ctx.env.host.and_then(|h| h.original()) {                 let fitted = effectcraft_effects::util::fit_buffer(source, b.img.width, b.img.height, b.offset, b.scale);                 original = Some(e.g.upload_image(&fitted)?);diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs--- a/crates/render/src/cache/dependencies.rs+++ b/crates/render/src/cache/dependencies.rs@@ -68,6 +68,13 @@ impl Dependencies<'_> {         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.+        if layer.source.is_model() {+            return None;+        }         self.remaining = self.remaining.checked_sub(1)?;         if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) {             return None;diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs--- a/crates/render/src/fx_host.rs+++ b/crates/render/src/fx_host.rs@@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> {             return None;         }         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+        // comp, centred on the layer's anchor).+        if other.source.is_model() {+            let buf = sub.solo_3d(self.ctx, other);+            return Some(self.pixels(other, buf, false));+        }         // The cached layer buffer is shared; effects get their own copy.         let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };         Some(self.pixels(other, buf, true))diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs--- a/crates/render/src/pipeline.rs+++ b/crates/render/src/pipeline.rs@@ -831,6 +831,30 @@ impl<'a> Renderer<'a> {         self.source(ctx, layer)     } +    /// A 3D model or primitive layer rendered on its own, through the comp's camera: what an+    /// effect layer parameter reads when it references one, as After Effects, which renders 3D+    /// layers through the active camera. The comp's pixels at this render's scale; the layer's+    /// blend mode, track matte and video switch don't apply (a source read, not a composite —+    /// the solo clone of a 3D track matte, see `three_d::adv`).+    pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf {+        let s = self.opts.scale;+        let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1);+        let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1);+        let mut canvas = Image::new(w, h);+        // Outside the layer's in/out points there is nothing to read: the copy is transparent+        // there, like any other layer reference (the 3D pipelines don't gate on the range).+        if !layer.is_active_at(ctx.time) {+            return Buf { img: canvas, offset: [0.0; 2], scale: s };+        }+        let mut solo = layer.clone();+        solo.track_matte = None;+        solo.blend_mode = effectcraft_color::BlendMode::Normal;+        solo.preserve_transparency = false;+        solo.switches.video = true;+        three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas);+        Buf { img: canvas, offset: [0.0; 2], scale: s }+    }+     /// Layer pixels after source → masks → effects (no layer styles).     pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {         self.content_buf_timed(ctx, layer, None).map(|(b, _)| b)diff --git a/docs/effects.md b/docs/effects.md--- a/docs/effects.md+++ b/docs/effects.md@@ -3,7 +3,7 @@ <!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.      Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current --> -EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.+EffectCraft ships 307 effects with 3033 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.  - **GPU**: 280 effects also run on the GPU compositor with identical results. - **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping.@@ -69,7 +69,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands |---|---|---:|:-:|:-:|---| | Arithmetic | `ec.channel.arithmetic` | 5 | GPU | 32 | Implemented | | Blend | `ec.channel.blend` | 4 | GPU | 32 | Implemented |-| CC Composite | `ec.channel.cccomposite` | 3 | GPU | 32 | Implemented |+| CC Composite | `ec.channel.cccomposite` | 4 | GPU | 32 | Implemented | | Calculations | `ec.channel.calculations` | 9 | GPU | 32 | Implemented | | Channel Combiner | `ec.channel.combiner` | 6 | GPU | 32 | Implemented | | Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | GPU | 32 | Implemented |