From 82cebba68fa603c74772cabcd1aecba94dd50189 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Sun, 25 Oct 2026 12:46:58 +0000 Subject: [PATCH 1/7] layer.create: the instance3d op - a 2D copy of a 3D model layer (#2100) Layer > Create > Create 3D Layer Instance, as After Effects (public docs): 3D model layers take no masks or effects, so the command puts a comp-sized solid at the top of the layer stack with CC Composite referencing the selected model, and switches the model off (back on to keep its shadow) - a 2D copy of its render, through the comp's camera, that 2D effects can be applied to. - CC Composite (ec.channel.cccomposite) gains an optional Layer parameter: with a layer chosen it takes the original's place; In Front replaces the solid's pixels, alpha included, the other Composite Original modes blend with them. The GPU compositor runs such an instance on the CPU. - FxHost serves a referenced model or primitive layer as After Effects does - the layer rendered on its own through the comp's camera (Renderer::solo_3d); the owner keeps no cache entry for it (it moves with the camera and lights, without a trace in its own properties). - layer.create {op: instance3d} skips layers that are not 3D model or primitive layers (the ones that take no effects); one undo step, the copy selected, the model's video off. Tests: tests_instance3d.rs (behaviour, pixels, undo/redo, serde, 2D effects on the copy), a CC Composite layer-parameter unit test; docs/effects.md regenerated (3033 parameters). --- crates/effects/src/channel2.rs | 55 ++++++++- crates/engine/src/commands/create.rs | 52 +++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_instance3d.rs | 157 ++++++++++++++++++++++++ crates/gpu/src/fx_pixel2.rs | 6 + crates/render/src/cache/dependencies.rs | 7 ++ crates/render/src/lib.rs | 27 ++++ docs/effects.md | 4 +- 8 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/tests_instance3d.rs diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 5be3c94e7..1c56ea2bd 100644 --- 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 { "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/commands/create.rs b/crates/engine/src/commands/create.rs index 850d38ada..f6ab9264d 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/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 { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; @@ -151,6 +154,49 @@ fn create(s: &mut Session, p: &Value) -> Result { })?; created.push(id.0); } + "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). + if !src.source.is_model() { + skipped.push(format!("{}: not a 3D model layer", src.name)); + continue; + } + let id = s.edit("Create 3D Layer Instance", None, |proj, st| { + let name = comp.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); + // 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()); + } + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + // The model is switched off (After Effects turns the referenced layer off); + // the copy goes at the top of the stack, selected. + 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); + st.selected_layers = vec![id]; + Ok(id) + })?; + created.push(id.0); + } other => return Err(bad("layer.create", format!("`{other}` is not available yet (supported: {OPS})"))), } } @@ -198,5 +244,5 @@ fn insert_above( } pub fn specs() -> Vec { - 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/lib.rs b/crates/engine/src/lib.rs index 382534a44..fa8450829 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1305,6 +1305,8 @@ mod tests_fuzz_open; #[cfg(test)] mod tests_import_project; #[cfg(test)] +mod tests_instance3d; +#[cfg(test)] mod tests_key_metadata_order; #[cfg(test)] mod tests_keylight; diff --git a/crates/engine/src/tests_instance3d.rs b/crates/engine/src/tests_instance3d.rs new file mode 100644 index 000000000..46b8ec434 --- /dev/null +++ b/crates/engine/src/tests_instance3d.rs @@ -0,0 +1,157 @@ +//! 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> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + 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 { + 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()); +} diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index acba936d0..e764e83d0 100644 --- 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 { 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 index 483b19da3..903a68a14 100644 --- 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/lib.rs b/crates/render/src/lib.rs index bf3aa42ee..88d6e8cfe 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -277,6 +277,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)) @@ -1493,6 +1501,25 @@ 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`). + 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 solo = layer.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + let mut canvas = Image::new(w, h); + 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> { self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) diff --git a/docs/effects.md b/docs/effects.md index b8214e438..7ed4a6d62 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -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..`) 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 | From 601a15bc1f01a8405d11969daca496b27d701f4e Mon Sep 17 00:00:00 2001 From: Minher0 Date: Sun, 25 Oct 2026 13:21:57 +0000 Subject: [PATCH 2/7] render: a 3D layer read outside its in/out range is transparent solo_3d passed the layer to the 3D pipelines whatever the comp time, so an instance (CC Composite referencing a model layer) rendered before the source layer's in-point or after its out-point. Like any other layer reference, there is nothing to read outside the range: return the transparent canvas. --- crates/engine/src/tests_instance3d.rs | 22 ++++++++++++++++++++++ crates/render/src/lib.rs | 7 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_instance3d.rs b/crates/engine/src/tests_instance3d.rs index 46b8ec434..c8c91ccf7 100644 --- a/crates/engine/src/tests_instance3d.rs +++ b/crates/engine/src/tests_instance3d.rs @@ -155,3 +155,25 @@ fn instance3d_needs_a_3d_layer() { 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_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)); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 88d6e8cfe..73e59f252 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1510,12 +1510,17 @@ impl<'a> Renderer<'a> { 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; - let mut canvas = Image::new(w, h); three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas); Buf { img: canvas, offset: [0.0; 2], scale: s } } From b50e74f295b0a2d191c4c49b2f59367ccba39ca2 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Sun, 25 Oct 2026 13:22:14 +0000 Subject: [PATCH 3/7] layer.create: every instance3d copy in one undo step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The op ran Session::edit once per selected layer, so each copy was a history step of its own: undoing removed them one at a time. Collect the eligible models first and create all the copies in a single edit — one undo (or redo) removes (or restores) them all, and each copy keeps a name of its own. --- crates/engine/src/commands/create.rs | 103 +++++++++++++++----------- crates/engine/src/tests_instance3d.rs | 54 ++++++++++++++ 2 files changed, 114 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index f6ab9264d..b9aa49424 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -22,6 +22,66 @@ fn create(s: &mut Session, p: &Value) -> Result { 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); + // 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 }; @@ -154,49 +214,6 @@ fn create(s: &mut Session, p: &Value) -> Result { })?; created.push(id.0); } - "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). - if !src.source.is_model() { - skipped.push(format!("{}: not a 3D model layer", src.name)); - continue; - } - let id = s.edit("Create 3D Layer Instance", None, |proj, st| { - let name = comp.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); - // 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()); - } - let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - // The model is switched off (After Effects turns the referenced layer off); - // the copy goes at the top of the stack, selected. - 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); - st.selected_layers = vec![id]; - Ok(id) - })?; - created.push(id.0); - } other => return Err(bad("layer.create", format!("`{other}` is not available yet (supported: {OPS})"))), } } diff --git a/crates/engine/src/tests_instance3d.rs b/crates/engine/src/tests_instance3d.rs index c8c91ccf7..aa31d3944 100644 --- a/crates/engine/src/tests_instance3d.rs +++ b/crates/engine/src/tests_instance3d.rs @@ -156,6 +156,60 @@ fn instance3d_needs_a_3d_layer() { 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(); From 36a50a63666f4bb183c7f4051edcf0ac731ffa4b Mon Sep 17 00:00:00 2001 From: Minher0 Date: Tue, 27 Oct 2026 09:46:32 +0000 Subject: [PATCH 4/7] layer.create: the instance copy takes the source's Solo switch A soloed model keeps its Solo switch with its video off, and any_solo hides non-soloed layers: the copy was excluded and the comp rendered blank. The copy now takes the Solo over; the source's video stays off. Covered by instance3d_keeps_the_copy_visible_when_the_source_is_soloed. --- crates/engine/src/cmd/create.rs | 3 +++ crates/engine/src/tests/instance3d.rs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index b9aa49424..246108371 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -52,6 +52,9 @@ fn create(s: &mut Session, p: &Value) -> Result { 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"))?; diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rs index aa31d3944..73577a0a8 100644 --- a/crates/engine/src/tests/instance3d.rs +++ b/crates/engine/src/tests/instance3d.rs @@ -231,3 +231,27 @@ fn instance3d_shows_nothing_outside_the_source_layer_range() { 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)); +} From 5b2f305f7299da00f751f49e28db0eddbd5e0041 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Thu, 29 Oct 2026 10:18:17 +0000 Subject: [PATCH 5/7] instance3d: a dedicated 3D Layer Instance effect, and the copy is cached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CC Composite stays After Effects-pure: its Layer parameter is gone, so parameter indices match AE again (scripts and expressions that address parameters by index). The model reference lives in its own effect, ec.3d.instance3d (3D Channel, CPU, one layer parameter), which replaces the layer's pixels with the referenced layer's render — alpha included. A referenced model's solo render is now hashed into the owner's cache key instead of declining the entry (Bflat, review of #2410): the layer, its animated values and transform, the mesh item, the comp camera and lights, draft 3D, the renderer setting and the in/out gate. Covered by a render test: the copy is cached, served on the second render, and re-rendered when the camera pans or the model leaves its in/out range. --- crates/effects/src/channel2.rs | 55 +----------- crates/effects/src/channel3d.rs | 19 ++++ crates/effects/src/channel3d/tests.rs | 26 ++++++ crates/engine/src/cmd/create.rs | 12 +-- crates/engine/src/tests/instance3d.rs | 8 +- crates/gpu/src/fx_pixel2.rs | 6 -- crates/render/src/cache/dependencies.rs | 36 +++++++- crates/render/src/fx_host.rs | 2 +- crates/render/src/tests.rs | 112 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/effects.rs | 2 + docs/effects.md | 9 +- 11 files changed, 210 insertions(+), 77 deletions(-) diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 1c56ea2bd..5be3c94e7 100644 --- a/crates/effects/src/channel2.rs +++ b/crates/effects/src/channel2.rs @@ -171,9 +171,7 @@ const CC_COMPOSITE_MODES: [&str; 16] = [ "Stencil Alpha", ]; -/// 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. +/// Composites the original stack input over/under/with the result of preceding effects. 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"); @@ -195,24 +193,11 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf { 15 => BlendMode::StencilAlpha, _ => BlendMode::Normal, }; - // 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))); + 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)); 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 replace { - orig - } else if m == 1 { - blend_pixel(BlendMode::Normal, orig, result, 0.5) - } else { - blend_pixel(mode, result, orig, 0.5) - }; + let mut o = 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); @@ -338,7 +323,6 @@ pub fn specs() -> Vec { "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), @@ -439,39 +423,6 @@ 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/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 08241107c..dc65c3255 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -7,6 +7,11 @@ //! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through //! (EXtractoR still maps the layer's own R, G, B and A). //! +//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux +//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model +//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a +//! 3D layer's render. +//! //! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer //! pixel's layer position, so render scale and layer padding are respected. @@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels +/// replaced with the referenced layer's render, alpha included — the copy is transparent +/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own +/// through the comp's camera, as After Effects renders 3D layers; another layer is read with +/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer +/// parameter with nothing chosen does. +fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(o) = ctx.layer_param("layer", true) { + b.img = crate::util::fit_layer(ctx, &b, &o, false); + } + b +} + pub fn specs() -> Vec { let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); vec![ @@ -682,6 +700,7 @@ pub fn specs() -> Vec { ], identifier, ), + spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d), ] } diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index 4fc84440e..e69c2a6aa 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() { let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); } + +#[test] +fn instance3d_replaces_the_pixels_with_the_referenced_layer() { + // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's + // own pixels, alpha included — the copy is transparent where the referenced layer isn't — + // and with no layer chosen the layer passes through. + struct Host(Image); + impl EffectHost for Host { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]); + let mut render = Image::new(4, 4); + render.set(1, 1, [1.0, 0.0, 0.0, 1.0]); + let env = EffectEnv { host: Some(&Host(render)), ..Default::default() }; + let out = run_fx("ec.3d.instance3d", &[("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 referenced layer's pixel"); + assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't"); + // No layer chosen: the layer's own pixels. + let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, solid().data); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 246108371..6c4b02c87 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -27,9 +27,9 @@ fn create(s: &mut Session, p: &Value) -> Result { 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). + // with a 3D Layer Instance effect 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 { @@ -55,9 +55,9 @@ fn create(s: &mut Session, p: &Value) -> Result { // 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"))?; + // A 3D Layer Instance effect referencing the 3D layer: the solid shows its + // render (the 2D copy). + let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance 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)); diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rs index 73577a0a8..48f4b7730 100644 --- a/crates/engine/src/tests/instance3d.rs +++ b/crates/engine/src/tests/instance3d.rs @@ -1,6 +1,6 @@ //! 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). +//! with a 3D Layer Instance effect 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; @@ -97,9 +97,9 @@ fn instance3d_creates_a_2d_copy_of_the_model() { 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. + // A 3D Layer Instance effect 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!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", 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"); diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index e764e83d0..acba936d0 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -247,12 +247,6 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { 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 index 903a68a14..5c45c9652 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -63,17 +63,45 @@ impl Dependencies<'_> { Some(()) } + /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself + /// (geometry, materials, switches, in/out), its animated values and transform at the time, + /// the mesh item, the comp's camera and lights at the time (the lights are off in draft 3D, + /// and the renderer setting changes the pixels), and the in/out gate (outside the range the + /// read is transparent). Comp size and render scale are in the owner's key already. + fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { + self.remaining = self.remaining.checked_sub(1)?; + // Expressions sampled at other times: not cacheable, as in the ordinary path. + if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { + return None; + } + hash_debug(self.h, layer); + hash_values(self.h, ctx, layer, &layer.props); + hash_debug(self.h, &ctx.world_matrix(layer)); + if let Some(item) = layer.source.item() { + hash_debug(self.h, &ctx.project.item(item)); + } + // The solo render goes through the comp's camera and lights. + hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); + hash_debug(self.h, &ctx.comp.draft_3d); + hash_debug(self.h, &ctx.comp.renderer); + // Outside the layer's in/out range the read is transparent: fold the gate in, so a + // static model renders once inside it and again when it leaves. + layer.is_active_at(ctx.time).hash(self.h); + Some(()) + } + fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { let id = (ctx.comp_id, layer.id); if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { return None; } // A referenced 3D model or primitive layer renders through the comp's camera and lights - // (an effect layer parameter reads it that way — Create 3D Layer Instance's CC - // Composite): those move its pixels without a trace in its own properties, so the owner - // keeps no cache entry for it. + // (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what + // moves its pixels, so the owner keeps a cache entry that changes when any of it does. if layer.source.is_model() { - return None; + self.soloed_model(ctx, layer)?; + return Some(()); } self.remaining = self.remaining.checked_sub(1)?; if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index b111daf64..f09db80b3 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -74,7 +74,7 @@ impl EffectHost for FxHost<'_, '_, '_> { let sub = self.r.nested(); // A 3D model or primitive layer has no 2D source of its own: an effect referencing one // reads it as After Effects does — the layer rendered on its own through the comp's - // camera (Create 3D Layer Instance's CC Composite), in the layer's effect space (the + // camera (Create 3D Layer Instance's effect), in the layer's effect space (the // comp, centred on the layer's anchor). if other.source.is_model() { let buf = sub.solo_3d(self.ctx, other); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 079274499..8857a739c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1073,3 +1073,115 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() { assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); assert_eq!(cache.stats().entries, 1); } + +// ------------------------------------------------------------------------------ 3D layer instances + +/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a +/// data URI), parsed in memory: a FootageSource that knows one model item. +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +struct SoloModels; + +impl crate::FootageSource for SoloModels { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option> { + effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new) + } +} + +/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the +/// copy: a solid with a 3D Layer Instance effect referencing the model. +fn instance_scene(pan: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + // Models are Advanced 3D (layer.newModel switches a Classic comp over). + p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + let mid = p.add_item( + "Triangle", + Label::Red, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "Triangle.gltf".into(), + kind: effectcraft_project::FootageKind::Model, + frame_rate: FrameRate::FPS_30, + codec: "glTF".into(), + ..Default::default() + }), + ); + let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + for path in ["transform/position", "transform/poi"] { + let pr = cam.props.prop_mut(path).unwrap(); + let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; + pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); + } + let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); + p.comp_mut(cid).unwrap().layers = vec![cam, owner, model]; + (p, cid) +} + +fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not. +fn cover_x(img: &crate::Image) -> f32 { + let (mut m, mut sx) = (0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let a = img.get(x as i64, y as i64)[3]; + m += a; + sx += a * x as f32; + } + } + assert!(m > 1.0, "the triangle is drawn"); + sx / m +} + +/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the +/// referenced model's solo render depends on — the camera, and the model's in/out gate. +#[test] +fn an_instance_of_a_model_is_cached_and_follows_the_camera() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let (q, _) = instance_scene(40.0); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key"); + // The same layer at a later time, outside its in/out range: the read turns transparent. + let mut out = p.clone(); + out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5); + assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key"); + // Two renders of one time: the second is served from the cache. + let cache = crate::LayerCache::default(); + let here = render_models(&p, cid, t, Some(&cache)); + let again = render_models(&p, cid, t, Some(&cache)); + assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one"); + assert!(cache.stats().hits > 0, "the copy was served from the cache"); + // Panning the camera: the copy (and the model) move with it. + let panned = render_models(&q, cid, t, Some(&cache)); + assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved"); + let (a, b) = (cover_x(&here), cover_x(&panned)); + assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); +} diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 162300f9c..41a4c762f 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -143,6 +143,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "雾 3D"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID 遮罩"), + ("ec.3d.instance3d", "3D Layer Instance", "3D 图层实例"), ("ec.audio.backwards", "Backwards", "倒放"), ("ec.audio.basstreble", "Bass & Treble", "低音和高音"), ("ec.audio.compressor", "Compressor", "压缩程序"), @@ -519,6 +520,7 @@ const GEORGIAN: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.3d.instance3d", "3D Layer Instance", "3D Layer Instance"), ("ec.audio.backwards", "Backwards", "უკუღმა"), ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), ("ec.audio.compressor", "Compressor", "კომპრესორი"), diff --git a/docs/effects.md b/docs/effects.md index 7ed4a6d62..c6fb40922 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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. +EffectCraft ships 308 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. -- **Status**: 307 implemented in full, 0 partial (what is missing is listed). +- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 308 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -16,6 +16,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented | +| 3D Layer Instance | `ec.3d.instance3d` | 1 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented | @@ -69,7 +70,7 @@ Effects are addressed by id (`ec..`) 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` | 4 | GPU | 32 | Implemented | +| CC Composite | `ec.channel.cccomposite` | 3 | 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 | From 93aed3bfd6049c7f7b20030ce03b9dcf20a029fd Mon Sep 17 00:00:00 2001 From: Minher0 Date: Thu, 29 Oct 2026 11:50:58 +0000 Subject: [PATCH 6/7] instance3d: the copy's cache key covers DoF, animated models and env light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The referenced model's solo render goes through the comp camera: its depth of field (focus, aperture, iris, highlights) blurs it, none of which is in the projection matrix — the evaluated Dof is hashed in now (CodeRabbit, review of #2410). A model with an animation clip selected is posed at its layer time (Animation Speed and Looping are static and hashed): the layer time is folded into the key, so every time is its own entry. A model without a clip keeps one key across times, as before. An Environment light's image lights Advanced 3D models, and its source can be any layer's render (or any environment layer) — too open-ended to hash: while one is on, the read is not cached. Each is covered by a render test. --- crates/render/src/cache/dependencies.rs | 32 ++++++++-- crates/render/src/tests.rs | 79 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 5c45c9652..a0910b0fd 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -3,7 +3,7 @@ //! cyclic/over-budget graphs or expressions sampled at unknown times. use super::{KeyHasher, hash_debug, hash_values}; use crate::EvalCtx; -use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup}; use std::hash::Hash; pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { @@ -65,9 +65,11 @@ impl Dependencies<'_> { /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself /// (geometry, materials, switches, in/out), its animated values and transform at the time, - /// the mesh item, the comp's camera and lights at the time (the lights are off in draft 3D, - /// and the renderer setting changes the pixels), and the in/out gate (outside the range the - /// read is transparent). Comp size and render scale are in the owner's key already. + /// the mesh item, the comp's camera (with its depth of field) and its lights at the time + /// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's + /// layer time while it plays an animation clip, and the in/out gate (outside the range the + /// read is transparent). An Environment light's image can be any layer's render: with one on + /// the read is not cached. Comp size and render scale are in the owner's key already. fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { self.remaining = self.remaining.checked_sub(1)?; // Expressions sampled at other times: not cacheable, as in the ordinary path. @@ -80,11 +82,31 @@ impl Dependencies<'_> { if let Some(item) = layer.source.item() { hash_debug(self.h, &ctx.project.item(item)); } - // The solo render goes through the comp's camera and lights. + // The solo render goes through the comp's camera and lights. The camera's depth of + // field blurs it (focus, aperture, iris, highlights) — none of that is in the + // projection hashed above. hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::active_camera(ctx).dof); hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); hash_debug(self.h, &ctx.comp.draft_3d); hash_debug(self.h, &ctx.comp.renderer); + // An Environment light's image lights Advanced 3D models (its source can be any + // layer's render, or any environment layer) — too open-ended to hash: while one is + // on (and lighting: Draft 3D lights nothing), don't cache the read. + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D + && !ctx.comp.draft_3d + && ctx.comp.layers.iter().any(|l| { + l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment }) + }) + { + return None; + } + // A model with an animation clip selected is posed at its layer time (Animation Speed + // and Looping are static, hashed above): every time is its own entry. Without one the + // pose is the same at every time: keep the static key. + if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 { + layer.layer_time(ctx.time).0.hash(self.h); + } // Outside the layer's in/out range the read is transparent: fold the gate in, so a // static model renders once inside it and again when it leaves. layer.is_active_at(ctx.time).hash(self.h); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 8857a739c..eec90436c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1185,3 +1185,82 @@ fn an_instance_of_a_model_is_cached_and_follows_the_camera() { let (a, b) = (cover_x(&here), cover_x(&panned)); assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); } + +/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) — +/// none of it is in the projection matrix: changing any of it must change the copy's key, or +/// it would serve the un-blurred pixels. +#[test] +fn the_copy_key_covers_the_cameras_depth_of_field() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| { + let mut q = p.clone(); + let cam = &mut q.comp_mut(cid).unwrap().layers[0]; + for (path, v) in + [("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))] + { + cam.props.prop_mut(path).unwrap().value = v; + } + q + }; + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + let ka = key_at(&set_dof(&p, true, 500.0, 20.0)); + let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture + let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus + let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off + assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key"); +} + +/// A model with an animation clip selected is posed at its layer time: every time is its own +/// entry. Without one (the default) the pose is the same at every time: one key inside the +/// in/out range, as before. +#[test] +fn the_copy_key_covers_an_animated_models_layer_time() { + let (p, cid) = instance_scene(0.0); + let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0)); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times"); + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1); + assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time"); + // Two times and back: each is rendered fresh, none served another time's pixels. + let cache = crate::LayerCache::default(); + let first = render_models(&q, cid, t0, Some(&cache)); + let second = render_models(&q, cid, t1, Some(&cache)); + assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels"); + assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render"); + let again = render_models(&q, cid, t0, Some(&cache)); + assert_eq!(cache.stats().hits, 1, "the first time is served on the way back"); + assert_same(&again, &first, "the way back is the first time's pixels"); +} + +/// An Environment light's image lights Advanced 3D models — its source can be any layer's +/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels. +#[test] +fn an_environment_light_makes_the_copy_uncached() { + let (p, cid) = instance_scene(0.0); + let mut q = p.clone(); + let comp = q.comp(cid).unwrap().clone(); + let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None); + q.comp_mut(cid).unwrap().layers.insert(0, env); + let t = Tick::from_seconds_f64(1.0); + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false) + }; + assert!(key_at(&q).is_none(), "with an Environment light the read is not cached"); + let cache = crate::LayerCache::default(); + let a = render_models(&q, cid, t, Some(&cache)); + let b = render_models(&q, cid, t, Some(&cache)); + assert_same(&a, &b, "two fresh renders agree"); + assert_eq!(cache.stats().hits, 0, "nothing is served from the cache"); + // The light off (not lighting): the cache comes back. + q.comp_mut(cid).unwrap().layers[0].switches.video = false; + assert!(key_at(&q).is_some(), "the light off, the read is cached again"); +} From 8b846ffdf1f63b4ccf6445d894760ad9babcad89 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 04:48:42 +0700 Subject: [PATCH 7/7] Render cache: the soloed-model key reads main's two-argument reads_other_times #2410's dependency walk for a soloed 3D model was written before reads_other_times took the effect count; it asks about all effects (None), as the other walks do. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache/dependencies.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 072b6cf0d..a25c6bf43 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -73,7 +73,7 @@ impl Dependencies<'_> { fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { self.remaining = self.remaining.checked_sub(1)?; // Expressions sampled at other times: not cacheable, as in the ordinary path. - if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { return None; } hash_debug(self.h, layer);