mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
dee16181fe
commit
76ac3f74f7
21 files changed
+2657
-67
No files matched your search
Generated
+1
@@ -1219,6 +1219,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LayerId> {
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
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<Value> {
|
||||
new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object")
|
||||
}
|
||||
fn new_camera(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
new_simple(s, p, LayerSource::Camera, "Camera 1", "New Camera")
|
||||
}
|
||||
fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
@@ -820,6 +813,12 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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",
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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<KV> {
|
||||
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<bool> {
|
||||
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<Option<f64>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<LightKind> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<View3D> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> = 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::<Vec<_>>(),
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
+134
-1
@@ -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
|
||||
}
|
||||
@@ -110,6 +110,9 @@ pub struct EditorState {
|
||||
pub snapping: bool,
|
||||
/// Last applied effect id (Effect ▸ last effect).
|
||||
pub last_effect: Option<String>,
|
||||
/// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras).
|
||||
#[serde(default)]
|
||||
pub views3d: std::collections::BTreeMap<ItemId, effectcraft_render::three_d::Views3D>,
|
||||
}
|
||||
|
||||
/// 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<effectcraft_render::three_d::CameraState> {
|
||||
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<String> {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -350,12 +350,36 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
|
||||
// ---------------------------------------------------------------- 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,
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,5 @@ effectcraft-text = { workspace = true }
|
||||
effectcraft-effects = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -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
|
||||
|
||||
@@ -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<three_d::CameraState>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<Dof>,
|
||||
}
|
||||
|
||||
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<Vec2> {
|
||||
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<View3D> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().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<View3D, ViewCam>,
|
||||
}
|
||||
|
||||
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<CameraState> {
|
||||
(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Geo>,
|
||||
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<Vec<f32>>,
|
||||
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<Geo> {
|
||||
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<Item<'a>> {
|
||||
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<Geo> = 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<Vec<f32>> {
|
||||
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<Item> = 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<Item> = 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
|
||||
}
|
||||
@@ -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<LightState> {
|
||||
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<LightState> {
|
||||
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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user