mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 14:52:15 +00:00
3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects
After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30] (488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %); X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node cameras (804, 805) likewise. Keyed Orientation moves along the shortest arc between the keys (quaternion slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30] read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15]; [0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg. lookAt() returns AE's angles (z axis on the target, x axis level; two AE read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit commands use the new decomposition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ef74d82902
commit
4545afb9ab
12 files changed
+557
-41
No files matched your search
@@ -179,13 +179,7 @@ impl<'a> EvalCtx<'a> {
|
||||
// Auto-orient (along path / towards camera) replaces Orientation.
|
||||
let orient = crate::three_d::camera::auto_orient_3d(self, layer, p)
|
||||
.unwrap_or_else(|| Mat4::orientation(Vec3::from(self.v3(layer, tr, "orientation", [0.0; 3]))));
|
||||
Mat4::translate(p)
|
||||
* orient
|
||||
* Mat4::rotate_z(rz)
|
||||
* Mat4::rotate_y(ry)
|
||||
* Mat4::rotate_x(rx)
|
||||
* Mat4::scale(Vec3::from(scale) / 100.0)
|
||||
* Mat4::translate(-Vec3::from(anchor))
|
||||
Mat4::translate(p) * orient * Mat4::rotation_xyz(rx, ry, rz) * Mat4::scale(Vec3::from(scale) / 100.0) * Mat4::translate(-Vec3::from(anchor))
|
||||
}
|
||||
|
||||
/// Layer space → comp (world) space, including parents.
|
||||
|
||||
@@ -116,12 +116,15 @@ pub fn basis_view(eye: Vec3, forward: Vec3, down_hint: Vec3) -> Mat4 {
|
||||
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`.
|
||||
/// Orientation angles (degrees, [`Mat4::orientation`]'s order) that point +z along `dir` with
|
||||
/// the layer's x axis kept level (no roll): the pitch about x, then the turn about the world's
|
||||
/// vertical axis, written as Orientation.
|
||||
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]
|
||||
let pitch = (-d.y).clamp(-1.0, 1.0).asin().to_degrees();
|
||||
let yaw = d.x.atan2(d.z).to_degrees();
|
||||
let o = (Mat4::rotate_y(yaw) * Mat4::rotate_x(pitch)).orientation_angles();
|
||||
[norm360(o.x), norm360(o.y), norm360(o.z)]
|
||||
}
|
||||
|
||||
fn norm360(a: f64) -> f64 {
|
||||
@@ -176,7 +179,7 @@ pub fn layer_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 {
|
||||
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)
|
||||
Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, rz)
|
||||
}
|
||||
|
||||
/// World matrix of a layer's parent chain (identity when unparented).
|
||||
|
||||
@@ -151,3 +151,264 @@ fn layer_cache_is_independent_of_camera_lights_and_view() {
|
||||
assert_ne!(third, second);
|
||||
assert!(cache.stats().hits > 0, "the cache was actually used");
|
||||
}
|
||||
|
||||
/// A 1920 × 1080 comp with a full-frame 3D solid whose transform `set` changes (and an optional
|
||||
/// one-node camera at the default camera's place), as the After Effects reference cases.
|
||||
fn ae_frame(set: impl Fn(&mut Layer), camera: Option<([f64; 3], [f64; 3])>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(1920, 1080, FrameRate::FPS_25, Tick::from_seconds_f64(5.0));
|
||||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let sid = p.add_item("L", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 1920, height: 1080, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "L", LayerSource::Solid { item: sid }, (1920, 1080), None);
|
||||
l.switches.three_d = true;
|
||||
set(&mut l);
|
||||
let mut layers = vec![];
|
||||
if let Some((orientation, rotation)) = camera {
|
||||
let mut cam = build::layer(&mut p, &comp, "K", LayerSource::Camera, (1920, 1080), None);
|
||||
cam.auto_orient = AutoOrient::Off;
|
||||
cam.props.prop_mut("transform/position").unwrap().value = Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0]);
|
||||
cam.props.prop_mut("transform/orientation").unwrap().value = Value::Vec3(orientation);
|
||||
for (m, v) in [("transform/rotationX", rotation[0]), ("transform/rotationY", rotation[1]), ("transform/rotation", rotation[2])] {
|
||||
cam.props.prop_mut(m).unwrap().value = Value::Scalar(v);
|
||||
}
|
||||
cam.props.prop_mut("cameraOptions/zoom").unwrap().value = Value::Scalar(1920.0 * 50.0 / 36.0);
|
||||
layers.push(cam);
|
||||
}
|
||||
layers.push(l);
|
||||
p.comp_mut(cid).unwrap().layers = layers;
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Whether comp pixel (x, y) shows the solid, from its layer → comp mapping.
|
||||
fn covers(p: &Project, cid: ItemId, secs: f64, x: u32, y: u32) -> bool {
|
||||
let c = p.comp(cid).unwrap();
|
||||
let ctx = EvalCtx::new(p, cid, c, Tick::from_seconds_f64(secs));
|
||||
let l = c.layers.last().unwrap();
|
||||
let inv = ctx.layer_to_comp(l).0.inverse().unwrap();
|
||||
let u = inv.apply(effectcraft_geom::vec2(x as f64 + 0.5, y as f64 + 0.5));
|
||||
(0.0..=1920.0).contains(&u.x) && (0.0..=1080.0).contains(&u.y)
|
||||
}
|
||||
|
||||
fn check_points(p: &Project, cid: ItemId, secs: f64, case: &str, pts: &[(u32, u32, bool)]) {
|
||||
for &(x, y, red) in pts {
|
||||
assert_eq!(covers(p, cid, secs, x, y), red, "case {case}: pixel ({x}, {y}) should be {}", if red { "red" } else { "black" });
|
||||
}
|
||||
}
|
||||
|
||||
fn set3(l: &mut Layer, m: &str, v: [f64; 3]) {
|
||||
l.props.prop_mut(m).unwrap().value = Value::Vec3(v);
|
||||
}
|
||||
|
||||
fn set1(l: &mut Layer, m: &str, v: f64) {
|
||||
l.props.prop_mut(m).unwrap().value = Value::Scalar(v);
|
||||
}
|
||||
|
||||
/// Orientation and X/Y/Z Rotation compose as `Rx · Ry · Rz` (Z turns the layer first), and
|
||||
/// Orientation comes before the axis rotations, in layers and cameras alike. The points are
|
||||
/// sampled from After Effects 2026 renders of the synthetic cases (each at least 3 px from an
|
||||
/// edge; red = the solid covers the pixel), a third of them where the old Z · Y · X order differed.
|
||||
#[test]
|
||||
fn rotation_order_matches_after_effects() {
|
||||
// 488: Orientation [10, 20, 30].
|
||||
let (p, cid) = ae_frame(|l| set3(l, "transform/orientation", [10.0, 20.0, 30.0]), None);
|
||||
check_points(
|
||||
&p,
|
||||
cid,
|
||||
1.0,
|
||||
"488",
|
||||
&[
|
||||
(328, 687, false),
|
||||
(416, 764, false),
|
||||
(209, 237, true),
|
||||
(548, 861, false),
|
||||
(1899, 425, false),
|
||||
(87, 462, true),
|
||||
(162, 581, false),
|
||||
(662, 948, false),
|
||||
(682, 849, true),
|
||||
(618, 413, true),
|
||||
(1380, 181, true),
|
||||
(605, 378, true),
|
||||
(1830, 518, true),
|
||||
(559, 197, true),
|
||||
(1760, 843, true),
|
||||
(550, 823, true),
|
||||
(572, 952, false),
|
||||
(272, 963, false),
|
||||
(268, 761, false),
|
||||
(628, 1073, false),
|
||||
(218, 721, false),
|
||||
(256, 807, false),
|
||||
(225, 881, false),
|
||||
(167, 637, false),
|
||||
],
|
||||
);
|
||||
// 800: X / Y / Z Rotation 10 / 20 / 30.
|
||||
let (p, cid) = ae_frame(
|
||||
|l| {
|
||||
set1(l, "transform/rotationX", 10.0);
|
||||
set1(l, "transform/rotationY", 20.0);
|
||||
set1(l, "transform/rotation", 30.0);
|
||||
},
|
||||
None,
|
||||
);
|
||||
check_points(
|
||||
&p,
|
||||
cid,
|
||||
1.0,
|
||||
"800",
|
||||
&[
|
||||
(104, 450, true),
|
||||
(755, 1002, false),
|
||||
(1407, 149, false),
|
||||
(271, 66, true),
|
||||
(288, 54, true),
|
||||
(113, 393, true),
|
||||
(204, 174, true),
|
||||
(1790, 360, false),
|
||||
(1414, 324, true),
|
||||
(682, 343, true),
|
||||
(1147, 153, true),
|
||||
(345, 446, true),
|
||||
(1516, 542, true),
|
||||
(666, 25, true),
|
||||
(1327, 1065, true),
|
||||
(778, 267, true),
|
||||
(1588, 5, false),
|
||||
(1706, 147, false),
|
||||
(357, 978, false),
|
||||
(97, 689, false),
|
||||
(1483, 79, false),
|
||||
(433, 848, false),
|
||||
(255, 802, false),
|
||||
(636, 1057, false),
|
||||
],
|
||||
);
|
||||
// 801: Orientation [0, 30, 0] with X Rotation 40 (Orientation first).
|
||||
let (p, cid) = ae_frame(
|
||||
|l| {
|
||||
set3(l, "transform/orientation", [0.0, 30.0, 0.0]);
|
||||
set1(l, "transform/rotationX", 40.0);
|
||||
},
|
||||
None,
|
||||
);
|
||||
check_points(
|
||||
&p,
|
||||
cid,
|
||||
1.0,
|
||||
"801",
|
||||
&[
|
||||
(1356, 35, true),
|
||||
(1762, 19, true),
|
||||
(190, 227, true),
|
||||
(108, 182, true),
|
||||
(502, 832, true),
|
||||
(1539, 203, true),
|
||||
(722, 812, true),
|
||||
(1872, 76, true),
|
||||
(1166, 165, true),
|
||||
(1640, 380, true),
|
||||
(629, 619, true),
|
||||
(1796, 574, true),
|
||||
(1767, 311, true),
|
||||
(1432, 421, true),
|
||||
(1819, 425, true),
|
||||
(283, 252, true),
|
||||
(563, 1062, false),
|
||||
(428, 963, false),
|
||||
(77, 417, false),
|
||||
(47, 833, false),
|
||||
(995, 933, false),
|
||||
(159, 839, false),
|
||||
(827, 947, false),
|
||||
(564, 908, false),
|
||||
],
|
||||
);
|
||||
// 804: a one-node camera with Orientation [4, 6, 30]; 805: the same as X / Y / Z Rotation.
|
||||
for (case, cam) in [("804", ([4.0, 6.0, 30.0], [0.0; 3])), ("805", ([0.0; 3], [4.0, 6.0, 30.0]))] {
|
||||
let (p, cid) = ae_frame(|_| {}, Some(cam));
|
||||
check_points(
|
||||
&p,
|
||||
cid,
|
||||
1.0,
|
||||
case,
|
||||
&[
|
||||
(204, 410, false),
|
||||
(1077, 38, false),
|
||||
(60, 492, false),
|
||||
(1732, 752, true),
|
||||
(265, 475, false),
|
||||
(1743, 821, true),
|
||||
(1417, 1043, true),
|
||||
(1559, 944, true),
|
||||
(1280, 310, true),
|
||||
(702, 984, true),
|
||||
(1072, 893, true),
|
||||
(370, 1042, true),
|
||||
(1353, 570, true),
|
||||
(1178, 136, true),
|
||||
(1201, 289, true),
|
||||
(1241, 605, true),
|
||||
(75, 337, false),
|
||||
(71, 138, false),
|
||||
(352, 24, false),
|
||||
(1764, 109, false),
|
||||
(186, 338, false),
|
||||
(1835, 668, false),
|
||||
(211, 269, false),
|
||||
(157, 143, false),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyed Orientation turns along the shortest arc between the keys (case 802: [0, 0, 0] at 0 s
|
||||
/// to [60, 80, 40] at 2 s, linear; points from the After Effects render at 1 s, a third of them
|
||||
/// where blending each angle on its own differed).
|
||||
#[test]
|
||||
fn keyed_orientation_renders_as_after_effects() {
|
||||
let (p, cid) = ae_frame(
|
||||
|l| {
|
||||
l.props.prop_mut("transform/orientation").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0; 3])), Keyframe::new(Tick::from_seconds_f64(2.0), Value::Vec3([60.0, 80.0, 40.0]))];
|
||||
},
|
||||
None,
|
||||
);
|
||||
check_points(
|
||||
&p,
|
||||
cid,
|
||||
1.0,
|
||||
"802",
|
||||
&[
|
||||
(1880, 472, false),
|
||||
(1598, 191, false),
|
||||
(248, 285, true),
|
||||
(1157, 51, false),
|
||||
(1628, 129, false),
|
||||
(357, 37, true),
|
||||
(1865, 891, true),
|
||||
(1915, 946, true),
|
||||
(1728, 1021, true),
|
||||
(1178, 665, true),
|
||||
(1303, 631, true),
|
||||
(1339, 954, true),
|
||||
(910, 891, true),
|
||||
(1257, 1002, true),
|
||||
(1349, 890, true),
|
||||
(1388, 551, true),
|
||||
(356, 500, false),
|
||||
(490, 1058, false),
|
||||
(524, 829, false),
|
||||
(73, 29, false),
|
||||
(393, 769, false),
|
||||
(1867, 124, false),
|
||||
(42, 733, false),
|
||||
(648, 1007, false),
|
||||
],
|
||||
);
|
||||
// The value After Effects reads back half-way.
|
||||
let c = p.comp(cid).unwrap();
|
||||
let v = c.layers[0].props.prop("transform/orientation").unwrap().value_at(Tick::from_seconds_f64(1.0)).as_vec3();
|
||||
let want = [30.6186, 35.7617, 28.4317];
|
||||
assert!((0..3).all(|i| (v[i] - want[i]).abs() < 1e-3), "{v:?}");
|
||||
}
|
||||
Reference in new issue
Block a user