From 76ac3f74f73d914e6cd0cbf0faa3a7620e7ec15c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:09:21 -0400 Subject: [PATCH] M7.1: Classic 3D renderer, cameras, lights, 3D views and camera-tool commands - render/three_d: camera eval (one/two-node, DOF), default 50mm comp camera, orthographic Front/Left/Top/Back/Right/Bottom + perspective Custom Views, orbit/pan/dolly rig math; lights (parallel/spot/point/ambient, falloff, cone feather) and material shading; per-pixel depth-sorted compositor for 3D runs (exact intersections, perspective-correct sampling, blend modes, track mattes, motion blur), ray-cast planar shadows with transmission and diffusion, per-layer DOF blur. RenderOpts.view camera override. - project: camera iris/highlight props, aperture per comp width, light orientation/rotations, two-node auto-orient defaults. - engine: layer.newCamera/newLight with presets & settings, layer.cameraSettings/lightSettings (Layer Settings dispatches), view.set3DView + View > Switch 3D View items (F10/F11/F12), last/reset view, camera.orbit/pan/dolly, view.get3D; EditorState.views3d. - demo: "3D Showcase" comp (camera move, key spot + ambient fill, shadows, DOF, intersecting planes, gridded floor). - tests: projection, depth order, intersections, lighting, shadows, DOF, views, rig math; command undo/redo. Fix a few pre-existing clippy lints. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/effectcraft-cli/src/main.rs | 3 +- crates/engine/src/commands/layer.rs | 27 +- crates/engine/src/commands/mod.rs | 4 +- crates/engine/src/commands/three_d.rs | 570 ++++++++++++++++++++++++++ crates/engine/src/demo.rs | 135 +++++- crates/engine/src/lib.rs | 14 + crates/engine/src/tests_3d.rs | 185 +++++++++ crates/host/src/lib.rs | 11 +- crates/project/src/build.rs | 42 +- crates/render/Cargo.toml | 1 + crates/render/src/eval.rs | 38 +- crates/render/src/lib.rs | 11 +- crates/render/src/three_d/camera.rs | 431 +++++++++++++++++++ crates/render/src/three_d/compose.rs | 483 ++++++++++++++++++++++ crates/render/src/three_d/light.rs | 242 +++++++++++ crates/render/src/three_d/mod.rs | 36 ++ crates/render/src/three_d/tests.rs | 484 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 2 +- 21 files changed, 2657 insertions(+), 67 deletions(-) create mode 100644 crates/engine/src/commands/three_d.rs create mode 100644 crates/engine/src/tests_3d.rs create mode 100644 crates/render/src/three_d/camera.rs create mode 100644 crates/render/src/three_d/compose.rs create mode 100644 crates/render/src/three_d/light.rs create mode 100644 crates/render/src/three_d/mod.rs create mode 100644 crates/render/src/three_d/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b29b9181..87de1a5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1219,6 +1219,7 @@ dependencies = [ "kurbo", "log", "rayon", + "serde", ] [[package]] diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58..71c7c4f6 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -22,7 +22,8 @@ fn open(s: &mut Session, args: &[String]) { eprintln!("effectcraft: {e}"); std::process::exit(1); } - } else if args.iter().any(|a| a == "--demo") || true { + } else { + // No project given (or `--demo`): open the demo project. let _ = s.execute("file.openDemoProject", json!({})); } } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index f329dfaa..911ffb02 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Justify, ShapePath, TextDoc, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte, + Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte, }; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p}; use crate::{EngineError, Result, Session, cmd}; -fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { +pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { match p.get(k)? { Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]), Value::Array(a) => { @@ -24,7 +24,7 @@ fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { } /// Insert a new layer above the selection (or at the top) and select it. -fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { +pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0); let id = layer.id; @@ -209,13 +209,6 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: & fn new_null(s: &mut Session, p: &Value) -> Result { new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object") } -fn new_camera(s: &mut Session, p: &Value) -> Result { - new_simple(s, p, LayerSource::Camera, "Camera 1", "New Camera") -} -fn new_light(s: &mut Session, p: &Value) -> Result { - let kind = str_p(p, "kind").and_then(|k| LightKind::ALL.into_iter().find(|l| l.label().eq_ignore_ascii_case(k))).unwrap_or(LightKind::Point); - new_simple(s, p, LayerSource::Light { kind }, "Light 1", "New Light") -} fn add_item(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; @@ -820,6 +813,12 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; + // Cameras and lights open their own settings (Camera Settings / Light Settings). + match s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.source.clone()) { + Some(LayerSource::Camera) => return s.execute("layer.cameraSettings", with_layer(p, lid)), + Some(LayerSource::Light { .. }) => return s.execute("layer.lightSettings", with_layer(p, lid)), + _ => {} + } let color = color_p(p, "color"); let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32); let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32); @@ -849,12 +848,16 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +fn with_layer(p: &Value, lid: LayerId) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["layer"] = json!(lid.0); + p +} + pub fn specs() -> Vec { vec![ cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text), cmd!("layer.newSolid", "Solid…", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid), - cmd!("layer.newLight", "Light…", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), "{kind?: Parallel|Spot|Point|Ambient}", has_comp, new_light), - cmd!("layer.newCamera", "Camera…", ["Layer", "New"], Some("Cmd+Alt+Shift+C"), "{name?}", has_comp, new_camera), cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null), cmd!( "layer.newShape", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a7b41a8..42b5d122 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -9,6 +9,7 @@ mod help; mod layer; mod prop; mod query; +mod three_d; mod time; use std::sync::OnceLock; @@ -68,6 +69,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(comp::specs()); v.extend(layer::specs()); + v.extend(three_d::specs()); v.extend(prop::specs()); v.extend(effect::specs()); v.extend(time::specs()); @@ -182,6 +184,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { s.time() } -pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> { +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) } diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs new file mode 100644 index 00000000..1746b3d1 --- /dev/null +++ b/crates/engine/src/commands/three_d.rs @@ -0,0 +1,570 @@ +//! 3D: cameras (Layer ▸ New ▸ Camera, Camera Settings), lights (Layer ▸ New ▸ Light, Light +//! Settings), the viewer's 3D views (View ▸ Switch 3D View, Reset 3D View) and the camera tools +//! (Orbit, Pan, Dolly) acting on the current 3D view. + +use effectcraft_geom::{Mat4, Vec3, vec3}; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build; +use effectcraft_project::{AutoOrient, Comp, ItemId, Layer, LayerId, LayerSource, LightKind}; +use effectcraft_render::EvalCtx; +use effectcraft_render::three_d::camera::{self, PRESETS, Rig, View3D, ViewCam, Views3D, orientation_for, zoom_for_focal}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::layer::{color_p, insert_layer}; +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; +use crate::{EngineError, Result, Session, cmd, query}; + +fn v3_p(p: &Value, k: &str) -> Option<[f64; 3]> { + let a = p.get(k)?.as_array()?; + let g = |i: usize| a.get(i).and_then(Value::as_f64); + Some([g(0)?, g(1)?, g(2).unwrap_or(0.0)]) +} + +/// Set a property at comp time `t` (adds a key when it is animated). +fn set_at(l: &mut Layer, path: &str, t: Tick, v: KV) { + let lt = l.layer_time(t); + if let Some(pr) = l.props.prop_mut(path) { + pr.set_value_at(lt, v); + } +} + +fn get_at(l: &Layer, path: &str, t: Tick) -> Option { + l.props.prop(path).map(|pr| pr.value_at(l.layer_time(t))) +} + +// ---------------------------------------------------------------- cameras + +/// Parse a camera type: `oneNode`/`twoNode` (or "One-Node Camera"…). Some(true) = two-node. +fn two_node_p(p: &Value) -> Option { + let t = str_p(p, "type")?.to_ascii_lowercase().replace(['-', ' ', '_'], ""); + if t.starts_with("one") { + Some(false) + } else if t.starts_with("two") { + Some(true) + } else { + None + } +} + +/// Zoom from `zoom`, `preset` ("50mm"), `focalLength` (mm) or `angleOfView` (degrees). +fn zoom_p(p: &Value, comp_w: f64) -> Result> { + if let Some(z) = f_p(p, "zoom") { + return Ok(Some(z.max(0.1))); + } + if let Some(pr) = p.get("preset") { + let focal = match pr { + Value::Number(n) => n.as_f64(), + Value::String(s) => { + let k = s.trim().to_ascii_lowercase(); + PRESETS.iter().find(|(n, _)| n.eq_ignore_ascii_case(&k)).map(|(_, f)| *f).or_else(|| k.trim_end_matches("mm").trim().parse().ok()) + } + _ => None, + } + .ok_or_else(|| bad("layer.newCamera", format!("unknown preset {pr}; use one of 15mm, 20mm, 24mm, 28mm, 35mm, 50mm, 80mm, 135mm, 200mm")))?; + return Ok(Some(zoom_for_focal(comp_w, focal))); + } + if let Some(f) = f_p(p, "focalLength") { + return Ok(Some(zoom_for_focal(comp_w, f))); + } + if let Some(a) = f_p(p, "angleOfView") { + return Ok(Some(comp_w * 0.5 / (a.clamp(0.1, 179.0).to_radians() * 0.5).tan())); + } + Ok(None) +} + +/// Apply Camera Settings parameters to a camera layer at comp time `t`. +fn apply_camera(l: &mut Layer, comp: &Comp, p: &Value, t: Tick, cmd: &str) -> Result<()> { + let w = comp.width as f64; + if let Some(z) = zoom_p(p, w)? { + set_at(l, "cameraOptions/zoom", t, KV::Scalar(z)); + // "Lock to Zoom" (default): the focus distance follows the zoom unless one is given. + if b_p(p, "lockToZoom").unwrap_or(true) && f_p(p, "focusDistance").is_none() { + set_at(l, "cameraOptions/focusDistance", t, KV::Scalar(z)); + } + } + if let Some(v) = b_p(p, "dof").or_else(|| b_p(p, "depthOfField")) { + set_at(l, "cameraOptions/dof", t, KV::Bool(v)); + } + for (k, path) in [("focusDistance", "cameraOptions/focusDistance"), ("aperture", "cameraOptions/aperture"), ("blurLevel", "cameraOptions/blurLevel")] { + if let Some(v) = f_p(p, k) { + set_at(l, path, t, KV::Scalar(v.max(0.0))); + } + } + if let Some(fs) = f_p(p, "fStop") { + // F-Stop = focal length / aperture, apertures in mm scaled like the presets (25.3 mm at 1920 px). + let z = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(1000.0); + let focal = camera::focal_for_zoom(w, z); + set_at(l, "cameraOptions/aperture", t, KV::Scalar(focal / fs.max(0.01) * w / 1920.0)); + } + if let Some(pos) = v3_p(p, "position") { + set_at(l, "transform/position", t, KV::Vec3(pos)); + } + if let Some(poi) = v3_p(p, "poi").or_else(|| v3_p(p, "pointOfInterest")) { + set_at(l, "transform/poi", t, KV::Vec3(poi)); + } + if let Some(two) = two_node_p(p) { + let was = l.auto_orient == AutoOrient::TowardsPointOfInterest; + if two != was { + let pos = Vec3::from(get_at(l, "transform/position", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3])); + let zoom = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(1000.0); + if two { + // Keep the view: put the POI where the camera looks, clear the orientation. + let o = get_at(l, "transform/orientation", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3]); + let fwd = Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0)); + let poi = pos + fwd * zoom; + set_at(l, "transform/poi", t, KV::Vec3([poi.x, poi.y, poi.z])); + set_at(l, "transform/orientation", t, KV::Vec3([0.0; 3])); + l.auto_orient = AutoOrient::TowardsPointOfInterest; + } else { + let poi = Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3])); + set_at(l, "transform/orientation", t, KV::Vec3(orientation_for(poi - pos))); + l.auto_orient = AutoOrient::Off; + } + } + } else if p.get("type").is_some() { + return Err(bad(cmd, "type: oneNode|twoNode")); + } + Ok(()) +} + +fn new_camera(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let name = str_p(p, "name").unwrap_or("Camera 1").to_string(); + let t = time_p(s, p, Some(&comp)); + let id = s.edit("New Camera", None, |proj, st| { + let mut l = build::layer(proj, &comp, &name, LayerSource::Camera, (comp.width, comp.height), None); + apply_camera(&mut l, &comp, p, t, "layer.newCamera")?; + insert_layer(proj, st, cid, l) + })?; + Ok(json!({"layer": id.0})) +} + +/// The camera or light layer a settings command targets. +fn target(s: &Session, p: &Value, cmd: &str, want_camera: bool) -> Result<(ItemId, LayerId)> { + let (cid, lid) = layer_p(s, p, cmd)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let ok = if want_camera { l.is_camera() } else { l.is_light() }; + if !ok { + return Err(bad(cmd, format!("layer `{}` is not a {}", l.name, if want_camera { "camera" } else { "light" }))); + } + Ok((cid, lid)) +} + +fn camera_settings(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = target(s, p, "layer.cameraSettings", true)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let t = time_p(s, p, Some(&comp)); + let name = str_p(p, "name").map(str::to_string); + s.edit("Camera Settings", None, |proj, _| { + let l = layer_mut(proj, cid, lid)?; + if let Some(n) = &name { + l.name = n.clone(); + } + apply_camera(l, &comp, p, t, "layer.cameraSettings") + })?; + Ok(Value::Null) +} + +// ---------------------------------------------------------------- lights + +fn light_kind_p(p: &Value, k: &str) -> Option { + let v = str_p(p, k)?; + LightKind::ALL.into_iter().find(|l| l.label().eq_ignore_ascii_case(v.trim())) +} + +fn apply_light(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<()> { + if let Some(c) = color_p(p, "color") { + set_at(l, "lightOptions/color", t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])); + } + for k in ["intensity", "coneAngle", "coneFeather", "radius", "falloffDistance", "shadowDarkness", "shadowDiffusion"] { + if let Some(v) = f_p(p, k) { + set_at(l, &format!("lightOptions/{k}"), t, KV::Scalar(v)); + } + } + if let Some(v) = b_p(p, "castsShadows") { + set_at(l, "lightOptions/castsShadows", t, KV::Bool(v)); + } + if let Some(f) = p.get("falloff") { + let i = match f { + Value::Number(n) => n.as_u64().map(|v| v as u32), + Value::String(s) => { + let k = s.to_ascii_lowercase(); + if k.starts_with("none") { + Some(0) + } else if k.starts_with("smooth") { + Some(1) + } else if k.starts_with("inverse") { + Some(2) + } else { + None + } + } + _ => None, + } + .filter(|i| *i <= 2) + .ok_or_else(|| bad(cmd, "falloff: None|Smooth|Inverse Square Clamped"))?; + set_at(l, "lightOptions/falloff", t, KV::Enum(i)); + } + if let Some(pos) = v3_p(p, "position") { + set_at(l, "transform/position", t, KV::Vec3(pos)); + } + if let Some(poi) = v3_p(p, "poi").or_else(|| v3_p(p, "pointOfInterest")) { + set_at(l, "transform/poi", t, KV::Vec3(poi)); + } + Ok(()) +} + +fn new_light(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let kind = match p.get("kind").or_else(|| p.get("type")) { + Some(_) => light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.newLight", "kind: Parallel|Spot|Point|Ambient"))?, + None => LightKind::Spot, + }; + let name = str_p(p, "name").unwrap_or("Light 1").to_string(); + let t = time_p(s, p, Some(&comp)); + let id = s.edit("New Light", None, |proj, st| { + let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); + apply_light(&mut l, p, t, "layer.newLight")?; + insert_layer(proj, st, cid, l) + })?; + Ok(json!({"layer": id.0})) +} + +fn light_settings(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = target(s, p, "layer.lightSettings", false)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let t = time_p(s, p, Some(&comp)); + let name = str_p(p, "name").map(str::to_string); + let kind = match p.get("kind").or_else(|| p.get("type")) { + Some(_) => { + Some(light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.lightSettings", "kind: Parallel|Spot|Point|Ambient"))?) + } + None => None, + }; + s.edit("Light Settings", None, |proj, _| { + let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; + let mut l = old.clone(); + if let Some(k) = kind + && old.source != (LayerSource::Light { kind: k }) + { + // Light Type change: rebuild the light's groups, carrying over shared values. + let fresh = build::layer(proj, &comp, &old.name, LayerSource::Light { kind: k }, (comp.width, comp.height), None); + l.source = fresh.source.clone(); + l.auto_orient = fresh.auto_orient; + l.props = fresh.props; + for path in [ + "transform/position", + "transform/poi", + "transform/orientation", + "transform/rotationX", + "transform/rotationY", + "transform/rotation", + "lightOptions/intensity", + "lightOptions/color", + "lightOptions/coneAngle", + "lightOptions/coneFeather", + "lightOptions/falloff", + "lightOptions/radius", + "lightOptions/falloffDistance", + "lightOptions/castsShadows", + "lightOptions/shadowDarkness", + "lightOptions/shadowDiffusion", + ] { + if let (Some(src), Some(dst)) = (old.props.prop(path), l.props.prop_mut(path)) { + dst.value = src.value.clone(); + dst.keys = src.keys.clone(); + dst.expr = src.expr.clone(); + } + } + } + if let Some(n) = &name { + l.name = n.clone(); + } + apply_light(&mut l, p, t, "layer.lightSettings")?; + *layer_mut(proj, cid, lid)? = l; + Ok(()) + })?; + Ok(Value::Null) +} + +// ---------------------------------------------------------------- 3D views + +fn views(s: &mut Session, cid: ItemId) -> &mut Views3D { + s.state.views3d.entry(cid).or_default() +} + +fn view_p(p: &Value, cmd: &str) -> Result { + let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?; + View3D::from_id(v).ok_or_else(|| bad(cmd, format!("unknown view `{v}`"))) +} + +fn switch_view(s: &mut Session, cid: ItemId, v: View3D) -> Value { + let vs = views(s, cid); + if vs.current != v { + vs.last = vs.current; + vs.current = v; + } + json!({"view": v.id()}) +} + +fn set_3d_view(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let v = view_p(p, "view.set3DView")?; + Ok(switch_view(s, cid, v)) +} + +macro_rules! view_cmd { + ($f:ident, $v:expr) => { + fn $f(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + Ok(switch_view(s, cid, $v)) + } + }; +} +view_cmd!(view_active, View3D::ActiveCamera); +view_cmd!(view_front, View3D::Front); +view_cmd!(view_left, View3D::Left); +view_cmd!(view_top, View3D::Top); +view_cmd!(view_back, View3D::Back); +view_cmd!(view_right, View3D::Right); +view_cmd!(view_bottom, View3D::Bottom); +view_cmd!(view_custom1, View3D::Custom1); +view_cmd!(view_custom2, View3D::Custom2); +view_cmd!(view_custom3, View3D::Custom3); + +fn last_view(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let last = views(s, cid).last; + Ok(switch_view(s, cid, last)) +} + +fn reset_view(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let cur = views(s, cid).current; + if cur != View3D::ActiveCamera { + views(s, cid).cams.remove(&cur); + return Ok(json!({"view": cur.id()})); + } + // Active camera: back to the default framing (50 mm-style distance, looking at the centre). + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); + let cam = comp.active_camera(t).map(|l| l.id).ok_or_else(|| bad("view.reset3DView", "the comp has no camera layer"))?; + let (w, h) = (comp.width as f64, comp.height as f64); + s.edit("Reset 3D View", None, |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let zoom = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(effectcraft_geom::default_camera_zoom(w)); + set_at(l, "transform/position", t, KV::Vec3([w / 2.0, h / 2.0, -zoom])); + set_at(l, "transform/poi", t, KV::Vec3([w / 2.0, h / 2.0, 0.0])); + set_at(l, "transform/orientation", t, KV::Vec3([0.0; 3])); + Ok(()) + })?; + Ok(json!({"view": "activeCamera"})) +} + +fn set_view_camera(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let v = view_p(p, "view.set3DViewCamera")?; + if v == View3D::ActiveCamera { + return Err(bad("view.set3DViewCamera", "edit the camera layer for the active camera view")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (w, h) = (comp.width as f64, comp.height as f64); + let vs = views(s, cid); + let mut c = vs.cam(v, w, h); + if let Some(e) = v3_p(p, "eye") { + c.eye = e; + } + if let Some(e) = v3_p(p, "poi") { + c.poi = e; + } + if let Some(z) = f_p(p, "zoom") { + c.zoom = z.max(1e-3); + } + vs.cams.insert(v, c); + Ok(json!({"view": v.id()})) +} + +fn get_3d(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (w, h) = (comp.width as f64, comp.height as f64); + let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); + let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); + let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() }; + let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); + let f = cam.forward(); + let lights: Vec = comp + .layers + .iter() + .filter_map(|l| match l.source { + LayerSource::Light { kind } => Some(json!({"id": l.id.0, "name": l.name, "kind": kind.label()})), + _ => None, + }) + .collect(); + let active = comp.active_camera(t).map(|l| json!({"id": l.id.0, "name": l.name, "twoNode": camera::is_two_node(l)})); + Ok(json!({ + "view": vs.current.id(), + "label": vs.current.label(), + "views": View3D::ALL.iter().map(|v| v.id()).collect::>(), + "camera": {"eye": [cam.eye.x, cam.eye.y, cam.eye.z], "forward": [f.x, f.y, f.z], "zoom": cam.zoom, "ortho": cam.ortho}, + "activeCameraLayer": active, + "lights": lights, + })) +} + +// ---------------------------------------------------------------- camera tools + +enum ToolOp { + Orbit(f64, f64), + Pan(f64, f64), + Dolly(f64), +} + +fn camera_tool(s: &mut Session, p: &Value, op: ToolOp, cmd: &str, label: &str) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let (w, h) = (comp.width as f64, comp.height as f64); + let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + let apply = |r: &mut Rig, layer: bool| match op { + ToolOp::Orbit(yaw, pitch) => r.orbit(yaw, pitch), + ToolOp::Pan(dx, dy) => r.pan(dx, dy), + ToolOp::Dolly(a) => r.dolly(a, layer), + }; + if cur != View3D::ActiveCamera { + let vs = views(s, cid); + let vc = vs.cam(cur, w, h); + let mut r = Rig::from_view(&vc); + apply(&mut r, false); + let nc = ViewCam { eye: camera::arr(r.eye), poi: camera::arr(r.poi), zoom: r.zoom, ..vc }; + vs.cams.insert(cur, nc); + return Ok(json!({"view": cur.id(), "eye": nc.eye, "poi": nc.poi, "zoom": nc.zoom})); + } + let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); + let cam = match p.get("layer") { + Some(v) => super::resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(|l| l.is_camera())), + None => comp.active_camera(t).map(|l| l.id), + } + .ok_or_else(|| bad(cmd, "the comp has no active camera layer (Layer ▸ New ▸ Camera), or switch to a custom 3D view"))?; + let merge = merge_p(p).map(str::to_string); + let r = s.edit(label, merge.as_deref(), |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let pos = Vec3::from(get_at(l, "transform/position", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, -1000.0])); + let zoom = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(1000.0); + let two = camera::is_two_node(l); + let o = get_at(l, "transform/orientation", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3]); + let rx = get_at(l, "transform/rotationX", t).map(|v| v.as_f64()).unwrap_or(0.0); + let ry = get_at(l, "transform/rotationY", t).map(|v| v.as_f64()).unwrap_or(0.0); + let poi = if two { + Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0])) + } else { + let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).apply_vec(vec3(0.0, 0.0, 1.0)); + pos + fwd * zoom + }; + let mut rig = Rig { eye: pos, poi, zoom, ortho: false }; + apply(&mut rig, true); + set_at(l, "transform/position", t, KV::Vec3(camera::arr(rig.eye))); + if two { + set_at(l, "transform/poi", t, KV::Vec3(camera::arr(rig.poi))); + } else { + set_at(l, "transform/orientation", t, KV::Vec3(orientation_for(rig.poi - rig.eye))); + set_at(l, "transform/rotationX", t, KV::Scalar(0.0)); + set_at(l, "transform/rotationY", t, KV::Scalar(0.0)); + } + Ok(json!({"layer": cam.0, "position": camera::arr(rig.eye), "poi": camera::arr(rig.poi)})) + })?; + Ok(r) +} + +fn orbit(s: &mut Session, p: &Value) -> Result { + camera_tool( + s, + p, + ToolOp::Orbit(f_p(p, "yaw").or_else(|| f_p(p, "dx")).unwrap_or(0.0), f_p(p, "pitch").or_else(|| f_p(p, "dy")).unwrap_or(0.0)), + "camera.orbit", + "Orbit Camera", + ) +} +fn pan(s: &mut Session, p: &Value) -> Result { + camera_tool(s, p, ToolOp::Pan(f_p(p, "dx").unwrap_or(0.0), f_p(p, "dy").unwrap_or(0.0)), "camera.pan", "Pan Camera") +} +fn dolly(s: &mut Session, p: &Value) -> Result { + camera_tool(s, p, ToolOp::Dolly(f_p(p, "amount").or_else(|| f_p(p, "dz")).unwrap_or(0.0)), "camera.dolly", "Dolly Camera") +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.newLight", + "Light…", + ["Layer", "New"], + Some("Cmd+Alt+Shift+L"), + "{kind?: Parallel|Spot|Point|Ambient (default Spot), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", + has_comp, + new_light + ), + cmd!( + "layer.newCamera", + "Camera…", + ["Layer", "New"], + Some("Cmd+Alt+Shift+C"), + "{name?, type?: oneNode|twoNode (default twoNode), preset?: 15mm|20mm|24mm|28mm|35mm|50mm|80mm|135mm|200mm, zoom?, focalLength? mm, angleOfView? deg, dof?, focusDistance?, aperture?, fStop?, blurLevel?, lockToZoom?, position? [x,y,z], poi? [x,y,z]}", + has_comp, + new_camera + ), + cmd!( + "layer.cameraSettings", + "Camera Settings…", + ["Layer"], + None, + "{layer?, name?, type?, preset?, zoom?, focalLength?, angleOfView?, dof?, focusDistance?, aperture?, fStop?, blurLevel?, position?, poi?}", + has_comp, + camera_settings + ), + cmd!( + "layer.lightSettings", + "Light Settings…", + ["Layer"], + None, + "{layer?, name?, kind?, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", + has_comp, + light_settings + ), + cmd!( + "view.set3DView", + "Switch 3D View", + [], + None, + "{view: activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}", + has_comp, + set_3d_view + ), + cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active), + cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front), + cmd!("view.3d.left", "Left", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_left), + cmd!("view.3d.top", "Top", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_top), + cmd!("view.3d.back", "Back", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_back), + cmd!("view.3d.right", "Right", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_right), + cmd!("view.3d.bottom", "Bottom", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_bottom), + cmd!("view.3d.custom1", "Custom View 1", ["View", "Switch 3D View"], Some("F11"), "{comp?}", has_comp, view_custom1), + cmd!("view.3d.custom2", "Custom View 2", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom2), + cmd!("view.3d.custom3", "Custom View 3", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom3), + cmd!("view.3d.last", "Switch to Last 3D View", ["View"], None, "{comp?}", has_comp, last_view), + cmd!("view.reset3DView", "Reset 3D View", ["View"], None, "{comp?}", has_comp, reset_view), + cmd!("view.set3DViewCamera", "Set 3D View Camera", [], None, "{view, eye? [x,y,z], poi? [x,y,z], zoom?}", has_comp, set_view_camera), + cmd!( + "camera.orbit", + "Orbit Camera", + [], + None, + "{yaw|dx deg, pitch|dy deg, layer?, merge?} — current 3D view (camera layer in Active Camera)", + has_comp, + orbit + ), + cmd!("camera.pan", "Pan Camera", [], None, "{dx, dy (comp px), layer?, merge?}", has_comp, pan), + cmd!("camera.dolly", "Dolly Camera", [], None, "{amount (px, + = forward), layer?, merge?}", has_comp, dolly), + query!("view.get3D", "3D View State", "{comp?} → current 3D view, view camera, active camera layer, lights", get_3d), + ] +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index eca58e6d..f356bf59 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -1,6 +1,8 @@ //! The demo project: an animated title sequence built entirely from procedural content (original //! work, MIT OR Apache-2.0): gradient background, orbiting shape rings with trim paths and a -//! repeater burst, an animated title with a text animator and glow, a lower-third precomp. +//! repeater burst, an animated title with a text animator and glow, a lower-third precomp; and a +//! "3D Showcase" comp: a two-node camera move with depth of field over lit, shadow-casting 3D +//! cards, intersecting planes and a gridded floor. use effectcraft_color::BlendMode; use effectcraft_color::Label; @@ -10,6 +12,7 @@ use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, P use effectcraft_time::{FrameRate, Tick}; pub const MAIN_COMP: &str = "EffectCraft Intro"; +pub const SHOWCASE_3D: &str = "3D Showcase"; fn t(s: f64) -> Tick { Tick::from_seconds_f64(s) @@ -121,6 +124,134 @@ fn lower_third(p: &mut Project) -> Comp { c } +fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None) +} + +/// A 3D card: a solid with a diagonal gradient and a thin border grid. +fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer { + let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1); + let (w, h) = (size.0 as f64, size.1 as f64); + effect( + p, + &mut l, + "ec.generate.gradientramp", + [w, h], + &[("start", Value::Vec2([0.0, 0.0])), ("end", Value::Vec2([w, h])), ("startColor", Value::Color(hex(a))), ("endColor", Value::Color(hex(b)))], + ); + effect( + p, + &mut l, + "ec.generate.grid", + [w, h], + &[("width", Value::Scalar(w)), ("height", Value::Scalar(h)), ("border", Value::Scalar(10.0)), ("opacity", Value::Scalar(35.0))], + ); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + set(&mut l, "materialOptions/castsShadows", Value::Enum(1)); + set(&mut l, "materialOptions/specularIntensity", Value::Scalar(35.0)); + set(&mut l, "materialOptions/specularShininess", Value::Scalar(40.0)); + l +} + +/// The "3D Showcase" comp: camera move, key + fill lights, shadows, DOF, intersecting planes. +fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { + let mut c = Comp::new(1920, 1080, FrameRate::FPS_29_97, t(10.0)); + c.background = [0.02, 0.02, 0.035]; + // Floor: a gridded plane laid flat (X rotation 90°) below the cards. + let mut floor = solid_sized(p, &c, solids, "Floor", [0.16, 0.17, 0.22], 1000, 1000); + effect( + p, + &mut floor, + "ec.generate.grid", + [1000.0, 1000.0], + &[ + ("width", Value::Scalar(100.0)), + ("height", Value::Scalar(100.0)), + ("border", Value::Scalar(3.0)), + ("color", Value::Color(hex("#6F7BB8"))), + ("opacity", Value::Scalar(45.0)), + ], + ); + floor.switches.three_d = true; + set(&mut floor, "transform/position", Value::Vec3([960.0, 800.0, 500.0])); + set(&mut floor, "transform/scale", Value::Vec3([400.0, 400.0, 100.0])); + set(&mut floor, "transform/rotationX", Value::Scalar(90.0)); + set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0)); + + // Cards at different depths and angles; the centre pair intersects. + let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0); + let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0); + let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0); + set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0)); + let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0); + // Hero title in 3D space, gently turning. + let mut title = text_layer( + p, + &c, + "3D Title", + TextDoc { text: "CLASSIC 3D".into(), size: 150.0, style: "Bold".into(), tracking: 60.0, justify: Justify::Center, ..Default::default() }, + [960.0, 560.0], + ); + title.switches.three_d = true; + set(&mut title, "transform/position", Value::Vec3([960.0, 600.0, -160.0])); + anim(&mut title, "transform/rotationY", keys(&[(0.0, Value::Scalar(-18.0)), (10.0, Value::Scalar(18.0))])); + set(&mut title, "materialOptions/castsShadows", Value::Enum(1)); + + // Camera: two-node, 35 mm, sweeping around the set with depth of field on the title. + let mut cam = build::layer(p, &c, "Camera 1", LayerSource::Camera, (1920, 1080), None); + let zoom = 1920.0 * 35.0 / 36.0; + set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom)); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(40.0)); + set(&mut cam, "cameraOptions/blurLevel", Value::Scalar(100.0)); + set(&mut cam, "transform/poi", Value::Vec3([960.0, 560.0, 150.0])); + anim( + &mut cam, + "transform/position", + keys(&[(0.0, Value::Vec3([-200.0, 160.0, -1500.0])), (5.0, Value::Vec3([1100.0, 80.0, -1850.0])), (10.0, Value::Vec3([2150.0, 220.0, -1300.0]))]), + ); + anim(&mut cam, "cameraOptions/focusDistance", keys(&[(0.0, Value::Scalar(1700.0)), (5.0, Value::Scalar(1900.0)), (10.0, Value::Scalar(1650.0))])); + + // Key light: warm spot casting soft shadows. Fill: cool ambient. + let mut key = build::layer(p, &c, "Key Light", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None); + set(&mut key, "transform/position", Value::Vec3([500.0, -500.0, -700.0])); + set(&mut key, "transform/poi", Value::Vec3([960.0, 600.0, 300.0])); + set(&mut key, "lightOptions/intensity", Value::Scalar(150.0)); + set(&mut key, "lightOptions/color", Value::Color(hex("#FFE6C7"))); + set(&mut key, "lightOptions/coneAngle", Value::Scalar(110.0)); + set(&mut key, "lightOptions/coneFeather", Value::Scalar(60.0)); + set(&mut key, "lightOptions/castsShadows", Value::Bool(true)); + set(&mut key, "lightOptions/shadowDarkness", Value::Scalar(70.0)); + set(&mut key, "lightOptions/shadowDiffusion", Value::Scalar(12.0)); + let mut fill = build::layer(p, &c, "Fill Light", LayerSource::Light { kind: effectcraft_project::LightKind::Ambient }, (1920, 1080), None); + set(&mut fill, "lightOptions/intensity", Value::Scalar(35.0)); + set(&mut fill, "lightOptions/color", Value::Color(hex("#B9C8FF"))); + + // 2D caption on top (2D layers break 3D groups, so it always draws over the 3D scene). + let mut caption = text_layer( + p, + &c, + "Caption", + TextDoc { + text: "CAMERAS · LIGHTS · SHADOWS · DEPTH OF FIELD".into(), + size: 30.0, + style: "Medium".into(), + tracking: 220.0, + fill: [0.78, 0.83, 1.0, 1.0], + justify: Justify::Center, + ..Default::default() + }, + [960.0, 1000.0], + ); + anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))])); + + c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor]; + c +} + /// Build the demo project. pub fn demo_project() -> Project { let mut p = Project::default(); @@ -318,6 +449,8 @@ pub fn demo_project() -> Project { } comp.work_area = (Tick::ZERO, t(10.0)); p.add_item(MAIN_COMP, Label::Sandstone, None, ItemKind::Comp(comp.into())); + let show = showcase_3d(&mut p, solids); + p.add_item(SHOWCASE_3D, Label::Sandstone, None, ItemKind::Comp(show.into())); p.fix_next_id(); p } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18..1f4be7ca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -110,6 +110,9 @@ pub struct EditorState { pub snapping: bool, /// Last applied effect id (Effect ▸ last effect). pub last_effect: Option, + /// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras). + #[serde(default)] + pub views3d: std::collections::BTreeMap, } /// Events for frontends (drained each frame). @@ -299,6 +302,15 @@ impl Session { std::mem::take(&mut self.events) } + /// The 3D view camera of a comp's viewer (None = the active camera). + pub fn view_camera(&self, comp: ItemId) -> Option { + let c = self.project.comp(comp)?; + if !c.has_3d() { + return None; + } + self.state.views3d.get(&comp)?.override_camera(c.width as f64, c.height as f64) + } + /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); @@ -324,6 +336,8 @@ impl Session { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_3d; /// Font families available to text layers (bundled + scanned system fonts). pub fn text_families() -> Vec { diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs new file mode 100644 index 00000000..4bbddbcd --- /dev/null +++ b/crates/engine/src/tests_3d.rs @@ -0,0 +1,185 @@ +//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp. + +use effectcraft_project::{AutoOrient, LayerSource, LightKind}; +use effectcraft_render::three_d::View3D; +use serde_json::json; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + s +} + +fn layer_count(s: &Session) -> usize { + s.active_comp().unwrap().layers.len() +} + +fn prop(s: &Session, layer: u64, path: &str) -> effectcraft_keyframe::Value { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap(); + l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).value.clone() +} + +#[test] +fn new_camera_with_preset_undo_redo() { + let mut s = session(); + let r = s.execute("layer.newCamera", json!({"preset": "35mm"})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + assert_eq!(layer_count(&s), 1); + let zoom = prop(&s, id, "cameraOptions/zoom").as_f64(); + assert!((zoom - 640.0 * 35.0 / 36.0).abs() < 1e-9); + assert_eq!(prop(&s, id, "cameraOptions/focusDistance").as_f64(), zoom); + let l = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(l.source, LayerSource::Camera); + assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest); + assert!(s.undo()); + assert_eq!(layer_count(&s), 0); + assert!(s.redo()); + assert_eq!(layer_count(&s), 1); + assert!(s.execute("layer.newCamera", json!({"preset": "13mm-fisheye"})).is_err()); +} + +#[test] +fn camera_settings_one_node_keeps_view() { + let mut s = session(); + let id = s.execute("layer.newCamera", json!({"poi": [100, 50, 0], "position": [320, 180, -400]})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let fwd = |s: &Session| { + let c = s.project.comp(cid).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, c, s.time()); + effectcraft_render::three_d::active_camera(&ctx).forward() + }; + let before = fwd(&s); + s.execute("layer.cameraSettings", json!({"layer": id, "type": "oneNode", "dof": true, "aperture": 30})).unwrap(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(l.auto_orient, AutoOrient::Off); + assert!((fwd(&s) - before).length() < 1e-9, "one-node camera keeps looking the same way"); + assert!(prop(&s, id, "cameraOptions/dof").as_bool()); + // Layer Settings (Cmd+Shift+Y) on a camera opens Camera Settings. + s.execute("layer.settings", json!({"layer": id, "zoom": 500})).unwrap(); + assert_eq!(prop(&s, id, "cameraOptions/zoom").as_f64(), 500.0); + s.undo(); + s.undo(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest); + assert!(!prop(&s, id, "cameraOptions/dof").as_bool()); +} + +#[test] +fn new_light_and_change_type() { + let mut s = session(); + let id = s + .execute( + "layer.newLight", + json!({"kind": "Spot", "intensity": 150, "color": [1, 0.5, 0.25], "coneAngle": 60, "castsShadows": true, "falloff": "Smooth"}), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + assert_eq!(prop(&s, id, "lightOptions/intensity").as_f64(), 150.0); + assert_eq!(prop(&s, id, "lightOptions/coneAngle").as_f64(), 60.0); + assert_eq!(prop(&s, id, "lightOptions/falloff").as_enum(), 1); + assert!(prop(&s, id, "lightOptions/castsShadows").as_bool()); + s.execute("layer.lightSettings", json!({"layer": id, "kind": "Point"})).unwrap(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(l.source, LayerSource::Light { kind: LightKind::Point }); + assert!(l.props.prop("lightOptions/coneAngle").is_none()); + assert_eq!(prop(&s, id, "lightOptions/intensity").as_f64(), 150.0, "shared values carry over"); + s.undo(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(l.source, LayerSource::Light { kind: LightKind::Spot }); + assert!(s.execute("layer.newLight", json!({"kind": "Laser"})).is_err()); +} + +#[test] +fn three_d_switch_undo_redo() { + let mut s = session(); + let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap(); + assert!(s.active_comp().unwrap().layers[0].is_3d()); + s.undo(); + assert!(!s.active_comp().unwrap().layers[0].is_3d()); + s.redo(); + assert!(s.active_comp().unwrap().layers[0].is_3d()); +} + +#[test] +fn views_switch_and_override_camera() { + let mut s = session(); + let cid = s.active_comp_id().unwrap(); + let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap(); + assert!(s.view_camera(cid).is_none()); + s.execute("view.3d.top", json!({})).unwrap(); + let cam = s.view_camera(cid).unwrap(); + assert!(cam.ortho); + assert!((cam.forward().y - 1.0).abs() < 1e-9); + s.execute("view.set3DView", json!({"view": "Custom View 2"})).unwrap(); + assert!(!s.view_camera(cid).unwrap().ortho); + s.execute("view.3d.last", json!({})).unwrap(); + assert_eq!(s.state.views3d[&cid].current, View3D::Top); + // Orbit a custom view: edits the view camera, not the project. + s.execute("view.3d.custom1", json!({})).unwrap(); + let rev = s.revision; + let before = s.view_camera(cid).unwrap().eye; + s.execute("camera.orbit", json!({"yaw": 20})).unwrap(); + assert!((s.view_camera(cid).unwrap().eye - before).length() > 1.0); + assert_eq!(s.revision, rev); + s.execute("view.reset3DView", json!({})).unwrap(); + assert!((s.view_camera(cid).unwrap().eye - before).length() < 1e-9); + s.execute("view.3d.activeCamera", json!({})).unwrap(); + assert!(s.view_camera(cid).is_none()); + let q = s.execute("view.get3D", json!({})).unwrap(); + assert_eq!(q["view"], "activeCamera"); + assert!(s.execute("view.set3DView", json!({"view": "sideways"})).is_err()); +} + +#[test] +fn camera_tools_edit_active_camera_with_undo() { + let mut s = session(); + // No camera layer: the tools explain what is needed. + assert!(s.execute("camera.orbit", json!({"yaw": 10})).is_err()); + let id = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let p0 = prop(&s, id, "transform/position").as_vec3(); + let poi = prop(&s, id, "transform/poi").as_vec3(); + let dist = |a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + // A drag = several merged steps → one undo. + let undo_len = s.history.undo.len(); + for _ in 0..5 { + s.execute("camera.orbit", json!({"yaw": 6, "pitch": -2, "merge": "drag-1"})).unwrap(); + } + assert_eq!(s.history.undo.len(), undo_len + 1); + let p1 = prop(&s, id, "transform/position").as_vec3(); + assert!((dist(p1, poi) - dist(p0, poi)).abs() < 1e-6, "orbit keeps the distance to the POI"); + assert!(dist(p1, p0) > 10.0); + s.execute("camera.pan", json!({"dx": 50, "dy": 0})).unwrap(); + let poi2 = prop(&s, id, "transform/poi").as_vec3(); + assert!(dist(poi2, poi) > 1.0, "pan moves the POI too"); + s.execute("camera.dolly", json!({"amount": 100})).unwrap(); + s.undo(); + s.undo(); + s.undo(); + assert_eq!(prop(&s, id, "transform/position").as_vec3(), p0); + // One-node cameras orbit by re-orienting. + s.execute("layer.cameraSettings", json!({"layer": id, "type": "oneNode"})).unwrap(); + s.execute("camera.orbit", json!({"yaw": 30})).unwrap(); + let o = prop(&s, id, "transform/orientation").as_vec3(); + assert!(o[1].abs() > 1.0, "{o:?}"); +} + +#[test] +fn demo_has_3d_showcase() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let item = s.project.find_by_name(crate::demo::SHOWCASE_3D).unwrap(); + let cid = item.id; + let c = s.project.comp(cid).unwrap(); + assert!(c.layers.iter().any(|l| l.is_camera())); + assert!(c.layers.iter().any(|l| l.is_light())); + assert!(c.layers.iter().filter(|l| l.switches.three_d).count() >= 4); + let img = + s.render(cid, effectcraft_time::Tick::from_seconds_f64(4.0), effectcraft_render::RenderOpts { scale: 0.125, motion_blur: false, ..Default::default() }); + let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count(); + assert!(lit > 2_000, "{lit}"); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 15ef3f54..9eb8ee7e 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -16,11 +16,12 @@ impl Importer for MediaImporter { /// A new session with media, import and expressions enabled. pub fn session() -> Session { - let mut s = Session::default(); - s.footage = Arc::new(effectcraft_media::MediaPool::new()); - s.importer = Some(Arc::new(MediaImporter)); - s.expr = Some(Arc::new(effectcraft_expr::Expressions)); - s + Session { + footage: Arc::new(effectcraft_media::MediaPool::new()), + importer: Some(Arc::new(MediaImporter)), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + ..Default::default() + } } #[cfg(test)] diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index a6fcdf1f..9f78aa50 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -350,12 +350,36 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { // ---------------------------------------------------------------- camera / light / 3D pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup { + camera_options_for(ids, zoom, zoom * 36.0 / 50.0) +} + +/// Camera Options for a comp `comp_w` wide (aperture from the 50 mm preset: 25.3 mm at 1920 px). +pub fn camera_options_for(ids: &mut Ids, zoom: f64, comp_w: f64) -> PropGroup { + let dof = ids.prop("dof", "Depth of Field", Value::Bool(false)).with_ui(ParamUi::Checkbox); ids.group("cameraOptions", "Camera Options") .with(ids.prop("zoom", "Zoom", Value::Scalar(zoom)).with_ui(ParamUi::Pixels)) - .with(ids.prop("dof", "Depth of Field", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(dof) .with(ids.prop("focusDistance", "Focus Distance", Value::Scalar(zoom)).with_ui(ParamUi::Pixels)) - .with(ids.prop("aperture", "Aperture", Value::Scalar(25.3)).with_ui(ParamUi::Pixels)) + .with(ids.prop("aperture", "Aperture", Value::Scalar(25.3 * comp_w / 1920.0)).with_ui(ParamUi::Pixels)) .with(ids.prop("blurLevel", "Blur Level", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("irisShape", "Iris Shape", Value::Enum(0)).with_ui(popup(&[ + "Fast Rectangle", + "Triangle", + "Square", + "Pentagon", + "Hexagon", + "Heptagon", + "Octagon", + "Nonagon", + "Decagon", + ]))) + .with(ids.prop("irisRotation", "Iris Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("irisRoundness", "Iris Roundness", Value::Scalar(0.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("irisAspectRatio", "Iris Aspect Ratio", Value::Scalar(1.0)).with_ui(slider(0.01, 100.0, 0.01, 4.0, 2))) + .with(ids.prop("irisDiffractionFringe", "Iris Diffraction Fringe", Value::Scalar(0.0)).with_ui(slider(0.0, 1000.0, 0.0, 100.0, 1))) + .with(ids.prop("highlightGain", "Highlight Gain", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("highlightThreshold", "Highlight Threshold", Value::Scalar(255.0)).with_ui(slider(0.0, 255.0, 0.0, 255.0, 0))) + .with(ids.prop("highlightSaturation", "Highlight Saturation", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) } pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { @@ -457,7 +481,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource .with(ids.prop("rotationY", "Y Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) .with(ids.prop("rotation", "Z Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); root.children.push(tr.into()); - root.children.push(camera_options(&mut ids, zoom).into()); + root.children.push(camera_options_for(&mut ids, zoom, cw).into()); } else if let LayerSource::Light { kind } = source { let mut g = ids.group("transform", "Transform"); if matches!(kind, LightKind::Spot | LightKind::Parallel) { @@ -466,6 +490,10 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource if kind != LightKind::Ambient { g.children .push(ids.prop("position", "Position", Value::Vec3([cw / 2.0 - 260.0, ch / 2.0 - 260.0, -440.0])).with_ui(ParamUi::Point3).spatial().into()); + g.children.push(ids.prop("orientation", "Orientation", Value::Vec3([0.0; 3])).with_ui(ParamUi::Angle).into()); + g.children.push(ids.prop("rotationX", "X Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle).into()); + g.children.push(ids.prop("rotationY", "Y Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle).into()); + g.children.push(ids.prop("rotation", "Z Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle).into()); } root.children.push(g.into()); root.children.push(light_options(&mut ids, kind).into()); @@ -485,6 +513,12 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource root.children.push(audio(&mut ids).into()); } let out = duration.map(|d| d.min(comp.duration)).unwrap_or(comp.duration); + // Two-node cameras and spot/parallel lights aim at their Point of Interest. + let auto_orient = if matches!(source, LayerSource::Camera | LayerSource::Light { kind: LightKind::Spot | LightKind::Parallel }) { + crate::AutoOrient::TowardsPointOfInterest + } else { + crate::AutoOrient::Off + }; Layer { id, name: comp.unique_layer_name(name), @@ -501,7 +535,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource track_matte: None, parent: None, markers: vec![], - auto_orient: Default::default(), + auto_orient, props: root, } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 864d87ac..53019406 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -21,4 +21,5 @@ effectcraft-text = { workspace = true } effectcraft-effects = { workspace = true } kurbo = { workspace = true } rayon = { workspace = true } +serde = { workspace = true } log = { workspace = true } diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 994b6038..abe95ce8 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -3,7 +3,7 @@ //! Keyframe times are stored in **layer time** (they move with the layer), so evaluating a //! property at comp time `t` first maps `t` through the layer's start time and stretch. -use effectcraft_geom::{Mat3, Mat4, Vec3, look_at, vec3}; +use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, Layer, LayerId, LayerSource, Project, PropGroup, Property}; use effectcraft_time::Tick; @@ -111,40 +111,10 @@ impl<'a> EvalCtx<'a> { layer.transform().map(|tr| self.f(layer, tr, "opacity", 100.0)).unwrap_or(100.0) / 100.0 } - /// The comp's camera at this time: (view-projection to comp pixels, eye position). + /// The comp's active camera at this time: (view-projection to comp pixels, eye, zoom). pub fn camera(&self) -> (Mat4, Vec3, f64) { - let (w, h) = (self.comp.width as f64, self.comp.height as f64); - if let Some(cam) = self.comp.active_camera(self.time) - && let Some(tr) = cam.transform() - { - let pos = Vec3::from(self.v3(cam, tr, "position", [w / 2.0, h / 2.0, -1000.0])); - let zoom = cam.props.sub("cameraOptions").map(|g| self.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0); - let o = self.v3(cam, tr, "orientation", [0.0; 3]); - let rx = self.f(cam, tr, "rotationX", 0.0); - let ry = self.f(cam, tr, "rotationY", 0.0); - let rz = self.f(cam, tr, "rotation", 0.0); - // Extra camera rotation is applied in camera space (inverse, because it rotates the eye). - let extra = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).transpose(); - let view = if let Some(poi) = tr.get("poi").map(|p| self.value(cam, p).as_vec3()) { - look_at(pos, Vec3::from(poi), extra) - } else { - look_at(pos, pos + vec3(0.0, 0.0, 1.0), extra) - }; - let mut view = view; - // Parented cameras: apply the parent's world matrix inverse. - if let Some(pid) = cam.parent - && let Some(p) = self.comp.layer(pid) - && let Some(inv) = self.world_matrix(p).inverse() - { - view = view * inv; - let _ = pos; - } - return (effectcraft_geom::camera_matrix(w, h, pos, view, zoom), pos, zoom); - } - let zoom = effectcraft_geom::default_camera_zoom(w); - let pos = vec3(w / 2.0, h / 2.0, -zoom); - let view = look_at(pos, vec3(w / 2.0, h / 2.0, 0.0), Mat4::IDENTITY); - (effectcraft_geom::camera_matrix(w, h, pos, view, zoom), pos, zoom) + let c = crate::three_d::active_camera(self); + (c.projection(self.comp.width as f64, self.comp.height as f64), c.eye, c.zoom) } /// Layer space → comp pixel space as a 2D projective matrix, plus camera-space depth of the diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd335..a5b39ada 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ pub mod eval; pub mod masks; pub mod shapes; pub mod text; +pub mod three_d; use std::sync::Arc; @@ -46,11 +47,13 @@ pub struct RenderOpts { pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, + /// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera. + pub view: Option, } impl Default for RenderOpts { fn default() -> Self { - RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false } + RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None } } } @@ -98,11 +101,7 @@ impl<'a> Renderer<'a> { while j < visible.len() && visible[j].is_3d() { j += 1; } - let mut run: Vec<(&Layer, f64)> = visible[i..j].iter().map(|l| (*l, ctx.layer_to_comp(l).1)).collect(); - run.sort_by(|a, b| b.1.total_cmp(&a.1)); - for (l, _) in run { - self.draw_layer(&ctx, l, &mut canvas); - } + three_d::compose::draw_run(self, &ctx, &visible[i..j], &mut canvas); i = j; } else { self.draw_layer(&ctx, visible[i], &mut canvas); diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs new file mode 100644 index 00000000..de453642 --- /dev/null +++ b/crates/render/src/three_d/camera.rs @@ -0,0 +1,431 @@ +//! Cameras: the comp's active camera (camera layers or the default 50 mm comp camera), the 3D +//! views of the viewer (Front/Left/Top/Back/Right/Bottom orthographic views and three custom +//! perspective views) and the camera-tool math (orbit, pan, dolly) shared by the engine commands. +//! +//! Conventions: world space is comp space (x right, y down, z into the screen). Camera space has +//! x right, y down and z forward (depth). The perspective projection maps camera space to comp +//! pixels as `x' = zoom·x/z + w/2`; orthographic views use `x' = zoom·x + w/2`. + +use std::collections::BTreeMap; + +use effectcraft_geom::{Mat4, Vec2, Vec3, vec2, vec3}; +use effectcraft_project::{AutoOrient, Layer}; +use serde::{Deserialize, Serialize}; + +use crate::EvalCtx; + +/// Closest a perspective camera renders (camera-space z, pixels). +pub const NEAR: f64 = 1.0; + +/// Depth of field settings of a camera. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Dof { + /// Distance from the eye to the plane in focus (pixels). + pub focus: f64, + /// Aperture diameter (pixels). + pub aperture: f64, + /// Blur Level (1.0 = 100%). + pub blur_level: f64, +} + +impl Dof { + /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. + pub fn coc(&self, z: f64) -> f64 { + if z <= NEAR || self.focus <= 0.0 { + return 0.0; + } + self.aperture * (z - self.focus).abs() / z * self.blur_level + } +} + +/// An evaluated camera. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct CameraState { + /// World → camera space (rigid). + pub view: Mat4, + /// Eye position in world space. + pub eye: Vec3, + /// Perspective: distance from the eye to the image plane (pixels). Orthographic: pixels per + /// world unit. + pub zoom: f64, + pub ortho: bool, + pub dof: Option, +} + +impl CameraState { + /// World → comp pixels (homogeneous: divide by `w` for perspective cameras). + pub fn projection(&self, comp_w: f64, comp_h: f64) -> Mat4 { + if self.ortho { + let z = self.zoom; + Mat4([[z, 0.0, 0.0, comp_w * 0.5], [0.0, z, 0.0, comp_h * 0.5], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) * self.view + } else { + effectcraft_geom::camera_matrix(comp_w, comp_h, self.eye, self.view, self.zoom) + } + } + /// Camera-space depth of a world point. + pub fn depth(&self, p: Vec3) -> f64 { + self.view.apply(p).z + } + /// Unit view direction (world). + pub fn forward(&self) -> Vec3 { + let m = &self.view.0; + vec3(m[2][0], m[2][1], m[2][2]) + } + pub fn right(&self) -> Vec3 { + let m = &self.view.0; + vec3(m[0][0], m[0][1], m[0][2]) + } + pub fn down(&self) -> Vec3 { + let m = &self.view.0; + vec3(m[1][0], m[1][1], m[1][2]) + } + /// Project a world point to comp pixels; None when behind a perspective camera. + pub fn project(&self, comp_w: f64, comp_h: f64, p: Vec3) -> Option { + let c = self.view.apply(p); + if self.ortho { + return Some(vec2(self.zoom * c.x + comp_w * 0.5, self.zoom * c.y + comp_h * 0.5)); + } + if c.z < NEAR { + return None; + } + Some(vec2(self.zoom * c.x / c.z + comp_w * 0.5, self.zoom * c.y / c.z + comp_h * 0.5)) + } + /// Direction from a shaded point towards the viewer. + pub fn to_viewer(&self, p: Vec3) -> Vec3 { + if self.ortho { -self.forward() } else { (self.eye - p).normalize() } + } +} + +/// View matrix from an eye, a forward direction and a screen-down hint. +pub fn basis_view(eye: Vec3, forward: Vec3, down_hint: Vec3) -> Mat4 { + let f = forward.normalize(); + let f = if f.length() == 0.0 { vec3(0.0, 0.0, 1.0) } else { f }; + let mut d = down_hint - f * down_hint.dot(f); + if d.length() < 1e-9 { + d = if f.y.abs() < 0.99 { vec3(0.0, 1.0, 0.0) } else { vec3(0.0, 0.0, -1.0) }; + d = d - f * d.dot(f); + } + let d = d.normalize(); + let r = d.cross(f).normalize(); + Mat4([[r.x, r.y, r.z, 0.0], [d.x, d.y, d.z, 0.0], [f.x, f.y, f.z, 0.0], [0.0, 0.0, 0.0, 1.0]]) * Mat4::translate(-eye) +} + +/// Orientation angles (degrees, AE order X→Y→Z with Z = 0) that point +z along `dir`. +pub fn orientation_for(dir: Vec3) -> [f64; 3] { + let d = dir.normalize(); + let x = (-d.y).clamp(-1.0, 1.0).asin().to_degrees(); + let y = d.x.atan2(d.z).to_degrees(); + [norm360(x), norm360(y), 0.0] +} + +fn norm360(a: f64) -> f64 { + let r = a.rem_euclid(360.0); + if (r - 360.0).abs() < 1e-9 { 0.0 } else { r } +} + +/// AE's camera presets (35 mm film width 36 mm): (name, focal length mm). +pub const PRESETS: [(&str, f64); 9] = + [("15mm", 15.0), ("20mm", 20.0), ("24mm", 24.0), ("28mm", 28.0), ("35mm", 35.0), ("50mm", 50.0), ("80mm", 80.0), ("135mm", 135.0), ("200mm", 200.0)]; + +/// Film width the presets assume (mm, measured horizontally). +pub const FILM_SIZE_MM: f64 = 36.0; + +/// Zoom (pixels) for a focal length on a comp `comp_w` pixels wide. +pub fn zoom_for_focal(comp_w: f64, focal_mm: f64) -> f64 { + comp_w * focal_mm / FILM_SIZE_MM +} + +/// Focal length (mm) of a zoom value. +pub fn focal_for_zoom(comp_w: f64, zoom: f64) -> f64 { + zoom * FILM_SIZE_MM / comp_w.max(1.0) +} + +/// Horizontal angle of view (degrees) of a zoom value. +pub fn angle_of_view(comp_w: f64, zoom: f64) -> f64 { + 2.0 * (comp_w * 0.5 / zoom.max(1e-9)).atan().to_degrees() +} + +/// Default aperture (pixels) of the presets: 25.3 mm at a 1920-pixel-wide comp, proportional. +pub fn default_aperture(comp_w: f64) -> f64 { + 25.3 * comp_w / 1920.0 +} + +/// The comp camera used when there is no camera layer: the 50 mm preset at (w/2, h/2, −zoom) +/// looking at the comp centre, which shows the z = 0 plane at 100%. +pub fn default_camera(comp_w: f64, comp_h: f64) -> CameraState { + let zoom = effectcraft_geom::default_camera_zoom(comp_w); + let eye = vec3(comp_w / 2.0, comp_h / 2.0, -zoom); + CameraState { view: basis_view(eye, vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)), eye, zoom, ortho: false, dof: None } +} + +/// Whether a camera layer is two-node (auto-orients towards its Point of Interest). +pub fn is_two_node(layer: &Layer) -> bool { + layer.auto_orient == AutoOrient::TowardsPointOfInterest && layer.transform().is_some_and(|t| t.get("poi").is_some()) +} + +/// Rotation of a camera/light layer from its Orientation and X/Y/Z Rotation (layer → parent). +pub fn layer_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 { + let Some(tr) = layer.transform() else { return Mat4::IDENTITY }; + let o = ctx.v3(layer, tr, "orientation", [0.0; 3]); + let rx = ctx.f(layer, tr, "rotationX", 0.0); + let ry = ctx.f(layer, tr, "rotationY", 0.0); + let rz = ctx.f(layer, tr, "rotation", 0.0); + Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx) +} + +/// World matrix of a layer's parent chain (identity when unparented). +pub fn parent_world(ctx: &EvalCtx, layer: &Layer) -> Mat4 { + match layer.parent.and_then(|p| ctx.comp.layer(p)) { + Some(p) => ctx.world_matrix(p), + None => Mat4::IDENTITY, + } +} + +/// (eye, forward, down) in world space of a camera or light layer. +pub fn layer_frame(ctx: &EvalCtx, layer: &Layer) -> (Vec3, Vec3, Vec3) { + let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64); + let Some(tr) = layer.transform() else { return (vec3(w / 2.0, h / 2.0, -1000.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)) }; + let parent = parent_world(ctx, layer); + let pos = Vec3::from(ctx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0])); + let rot = layer_rotation(ctx, layer); + // Local frame: auto-orient (two-node) towards the POI, then the layer's own rotations. + let base = if is_two_node(layer) { + let poi = Vec3::from(ctx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0])); + let v = basis_view(pos, poi - pos, vec3(0.0, 1.0, 0.0)); + // Rotation part of the view, transposed = camera → parent. + let m = &v.0; + Mat4([[m[0][0], m[1][0], m[2][0], 0.0], [m[0][1], m[1][1], m[2][1], 0.0], [m[0][2], m[1][2], m[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]]) + } else { + Mat4::IDENTITY + }; + let local = base * rot; + let eye = parent.apply(pos); + let fwd = parent.apply_vec(local.apply_vec(vec3(0.0, 0.0, 1.0))).normalize(); + let down = parent.apply_vec(local.apply_vec(vec3(0.0, 1.0, 0.0))).normalize(); + (eye, fwd, down) +} + +/// Evaluate a camera layer. +pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { + let w = ctx.comp.width as f64; + let (eye, fwd, down) = layer_frame(ctx, cam); + let opts = cam.props.sub("cameraOptions"); + let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { + focus: ctx.f(cam, g, "focusDistance", zoom), + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + }); + CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } +} + +/// The comp's active camera at the context time (topmost active camera layer, else the default). +pub fn active_camera(ctx: &EvalCtx) -> CameraState { + match ctx.comp.active_camera(ctx.time) { + Some(cam) => layer_camera(ctx, cam), + None => default_camera(ctx.comp.width as f64, ctx.comp.height as f64), + } +} + +// ---------------------------------------------------------------- 3D views + +/// The viewer's 3D views (View ▸ Switch 3D View). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum View3D { + #[default] + ActiveCamera, + Front, + Left, + Top, + Back, + Right, + Bottom, + Custom1, + Custom2, + Custom3, +} + +impl View3D { + pub const ALL: [View3D; 10] = [ + View3D::ActiveCamera, + View3D::Front, + View3D::Left, + View3D::Top, + View3D::Back, + View3D::Right, + View3D::Bottom, + View3D::Custom1, + View3D::Custom2, + View3D::Custom3, + ]; + pub fn label(self) -> &'static str { + match self { + View3D::ActiveCamera => "Active Camera", + View3D::Front => "Front", + View3D::Left => "Left", + View3D::Top => "Top", + View3D::Back => "Back", + View3D::Right => "Right", + View3D::Bottom => "Bottom", + View3D::Custom1 => "Custom View 1", + View3D::Custom2 => "Custom View 2", + View3D::Custom3 => "Custom View 3", + } + } + /// Stable id used by commands (`activeCamera`, `front`, `custom1`…). + pub fn id(self) -> &'static str { + match self { + View3D::ActiveCamera => "activeCamera", + View3D::Front => "front", + View3D::Left => "left", + View3D::Top => "top", + View3D::Back => "back", + View3D::Right => "right", + View3D::Bottom => "bottom", + View3D::Custom1 => "custom1", + View3D::Custom2 => "custom2", + View3D::Custom3 => "custom3", + } + } + pub fn from_id(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + View3D::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(&k) || v.label().replace(' ', "").eq_ignore_ascii_case(&k)) + } + pub fn is_ortho(self) -> bool { + matches!(self, View3D::Front | View3D::Left | View3D::Top | View3D::Back | View3D::Right | View3D::Bottom) + } +} + +/// A view camera: eye, point of interest, screen-down hint, zoom (perspective) or scale (ortho). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct ViewCam { + pub eye: [f64; 3], + pub poi: [f64; 3], + pub down: [f64; 3], + pub zoom: f64, + pub ortho: bool, +} + +impl ViewCam { + pub fn state(&self) -> CameraState { + let eye = Vec3::from(self.eye); + let poi = Vec3::from(self.poi); + CameraState { view: basis_view(eye, poi - eye, Vec3::from(self.down)), eye, zoom: self.zoom, ortho: self.ortho, dof: None } + } +} + +/// Default camera of a view for a `w`×`h` comp. +pub fn default_view_cam(v: View3D, w: f64, h: f64) -> ViewCam { + let c = vec3(w / 2.0, h / 2.0, 0.0); + let zoom = effectcraft_geom::default_camera_zoom(w); + let far = 10_000.0; + let ortho = |fwd: Vec3, down: Vec3| ViewCam { eye: arr(c - fwd * far), poi: arr(c), down: arr(down), zoom: 0.5, ortho: true }; + let custom = |yaw: f64, pitch: f64| { + // Orbit the default camera around the comp centre (yaw about y, then pitch). + let off = Mat4::rotate_y(yaw).apply_vec(Mat4::rotate_x(pitch).apply_vec(vec3(0.0, 0.0, -zoom * 1.6))); + ViewCam { eye: arr(c + off), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false } + }; + match v { + View3D::ActiveCamera | View3D::Front => ortho(vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)), + View3D::Back => ortho(vec3(0.0, 0.0, -1.0), vec3(0.0, 1.0, 0.0)), + View3D::Left => ortho(vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0)), + View3D::Right => ortho(vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0)), + View3D::Top => ortho(vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, -1.0)), + View3D::Bottom => ortho(vec3(0.0, -1.0, 0.0), vec3(0.0, 0.0, 1.0)), + // Custom views look down at the comp from above-left, above and above-right. + View3D::Custom1 => custom(30.0, -20.0), + View3D::Custom2 => custom(0.0, -35.0), + View3D::Custom3 => custom(-30.0, -20.0), + } +} + +pub fn arr(v: Vec3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +/// Per-comp 3D view state of the viewer (current view + edited view cameras). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Views3D { + pub current: View3D, + /// The view before the last switch (View ▸ Switch to Last 3D View). + #[serde(default)] + pub last: View3D, + /// Edited view cameras (views not present use their defaults). + #[serde(default)] + pub cams: BTreeMap, +} + +impl Views3D { + pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam { + self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h)) + } + /// Camera override for rendering: None for the active camera view. + pub fn override_camera(&self, w: f64, h: f64) -> Option { + (self.current != View3D::ActiveCamera).then(|| self.cam(self.current, w, h).state()) + } +} + +// ---------------------------------------------------------------- camera tools + +/// A camera as edited by the tools: position, point of interest, zoom (two-node form). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rig { + pub eye: Vec3, + pub poi: Vec3, + pub zoom: f64, + pub ortho: bool, +} + +impl Rig { + pub fn from_view(v: &ViewCam) -> Rig { + Rig { eye: Vec3::from(v.eye), poi: Vec3::from(v.poi), zoom: v.zoom, ortho: v.ortho } + } + fn frame(&self) -> (Vec3, Vec3, Vec3) { + let s = + CameraState { view: basis_view(self.eye, self.poi - self.eye, vec3(0.0, 1.0, 0.0)), eye: self.eye, zoom: self.zoom, ortho: self.ortho, dof: None }; + (s.forward(), s.right(), s.down()) + } + /// Orbit the eye around the point of interest: `yaw` about world Y, `pitch` about the + /// camera's right axis (degrees). Pitch is limited short of straight up/down. + pub fn orbit(&mut self, yaw: f64, pitch: f64) { + if self.ortho { + return; + } + let off = self.eye - self.poi; + let dist = off.length().max(1e-6); + let cur_pitch = (off.y / dist).clamp(-1.0, 1.0).asin().to_degrees(); + let new_pitch = (cur_pitch + pitch).clamp(-89.0, 89.0); + let horiz = vec3(off.x, 0.0, off.z); + let hl = horiz.length(); + let az = if hl > 1e-9 { off.x.atan2(-off.z).to_degrees() } else { 0.0 } + yaw; + let (sp, cp) = new_pitch.to_radians().sin_cos(); + let (sa, ca) = az.to_radians().sin_cos(); + self.eye = self.poi + vec3(sa * cp, sp, -ca * cp) * dist; + } + /// Pan in the image plane by (`dx`, `dy`) comp pixels (as seen at the point of interest). + pub fn pan(&mut self, dx: f64, dy: f64) { + let (_, r, d) = self.frame(); + let k = if self.ortho { 1.0 / self.zoom.max(1e-9) } else { (self.poi - self.eye).length() / self.zoom.max(1e-9) }; + let delta = r * (-dx * k) + d * (-dy * k); + self.eye += delta; + self.poi += delta; + } + /// Dolly along the view direction by `amount` pixels (positive = towards the POI). With + /// `with_poi` the point of interest travels too (camera layers: Track Z); otherwise the + /// eye stops short of the POI. Orthographic views scale instead. + pub fn dolly(&mut self, amount: f64, with_poi: bool) { + if self.ortho { + self.zoom = (self.zoom * (amount / 500.0).exp()).clamp(0.01, 100.0); + return; + } + let (f, _, _) = self.frame(); + if with_poi { + self.eye += f * amount; + self.poi += f * amount; + } else { + let dist = (self.poi - self.eye).length(); + let d = amount.min(dist - 1.0); + self.eye += f * d; + } + } +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs new file mode 100644 index 00000000..9b04d900 --- /dev/null +++ b/crates/render/src/three_d/compose.rs @@ -0,0 +1,483 @@ +//! The Classic 3D compositor for a run of consecutive 3D layers. +//! +//! Each layer is a textured plane: its processed buffer (source → masks → effects, DOF-blurred) +//! mapped through `projection · world`. Rendering is per output pixel (rows in parallel): every +//! plane covering the pixel is inverse-mapped through its homography (exact perspective-correct +//! texture coordinates, bilinear/bicubic sampling), its camera depth taken from the plane +//! equation, and the fragment shaded by the comp's lights. The pixel's fragments are then sorted +//! far→near (coplanar fragments keep layer-stack order) and blended with each layer's mode and +//! opacity. Sorting per pixel handles intersecting layers exactly, and blend modes and partial +//! transparency still compose in depth order, as in AE's Classic 3D renderer. +//! +//! Shadows are ray-cast against the shadow-casting planes (exact for planar layers, including +//! alpha-shaped and coloured shadows through Light Transmission); Shadow Diffusion softens them +//! with a penumbra that grows with the distance between caster and receiver. + +use effectcraft_color::{BlendMode, blend_pixel}; +use effectcraft_effects::Buf; +use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; +use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_raster::{Image, Px, hash_noise}; +use effectcraft_time::Tick; +use rayon::prelude::*; + +use super::camera::{CameraState, NEAR, active_camera}; +use super::light::{LightState, Material, lights_at, shade}; +use crate::{EvalCtx, Renderer}; + +/// One position of a plane (several with motion blur). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Geo { + /// Output pixel → homogeneous layer coordinates. + hinv: Mat3, + /// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`. + depth: [f64; 3], + world: Mat4, + normal: Vec3, + /// Output pixel bounds [x0, y0, x1, y1) covered by the plane. + bbox: [i64; 4], + cam: CameraState, +} + +/// A prepared 3D layer. +pub(crate) struct Item<'a> { + pub layer: &'a Layer, + /// Position in the layer stack (higher = above). + order: usize, + buf: Buf, + bicubic: bool, + geos: Vec, + mat: Material, + opacity: f32, + /// Drawn (false for "Casts Shadows: Only" and shadow-only casters outside the run). + draw: bool, + /// Screen-space track matte factor (output size). + matte: Option>, + preserve: bool, + /// World → layer (for shadow rays) and the buffer's layer-space bounds. + winv: Mat4, + bounds: [f64; 4], + bbox: [i64; 4], +} + +impl Item<'_> { + #[inline] + fn texel(&self, u: f64, v: f64) -> Px { + let x = u * self.buf.scale + self.buf.offset[0]; + let y = v * self.buf.scale + self.buf.offset[1]; + if self.bicubic { self.buf.img.sample_bicubic(x, y) } else { self.buf.img.sample_bilinear(x, y) } + } +} + +/// Build the plane geometry of a layer for a camera. +fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { + let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); + let full = proj * world; + let h = full.plane_to_mat3(); + let hinv = h.inverse()?; + let cv = cam.view * world; + let depth = [cv.0[2][0], cv.0[2][1], cv.0[2][3]]; + let normal = world.apply_vec(vec3(1.0, 0.0, 0.0)).cross(world.apply_vec(vec3(0.0, 1.0, 0.0))); + if normal.length() < 1e-12 { + return None; + } + let normal = normal.normalize(); + let b = buf_bounds(buf); + let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])]; + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut behind = 0; + let mut full_frame = false; + for (u, v) in corners { + let z = depth[0] * u + depth[1] * v + depth[2]; + if !cam.ortho && z < NEAR { + behind += 1; + full_frame = true; + continue; + } + let p = full.apply(vec3(u, v, 0.0)); + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + if behind == 4 { + return None; + } + let (ow, oh) = (out.0 as i64, out.1 as i64); + let bbox = if full_frame { + [0, 0, ow, oh] + } else { + [((x0.floor() as i64) - 2).max(0), ((y0.floor() as i64) - 2).max(0), ((x1.ceil() as i64) + 2).min(ow), ((y1.ceil() as i64) + 2).min(oh)] + }; + if bbox[0] >= bbox[2] || bbox[1] >= bbox[3] { + return None; + } + Some(Geo { hinv, depth, world, normal, bbox, cam: *cam }) +} + +/// Layer-space rectangle covered by a buffer. +fn buf_bounds(buf: &Buf) -> [f64; 4] { + let s = buf.scale.max(1e-9); + [-buf.offset[0] / s, -buf.offset[1] / s, (buf.img.width as f64 - buf.offset[0]) / s, (buf.img.height as f64 - buf.offset[1]) / s] +} + +/// Inverse-map an output pixel centre onto a plane: (u, v, camera depth). +#[inline] +fn unproject(g: &Geo, sx: f64, sy: f64) -> Option<(f64, f64, f64)> { + let h = &g.hinv.0; + let qz = h[2][0] * sx + h[2][1] * sy + h[2][2]; + if !g.cam.ortho && qz <= 0.0 { + return None; + } + if qz.abs() < 1e-300 { + return None; + } + let u = (h[0][0] * sx + h[0][1] * sy + h[0][2]) / qz; + let v = (h[1][0] * sx + h[1][1] * sy + h[1][2]) / qz; + let z = g.depth[0] * u + g.depth[1] * v + g.depth[2]; + if !g.cam.ortho && z < NEAR { + return None; + } + Some((u, v, z)) +} + +/// The camera a renderer uses at a context time (view override only for the top-level comp). +pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { + match r.opts.view { + Some(v) if r.depth == 0 => v, + _ => active_camera(ctx), + } +} + +/// Number of motion-blur sub-samples for a layer. +fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { + if r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { + if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } + } else { + 1 + } +} + +/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { + let mut buf = r.layer_buf(ctx, layer)?; + if buf.img.is_empty() { + return None; + } + let comp = (ctx.comp.width as f64, ctx.comp.height as f64); + let s = r.opts.scale; + let cam = camera_for(r, ctx); + let world = ctx.world_matrix(layer); + // Depth of field: blur the layer by its circle of confusion at its centre. + if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { + let b = buf_bounds(&buf); + let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0)); + let z = cam.depth(centre); + let coc = dof.coc(z); + // Comp pixels on screen per layer pixel at that depth. + let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); + let radius = coc * 0.5 / k * buf.scale; + if radius > 0.3 { + let sigma = radius / 1.5; + let pad = (sigma * 3.0).ceil() as u32 + 1; + let padded = buf.img.padded(pad); + buf.img = effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false); + buf.offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; + } + } + let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; + let geos: Vec = if n <= 1 { + geo(&cam, world, &buf, comp, s, out).into_iter().collect() + } else { + let fd = ctx.comp.frame_duration().seconds(); + let angle = ctx.comp.shutter_angle / 360.0; + let phase = ctx.comp.shutter_phase / 360.0; + (0..n) + .filter_map(|i| { + let f = phase + angle * i as f64 / (n - 1) as f64; + let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); + let c = camera_for(r, &sub); + geo(&c, sub.world_matrix(layer), &buf, comp, s, out) + }) + .collect() + }; + if geos.is_empty() && in_run { + return None; + } + let mut bbox = [i64::MAX, i64::MAX, i64::MIN, i64::MIN]; + for g in &geos { + bbox = [bbox[0].min(g.bbox[0]), bbox[1].min(g.bbox[1]), bbox[2].max(g.bbox[2]), bbox[3].max(g.bbox[3])]; + } + let mat = Material::of(ctx, layer); + let bounds = buf_bounds(&buf); + Some(Item { + layer, + order, + bicubic: matches!(r.sampling(layer), effectcraft_raster::Sampling::Bicubic), + buf, + geos, + opacity: ctx.opacity(layer) as f32, + draw: in_run && mat.casts_shadows != 2, + mat, + matte: None, + preserve: layer.preserve_transparency, + winv: world.inverse().unwrap_or(Mat4::IDENTITY), + bounds, + bbox, + }) +} + +/// Screen-space track matte factor for a layer, or None. +fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option> { + let tm = layer.track_matte?; + let m = ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id)?; + let mut img = Image::new(out.0, out.1); + if m.is_active_at(ctx.time) { + if m.is_3d() { + // Render the matte plane alone (its own matte is ignored). + let mut solo = m.clone(); + solo.track_matte = None; + solo.preserve_transparency = false; + solo.blend_mode = BlendMode::Normal; + if let Some(it) = prepare(r, ctx, &solo, 0, out, true) { + let lights = lights_at(ctx); + composite(&mut img, &[it], &lights, &[], m.id.0 as u32); + } + } else if let Some(mb) = r.layer_buf(ctx, m) { + r.place(ctx, m, &mb, &mut img, BlendMode::Normal, ctx.opacity(m) as f32); + } + } + Some( + img.data + .par_iter() + .map(|q| { + match tm.kind { + MatteKind::Alpha => q[3], + MatteKind::AlphaInverted => 1.0 - q[3], + MatteKind::Luma => effectcraft_color::luminance(q[0], q[1], q[2]), + MatteKind::LumaInverted => 1.0 - effectcraft_color::luminance(q[0], q[1], q[2]), + } + .clamp(0.0, 1.0) + }) + .collect(), + ) +} + +/// 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) { + // 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); + r.draw_layer(ctx, run[k], canvas); + draw_run(r, ctx, &run[k + 1..], canvas); + return; + } + if run.is_empty() { + return; + } + let out = (canvas.width, canvas.height); + let lights = lights_at(ctx); + let mut items: Vec = run + .par_iter() + .enumerate() + .filter(|(_, l)| ctx.opacity(l) > 0.0) + .filter_map(|(i, l)| { + let mut it = prepare(r, ctx, l, i, out, true)?; + it.matte = matte_for(r, ctx, l, out); + Some(it) + }) + .collect(); + // Shadow casters outside this run (other 3D layers of the comp). + let mut extra: Vec = vec![]; + if lights.iter().any(|l| l.casts_shadows && l.kind != LightKind::Ambient) { + let in_run: Vec<_> = run.iter().map(|l| l.id).collect(); + extra = ctx + .comp + .layers + .par_iter() + .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) + .filter(|l| Material::of(ctx, l).casts_shadows != 0) + .filter_map(|l| prepare(r, ctx, l, 0, out, false)) + .collect(); + } + items.sort_by_key(|i| i.order); + let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); + composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); +} + +/// Per-pixel depth-sorted compositing of prepared planes. +fn composite(canvas: &mut Image, items: &[Item], lights: &[LightState], casters: &[&Item], seed: u32) { + let w = canvas.width as usize; + canvas.rows_mut().for_each(|(y, row)| { + let yi = y as i64; + let active: Vec<&Item> = items.iter().filter(|it| it.draw && yi >= it.bbox[1] && yi < it.bbox[3]).collect(); + if active.is_empty() { + return; + } + let mut frags: Vec<(f64, usize, Px, usize)> = Vec::with_capacity(active.len()); + for x in 0..w { + let xi = x as i64; + frags.clear(); + for (k, it) in active.iter().enumerate() { + if xi < it.bbox[0] || xi >= it.bbox[2] { + continue; + } + if let Some((z, px)) = fragment(it, x as f64 + 0.5, y as f64 + 0.5, lights, casters) { + let px = match &it.matte { + Some(m) => { + let f = m[y * w + x]; + [px[0] * f, px[1] * f, px[2] * f, px[3] * f] + } + None => px, + }; + if px[3] > 0.0 || px[0] != 0.0 || px[1] != 0.0 || px[2] != 0.0 { + frags.push((z, it.order, px, k)); + } + } + } + if frags.is_empty() { + continue; + } + // Far → near; (near-)coplanar fragments in stack order (lower layer first). + for i in 1..frags.len() { + let mut j = i; + while j > 0 && before(&frags[j], &frags[j - 1]) { + frags.swap(j, j - 1); + j -= 1; + } + } + let mut d = row[x]; + for (_, _, px, k) in &frags { + let it = active[*k]; + let mut s = *px; + let op = it.opacity * if it.preserve { d[3] } else { 1.0 }; + for c in s.iter_mut() { + *c *= op; + } + let mode = it.layer.blend_mode; + let n = if matches!(mode, BlendMode::Dissolve | BlendMode::DancingDissolve) { + hash_noise(x as u32, y as u32, seed ^ it.layer.id.0 as u32) + } else { + 0.5 + }; + d = blend_pixel(mode, d, s, n); + } + row[x] = d; + } + }); +} + +#[inline] +fn before(a: &(f64, usize, Px, usize), b: &(f64, usize, Px, usize)) -> bool { + let tol = 1e-6 * a.0.abs().max(b.0.abs()).max(1.0); + if (a.0 - b.0).abs() <= tol { a.1 < b.1 } else { a.0 > b.0 } +} + +/// Shaded premultiplied fragment of a plane at an output pixel centre, with its depth. +fn fragment(it: &Item, sx: f64, sy: f64, lights: &[LightState], casters: &[&Item]) -> Option<(f64, Px)> { + let n = it.geos.len(); + let mut acc = [0.0f32; 4]; + let mut depth = None; + for g in &it.geos { + let Some((u, v, z)) = unproject(g, sx, sy) else { continue }; + let t = it.texel(u, v); + if t[3] <= 1e-6 { + continue; + } + depth.get_or_insert(z); + let px = shade_texel(it, g, u, v, t, lights, casters); + for c in 0..4 { + acc[c] += px[c]; + } + } + let depth = depth?; + if n > 1 { + let k = 1.0 / n as f32; + for c in acc.iter_mut() { + *c *= k; + } + } + Some((depth, acc)) +} + +fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState], casters: &[&Item]) -> Px { + if lights.is_empty() || !it.mat.accepts_lights { + if it.mat.accepts_shadows == 2 { + return [0.0; 4]; + } + return t; + } + let a = t[3]; + let rgb = [t[0] / a, t[1] / a, t[2] / a]; + let p = g.world.apply(vec3(u, v, 0.0)); + let shadow = |li: usize, p: Vec3| -> [f32; 3] { + let l = &lights[li]; + let mut tr = [1.0f32; 3]; + for c in casters { + if std::ptr::eq(*c, it) || c.layer.id == it.layer.id { + continue; + } + let o = occlusion(c, p, l); + for k in 0..3 { + tr[k] *= o[k]; + } + } + tr + }; + let (lit, shadow_amt) = shade(rgb, p, g.normal, g.cam.to_viewer(p), &it.mat, lights, &shadow); + if it.mat.accepts_shadows == 2 { + return [0.0, 0.0, 0.0, a * shadow_amt]; + } + [lit[0] * a, lit[1] * a, lit[2] * a, a] +} + +/// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded). +fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { + const FAR: f64 = 1.0e6; + let a = c.winv.apply(p); + let target = if l.kind == LightKind::Parallel { p - l.dir * FAR } else { l.pos }; + let b = c.winv.apply(target); + if a.z == b.z || a.z.signum() == b.z.signum() { + return [1.0; 3]; + } + let t = a.z / (a.z - b.z); + if t <= 1e-6 || t >= 1.0 - 1e-9 { + return [1.0; 3]; + } + let hit = a + (b - a) * t; + // Penumbra radius (layer pixels) from Shadow Diffusion: grows with receiver–caster distance. + let seg = (target - p).length(); + let d_pc = seg * t; + let d_cl = if l.kind == LightKind::Parallel { 1000.0 } else { (seg * (1.0 - t)).max(1.0) }; + let r = l.shadow_diffusion * (d_pc / d_cl).min(4.0); + let bd = &c.bounds; + if hit.x < bd[0] - r || hit.y < bd[1] - r || hit.x > bd[2] + r || hit.y > bd[3] + r { + return [1.0; 3]; + } + let mut px = [0.0f32; 4]; + if r * c.buf.scale < 0.5 { + px = c.texel(hit.x, hit.y); + } else { + for j in -1..=1 { + for i in -1..=1 { + let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66); + for k in 0..4 { + px[k] += q[k] / 9.0; + } + } + } + } + let alpha = (px[3] * c.opacity).clamp(0.0, 1.0); + if alpha <= 0.0 { + return [1.0; 3]; + } + let tr = c.mat.light_transmission; + let dark = l.shadow_darkness as f32; + let mut out = [1.0f32; 3]; + for k in 0..3 { + let straight = if px[3] > 0.0 { (px[k] / px[3]).clamp(0.0, 1.0) } else { 0.0 }; + let pass = (1.0 - alpha) + alpha * tr * straight; + out[k] = 1.0 - dark * (1.0 - pass); + } + out +} diff --git a/crates/render/src/three_d/light.rs b/crates/render/src/three_d/light.rs new file mode 100644 index 00000000..9476f98d --- /dev/null +++ b/crates/render/src/three_d/light.rs @@ -0,0 +1,242 @@ +//! Lights and Classic 3D shading. +//! +//! Shading model (per light, Blinn-Phong on the layer plane, lit from both sides): +//! `colour = texture · (ambient_mat · Σ ambient + diffuse_mat · Σ diffuse) + specular`, where a +//! light's diffuse term is `colour · intensity · max(N·L, 0) · falloff · cone · shadow`, light from +//! behind the layer passes through by Light Transmission, and the specular highlight +//! `(N·H)^shininess` takes the light's colour mixed towards the layer's colour by Metal. +//! A comp without lights shows 3D layers unlit. + +use effectcraft_geom::{Vec3, vec3}; +use effectcraft_project::{Layer, LightKind}; + +use super::camera::layer_frame; +use crate::EvalCtx; + +/// An evaluated light. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LightState { + pub kind: LightKind, + /// World position (point/spot; parallel lights use it only for the shadow source). + pub pos: Vec3, + /// Unit direction the light travels (parallel/spot). + pub dir: Vec3, + /// Linear colour × intensity (1.0 = 100%). + pub color: [f32; 3], + /// Spot cone half-angle (radians) and feather (0..1). + pub cone_half: f64, + pub feather: f64, + /// Falloff: 0 None, 1 Smooth, 2 Inverse Square Clamped. + pub falloff: u32, + pub radius: f64, + pub falloff_distance: f64, + pub casts_shadows: bool, + /// 0..1. + pub shadow_darkness: f64, + /// Pixels. + pub shadow_diffusion: f64, +} + +impl LightState { + /// Evaluate a light layer. + pub fn from_layer(ctx: &EvalCtx, layer: &Layer) -> Option { + let effectcraft_project::LayerSource::Light { kind } = layer.source else { return None }; + let g = layer.props.sub("lightOptions")?; + let intensity = ctx.f(layer, g, "intensity", 100.0) / 100.0; + let c = ctx.color(layer, g, "color"); + let color = [(c[0] as f64 * intensity) as f32, (c[1] as f64 * intensity) as f32, (c[2] as f64 * intensity) as f32]; + let (pos, fwd, _) = layer_frame(ctx, layer); + // Spot/parallel lights aim at their point of interest (auto-orient), then rotate. + let dir = fwd; + Some(LightState { + kind, + pos, + dir, + color, + cone_half: (ctx.f(layer, g, "coneAngle", 90.0).clamp(0.0, 180.0) / 2.0).to_radians(), + feather: ctx.f(layer, g, "coneFeather", 50.0).clamp(0.0, 100.0) / 100.0, + falloff: ctx.e(layer, g, "falloff"), + radius: ctx.f(layer, g, "radius", 500.0).max(0.0), + falloff_distance: ctx.f(layer, g, "falloffDistance", 500.0).max(0.0), + casts_shadows: ctx.b(layer, g, "castsShadows"), + shadow_darkness: ctx.f(layer, g, "shadowDarkness", 100.0).clamp(0.0, 100.0) / 100.0, + shadow_diffusion: ctx.f(layer, g, "shadowDiffusion", 0.0).max(0.0), + }) + } + + /// Unit vector from a point towards the light, and the distance (∞ for parallel lights). + pub fn to_light(&self, p: Vec3) -> (Vec3, f64) { + match self.kind { + LightKind::Parallel => (-self.dir, f64::INFINITY), + _ => { + let d = self.pos - p; + let len = d.length(); + if len < 1e-9 { (vec3(0.0, 0.0, -1.0), 0.0) } else { (d / len, len) } + } + } + } + + /// Distance attenuation × spot cone factor at `p` (0..1). + pub fn attenuation(&self, p: Vec3) -> f64 { + let (l, dist) = self.to_light(p); + let mut k = match self.falloff { + 1 if dist.is_finite() => { + // Smooth: full within Radius, smooth to zero over Falloff Distance. + if dist <= self.radius { + 1.0 + } else if self.falloff_distance <= 0.0 { + 0.0 + } else { + let t = ((dist - self.radius) / self.falloff_distance).clamp(0.0, 1.0); + 1.0 - t * t * (3.0 - 2.0 * t) + } + } + 2 if dist.is_finite() => { + // Inverse square, clamped to 1 inside Radius. + if dist <= self.radius || self.radius <= 0.0 && dist <= 1.0 { 1.0 } else { (self.radius.max(1.0) / dist).powi(2) } + } + _ => 1.0, + }; + if self.kind == LightKind::Spot { + let cos_a = (-l).dot(self.dir).clamp(-1.0, 1.0); + let a = cos_a.acos(); + let outer = self.cone_half; + let inner = outer * (1.0 - self.feather); + k *= if a <= inner { + 1.0 + } else if a >= outer { + 0.0 + } else { + let t = (a - inner) / (outer - inner).max(1e-9); + 1.0 - t * t * (3.0 - 2.0 * t) + }; + } + k + } +} + +/// All lights switched on and active at the context time. +pub fn lights_at(ctx: &EvalCtx) -> Vec { + ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)).filter_map(|l| LightState::from_layer(ctx, l)).collect() +} + +/// Material Options of a 3D layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Material { + /// 0 Off, 1 On, 2 Only. + pub casts_shadows: u32, + /// 0..1. + pub light_transmission: f32, + /// 0 Off, 1 On, 2 Only. + pub accepts_shadows: u32, + pub accepts_lights: bool, + pub ambient: f32, + pub diffuse: f32, + pub specular: f32, + /// 0..1. + pub shininess: f32, + pub metal: f32, +} + +impl Default for Material { + fn default() -> Self { + Material { + casts_shadows: 0, + light_transmission: 0.0, + accepts_shadows: 1, + accepts_lights: true, + ambient: 1.0, + diffuse: 0.5, + specular: 0.5, + shininess: 0.05, + metal: 1.0, + } + } +} + +impl Material { + pub fn of(ctx: &EvalCtx, layer: &Layer) -> Material { + let Some(g) = layer.props.sub("materialOptions") else { return Material::default() }; + let d = Material::default(); + let pct = |m: &str, def: f32| (ctx.f(layer, g, m, def as f64 * 100.0) / 100.0) as f32; + Material { + casts_shadows: g.get("castsShadows").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(d.casts_shadows), + light_transmission: pct("lightTransmission", d.light_transmission).clamp(0.0, 1.0), + accepts_shadows: g.get("acceptsShadows").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(d.accepts_shadows), + accepts_lights: g.get("acceptsLights").map(|p| ctx.value(layer, p).as_bool()).unwrap_or(true), + ambient: pct("ambient", d.ambient), + diffuse: pct("diffuse", d.diffuse), + specular: pct("specularIntensity", d.specular), + shininess: pct("specularShininess", d.shininess).clamp(0.0, 1.0), + metal: pct("metal", d.metal).clamp(0.0, 1.0), + } + } + /// Blinn-Phong exponent for Specular Shininess (0% → 1, 100% → 256). + pub fn exponent(&self) -> f64 { + 256f64.powf(self.shininess as f64).max(1.0) + } +} + +/// Shade one straight-alpha texel at world point `p` with unit normal `n`. +/// +/// `to_viewer` is the unit direction towards the viewer; `shadow(light_index, p)` returns the +/// RGB light transmittance (1 = unshadowed). Returns the lit straight RGB colour and the summed +/// shadow amount (0..1, used by "Accepts Shadows: Only"). +pub fn shade( + rgb: [f32; 3], + p: Vec3, + n: Vec3, + to_viewer: Vec3, + mat: &Material, + lights: &[LightState], + shadow: &dyn Fn(usize, Vec3) -> [f32; 3], +) -> ([f32; 3], f32) { + if lights.is_empty() || !mat.accepts_lights { + return (rgb, 0.0); + } + let n = if n.dot(to_viewer) < 0.0 { -n } else { n }; + let mut amb = [0.0f32; 3]; + let mut diff = [0.0f32; 3]; + let mut spec = [0.0f32; 3]; + let mut shadow_amt = 0.0f32; + let exp = mat.exponent(); + for (i, l) in lights.iter().enumerate() { + if l.kind == LightKind::Ambient { + for c in 0..3 { + amb[c] += l.color[c]; + } + continue; + } + let k = l.attenuation(p); + if k <= 0.0 { + continue; + } + let (lv, _) = l.to_light(p); + let ndl = n.dot(lv); + let front = ndl > 0.0; + let lambert = if front { ndl } else { -ndl * mat.light_transmission as f64 }; + if lambert <= 0.0 { + continue; + } + let sh = if l.casts_shadows && mat.accepts_shadows != 0 { shadow(i, p) } else { [1.0; 3] }; + shadow_amt = shadow_amt.max(1.0 - (sh[0] + sh[1] + sh[2]) / 3.0); + let kd = (lambert * k) as f32; + for c in 0..3 { + diff[c] += l.color[c] * kd * sh[c]; + } + if front && mat.specular > 0.0 { + let h = (lv + to_viewer).normalize(); + let s = (n.dot(h).max(0.0).powf(exp) * k) as f32; + for c in 0..3 { + // Metal: highlight colour from the light (0%) to the layer colour (100%). + let tint = 1.0 + (rgb[c] - 1.0) * mat.metal; + spec[c] += l.color[c] * s * sh[c] * tint; + } + } + } + let mut out = [0.0f32; 3]; + for c in 0..3 { + out[c] = rgb[c] * (mat.ambient * amb[c] + mat.diffuse * diff[c]) + mat.specular * spec[c]; + } + (out, shadow_amt) +} diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs new file mode 100644 index 00000000..bf596d60 --- /dev/null +++ b/crates/render/src/three_d/mod.rs @@ -0,0 +1,36 @@ +//! Classic 3D: cameras, 3D views, lights, materials and the depth-sorted, lit, shadowed +//! compositor for runs of 3D layers. +//! +//! The 2D compositor (`Renderer::comp_frame`) walks the layer stack bottom-to-top; each run of +//! consecutive 3D layers is handed to [`compose::draw_run`] as a group (2D layers break 3D +//! groups, as in After Effects). + +pub mod camera; +pub(crate) mod compose; +pub mod light; + +#[cfg(test)] +mod tests; + +pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; +pub use light::{LightState, Material, lights_at}; + +use effectcraft_geom::Mat3; +use effectcraft_project::Layer; + +use crate::EvalCtx; + +/// Layer space → comp pixels through `cam` (2D projective), for viewer overlays and picking in +/// any 3D view. 2D layers ignore the camera. +pub fn layer_to_view(ctx: &EvalCtx, layer: &Layer, cam: &CameraState) -> Mat3 { + if !layer.is_3d() { + return ctx.layer_to_comp(layer).0; + } + let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64); + (cam.projection(w, h) * ctx.world_matrix(layer)).plane_to_mat3() +} + +/// Project a world point into comp pixels through `cam` (None behind a perspective camera). +pub fn project_point(ctx: &EvalCtx, cam: &CameraState, p: [f64; 3]) -> Option<[f64; 2]> { + cam.project(ctx.comp.width as f64, ctx.comp.height as f64, p.into()).map(|v| [v.x, v.y]) +} diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs new file mode 100644 index 00000000..2a068e3a --- /dev/null +++ b/crates/render/src/three_d/tests.rs @@ -0,0 +1,484 @@ +use effectcraft_color::Label; +use effectcraft_geom::{Vec3, vec3}; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, LightKind, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use super::camera::{Rig, View3D, default_view_cam, orientation_for}; +use super::*; +use crate::{NoFootage, RenderOpts, Renderer, render_frame}; + +const W: u32 = 200; +const H: u32 = 100; + +fn setup() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +/// A 3D solid of `w`×`h` centred at `pos`. +fn solid3(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 3]) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + l +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; +} + +fn push(p: &mut Project, cid: ItemId, l: Layer) { + // Appended = lowest in the stack. + p.comp_mut(cid).unwrap().layers.push(l); +} + +fn render(p: &Project, cid: ItemId) -> effectcraft_raster::Image { + render_frame(p, cid, Tick::ZERO, 1.0) +} + +fn close(a: f32, b: f32, eps: f32) -> bool { + (a - b).abs() <= eps +} + +fn zoom() -> f64 { + W as f64 * 50.0 / 36.0 +} + +// ---------------------------------------------------------------- projection + +#[test] +fn z0_plane_matches_2d() { + let (mut p, cid, comp) = setup(); + let l = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 20, [50.0, 30.0, 0.0]); + push(&mut p, cid, l); + let img = render(&p, cid); + // Covers x 30..70, y 20..40. + assert_eq!(img.get(31, 21), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(img.get(68, 38), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(img.get(29, 30)[3], 0.0); + assert_eq!(img.get(71, 30)[3], 0.0); +} + +#[test] +fn farther_layers_project_smaller() { + let (mut p, cid, comp) = setup(); + // At z = zoom the plane is twice as far from the eye: half size around the comp centre. + let l = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 80, 40, [100.0, 50.0, zoom()]); + push(&mut p, cid, l); + let img = render(&p, cid); + // 40×20 centred on (100, 50): x 80..120, y 40..60. + assert!(img.get(82, 42)[3] > 0.99); + assert!(img.get(117, 57)[3] > 0.99); + assert!(img.get(77, 50)[3] < 0.01, "{:?}", img.get(77, 50)); + assert!(img.get(100, 37)[3] < 0.01); +} + +#[test] +fn projection_math_matches_camera_state() { + let cam = default_camera(1920.0, 1080.0); + let pr = cam.projection(1920.0, 1080.0); + let p = pr.apply(vec3(100.0, 200.0, 0.0)); + assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6); + // A point at depth 2·zoom maps halfway to the centre. + let q = cam.project(1920.0, 1080.0, vec3(0.0, 0.0, cam.zoom)).unwrap(); + assert!((q.x - 480.0).abs() < 1e-6 && (q.y - 270.0).abs() < 1e-6, "{q:?}"); + assert!(cam.project(1920.0, 1080.0, vec3(960.0, 540.0, -cam.zoom - 10.0)).is_none()); +} + +#[test] +fn y_rotation_foreshortens_with_correct_perspective() { + let (mut p, cid, comp) = setup(); + let mut l = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 60, [100.0, 50.0, 0.0]); + set(&mut l, "transform/rotationY", Value::Scalar(60.0)); + push(&mut p, cid, l); + let img = render(&p, cid); + // cos 60° = 0.5: roughly half as wide; the near edge (right, z<0) is taller than the far edge. + assert!(img.get(100, 50)[3] > 0.99); + assert!(img.get(140, 50)[3] < 0.01); + let col_h = |x: u32| (0..H).filter(|&y| img.get(x as i64, y as i64)[3] > 0.5).count(); + assert!(col_h(122) > col_h(78), "near edge {} vs far edge {}", col_h(122), col_h(78)); +} + +#[test] +fn layer_behind_camera_is_clipped() { + let (mut p, cid, comp) = setup(); + let l = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100, [100.0, 50.0, -zoom() - 50.0]); + push(&mut p, cid, l); + let img = render(&p, cid); + assert!(img.data.iter().all(|px| px[3] == 0.0)); +} + +// ---------------------------------------------------------------- depth ordering + +#[test] +fn nearer_layer_wins_regardless_of_stack_order() { + let (mut p, cid, comp) = setup(); + let far = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 600, 300, [100.0, 50.0, 200.0]); + let near = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 40, 40, [100.0, 50.0, 0.0]); + // Far layer is on top of the stack. + push(&mut p, cid, far); + push(&mut p, cid, near); + let img = render(&p, cid); + assert_eq!(img.get(100, 50), [0.0, 1.0, 0.0, 1.0]); + assert_eq!(img.get(5, 50)[0], 1.0); +} + +#[test] +fn coplanar_layers_keep_stack_order() { + let (mut p, cid, comp) = setup(); + let top = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 40, [100.0, 50.0, 30.0]); + let bottom = solid3(&mut p, &comp, [0.0, 0.0, 1.0], 80, 80, [100.0, 50.0, 30.0]); + push(&mut p, cid, top); + push(&mut p, cid, bottom); + let img = render(&p, cid); + assert_eq!(img.get(100, 50), [1.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn intersecting_planes_split_per_pixel() { + let (mut p, cid, comp) = setup(); + let flat = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 160, 80, [100.0, 50.0, 0.0]); + let mut tilted = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 160, 80, [100.0, 50.0, 0.0]); + // Rotated about Y: its right half comes towards the camera, its left half goes behind. + set(&mut tilted, "transform/rotationY", Value::Scalar(40.0)); + push(&mut p, cid, tilted); + push(&mut p, cid, flat); + let img = render(&p, cid); + assert_eq!(img.get(125, 50), [0.0, 1.0, 0.0, 1.0], "right: tilted plane in front"); + assert_eq!(img.get(75, 50), [1.0, 0.0, 0.0, 1.0], "left: flat plane in front"); +} + +#[test] +fn two_d_layer_breaks_3d_group() { + let (mut p, cid, comp) = setup(); + let far_top = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 60, 60, [100.0, 50.0, 300.0]); + let sid = p.add_item("S2", Label::Red, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 20, height: 100, pixel_aspect: 1.0 })); + let mut two_d = build::layer(&mut p, &comp, "2D", LayerSource::Solid { item: sid }, (20, 100), None); + set(&mut two_d, "transform/position", Value::Vec3([100.0, 50.0, 0.0])); + let near_bottom = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100, [100.0, 50.0, 0.0]); + // Stack: far 3D (top), 2D, near 3D (bottom). The far layer is a separate group above the + // 2D layer, so it draws over everything even though it is farther away. + push(&mut p, cid, far_top); + push(&mut p, cid, two_d); + push(&mut p, cid, near_bottom); + let img = render(&p, cid); + assert_eq!(img.get(100, 50), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(img.get(5, 5), [0.0, 1.0, 0.0, 1.0]); +} + +// ---------------------------------------------------------------- lighting + +fn light(p: &mut Project, comp: &Comp, kind: LightKind, pos: [f64; 3]) -> Layer { + let mut l = build::layer(p, comp, "Light", LayerSource::Light { kind }, (W, H), None); + if kind != LightKind::Ambient { + set(&mut l, "transform/position", Value::Vec3(pos)); + } + l +} + +fn no_specular(l: &mut Layer) { + set(l, "materialOptions/specularIntensity", Value::Scalar(0.0)); +} + +#[test] +fn head_on_point_light_scales_by_diffuse() { + let (mut p, cid, comp) = setup(); + let mut s = solid3(&mut p, &comp, [1.0, 0.8, 0.6], 200, 100, [100.0, 50.0, 0.0]); + no_specular(&mut s); + let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -500.0]); + push(&mut p, cid, l); + push(&mut p, cid, s); + let img = render(&p, cid); + let c = img.get(100, 50); + // Diffuse 50% · N·L = 1 · intensity 100%. + assert!(close(c[0], 0.5, 1e-3) && close(c[1], 0.4, 1e-3) && close(c[2], 0.3, 1e-3), "{c:?}"); + // Off-axis pixels are lit at a grazing angle → darker. + assert!(img.get(2, 2)[0] < c[0]); +} + +#[test] +fn ambient_light_uses_ambient_coefficient() { + let (mut p, cid, comp) = setup(); + let mut s = solid3(&mut p, &comp, [0.8, 0.8, 0.8], 200, 100, [100.0, 50.0, 0.0]); + set(&mut s, "materialOptions/ambient", Value::Scalar(50.0)); + let mut l = light(&mut p, &comp, LightKind::Ambient, [0.0; 3]); + set(&mut l, "lightOptions/intensity", Value::Scalar(50.0)); + push(&mut p, cid, l); + push(&mut p, cid, s); + let c = render(&p, cid).get(10, 10); + assert!(close(c[0], 0.8 * 0.5 * 0.5, 1e-4), "{c:?}"); +} + +#[test] +fn lights_off_or_not_accepting_lights_render_unlit() { + let (mut p, cid, comp) = setup(); + let mut s = solid3(&mut p, &comp, [0.3, 0.6, 0.9], 200, 100, [100.0, 50.0, 0.0]); + set(&mut s, "materialOptions/acceptsLights", Value::Bool(false)); + let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -500.0]); + push(&mut p, cid, l); + push(&mut p, cid, s); + let c = render(&p, cid).get(100, 50); + assert!(close(c[0], 0.3, 1e-5) && close(c[2], 0.9, 1e-5), "{c:?}"); +} + +#[test] +fn light_behind_layer_needs_transmission() { + let (mut p, cid, comp) = setup(); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 0.0]); + no_specular(&mut s); + let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, 500.0]); + push(&mut p, cid, l); + push(&mut p, cid, s.clone()); + assert!(render(&p, cid).get(100, 50)[0] < 1e-4); + // 100% transmission passes the light through the layer. + let c = p.comp_mut(cid).unwrap(); + set(&mut c.layers[1], "materialOptions/lightTransmission", Value::Scalar(100.0)); + let v = render(&p, cid).get(100, 50)[0]; + assert!(close(v, 0.5, 1e-3), "{v}"); +} + +#[test] +fn specular_highlight_adds_light() { + let (mut p, cid, comp) = setup(); + let s = solid3(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100, [100.0, 50.0, 0.0]); + let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -zoom()]); + push(&mut p, cid, l); + push(&mut p, cid, s); + let c = render(&p, cid).get(100, 50); + // 0.5·0.5 diffuse + 0.5 specular (N·H = 1) tinted by the layer colour (Metal 100%) = 0.5. + assert!(close(c[0], 0.25 + 0.25, 2e-3), "{c:?}"); +} + +#[test] +fn spot_cone_limits_light() { + let (mut p, cid, comp) = setup(); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 0.0]); + no_specular(&mut s); + let mut l = light(&mut p, &comp, LightKind::Spot, [100.0, 50.0, -200.0]); + set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0])); + set(&mut l, "lightOptions/coneAngle", Value::Scalar(20.0)); + set(&mut l, "lightOptions/coneFeather", Value::Scalar(0.0)); + push(&mut p, cid, l); + push(&mut p, cid, s); + let img = render(&p, cid); + assert!(img.get(100, 50)[0] > 0.45); + // tan(10°)·200 ≈ 35 px radius. + assert!(img.get(160, 50)[0] < 1e-4); +} + +#[test] +fn falloff_inverse_square_clamped() { + let mut ls = LightState::from_layer(&crate::EvalCtx::new(&Project::default(), ItemId(0), &Comp::new(W, H, FrameRate::FPS_30, Tick::ZERO), Tick::ZERO), &{ + let (mut p, _, comp) = setup(); + light(&mut p, &comp, LightKind::Point, [0.0, 0.0, 0.0]) + }) + .unwrap(); + ls.falloff = 2; + ls.radius = 100.0; + assert!((ls.attenuation(vec3(50.0, 0.0, 0.0)) - 1.0).abs() < 1e-12); + assert!((ls.attenuation(vec3(200.0, 0.0, 0.0)) - 0.25).abs() < 1e-12); + ls.falloff = 1; + ls.falloff_distance = 100.0; + assert!((ls.attenuation(vec3(150.0, 0.0, 0.0)) - 0.5).abs() < 1e-12); + assert_eq!(ls.attenuation(vec3(250.0, 0.0, 0.0)), 0.0); +} + +// ---------------------------------------------------------------- shadows + +#[test] +fn caster_shadows_receiver() { + let (mut p, cid, comp) = setup(); + let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 100.0]); + no_specular(&mut recv); + let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 20, 20, [60.0, 50.0, 0.0]); + no_specular(&mut caster); + set(&mut caster, "materialOptions/castsShadows", Value::Enum(1)); + // Parallel light straight along +z: the shadow lands right behind the caster. + let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]); + set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0])); + set(&mut l, "lightOptions/castsShadows", Value::Bool(true)); + push(&mut p, cid, l); + push(&mut p, cid, caster); + push(&mut p, cid, recv); + let img = render(&p, cid); + // Lit receiver away from the shadow: 0.5. + assert!(close(img.get(150, 50)[0], 0.5, 1e-3)); + // Light from the left (direction (300, 0, 500)): the shadow shifts +60 px at the receiver's + // depth, to world x 110..130 → screen ≈ 107..122. + let c = p.comp_mut(cid).unwrap(); + set(&mut c.layers[0], "transform/position", Value::Vec3([100.0 - 300.0, 50.0, -500.0])); + let img = render(&p, cid); + let lit = img.get(150, 50)[0]; + let expect = 0.5 * (500.0 / (300.0f64 * 300.0 + 500.0 * 500.0).sqrt()) as f32; + assert!(close(lit, expect, 1e-3), "lit {lit} vs {expect}"); + assert!(img.get(115, 50)[0] < 1e-3, "shadowed {:?}", img.get(115, 50)); + assert!(close(img.get(60, 50)[0], expect, 1e-3), "caster itself is lit"); +} + +#[test] +fn shadow_darkness_and_accepts_off() { + let (mut p, cid, comp) = setup(); + let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]); + no_specular(&mut recv); + let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 0.0]); + set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster + let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]); + set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0])); + set(&mut l, "lightOptions/castsShadows", Value::Bool(true)); + set(&mut l, "lightOptions/shadowDarkness", Value::Scalar(50.0)); + push(&mut p, cid, l); + push(&mut p, cid, caster); + push(&mut p, cid, recv); + let v = render(&p, cid).get(100, 50)[0]; + assert!(close(v, 0.25, 1e-3), "half-dark shadow: {v}"); + let c = p.comp_mut(cid).unwrap(); + set(&mut c.layers[2], "materialOptions/acceptsShadows", Value::Enum(0)); + let v = render(&p, cid).get(100, 50)[0]; + assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}"); +} + +// ---------------------------------------------------------------- cameras + +fn camera(p: &mut Project, comp: &Comp) -> Layer { + build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) +} + +#[test] +fn default_camera_layer_matches_comp_camera() { + let (mut p, cid, comp) = setup(); + let cam = camera(&mut p, &comp); + assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value.as_f64(), zoom()); + let s = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 20, [50.0, 30.0, 0.0]); + push(&mut p, cid, s.clone()); + let a = render(&p, cid); + p.comp_mut(cid).unwrap().layers.insert(0, cam); + let b = render(&p, cid); + assert_eq!(a, b); +} + +#[test] +fn two_node_camera_looks_at_poi() { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + // Look at a layer far to the right: it lands in the middle of the frame. + set(&mut cam, "transform/poi", Value::Vec3([600.0, 50.0, 0.0])); + set(&mut cam, "transform/position", Value::Vec3([600.0, 50.0, -zoom()])); + let s = solid3(&mut p, &comp, [0.0, 0.0, 1.0], 20, 20, [600.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + assert!(img.get(100, 50)[2] > 0.99); + assert!(img.get(80, 50)[3] < 0.01); +} + +#[test] +fn one_node_camera_uses_orientation() { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + cam.auto_orient = effectcraft_project::AutoOrient::Off; + set(&mut cam, "transform/position", Value::Vec3([100.0, 50.0, 0.0])); + // Turn to look along +x. + set(&mut cam, "transform/orientation", Value::Vec3(orientation_for(vec3(1.0, 0.0, 0.0)))); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 0.0], 40, 40, [400.0, 50.0, 0.0]); + set(&mut s, "transform/rotationY", Value::Scalar(90.0)); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + assert!(img.get(100, 50)[3] > 0.99, "{:?}", img.get(100, 50)); +} + +#[test] +fn orientation_for_round_trips() { + for d in [vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(-0.3, 0.4, 0.8), vec3(0.2, -0.9, -0.1)] { + let o = orientation_for(d); + let f = effectcraft_geom::Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0)); + assert!((f - d.normalize()).length() < 1e-9, "{d:?} → {o:?} → {f:?}"); + } +} + +#[test] +fn depth_of_field_blurs_out_of_focus_layers() { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + // In focus (focus distance = zoom = distance to z=0): hard edge at x = 70. + let a = render(&p, cid); + assert!(a.get(71, 50)[3] > 0.99 && a.get(69, 50)[3] < 0.01); + let c = p.comp_mut(cid).unwrap(); + set(&mut c.layers[0], "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5)); + let b = render(&p, cid); + let e = b.get(70, 50)[3]; + assert!(e > 0.1 && e < 0.9, "soft edge {e}"); + assert!(b.get(66, 50)[3] > 0.01); +} + +// ---------------------------------------------------------------- views + +#[test] +fn view_ids_round_trip() { + for v in View3D::ALL { + assert_eq!(View3D::from_id(v.id()), Some(v)); + assert_eq!(View3D::from_id(v.label()), Some(v)); + } + assert_eq!(View3D::from_id("Custom View 2"), Some(View3D::Custom2)); +} + +#[test] +fn front_view_is_orthographic() { + let (mut p, cid, comp) = setup(); + // In an orthographic view depth doesn't change size. + let a = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 40, [60.0, 50.0, 0.0]); + let b = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 40, 40, [140.0, 50.0, 1000.0]); + push(&mut p, cid, a); + push(&mut p, cid, b); + let mut vc = default_view_cam(View3D::Front, W as f64, H as f64); + vc.zoom = 1.0; + let opts = RenderOpts { view: Some(vc.state()), ..Default::default() }; + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + assert!(img.get(41, 50)[0] > 0.99 && img.get(39, 50)[3] < 0.01); + assert!(img.get(121, 50)[1] > 0.99 && img.get(159, 50)[1] > 0.99 && img.get(161, 50)[3] < 0.01); +} + +#[test] +fn top_view_sees_planes_edge_on() { + let (mut p, cid, comp) = setup(); + let a = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 100, 100, [100.0, 50.0, 0.0]); + let mut b = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 100, 100, [100.0, 50.0, 0.0]); + set(&mut b, "transform/rotationX", Value::Scalar(90.0)); + push(&mut p, cid, a); + push(&mut p, cid, b); + let mut vc = default_view_cam(View3D::Top, W as f64, H as f64); + vc.zoom = 1.0; + let opts = RenderOpts { view: Some(vc.state()), ..Default::default() }; + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + // From the top, the X-rotated plane is a 100×100 square; the upright one is a line. + assert!(img.get(100, 30)[1] > 0.99 && img.get(100, 70)[1] > 0.99); + assert!(img.get(100, 30)[0] < 0.01); +} + +#[test] +fn rig_orbit_keeps_distance_and_pan_moves_both() { + let vc = default_view_cam(View3D::Custom1, 1920.0, 1080.0); + let mut r = Rig::from_view(&vc); + let d0 = (r.eye - r.poi).length(); + r.orbit(37.0, -15.0); + assert!(((r.eye - r.poi).length() - d0).abs() < 1e-6); + let (e, p) = (r.eye, r.poi); + r.pan(10.0, 0.0); + assert!(((r.eye - e) - (r.poi - p)).length() < 1e-9); + r.dolly(100.0, false); + assert!(((r.eye - r.poi).length() - (d0 - 100.0)).abs() < 1e-6); + // Pitch is clamped short of the poles. + r.orbit(0.0, 500.0); + let off = r.eye - r.poi; + assert!(off.y.abs() / off.length() < 0.9999); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d2..da11723e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -183,7 +183,7 @@ impl EffectcraftApp { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); - let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview }; + let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: None }; self.frames.request(&self.render_source(), key, comp, t, opts); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 87a2a2cf..1599715f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -6,7 +6,7 @@ use serde_json::json; use crate::icons::{self, Icon}; use crate::theme::Tokens; -use crate::{Dialog, EffectcraftApp, widgets}; +use crate::{Dialog, EffectcraftApp}; #[derive(Clone, Debug)] pub struct CompDraft { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 53b0cdc8..a07e1f66 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -439,7 +439,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let fd = comp.frame_duration().seconds(); let cy0 = ruler.min.y + 11.0; let mut run: Option<(i64, i64)> = None; - let mut draw_run = |a: i64, b: i64| { + let draw_run = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); let x1 = tm.x((b + 1) as f64 * fd); p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_green);