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
@@ -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();@@ -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)] }@@ -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;new file mode 100644@@ -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));+}@@ -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)?);@@ -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;@@ -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))@@ -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)@@ -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 |