From 82cebba68fa603c74772cabcd1aecba94dd50189 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Sun, 25 Oct 2026 12:46:58 +0000 Subject: [PATCH] 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 |