Merge pull request #2410 from Minher0/feat/2100-instance3d-layer-create

layer.create: implement the instance3d op - a 2D copy of a 3D model layer (#2100)
This commit is contained in:
BFlat
2026-10-30 05:08:44 +07:00
committed by GitHub
11 changed files with 659 additions and 7 deletions
+19
View File
@@ -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<EffectSpec> {
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<EffectSpec> {
],
identifier,
),
spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
]
}
+26
View File
@@ -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<LayerPixels> {
(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<Vec<f32>> {
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);
}
+69 -3
View File
@@ -1,14 +1,17 @@
//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,
//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).
//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer
//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to).
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{ItemKind, LayerSource, Solid};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
@@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let time = s.time();
let mut created = vec![];
let mut skipped = vec![];
// All the selected models in one edit: one undo (or redo) removes (or restores) every
// instance at once, as one command in the History panel.
if op == "instance3d" {
// After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take
// no masks or effects, so a comp-sized solid goes at the top of the layer stack
// with 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 {
match comp.layer(*lid) {
Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())),
Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)),
None => {}
}
}
if !eligible.is_empty() {
let ids = s.edit("Create 3D Layer Instance", None, |proj, st| {
let mut ids = vec![];
for (lid, src) in &eligible {
let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name));
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let sid = proj.add_item(
&name,
src.label,
Some(folder),
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
);
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
// A soloed source keeps its Solo switch with video off, and any_solo hides
// the non-soloed copy: the copy takes the Solo over to stay visible.
l.switches.solo = src.switches.solo;
// 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));
}
if let Some(fxg) = l.props.sub_mut("effects") {
fxg.children.push(g.into());
}
// The model is switched off (After Effects turns the referenced layer off);
// the copies go at the top of the stack, selected.
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) {
m.switches.video = false;
}
let id = l.id;
c.layers.insert(0, l);
ids.push(id);
}
st.selected_layers = ids.clone();
Ok(ids)
})?;
created.extend(ids.iter().map(|i| i.0));
}
if created.is_empty() && !skipped.is_empty() {
return Err(bad("layer.create", skipped.join("; ")));
}
return Ok(json!({"layers": created, "skipped": skipped}));
}
for lid in lids {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let Some(src) = comp.layer(lid).cloned() else { continue };
@@ -198,5 +264,5 @@ fn insert_above(
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)]
}
+1
View File
@@ -15,6 +15,7 @@ mod frame_export;
mod fuzz_open;
mod history;
mod import_project;
mod instance3d;
mod key_metadata_order;
mod key_time;
mod keyframe_text;
+257
View File
@@ -0,0 +1,257 @@
//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack
//! 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;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project};
use effectcraft_render::{FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Importer, Session};
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
s
}
fn cid(s: &Session) -> ItemId {
s.active_comp_id().unwrap()
}
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
}
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[(y * img.width + x) as usize]
}
fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool {
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
}
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
}
}
/// A red triangle in the Z = 0 plane (buffer embedded as a data URI).
const TRIANGLE: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
]
}"#;
fn triangle_model(s: &mut Session) -> u64 {
let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Triangle.gltf");
std::fs::write(&path, TRIANGLE).unwrap();
s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn instance3d_creates_a_2d_copy_of_the_model() {
let mut s = session();
let model = triangle_model(&mut s);
// The model renders through the comp's camera.
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180));
assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4));
// Create 3D Layer Instance.
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
let comp = cid(&s);
let l = layer_in(&s, comp, solid);
assert_eq!(l.name, "Triangle Instance");
assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack");
// 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.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");
assert_eq!(s.state.selected_layers, vec![LayerId(solid)]);
// The comp renders the same through the copy: a 2D copy of the 3D layer, transparent
// around it.
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4));
// Undo removes the copy and switches the model back on; redo returns it.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none());
assert!(layer_in(&s, comp, model).switches.video);
s.execute("edit.redo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some());
assert!(!layer_in(&s, comp, model).switches.video);
// Serde round-trips the layer parameter.
let j = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&j).unwrap();
assert_eq!(back, *s.project);
// 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue
// recolours the triangle.
s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap();
s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default());
let p = px(&with_fx, 320, 180);
assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}");
}
#[test]
fn instance3d_also_takes_3d_primitives() {
let mut s = session();
let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap();
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
let l = layer_in(&s, cid(&s), solid);
assert_eq!(l.name, "Cube Instance");
assert!(!layer_in(&s, cid(&s), prim).switches.video);
let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
assert!(close(&before, &after, 2e-2), "the copy shows the cube");
}
#[test]
fn instance3d_needs_a_3d_layer() {
let mut s = session();
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]}));
assert!(err.is_err());
let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
// A 3D-switched text layer is not a model layer either (it has 2D pixels of its own).
s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap();
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
}
#[test]
fn instance3d_creates_every_selected_copy_in_one_undo() {
let mut s = session();
let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();
let copies = r["layers"].as_array().unwrap();
assert_eq!(copies.len(), 2);
let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());
let comp = cid(&s);
assert_ne!(ia, ib);
assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");
// Both sources are off, both copies selected, above their sources in the stack.
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
let mut sel = s.state.selected_layers.clone();
let mut want = vec![LayerId(ia), LayerId(ib)];
sel.sort_by_key(|l| l.0);
want.sort_by_key(|l| l.0);
assert_eq!(sel, want);
let c = s.active_comp().unwrap();
let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();
assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");
// One undo removes both copies and switches both models back on; one redo returns them.
s.execute("edit.undo", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");
assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);
s.execute("edit.redo", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
}
#[test]
fn instance3d_in_a_mixed_selection_only_takes_the_models() {
let mut s = session();
let model = triangle_model(&mut s);
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();
let copies = r["layers"].as_array().unwrap();
assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");
let copy = copies[0].as_u64().unwrap();
let comp = cid(&s);
assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");
assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");
let skipped = r["skipped"].as_array().unwrap();
assert_eq!(skipped.len(), 1);
assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");
// The copy references the model, not the solid.
let l = layer_in(&s, comp, copy);
let g = l.effects().unwrap().groups().next().unwrap().clone();
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
}
#[test]
fn instance3d_shows_nothing_outside_the_source_layer_range() {
let mut s = session();
let model = triangle_model(&mut s);
// The model's range ends at 1 s; the comp runs to 2 s.
let comp = cid(&s);
s.edit("trim", None, |p, _| {
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0);
Ok(())
})
.unwrap();
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let _copy = r["layers"][0].as_u64().unwrap();
// Inside the model's range the copy shows it, transparent around it.
let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default());
assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180));
// After its out point the source has nothing to read: the copy is transparent.
let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default());
assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180));
}
#[test]
fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() {
let mut s = session();
let model = triangle_model(&mut s);
let comp = cid(&s);
// Solo the model, then create the instance: any_solo hides non-soloed layers and the
// source's video goes off, so the copy has to take the Solo over.
s.edit("solo", None, |p, _| {
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true;
Ok(())
})
.unwrap();
let before = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180));
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch");
assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off");
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180));
assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
}
+58 -1
View File
@@ -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<usize>) -> Option<()> {
@@ -63,11 +63,68 @@ 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 (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.
if (self.other_times || super::reads_other_times(layer, None)) && 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. 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);
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): hash what
// moves its pixels, so the owner keeps a cache entry that changes when any of it does.
if layer.source.is_model() {
self.soloed_model(ctx, layer)?;
return Some(());
}
self.remaining = self.remaining.checked_sub(1)?;
if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) {
return None;
+8
View File
@@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> {
return None;
}
let sub = self.r.nested();
// A 3D model or primitive layer has no 2D source of its own: an effect referencing one
// reads it as After Effects does — the layer rendered on its own through the comp's
// camera (Create 3D Layer Instance's 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);
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))
+24
View File
@@ -937,6 +937,30 @@ impl<'a> Renderer<'a> {
self.source(ctx, layer)
}
/// A 3D model or primitive layer rendered on its own, through the comp's camera: what an
/// effect layer parameter reads when it references one, as After Effects, which renders 3D
/// layers through the active camera. The comp's pixels at this render's scale; the layer's
/// blend mode, track matte and video switch don't apply (a source read, not a composite —
/// the solo clone of a 3D track matte, see `three_d::adv`).
pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf {
let s = self.opts.scale;
let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1);
let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1);
let mut canvas = Image::new(w, h);
// Outside the layer's in/out points there is nothing to read: the copy is transparent
// there, like any other layer reference (the 3D pipelines don't gate on the range).
if !layer.is_active_at(ctx.time) {
return Buf { img: canvas, offset: [0.0; 2], scale: s };
}
let mut solo = layer.clone();
solo.track_matte = None;
solo.blend_mode = effectcraft_color::BlendMode::Normal;
solo.preserve_transparency = false;
solo.switches.video = true;
three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas);
Buf { img: canvas, offset: [0.0; 2], scale: s }
}
/// Layer pixels after source → masks → effects (no layer styles).
pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.content_buf_timed(ctx, layer, None).map(|(b, _)| b)
+191
View File
@@ -1276,3 +1276,194 @@ 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<std::sync::Arc<crate::Image>> {
None
}
fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option<std::sync::Arc<effectcraft_model::Model>> {
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}");
}
/// 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");
}
+2
View File
@@ -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", "压缩程序"),
@@ -802,6 +803,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", "კომპრესორი"),
+4 -3
View File
@@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 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.<category>.<name>`) 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.<category>.<name>`) 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 |