mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
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:
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user