mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
f9f8d454e0
commit
8696d1e9ba
21 files changed
+1543
-35
No files matched your search
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@ mod channel3d;
|
||||
mod color2;
|
||||
mod color3;
|
||||
mod color_fx;
|
||||
mod controls;
|
||||
pub mod controls;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
mod distort3;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user