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.rs
new 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 |
