M7.7: camera/light rigs and Advanced 3D motion blur, blend modes, track mattes

Layer > Camera > Create Stereo 3D Rig (left/right eye comps with expression-linked eye
cameras, Stereo 3D Controls effect, stereo comp through 3D Glasses), Create Orbit Null,
Create Cameras from 3D Model; Layer > Light > Create Lights from 3D Model
(KHR_lights_punctual), Control Light with Camera, Create Environment Light Background
Layer (equirectangular sky drawn through the camera). glTF cameras and punctual lights
are parsed by effectcraft-model.

Advanced 3D: per-sample motion blur over the comp shutter, and blend modes / track
mattes / Preserve Transparency composited through the 2D path in depth order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 00:27:47 -04:00
1 parent f9f8d454e0
commit 8696d1e9ba
21 files changed
+1543 -35

No files matched your search

+19
View File
@@ -6,6 +6,13 @@ use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
/// Id of the Stereo 3D Controls effect (Layer ▸ Camera ▸ Create Stereo 3D Rig).
pub const STEREO_CONTROLS: &str = "ec.control.stereo3d";
/// Stereo 3D Controls ▸ Configuration: which eyes are offset from the master camera.
pub const STEREO_CONFIGURATIONS: [&str; 3] = ["Stereo Pair (Left & Right)", "Center & Right", "Center & Left"];
/// Stereo 3D Controls ▸ Convergence Of: the zero-parallax distance.
pub const STEREO_CONVERGENCE: [&str; 2] = ["Camera Point of Interest", "Camera Zoom Plane"];
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
b
}
@@ -24,5 +31,17 @@ pub fn specs() -> Vec<EffectSpec> {
spec("ec.control.point3d", "3D Point Control", vec![p("point", "3D Point", Value::Vec3([0.0, 0.0, 0.0]), ParamUi::Point3)]),
spec("ec.control.layer", "Layer Control", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)]),
spec("ec.control.dropdown", "Dropdown Menu Control", vec![p("menu", "Menu", Value::Enum(0), popup(&["Item 1", "Item 2", "Item 3"]))]),
// Layer ▸ Camera ▸ Create Stereo 3D Rig: the rig's eye cameras read these (expressions).
spec(
STEREO_CONTROLS,
"Stereo 3D Controls",
vec![
p("configuration", "Configuration", Value::Enum(0), popup(&STEREO_CONFIGURATIONS)),
p("sceneDepth", "Stereo Scene Depth", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("convergence", "Enable Convergence", Value::Bool(false), ParamUi::Checkbox),
p("convergenceOf", "Convergence Of", Value::Enum(0), popup(&STEREO_CONVERGENCE)),
p("zOffset", "Convergence Z Offset", num(0.0), slider(-100_000.0, 100_000.0, -2000.0, 2000.0, 1)),
],
),
]
}
+1 -1
View File
@@ -16,7 +16,7 @@ mod channel3d;
mod color2;
mod color3;
mod color_fx;
mod controls;
pub mod controls;
mod distort;
mod distort2;
mod distort3;
+2
View File
@@ -36,6 +36,7 @@ mod proxy;
pub mod puppet;
mod query;
mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod settings;
mod stubs;
@@ -111,6 +112,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(model3d::specs());
v.extend(rig3d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());
+614
View File
@@ -0,0 +1,614 @@
//! Camera and light rigs: Layer ▸ Camera ▸ Create Stereo 3D Rig / Create Orbit Null / Create
//! Cameras from 3D Model, Layer ▸ Light ▸ Create Lights from 3D Model / Control Light with
//! Camera / Create Environment Light Background Layer.
use effectcraft_color::Label;
use effectcraft_effects::controls::{STEREO_CONFIGURATIONS, STEREO_CONTROLS, STEREO_CONVERGENCE};
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{AutoOrient, Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::adv::scene::model_to_layer;
use effectcraft_render::three_d::camera::{self, layer_frame};
use serde_json::{Value, json};
use super::layer::insert_layer;
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, resolve_layer};
use crate::{EngineError, Result, Session, cmd};
fn arr(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// Set a property's static value (clears its keyframes and expression).
fn set(l: &mut Layer, path: &str, v: KV) {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = v;
pr.keys.clear();
pr.expr = None;
}
}
fn set_expr(l: &mut Layer, path: &str, text: String) {
if let Some(pr) = l.props.prop_mut(path) {
pr.expr = Some(Expression { text, enabled: true });
}
}
/// What evaluation needs from the session (cloned so edits can evaluate their own project).
struct Env {
time: effectcraft_time::Tick,
expr: Option<std::sync::Arc<dyn effectcraft_render::ExprHost>>,
footage: std::sync::Arc<dyn effectcraft_render::FootageSource>,
}
impl Env {
fn of(s: &Session, cid: ItemId) -> Env {
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
}
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
}
}
/// The camera a rig command acts on: `layer`, else a selected camera, else the active camera.
fn camera_p(s: &Session, p: &Value, cid: ItemId, cmd: &str) -> Result<LayerId> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let id = match p.get("layer").or_else(|| p.get("camera")) {
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, "no such layer"))?,
None => s
.state
.selected_layers
.iter()
.copied()
.find(|id| comp.layer(*id).is_some_and(Layer::is_camera))
.or_else(|| comp.active_camera(s.time_of(cid)).map(|l| l.id))
.ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
if !comp.layer(id).is_some_and(Layer::is_camera) {
return Err(bad(cmd, "the layer is not a camera"));
}
Ok(id)
}
/// Distance from a camera to what it looks at: its point of interest (two-node), else its zoom.
fn focus_distance(ctx: &EvalCtx, cam: &Layer) -> f64 {
let st = camera::layer_camera(ctx, cam);
if camera::is_two_node(cam)
&& let Some(tr) = cam.transform()
{
let poi = Vec3::from(ctx.v3(cam, tr, "poi", [0.0; 3]));
let poi = camera::parent_world(ctx, cam).apply(poi);
return (poi - st.eye).length().max(1.0);
}
st.zoom
}
// ---------------------------------------------------------------- stereo 3D rig
/// Stereo rig settings (Stereo 3D Controls).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StereoParams {
/// 0 Stereo Pair (Left & Right), 1 Center & Right, 2 Center & Left.
pub configuration: u32,
/// Interaxial separation, % of the comp width.
pub scene_depth: f64,
pub convergence: bool,
/// 0 the camera's point of interest, 1 its zoom plane.
pub convergence_of: u32,
pub z_offset: f64,
}
/// Eye offsets along the camera's x axis, in units of the interaxial separation.
pub fn eye_offsets(configuration: u32) -> [f64; 2] {
match configuration {
1 => [0.0, 1.0],
2 => [-1.0, 0.0],
_ => [-0.5, 0.5],
}
}
/// (position, point of interest) of the left (`eye` 0) or right (1) camera, from the master
/// camera's eye, axes, focus distance and zoom.
pub fn eye_camera(sp: &StereoParams, eye: usize, comp_w: f64, master: (Vec3, Vec3, Vec3), focus: f64, zoom: f64) -> (Vec3, Vec3) {
let (e, fwd, right) = master;
let d = sp.scene_depth / 100.0 * comp_w;
let pos = e + right * (eye_offsets(sp.configuration)[eye.min(1)] * d);
let poi = if sp.convergence {
let dc = if sp.convergence_of == 1 { zoom } else { focus } + sp.z_offset;
e + fwd * dc.max(1.0)
} else {
pos + fwd * focus
};
(pos, poi)
}
/// Expression snippets that rebuild [`eye_camera`] from the master camera and the controls.
fn eye_exprs(src_comp: &str, master: &str, controls: &str, two_node: bool, eye: usize) -> (String, String, String) {
let q = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into());
let k = eye_offsets(0)[eye.min(1)];
let k1 = eye_offsets(1)[eye.min(1)];
let k2 = eye_offsets(2)[eye.min(1)];
let head = format!(
"var src = comp({c});\nvar m = src.layer({m});\nvar fx = src.layer({x}).effect(\"Stereo 3D Controls\");\n\
var d = fx(\"Stereo Scene Depth\").value / 100 * src.width;\nvar cfg = fx(\"Configuration\").value;\n\
var k = cfg == 2 ? {k1} : (cfg == 3 ? {k2} : {k});\n\
var E = m.toWorld([0, 0, 0]);\nvar F = normalize(m.toWorldVec([0, 0, 1]));\n\
var eye = add(E, mul(normalize(m.toWorldVec([1, 0, 0])), k * d));\n",
c = q(src_comp),
m = q(master),
x = q(controls),
);
let dm = if two_node { "Math.max(length(sub(m.transform.pointOfInterest, m.transform.position)), 1)" } else { "m.cameraOption.zoom" };
let pos = format!("{head}eye;");
let poi = format!(
"{head}var dm = {dm};\nvar dc = (fx(\"Convergence Of\").value == 2 ? m.cameraOption.zoom : dm) + fx(\"Convergence Z Offset\").value;\n\
fx(\"Enable Convergence\").value ? add(E, mul(F, Math.max(dc, 1))) : add(eye, mul(F, dm));"
);
let zoom = format!("comp({}).layer({}).cameraOption.zoom;", q(src_comp), q(master));
(pos, poi, zoom)
}
fn stereo_p(p: &Value, cmd: &str) -> Result<StereoParams> {
let pick = |k: &str, opts: &[&str], ids: &[&str]| -> Result<Option<u32>> {
match p.get(k) {
None => Ok(None),
Some(Value::Number(n)) => Ok(n.as_u64().map(|v| (v as u32).min(opts.len() as u32 - 1))),
Some(Value::String(s)) => {
let n = s.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "");
let i = ids
.iter()
.position(|v| v.to_ascii_lowercase() == n)
.or_else(|| opts.iter().position(|o| o.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "") == n));
i.map(|i| Some(i as u32)).ok_or_else(|| bad(cmd, format!("{k}: {}", ids.join("|"))))
}
_ => Err(bad(cmd, format!("{k}: {}", ids.join("|")))),
}
};
Ok(StereoParams {
configuration: pick("configuration", &STEREO_CONFIGURATIONS, &["stereoPair", "centerRight", "centerLeft"])?.unwrap_or(0),
scene_depth: f_p(p, "sceneDepth").unwrap_or(3.0).clamp(0.0, 100.0),
convergence: b_p(p, "convergence").unwrap_or(false),
convergence_of: pick("convergenceOf", &STEREO_CONVERGENCE, &["poi", "zoom"])?.unwrap_or(0),
z_offset: f_p(p, "zOffset").unwrap_or(0.0),
})
}
fn unique_item_name(proj: &Project, base: &str) -> String {
let mut name = base.to_string();
let mut k = 2;
while proj.items.values().any(|i| i.name == name) {
name = format!("{base} {k}");
k += 1;
}
name
}
/// An empty comp with the settings of `src`.
fn sibling_comp(src: &Comp) -> Comp {
let mut c = src.clone();
c.layers.clear();
c.markers.clear();
c
}
fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.stereoRig";
let cid = comp_id(s, p)?;
let sp = stereo_p(p, cmd)?;
let view3d = p.get("view3d").and_then(Value::as_u64).unwrap_or(5) as u32;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let t = s.time_of(cid);
let w = src.width as f64;
let existing = src.active_camera(t).map(|l| l.id);
let env = Env::of(s, cid);
let r = s.edit("Create Stereo 3D Rig", None, |proj, st| {
// The master camera: the comp's active camera, else a new one.
let master_id = match existing {
Some(id) => id,
None => {
let name = src.unique_layer_name("Master Camera");
let l = build::layer(proj, &src, &name, LayerSource::Camera, (src.width, src.height), None);
insert_layer(proj, st, cid, l)?
}
};
let controls_name = proj.comp(cid).map(|c| c.unique_layer_name("Stereo 3D Controls")).unwrap_or_default();
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let mut ctl = build::layer(proj, &src_now, &controls_name, LayerSource::Null, (src.width, src.height), None);
ctl.switches.video = false;
let spec = effectcraft_effects::find(STEREO_CONTROLS).ok_or_else(|| bad(cmd, "Stereo 3D Controls effect missing"))?;
let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut proj.next_id), spec.name, [100.0, 100.0]);
for (k, v) in [
("configuration", KV::Enum(sp.configuration)),
("sceneDepth", KV::Scalar(sp.scene_depth)),
("convergence", KV::Bool(sp.convergence)),
("convergenceOf", KV::Enum(sp.convergence_of)),
("zOffset", KV::Scalar(sp.z_offset)),
] {
if let Some(pr) = fx.get_mut(k) {
pr.value = v;
}
}
if let Some(g) = ctl.props.sub_mut("effects") {
g.children.push(fx.into());
}
let ctl_id = ctl.id;
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let at = src_now.layers.iter().position(|l| l.id == master_id).unwrap_or(0);
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(at, ctl);
// Master camera frame at the current time.
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let master = src_now.layer(master_id).cloned().ok_or(EngineError::NoComp)?;
let ctx = env.ctx(proj, cid, &src_now);
let (e, fwd, down) = layer_frame(&ctx, &master);
let right = down.cross(fwd).normalize();
let focus = focus_distance(&ctx, &master);
let zoom = camera::layer_camera(&ctx, &master).zoom;
let two = camera::is_two_node(&master);
// Left and right eye comps: the source comp collapsed, seen by an eye camera.
let mut eye_comps = vec![];
for (eye, label) in [(0usize, "Left"), (1, "Right")] {
let name = unique_item_name(proj, &format!("{src_name} {label} Eye"));
let ec = sibling_comp(&src_now);
let eid = proj.add_item(&name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(ec.clone().into()));
let mut nest = build::layer(proj, &ec, &src_name, LayerSource::Comp { item: cid }, (src.width, src.height), Some(src.duration));
nest.switches.three_d = true;
nest.switches.collapse = true;
let (pos, poi) = eye_camera(&sp, eye, w, (e, fwd, right), focus, zoom);
let mut cam = build::layer(proj, &ec, &format!("{label} Eye Camera"), LayerSource::Camera, (src.width, src.height), None);
set(&mut cam, "transform/position", KV::Vec3(arr(pos)));
set(&mut cam, "transform/poi", KV::Vec3(arr(poi)));
set(&mut cam, "cameraOptions/zoom", KV::Scalar(zoom));
set(&mut cam, "cameraOptions/focusDistance", KV::Scalar(focus));
let (pe, poe, ze) = eye_exprs(&src_name, &master.name, &controls_name, two, eye);
set_expr(&mut cam, "transform/position", pe);
set_expr(&mut cam, "transform/poi", poe);
set_expr(&mut cam, "cameraOptions/zoom", ze);
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
comp.layers = vec![cam, nest];
eye_comps.push(eid);
}
// The output comp: both eyes through 3D Glasses.
let out_name = unique_item_name(proj, &format!("{src_name} Stereo 3D"));
let oc = sibling_comp(&src_now);
let oid = proj.add_item(&out_name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(oc.clone().into()));
let left_name = proj.item(eye_comps[0]).map(|i| i.name.clone()).unwrap_or_default();
let right_name = proj.item(eye_comps[1]).map(|i| i.name.clone()).unwrap_or_default();
let mut left = build::layer(proj, &oc, &left_name, LayerSource::Comp { item: eye_comps[0] }, (src.width, src.height), Some(src.duration));
let mut right = build::layer(proj, &oc, &right_name, LayerSource::Comp { item: eye_comps[1] }, (src.width, src.height), Some(src.duration));
right.switches.video = false;
let glasses = effectcraft_effects::find("ec.perspective.3dglasses").ok_or_else(|| bad(cmd, "3D Glasses effect missing"))?;
let mut g = effectcraft_effects::instantiate(glasses, &mut Ids(&mut proj.next_id), glasses.name, [w, src.height as f64]);
for (k, v) in [("leftView", KV::Layer(Some(left.id.0))), ("rightView", KV::Layer(Some(right.id.0))), ("view3d", KV::Enum(view3d.min(8)))] {
if let Some(pr) = g.get_mut(k) {
pr.value = v;
}
}
if let Some(fxg) = left.props.sub_mut("effects") {
fxg.children.push(g.into());
}
let (lid, rid) = (left.id, right.id);
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
st.project_selection = vec![oid];
Ok(json!({
"controls": ctl_id.0,
"master": master_id.0,
"leftComp": eye_comps[0].0,
"rightComp": eye_comps[1].0,
"stereoComp": oid.0,
"leftLayer": lid.0,
"rightLayer": rid.0,
}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- orbit null
/// Create Orbit Null: a 3D null at the camera's point of interest becomes the camera's parent,
/// so rotating the null orbits the camera around what it looks at.
fn orbit_null(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.orbitNull";
let cid = comp_id(s, p)?;
let cam_id = camera_p(s, p, cid, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let cam = comp.layer(cam_id).cloned().ok_or(EngineError::NoComp)?;
if cam.parent.is_some() {
return Err(bad(cmd, format!("`{}` already has a parent", cam.name)));
}
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let (eye, fwd, _) = layer_frame(&ctx, &cam);
let target = eye + fwd * focus_distance(&ctx, &cam);
let name = comp.unique_layer_name(&format!("{} Orbit Null", cam.name));
let r = s.edit("Create Orbit Null", None, |proj, st| {
let mut null = build::layer(proj, &comp, &name, LayerSource::Null, (comp.width, comp.height), None);
null.switches.three_d = true;
null.switches.video = true;
set(&mut null, "transform/position", KV::Vec3(arr(target)));
// World → null space (a translated, unrotated, unscaled null: p − P + anchor).
let anchor = Vec3::from(null.props.prop("transform/anchor").map(|a| a.value.as_vec3()).unwrap_or([0.0; 3]));
let to_null = |v: [f64; 3]| arr(Vec3::from(v) - target + anchor);
let nid = null.id;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| l.id == cam_id).unwrap_or(0);
c.layers.insert(at, null);
let l = c.layer_mut(cam_id).ok_or(EngineError::NoComp)?;
l.parent = Some(nid);
for path in ["transform/position", "transform/poi"] {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = KV::Vec3(to_null(pr.value.as_vec3()));
for k in &mut pr.keys {
k.value = KV::Vec3(to_null(k.value.as_vec3()));
}
}
}
st.selected_layers = vec![nid];
Ok(json!({"null": nid.0, "camera": cam_id.0, "position": arr(target)}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- control light with camera
/// Control Light with Camera: the light rides on the camera (parented at the camera's eye,
/// shining where it looks). Again (or `on: false`) releases it where it is.
fn control_light(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.controlWithCamera";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let lights: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(&comp, v)).collect(),
Some(v) => resolve_layer(&comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(Layer::is_light)).collect(),
};
if lights.is_empty() || lights.iter().any(|id| !comp.layer(*id).is_some_and(Layer::is_light)) {
return Err(bad(cmd, "select a light layer"));
}
let cam_id = match p.get("camera") {
Some(v) => resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(Layer::is_camera)).ok_or_else(|| bad(cmd, "no such camera"))?,
None => comp.active_camera(s.time_of(cid)).map(|l| l.id).ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
let on = b_p(p, "on").unwrap_or_else(|| comp.layer(lights[0]).is_none_or(|l| l.parent != Some(cam_id)));
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
// Released lights keep their current world placement.
let frames: Vec<(LayerId, Vec3, Vec3)> = lights
.iter()
.filter_map(|id| comp.layer(*id))
.map(|l| {
let (e, f, _) = layer_frame(&ctx, l);
(l.id, e, f)
})
.collect();
let (w, h) = (comp.width as f64, comp.height as f64);
s.edit(if on { "Control Light with Camera" } else { "Release Light from Camera" }, None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (id, e, f) in &frames {
let l = c.layer_mut(*id).ok_or(EngineError::NoComp)?;
if on {
l.parent = Some(cam_id);
set(l, "transform/position", KV::Vec3([0.0; 3]));
set(l, "transform/poi", KV::Vec3([0.0, 0.0, 1000.0]));
set(l, "transform/orientation", KV::Vec3([0.0; 3]));
for k in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
set(l, k, KV::Scalar(0.0));
}
l.auto_orient = AutoOrient::Off;
} else if l.parent == Some(cam_id) {
l.parent = None;
set(l, "transform/position", KV::Vec3(arr(*e)));
set(l, "transform/poi", KV::Vec3(arr(*e + *f * w.max(h))));
if l.transform().is_some_and(|t| t.get("poi").is_some()) {
l.auto_orient = AutoOrient::TowardsPointOfInterest;
}
}
}
Ok(())
})?;
Ok(json!({"controlled": on, "camera": cam_id.0, "lights": lights.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
// ---------------------------------------------------------------- cameras and lights from models
/// Model layers a "from 3D Model" command reads: `layers`/`layer`, else the selected model layers.
fn model_layers(s: &Session, p: &Value, comp: &Comp, cmd: &str) -> Result<Vec<LayerId>> {
let ids: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
Some(v) => resolve_layer(comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))).collect(),
};
if ids.is_empty() || ids.iter().any(|id| !comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))) {
return Err(bad(cmd, "select a 3D model layer"));
}
Ok(ids)
}
/// A camera or light found in a model: (name, world eye, world forward, the model data).
enum FromModel {
Camera { name: String, eye: Vec3, fwd: Vec3, zoom: f64 },
Light { name: String, eye: Vec3, fwd: Vec3, light: effectcraft_model::ModelLight },
}
fn from_model(s: &mut Session, p: &Value, lights: bool) -> Result<Value> {
let cmd = if lights { "light.fromModel" } else { "camera.fromModel" };
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let ids = model_layers(s, p, &comp, cmd)?;
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let mut found = vec![];
for id in &ids {
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
let LayerSource::Model { item } = l.source else { continue };
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { continue };
let Some(m) = s.footage.model(item, f) else { return Err(bad(cmd, format!("can't read the model of `{}`", l.name))) };
let unit = l.props.sub("geometryOptions").map_or(1.0, |g| ctx.f(l, g, "unitScale", 1.0));
let to_world = ctx.world_matrix(l) * model_to_layer(unit);
for pl in m.placed(lights, None, 0.0) {
let w: Mat4 = to_world * pl.world;
let eye = w.apply(Vec3::ZERO);
let fwd = w.apply_vec(vec3(0.0, 0.0, -1.0)).normalize();
if lights {
let ml = m.lights[pl.index].clone();
found.push(FromModel::Light { name: ml.name.clone(), eye, fwd, light: ml });
} else {
let mc = &m.cameras[pl.index];
let zoom = match mc.projection {
effectcraft_model::CameraProjection::Perspective { yfov, .. } => comp.height as f64 * 0.5 / (yfov * 0.5).tan(),
// Orthographic cameras become perspective ones framing the same height.
effectcraft_model::CameraProjection::Orthographic { .. } => effectcraft_geom::default_camera_zoom(comp.width as f64),
};
found.push(FromModel::Camera { name: mc.name.clone(), eye, fwd, zoom });
}
}
}
if found.is_empty() {
return Err(bad(cmd, if lights { "the model has no lights (KHR_lights_punctual)" } else { "the model has no cameras" }));
}
let made = s.edit(if lights { "Create Lights from 3D Model" } else { "Create Cameras from 3D Model" }, None, |proj, st| {
let mut made = vec![];
for f in &found {
let c = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let l = match f {
FromModel::Camera { name, eye, fwd, zoom } => {
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Camera, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * *zoom)));
set(&mut l, "cameraOptions/zoom", KV::Scalar(*zoom));
set(&mut l, "cameraOptions/focusDistance", KV::Scalar(*zoom));
l
}
FromModel::Light { name, eye, fwd, light } => {
let kind = match light.kind {
effectcraft_model::ModelLightKind::Directional => LightKind::Parallel,
effectcraft_model::ModelLightKind::Point => LightKind::Point,
effectcraft_model::ModelLightKind::Spot { .. } => LightKind::Spot,
};
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Light { kind }, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * c.width.max(c.height) as f64)));
let col = light.color;
set(&mut l, "lightOptions/color", KV::Color([col[0], col[1], col[2], 1.0]));
// Intensity: 1 unit of the file (lux / candela) = 100%.
set(&mut l, "lightOptions/intensity", KV::Scalar((light.intensity * 100.0).clamp(0.0, 1000.0)));
if let effectcraft_model::ModelLightKind::Spot { inner, outer } = light.kind {
set(&mut l, "lightOptions/coneAngle", KV::Scalar((outer * 2.0).to_degrees().clamp(0.0, 180.0)));
set(&mut l, "lightOptions/coneFeather", KV::Scalar(((1.0 - inner / outer.max(1e-9)) * 100.0).clamp(0.0, 100.0)));
}
if let Some(r) = light.range {
set(&mut l, "lightOptions/falloff", KV::Enum(1));
set(&mut l, "lightOptions/falloffDistance", KV::Scalar(r * 100.0));
}
l
}
};
made.push(insert_layer(proj, st, cid, l)?.0);
}
Ok(made)
})?;
Ok(json!({"layers": made}))
}
// ---------------------------------------------------------------- environment background
/// Create Environment Light Background Layer: a layer showing the environment light's image as
/// the scene's backdrop (an equirectangular sky drawn through the camera).
fn environment_background(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.environmentBackground";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time_of(cid);
let is_env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
let light = match p.get("layer") {
Some(v) => resolve_layer(&comp, v).and_then(|id| comp.layer(id)).filter(|l| is_env(l)).ok_or_else(|| bad(cmd, "not an environment light"))?,
None => s
.state
.selected_layers
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| is_env(l))
.or_else(|| comp.layers.iter().find(|l| is_env(l)))
.ok_or_else(|| bad(cmd, "the composition has no environment light (Layer ▸ New ▸ Light ▸ Environment)"))?,
};
let src_layer = light
.props
.prop("lightOptions/source")
.and_then(|pr| pr.value_at(light.layer_time(t)).as_layer())
.and_then(|id| comp.layer(LayerId(id)))
.or_else(|| comp.layers.iter().find(|l| l.environment))
.ok_or_else(|| bad(cmd, "the environment light has no Source (or set an Environment Layer)"))?
.clone();
if !matches!(src_layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) {
return Err(bad(cmd, "the environment source must be a footage, comp or solid layer"));
}
let light_id = light.id;
let name = comp.unique_layer_name(&format!("{} Background", src_layer.name));
let size = effectcraft_render::source_size(&s.project, &src_layer);
let id = s.edit("Create Environment Light Background Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, src_layer.source.clone(), size, None);
l.switches.three_d = true;
l.environment_background = true;
// The backdrop sits at the bottom of the stack.
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let id = l.id;
c.layers.push(l);
st.selected_layers = vec![id];
Ok(id)
})?;
Ok(json!({"layer": id.0, "light": light_id.0, "source": src_layer.id.0}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"camera.stereoRig",
"Create Stereo 3D Rig",
["Layer", "Camera"],
None,
"{comp?, configuration?: stereoPair|centerRight|centerLeft, sceneDepth? (% of comp width, default 3), convergence?: bool, convergenceOf?: poi|zoom, zOffset?, view3d?: 3D Glasses view index (default 5 Balanced Colored Red Blue)}",
has_comp,
stereo_rig
),
cmd!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], None, "{comp?, layer?: camera (default selected/active)}", has_comp, orbit_null),
cmd!(
"camera.fromModel",
"Create Cameras from 3D Model",
["Layer", "Camera"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, false) }
),
cmd!(
"light.fromModel",
"Create Lights from 3D Model",
["Layer", "Light"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, true) }
),
cmd!(
"light.controlWithCamera",
"Control Light with Camera",
["Layer", "Light"],
None,
"{comp?, layers?: lights (default selected), camera? (default active), on?: bool (default toggle)}",
has_comp,
control_light
),
cmd!(
"light.environmentBackground",
"Create Environment Light Background Layer",
["Layer", "Light"],
None,
"{comp?, layer?: environment light (default selected / first)}",
has_comp,
environment_background
),
]
}
-6
View File
@@ -26,15 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
// Cameras / lights / materials / 3D views (3D milestone).
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"),
stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"),
stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"),
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
// Color management.
+2
View File
@@ -673,6 +673,8 @@ mod tests_project_items;
#[cfg(test)]
mod tests_proxy;
#[cfg(test)]
mod tests_rig3d;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;
+1 -1
View File
@@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}
+338
View File
@@ -0,0 +1,338 @@
//! Camera and light rigs (M7.7): Create Stereo 3D Rig, Create Orbit Null, Control Light with
//! Camera, Create Cameras / Lights from 3D Model and Create Environment Light Background Layer
//! (behaviour, values, pixels, undo/redo, serde).
use std::sync::Arc;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::three_d::camera::layer_frame;
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, 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 v3(l: &Layer, path: &str) -> [f64; 3] {
l.props.prop(path).unwrap().value.as_vec3()
}
fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() <= tol)
}
/// World (eye, forward) of a layer in the active comp at time 0.
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
fn camera(s: &mut Session) -> u64 {
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
// Controls on a hidden null in the source comp.
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
assert_eq!(ctl.name, "Stereo 3D Controls");
assert!(!ctl.switches.video);
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
let ec = ItemId(r[key].as_u64().unwrap());
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
let out = s.project.comp(oc).unwrap();
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
let (l, rr) = (r["leftLayer"].as_u64().unwrap(), r["rightLayer"].as_u64().unwrap());
assert_eq!(out.layers[0].id.0, l);
assert!(!out.layers[1].switches.video);
let g = out.layers[0].effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
// One undo step; redo; serde.
let json = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&json).unwrap();
assert_eq!(back, *s.project);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before);
assert_eq!(s.project.comp(src).unwrap().layers.len(), 1);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3);
}
#[test]
fn stereo_rig_configurations_and_convergence() {
let mut s = session();
camera(&mut s);
let r = s.execute("camera.stereoRig", json!({"configuration": "centerRight", "sceneDepth": 5, "convergence": true})).unwrap();
let d = 0.05 * 640.0;
let eye = |key: &str| s.project.comp(ItemId(r[key].as_u64().unwrap())).unwrap().layers[0].clone();
let (l, rr) = (eye("leftComp"), eye("rightComp"));
assert!(close3(v3(&l, "transform/position"), [320.0, 180.0, -800.0], 1e-6));
assert!(close3(v3(&rr, "transform/position"), [320.0 + d, 180.0, -800.0], 1e-6));
// Converged on the master's point of interest.
assert!(close3(v3(&l, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
assert!(close3(v3(&rr, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
// A comp without a camera gets a Master Camera.
let mut s = session();
let r = s.execute("camera.stereoRig", json!({"configuration": "centerLeft"})).unwrap();
let m = layer_in(&s, cid(&s), r["master"].as_u64().unwrap());
assert_eq!(m.name, "Master Camera");
assert!(s.execute("camera.stereoRig", json!({"configuration": "sideways"})).is_err());
}
/// The eye cameras' expressions reproduce the static values (and follow the master).
#[test]
fn stereo_rig_maths() {
use crate::commands::rig3d::{StereoParams, eye_camera};
let sp = StereoParams { configuration: 0, scene_depth: 10.0, convergence: false, convergence_of: 0, z_offset: 0.0 };
let master = (vec3(0.0, 0.0, -100.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0));
let (p0, q0) = eye_camera(&sp, 0, 1000.0, master, 100.0, 500.0);
assert!(close3([p0.x, p0.y, p0.z], [-50.0, 0.0, -100.0], 1e-9) && close3([q0.x, q0.y, q0.z], [-50.0, 0.0, 0.0], 1e-9));
let sp = StereoParams { convergence: true, convergence_of: 1, z_offset: 50.0, ..sp };
let (_, q1) = eye_camera(&sp, 1, 1000.0, master, 100.0, 500.0);
// Zoom plane (500) + Z offset (50) in front of the master eye.
assert!(close3([q1.x, q1.y, q1.z], [0.0, 0.0, 450.0], 1e-9), "{q1:?}");
}
#[test]
fn orbit_null_parents_the_camera_at_its_point_of_interest() {
let mut s = session();
let cam = camera(&mut s);
let (eye0, f0) = frame(&s, cam);
let r = s.execute("camera.orbitNull", json!({})).unwrap();
let nid = r["null"].as_u64().unwrap();
let n = layer_in(&s, cid(&s), nid);
assert!(n.is_3d() && matches!(n.source, LayerSource::Null));
assert_eq!(n.name, "Cam Orbit Null");
assert!(close3(v3(&n, "transform/position"), [320.0, 180.0, 0.0], 1e-9));
let c = layer_in(&s, cid(&s), cam);
assert_eq!(c.parent, Some(LayerId(nid)));
// The camera stays where it was.
let (eye1, f1) = frame(&s, cam);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
// Turning the null orbits the camera around the point of interest.
s.execute("prop.set", json!({"layer": nid, "path": "transform/rotationY", "value": 90})).unwrap();
let (eye2, f2) = frame(&s, cam);
let poi = vec3(320.0, 180.0, 0.0);
assert!(((eye2 - poi).length() - 800.0).abs() < 1e-6, "{eye2:?}");
assert!((eye2.z).abs() < 1e-6 && (eye2.x - 320.0).abs() > 700.0, "{eye2:?}");
assert!(((poi - eye2).normalize() - f2).length() < 1e-6, "still looks at the POI: {f2:?}");
// A parented camera can't get another orbit null; undo restores the camera.
assert!(s.execute("camera.orbitNull", json!({"layer": cam})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_in(&s, cid(&s), cam).parent, None);
assert!(s.active_comp().unwrap().layer(LayerId(nid)).is_none());
}
#[test]
fn control_light_with_camera_follows_and_releases() {
let mut s = session();
let cam = camera(&mut s);
let light = s.execute("layer.newLight", json!({"kind": "Spot", "position": [0, 0, -500], "poi": [100, 100, 0]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [light]})).unwrap();
let r = s.execute("light.controlWithCamera", json!({})).unwrap();
assert_eq!(r["controlled"], true);
let l = layer_in(&s, cid(&s), light);
assert_eq!(l.parent, Some(LayerId(cam)));
let (ce, cf) = frame(&s, cam);
let (le, lf) = frame(&s, light);
assert!((le - ce).length() < 1e-6 && (lf - cf).length() < 1e-6, "light at the camera: {le:?} {lf:?}");
// Moving the camera carries the light.
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [100, 180, -800]})).unwrap();
let (le2, _) = frame(&s, light);
assert!((le2 - vec3(100.0, 180.0, -800.0)).length() < 1e-6, "{le2:?}");
// Again: released where it is (unparented, aimed the same way).
let r = s.execute("light.controlWithCamera", json!({"layers": [light]})).unwrap();
assert_eq!(r["controlled"], false);
let l = layer_in(&s, cid(&s), light);
assert_eq!(l.parent, None);
let (le3, lf3) = frame(&s, light);
assert!((le3 - le2).length() < 1e-6, "{le3:?}");
let (_, cf2) = frame(&s, cam);
assert!((lf3 - cf2).length() < 1e-6, "{lf3:?} {cf2:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_in(&s, cid(&s), light).parent, Some(LayerId(cam)));
// Not a light → error.
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
}
// ---------------------------------------------------------------- from 3D model
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() })
}
}
const RIGGED: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0, 1, 2]}],
"nodes": [
{"name": "ShotCam", "camera": 0, "translation": [0, 0, 5]},
{"name": "Key", "translation": [0, 3, 0], "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 0}}},
{"name": "Fill", "translation": [2, 0, 0], "extensions": {"KHR_lights_punctual": {"light": 1}}}
],
"cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4}
]}}
}"#;
fn rigged_model(s: &mut Session) -> u64 {
let dir = std::env::temp_dir().join(format!("ec-rig3d-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rigged.gltf");
std::fs::write(&path, RIGGED).unwrap();
let r = s.execute("layer.newModel", json!({"path": path.to_string_lossy()})).unwrap();
r["layer"].as_u64().unwrap()
}
#[test]
fn cameras_and_lights_from_model() {
let mut s = session();
let model = rigged_model(&mut s);
let m = layer_in(&s, cid(&s), model);
let unit = m.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
let pos = v3(&m, "transform/position");
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("camera.fromModel", json!({})).unwrap();
let cams = r["layers"].as_array().unwrap();
assert_eq!(cams.len(), 1);
let c = layer_in(&s, cid(&s), cams[0].as_u64().unwrap());
assert_eq!(c.name, "Shot");
// glTF +Z (towards the viewer) is AE −Z; the camera looks into the scene (+Z).
let want = [pos[0], pos[1], pos[2] - 5.0 * unit];
assert!(close3(v3(&c, "transform/position"), want, 1e-6), "{:?} vs {want:?}", v3(&c, "transform/position"));
let zoom = 180.0 / (0.3f64).tan();
assert!((c.props.prop("cameraOptions/zoom").unwrap().value.as_f64() - zoom).abs() < 1e-6);
let (_, f) = frame(&s, c.id.0);
assert!((f - vec3(0.0, 0.0, 1.0)).length() < 1e-6, "{f:?}");
// Lights: a spot (cone 2 × outer, feather from inner/outer) and a point light.
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("light.fromModel", json!({})).unwrap();
let ls: Vec<Layer> = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect();
assert_eq!(ls.len(), 2);
let key = ls.iter().find(|l| l.name == "Key Light").unwrap();
assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot });
assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6);
assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
assert!((key.props.prop("lightOptions/intensity").unwrap().value.as_f64() - 150.0).abs() < 1e-9);
// Points down in glTF (−Y) = down on screen in AE (+Y).
let (ke, kf) = frame(&s, key.id.0);
assert!((kf - vec3(0.0, 1.0, 0.0)).length() < 1e-5, "{kf:?}");
assert!((ke.y - (pos[1] - 3.0 * unit)).abs() < 1e-6, "{ke:?}");
let fill = ls.iter().find(|l| l.name == "Fill Light").unwrap();
assert_eq!(fill.source, LayerSource::Light { kind: LightKind::Point });
assert!((fill.props.prop("lightOptions/falloffDistance").unwrap().value.as_f64() - 400.0).abs() < 1e-9);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(key.id).is_none());
// Not a model layer → error.
assert!(s.execute("camera.fromModel", json!({"layers": [c.id.0]})).is_err());
}
// ---------------------------------------------------------------- environment background
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[(y * img.width + x) as usize]
}
#[test]
fn environment_background_draws_the_sky_through_the_camera() {
let mut s = session();
let scene = cid(&s);
// An equirectangular "sky": red upper hemisphere, blue lower.
let sky = s.execute("comp.new", json!({"name": "Sky", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 200, "height": 100})).unwrap();
let top = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": top, "path": "transform/position", "value": [100, 25]})).unwrap();
s.execute("comp.open", json!({"comp": scene})).unwrap();
let src = s.execute("layer.addItem", json!({"item": sky})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.environment", json!({"layers": [src], "on": true})).unwrap();
// No environment light yet.
assert!(s.execute("light.environmentBackground", json!({})).is_err());
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("light.environmentBackground", json!({})).unwrap();
assert_eq!(r["light"].as_u64(), Some(env));
assert_eq!(r["source"].as_u64(), Some(src));
let bg = layer_in(&s, scene, r["layer"].as_u64().unwrap());
assert!(bg.environment_background && bg.is_3d());
assert_eq!(bg.source, LayerSource::Comp { item: ItemId(sky) });
assert_eq!(s.active_comp().unwrap().layers.last().unwrap().id, bg.id, "at the bottom");
let img = s.render(scene, Tick::ZERO, RenderOpts::default());
// Looking at the horizon: rays above it see red, below it blue.
let (t, b) = (px(&img, 320, 5), px(&img, 320, 354));
assert!(t[0] > 0.9 && t[2] < 0.1 && t[3] > 0.99, "top {t:?}");
assert!(b[2] > 0.9 && b[0] < 0.1, "bottom {b:?}");
// Serde keeps the flag; undo removes the layer.
let json = serde_json::to_value(&bg).unwrap();
assert_eq!(json["environment_background"], Value::Bool(true));
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(bg.id).is_none());
}
+2
View File
@@ -526,6 +526,8 @@ Layer.prototype = {
get opacity() { return this.transform.opacity; },
get anchorPoint() { return this.transform.anchorPoint; },
get text() { return this.__group('text'); },
get cameraOption() { return this.__group('cameraOptions'); },
get lightOption() { return this.__group('lightOptions'); },
get marker() { return new Markers(this.__c, this.__l); },
effect: function (k) {
var g = __child(this.__c, this.__l, '', 'effects');
+42
View File
@@ -424,4 +424,46 @@ mod tests {
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0);
}
/// Create Stereo 3D Rig: the eye cameras' expressions follow the master camera and the
/// Stereo 3D Controls (and agree with the values the command wrote).
#[test]
fn stereo_rig_expressions_follow_the_master_camera() {
let mut s = super::session();
s.execute("comp.new", json!({"name": "Shot", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
let src = s.active_comp_id().unwrap();
let cam = s.execute("layer.newCamera", json!({"name": "Main", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"]
.as_u64()
.unwrap();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
let ctl = r["controls"].as_u64().unwrap();
let left = effectcraft_engine::project::ItemId(r["leftComp"].as_u64().unwrap());
// The left eye camera's evaluated value (expressions on).
let v3 = |s: &effectcraft_engine::Session, path: &str| {
let comp = s.project.comp(left).unwrap();
let layer = &comp.layers[0];
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: left,
comp,
time: Default::default(),
expr: s.expr.as_deref(),
footage: None,
};
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
};
let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6);
let d = 0.04 * 640.0;
assert!(close(v3(&s, "transform/position"), [320.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
assert!(close(v3(&s, "transform/poi"), [320.0 - d / 2.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
// Moving the master moves the eyes.
s.execute("comp.open", json!({"comp": src.0})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [300, 180, -800]})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [300, 180, 0]})).unwrap();
assert!(close(v3(&s, "transform/position"), [300.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
// Controls: Center & Left puts the left eye a full separation left; convergence toes in.
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/configuration", "value": 2})).unwrap();
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/convergence", "value": true})).unwrap();
assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
}
}
+53 -3
View File
@@ -7,8 +7,9 @@
//! (base colour, metallic-roughness, normal, occlusion and emissive textures; alpha modes;
//! double-sided; `KHR_materials_emissive_strength`, `KHR_materials_unlit`), embedded or
//! external PNG/JPEG images with sampler wrap modes, skins and animations (step, linear and
//! cubic-spline translation/rotation/scale). Sparse accessors, morph targets, cameras and
//! lights are ignored.
//! cubic-spline translation/rotation/scale), perspective and orthographic cameras, and
//! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets
//! are ignored.
use std::sync::Arc;
@@ -363,6 +364,8 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
matrix,
mesh: idx(n, "mesh").filter(|&m| m < meshes.len()),
skin: idx(n, "skin"),
camera: idx(n, "camera"),
light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")),
children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
});
}
@@ -434,7 +437,54 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
animations.push(anim);
}
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations })
let cameras = arr(&j, "cameras").iter().enumerate().map(|(i, c)| camera(c, i)).collect();
let lights = j
.get("extensions")
.and_then(|e| e.get("KHR_lights_punctual"))
.map(|e| arr(e, "lights").iter().enumerate().map(|(i, l)| light(l, i)).collect())
.unwrap_or_default();
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights })
}
fn name_or(v: &Value, d: String) -> String {
v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string).unwrap_or(d)
}
/// A glTF camera (glTF 2.0 §5.12): `perspective` {yfov, aspectRatio?, znear} or
/// `orthographic` {xmag, ymag}.
fn camera(c: &Value, i: usize) -> crate::ModelCamera {
let projection = match (c.get("type").and_then(Value::as_str), c.get("orthographic")) {
(Some("orthographic"), Some(o)) => crate::CameraProjection::Orthographic { xmag: num(o, "xmag", 1.0), ymag: num(o, "ymag", 1.0) },
_ => {
let p = c.get("perspective").cloned().unwrap_or(Value::Null);
crate::CameraProjection::Perspective {
yfov: num(&p, "yfov", 0.8).clamp(1e-3, 3.1),
aspect: p.get("aspectRatio").and_then(Value::as_f64),
znear: num(&p, "znear", 0.01),
}
}
};
crate::ModelCamera { name: name_or(c, format!("Camera {}", i + 1)), projection }
}
/// A `KHR_lights_punctual` light: type, color, intensity, range, spot cone angles.
fn light(l: &Value, i: usize) -> crate::ModelLight {
let kind = match l.get("type").and_then(Value::as_str) {
Some("directional") => crate::ModelLightKind::Directional,
Some("spot") => {
let s = l.get("spot").cloned().unwrap_or(Value::Null);
let outer = num(&s, "outerConeAngle", std::f64::consts::FRAC_PI_4).clamp(1e-3, std::f64::consts::FRAC_PI_2);
crate::ModelLightKind::Spot { inner: num(&s, "innerConeAngle", 0.0).clamp(0.0, outer), outer }
}
_ => crate::ModelLightKind::Point,
};
crate::ModelLight {
name: name_or(l, format!("Light {}", i + 1)),
kind,
color: nums(l, "color", [1.0; 3]),
intensity: num(l, "intensity", 1.0).max(0.0),
range: l.get("range").and_then(Value::as_f64),
}
}
fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primitive>> {
+85
View File
@@ -295,6 +295,10 @@ pub struct Node {
pub matrix: Option<Mat4>,
pub mesh: Option<usize>,
pub skin: Option<usize>,
/// A camera of [`Model::cameras`] at this node (looking down the node's −Z, +Y up).
pub camera: Option<usize>,
/// A light of [`Model::lights`] at this node (`KHR_lights_punctual`, shining down −Z).
pub light: Option<usize>,
pub children: Vec<usize>,
}
@@ -308,6 +312,8 @@ impl Default for Node {
matrix: None,
mesh: None,
skin: None,
camera: None,
light: None,
children: vec![],
}
}
@@ -495,6 +501,60 @@ pub struct Model {
pub textures: Vec<Arc<Texture>>,
pub skins: Vec<Skin>,
pub animations: Vec<Animation>,
/// Cameras embedded in the file (glTF `cameras`).
pub cameras: Vec<ModelCamera>,
/// Punctual lights embedded in the file (glTF `KHR_lights_punctual`).
pub lights: Vec<ModelLight>,
}
/// A camera embedded in a model file.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelCamera {
pub name: String,
pub projection: CameraProjection,
}
/// glTF camera projections.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CameraProjection {
/// Vertical field of view (radians), optional aspect ratio, near plane.
Perspective { yfov: f64, aspect: Option<f64>, znear: f64 },
/// Half the orthographic view's width and height (model units).
Orthographic { xmag: f64, ymag: f64 },
}
/// Kinds of `KHR_lights_punctual` lights.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ModelLightKind {
Directional,
Point,
/// Inner and outer cone angles (radians, from the axis).
Spot {
inner: f64,
outer: f64,
},
}
/// A punctual light embedded in a model file.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelLight {
pub name: String,
pub kind: ModelLightKind,
/// Linear RGB.
pub color: [f64; 3],
/// Candela (point, spot) or lux (directional).
pub intensity: f64,
pub range: Option<f64>,
}
/// A camera or light placed in model space by [`Model::placed`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
/// Index into [`Model::cameras`] or [`Model::lights`].
pub index: usize,
pub node: usize,
/// Node → model space.
pub world: Mat4,
}
/// One mesh placed in model space by [`Model::instances`].
@@ -579,6 +639,31 @@ impl Model {
out
}
/// The scene's cameras (`lights = false`) or lights (`true`) placed at animation `clip`
/// time `t`, in node order.
pub fn placed(&self, lights: bool, clip: Option<usize>, t: f64) -> Vec<Placed> {
let world = self.world_pose(&self.local_pose(clip, t));
let mut reach = vec![false; self.nodes.len()];
let mut stack: Vec<usize> = self.roots.clone();
while let Some(i) = stack.pop() {
if i >= self.nodes.len() || reach[i] {
continue;
}
reach[i] = true;
stack.extend(self.nodes[i].children.iter().copied());
}
let count = if lights { self.lights.len() } else { self.cameras.len() };
self.nodes
.iter()
.enumerate()
.filter(|(i, _)| reach[*i])
.filter_map(|(i, n)| {
let index = if lights { n.light } else { n.camera }?;
(index < count).then_some(Placed { index, node: i, world: world[i] })
})
.collect()
}
/// Model-space bounds of the rest pose (None for an empty model).
pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> {
let mut lo = [f64::INFINITY; 3];
+44
View File
@@ -316,3 +316,47 @@ fn contours_group_into_outlines_by_nesting() {
assert_eq!(g.len(), 3);
assert_eq!(g.iter().map(|o| o.holes.len()).sum::<usize>(), 2);
}
#[test]
fn gltf_cameras_and_punctual_lights() {
let doc = r#"{
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 2]}],
"nodes": [
{"name": "Rig", "translation": [0, 0, 10], "children": [1]},
{"name": "Cam", "camera": 0, "translation": [1, 0, 0]},
{"name": "Sun", "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 1}}},
{"name": "Orphan", "camera": 1}
],
"cameras": [
{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.5, "znear": 0.1, "aspectRatio": 1.5}},
{"type": "orthographic", "orthographic": {"xmag": 2, "ymag": 1, "znear": 0, "zfar": 10}}
],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key", "color": [1, 0.5, 0.25], "intensity": 3, "spot": {"innerConeAngle": 0.2, "outerConeAngle": 0.4}},
{"type": "directional", "intensity": 2}
]}}
}"#;
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
assert_eq!(m.cameras.len(), 2);
assert_eq!(m.cameras[0].name, "Shot");
assert_eq!(m.cameras[0].projection, CameraProjection::Perspective { yfov: 0.5, aspect: Some(1.5), znear: 0.1 });
assert_eq!(m.cameras[1].projection, CameraProjection::Orthographic { xmag: 2.0, ymag: 1.0 });
assert_eq!(m.lights.len(), 2);
assert_eq!(m.lights[0].kind, ModelLightKind::Spot { inner: 0.2, outer: 0.4 });
assert_eq!(m.lights[0].color, [1.0, 0.5, 0.25]);
assert_eq!(m.lights[1].kind, ModelLightKind::Directional);
assert_eq!(m.lights[1].name, "Light 2");
// Only nodes of the scene are placed; the camera node inherits its parent's translation.
let cams = m.placed(false, None, 0.0);
assert_eq!(cams.len(), 1);
assert_eq!((cams[0].index, cams[0].node), (0, 1));
let eye = cams[0].world.apply(vec3(0.0, 0.0, 0.0));
assert!((eye.x - 1.0).abs() < 1e-9 && (eye.z - 10.0).abs() < 1e-9, "{eye:?}");
let lights = m.placed(true, None, 0.0);
assert_eq!(lights.len(), 1);
// −90° about X: the light's −Z axis points down (−Y in glTF).
let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0));
assert!((d.y + 1.0).abs() < 1e-6, "{d:?}");
}
+1
View File
@@ -818,6 +818,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
markers: vec![],
markers_locked: false,
environment: false,
environment_background: false,
auto_orient,
props: root,
}
+4
View File
@@ -542,6 +542,10 @@ pub struct Layer {
/// reflections) of Advanced 3D comps instead of being drawn.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub environment: bool,
/// Layer ▸ Light ▸ Create Environment Light Background Layer: the layer's (equirectangular)
/// image is drawn as the 3D scene's backdrop, seen through the camera, instead of as a card.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub environment_background: bool,
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
pub props: PropGroup,
}
+21 -9
View File
@@ -1016,7 +1016,7 @@ impl<'a> Renderer<'a> {
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
let r = self.opts.roi.filter(|_| self.depth == 0)?;
let s = self.opts.scale;
if r[2] <= 0.0 || r[3] <= 0.0 {
@@ -1218,17 +1218,29 @@ impl<'a> Renderer<'a> {
}
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) {
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, iso: Image, canvas: &mut Image, opacity: f32) {
self.composite_iso_with(ctx, layer, iso, canvas, opacity, None);
}
/// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D
/// matte seen through the camera); otherwise the matte layer is placed in 2D.
pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option<Image>) {
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
let preserve = layer.preserve_transparency;
if let Some((m, kind)) = matte {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
let mimg = match matte_img {
Some(i) if i.width == canvas.width && i.height == canvas.height => i,
_ => {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
mimg
}
};
iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
let k = match kind {
MatteKind::Alpha => q[3],
+122 -12
View File
@@ -140,25 +140,135 @@ pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) ->
scene::build(r, ctx, run, out)
}
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
if s.indices.is_empty() {
return;
/// Rasterise one scene (GPU when available, else CPU) and resolve it.
fn raster_resolved(r: &Renderer, s: &Scene) -> (Image, Vec<f32>) {
let target = r.active_accel().and_then(|a| a.raster_3d(s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(s));
resolve(&target, s.ssaa.max(1))
}
/// Number of motion-blur sub-samples for a set of layers (1 = none blurred).
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize {
if !layers.iter().any(|l| scene::motion_blurred(r, ctx, l)) {
return 1;
}
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
}
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
/// depth is the nearest over the samples.
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
let n = mb_samples(r, ctx, layers);
let (mut img, depth, linear) = if n <= 1 {
let s = scene::build(r, ctx, layers, out);
if s.indices.is_empty() {
return None;
}
let (img, depth) = raster_resolved(r, &s);
(img, depth, s.linear_io)
} else {
let fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let mut acc = Image::new(out.0, out.1);
let mut depth = vec![f32::INFINITY; (out.0 * out.1) as usize];
let mut linear = false;
let mut any = false;
let k = 1.0 / n as f32;
for i in 0..n {
let f = phase + angle * i as f64 / (n - 1) as f64;
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
let s = scene::build_at(r, ctx, Some(&sub), layers, out);
linear = s.linear_io;
if s.indices.is_empty() {
continue;
}
any = true;
let (img, d) = raster_resolved(r, &s);
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, p)| {
for c in 0..4 {
a[c] += p[c] * k;
}
});
depth.par_iter_mut().zip(d.par_iter()).for_each(|(a, b)| *a = a.min(*b));
}
if !any {
return None;
}
(acc, depth, linear)
};
let cam = camera_for(r, ctx);
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
}
if !s.linear_io {
if !linear {
encode(&mut img);
}
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
let k = 1.0 - s[3];
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
});
Some((img, depth))
}
/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path.
fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool {
l.blend_mode != effectcraft_color::BlendMode::Normal
|| l.preserve_transparency
|| l.track_matte.is_some_and(|tm| tm.layer != l.id && ctx.comp.layer(tm.layer).is_some())
}
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
///
/// Layers with a blend mode, a track matte or Preserve Transparency can't blend inside the
/// depth-buffered scene: they are rendered on their own (lit by the same lights, through the
/// same camera), hidden where the rest of the run is nearer, then composited through the 2D
/// path (matte, Preserve Transparency, blend mode) from the farthest to the nearest.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
let out = (canvas.width, canvas.height);
let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l));
let main = render_layers(r, ctx, &plain, out);
if let Some((img, _)) = &main {
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
let k = 1.0 - s[3];
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
});
}
if special.is_empty() {
return;
}
let cam = camera_for(r, ctx);
let mut order: Vec<(f64, &Layer)> = special
.into_iter()
.map(|l| {
let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3]));
(cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l)
})
.collect();
order.sort_by(|a, b| b.0.total_cmp(&a.0));
for (_, l) in order {
let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue };
if let Some((m, md)) = &main {
iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| {
if *mz < *z - 0.01 * z.abs().max(1.0) {
let k = 1.0 - q[3].clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= k;
}
}
});
}
// A 3D matte layer is drawn through the camera too.
let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| {
let mut solo = m.clone();
solo.track_matte = None;
solo.blend_mode = effectcraft_color::BlendMode::Normal;
solo.preserve_transparency = false;
solo.switches.video = true;
if m.is_active_at(ctx.time) {
render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1))
} else {
Image::new(out.0, out.1)
}
});
r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d);
}
}
#[cfg(test)]
+15
View File
@@ -806,6 +806,20 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
/// Build the scene of a run of 3D layers.
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
build_at(r, ctx, None, run, out)
}
/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option).
pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool {
r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
}
/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights
/// are evaluated at `sub`'s time; the other layers stay at `ctx`'s.
pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: &[&Layer], out: (u32, u32)) -> Scene {
let base = ctx;
let at = sub.unwrap_or(ctx);
let ctx = at;
let cam = camera_for(r, ctx);
let lin = r.pipe.linear || r.pipe.linear_blend;
let ssaa = ssaa_for(r, out);
@@ -816,6 +830,7 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)
cam,
};
for layer in run {
let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base };
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
continue;
}
+113
View File
@@ -312,3 +312,116 @@ fn extruded_text_renders_as_a_mesh() {
let img = render(&p, cid);
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
// ---------------------------------------------------------------- motion blur, blend modes, mattes
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
p.add_item("S", Default::default(), None, ItemKind::Solid(effectcraft_project::Solid { color, width: w, height: h, pixel_aspect: 1.0 }))
}
/// Pixels of row `y` whose alpha is partial (motion-blur smear or anti-aliasing).
fn partial(img: &crate::Image, y: u32) -> usize {
(0..img.width).filter(|x| (0.05..0.95).contains(&img.data[(y * img.width + x) as usize][3])).count()
}
#[test]
fn motion_blur_smears_moving_meshes() {
let (mut p, cid) = project(160, 120);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(30.0));
set(l, "geometryOptions/height", Value::Scalar(30.0));
set(l, "geometryOptions/depth", Value::Scalar(30.0));
// 60 px per frame to the right.
let pr = l.props.prop_mut("transform/position").unwrap();
pr.keys = vec![
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 60.0, 0.0])),
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0 + 30.0 * 60.0, 60.0, 0.0])),
];
});
let sharp = render(&p, cid);
let hard = partial(&sharp, 60);
assert!(hard <= 4, "no blur without the switch: {hard}");
p.comp_mut(cid).unwrap().layers[0].switches.motion_blur = true;
let blurred = render(&p, cid);
let smear = partial(&blurred, 60);
// A 180° shutter smears over half a frame of motion (≈ 30 px).
assert!(smear > 20, "smeared: {smear}");
// Coverage is conserved: the summed alpha along the row stays about the cube's width.
let sum = |im: &crate::Image| (0..160).map(|x| im.data[60 * 160 + x][3]).sum::<f32>();
assert!((sum(&blurred) - sum(&sharp)).abs() < 4.0, "{} vs {}", sum(&blurred), sum(&sharp));
// The comp's motion blur switch and the render option turn it off.
p.comp_mut(cid).unwrap().enable_motion_blur = false;
assert!(partial(&render(&p, cid), 60) <= 4);
p.comp_mut(cid).unwrap().enable_motion_blur = true;
let r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
assert!(partial(&r.comp_frame(cid, Tick::ZERO), 60) <= 4);
// Shutter angle 0 has no exposure time: no smear.
p.comp_mut(cid).unwrap().shutter_angle = 0.0;
assert!(partial(&render(&p, cid), 60) <= 4);
}
#[test]
fn blend_modes_and_occlusion_in_advanced_3d() {
let (mut p, cid) = project(120, 90);
let red = solid(&mut p, [1.0, 0.0, 0.0], 120, 90);
let gray = solid(&mut p, [0.5, 0.5, 0.5], 60, 40);
let blue = solid(&mut p, [0.0, 0.0, 1.0], 20, 20);
// Back: a red card; middle: a gray Multiply card; front: a small blue card on the left.
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Solid { item: gray }, |l| {
l.switches.three_d = true;
l.blend_mode = effectcraft_color::BlendMode::Multiply;
set(l, "transform/position", Value::Vec3([60.0, 45.0, -20.0]));
set(l, "transform/anchor", Value::Vec3([30.0, 20.0, 0.0]));
});
add(&mut p, cid, LayerSource::Solid { item: blue }, |l| {
l.switches.three_d = true;
set(l, "transform/position", Value::Vec3([45.0, 45.0, -40.0]));
set(l, "transform/anchor", Value::Vec3([10.0, 10.0, 0.0]));
});
let img = render(&p, cid);
let at = |x: usize, y: usize| img.data[y * 120 + x];
// Multiply over red: half red, no gray.
let c = at(70, 45);
assert!((c[0] - 0.5).abs() < 0.02 && c[1] < 0.02 && c[2] < 0.02 && c[3] > 0.99, "multiplied {c:?}");
// Outside the gray card: plain red.
let r = at(115, 5);
assert!(r[0] > 0.98 && r[1] < 0.02, "{r:?}");
// The nearer blue card hides the Multiply card behind it.
let b = at(40, 45);
assert!(b[2] > 0.98 && b[0] < 0.02, "occluded {b:?}");
// Normal mode for comparison: gray covers the red.
p.comp_mut(cid).unwrap().layers[1].blend_mode = effectcraft_color::BlendMode::Normal;
let n = render(&p, cid).data[45 * 120 + 70];
assert!((n[0] - 0.5).abs() < 0.02 && (n[1] - 0.5).abs() < 0.02, "{n:?}");
}
#[test]
fn track_mattes_in_advanced_3d() {
let (mut p, cid) = project(120, 90);
let white = solid(&mut p, [1.0, 1.0, 1.0], 120, 90);
let small = solid(&mut p, [1.0, 1.0, 1.0], 40, 30);
// Layer order (top first): the matte, then the matted layer.
add(&mut p, cid, LayerSource::Solid { item: white }, |l| l.switches.three_d = true);
add(&mut p, cid, LayerSource::Solid { item: small }, |l| {
l.switches.three_d = true;
l.switches.video = false;
// A 3D matte is seen through the camera.
set(l, "transform/position", Value::Vec3([30.0, 30.0, 0.0]));
set(l, "transform/anchor", Value::Vec3([20.0, 15.0, 0.0]));
});
let matte = p.comp(cid).unwrap().layers[0].id;
p.comp_mut(cid).unwrap().layers[1].track_matte = Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::Alpha });
let img = render(&p, cid);
let inside = img.data[30 * 120 + 30];
let outside = img.data[70 * 120 + 100];
assert!(inside[3] > 0.99 && inside[0] > 0.99, "{inside:?}");
assert!(outside[3] < 0.01, "{outside:?}");
// An inverted alpha matte keeps the outside instead.
p.comp_mut(cid).unwrap().layers[1].track_matte =
Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::AlphaInverted });
let img = render(&p, cid);
assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99);
}
+55
View File
@@ -313,6 +313,15 @@ fn scene_lights(ctx: &EvalCtx) -> Vec<LightState> {
/// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
// Environment background layers: an infinitely distant sky behind the rest of the run.
if run.iter().any(|l| l.environment_background) {
for l in run.iter().filter(|l| l.environment_background) {
draw_sky(r, ctx, l, canvas);
}
let rest: Vec<&Layer> = run.iter().copied().filter(|l| !l.environment_background).collect();
draw_run(r, ctx, &rest, canvas);
return;
}
// 3D adjustment layers act on everything below them in the stack (like 2D ones): split.
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
draw_run(r, ctx, &run[..k], canvas);
@@ -768,3 +777,49 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
aux
}
/// Rotation (radians) of the comp's first Environment light, which also turns its backdrop.
pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
ctx.comp
.layers
.iter()
.find(|l| matches!(l.source, effectcraft_project::LayerSource::Light { kind: LightKind::Environment }) && l.is_active_at(ctx.time))
.and_then(|l| l.props.sub("lightOptions").map(|g| ctx.f(l, g, "rotation", 0.0)))
.unwrap_or(0.0)
.to_radians() as f32
}
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
/// pixel's view direction through the camera, as an infinitely distant backdrop.
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return };
let img = &buf.img;
if img.width == 0 || img.height == 0 {
return;
}
let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
if op <= 0.0 {
return;
}
let cam = camera_for(r, ctx);
let (fwd, right, down) = (cam.forward(), cam.right(), cam.down());
let s = r.opts.scale.max(1e-9);
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y));
let rot = environment_rotation(ctx);
let (iw, ih) = (img.width as f64, img.height as f64);
canvas.rows_mut().for_each(|(y, row)| {
let cy = (y as f64 + 0.5 + oy) / s;
for (x, d) in row.iter_mut().enumerate() {
let cx = (x as f64 + 0.5 + ox) / s;
let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot);
// Wrap horizontally across the seam.
let sx = (u as f64 * iw).clamp(0.5, iw - 0.5);
let sy = (v as f64 * ih).clamp(0.5, ih - 0.5);
let p = img.sample_bilinear(sx, sy);
let k = 1.0 - p[3] * op;
*d = [p[0] * op + d[0] * k, p[1] * op + d[1] * k, p[2] * op + d[2] * k, p[3] * op + d[3] * k];
}
});
}
+9 -3
View File
@@ -101,9 +101,15 @@ transparent ones are sorted back to front and blended. The software rasteriser
`Accelerator::raster_3d` with a wgpu render pipeline (`advanced3d.wgsl`) that repeats the
shading step for step, and tests compare the two. Resolve, depth of field (a gather blur by
each pixel's circle of confusion from the camera's Depth of Field settings) and the conversion
back to the working encoding run on the CPU for both. Not yet: motion blur, track mattes and
blend modes inside an Advanced 3D run, collapsed 3D precomps (drawn flattened), morph targets,
extruded strokes.
back to the working encoding run on the CPU for both. **Motion blur** renders the scene at
sub-samples spread over the comp's shutter (angle, phase, samples per frame) — layers with the
Motion Blur switch, the camera and the lights at the sub-sample time, other layers at the frame
time — and averages them. Layers with a **blend mode, a track matte or Preserve Transparency**
are rendered on their own through the same camera and lights, hidden where the rest of the run
is nearer, and composited from the farthest to the nearest through the 2D path (a 3D matte is
itself drawn through the camera). **Environment Light Background** layers (Layer ▸ Light) draw
their equirectangular image behind the run, looked up by each pixel's view direction. Not yet:
collapsed 3D precomps (drawn flattened), morph targets, extruded strokes.
A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of
its evaluated inputs, excluding the transform. Static and transform-only layers render once;