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:
vangel76andClaude Opus 5.5 committed 2026-10-19 16:47:37 +02:00
1 parent ef74d82902
commit 4545afb9ab
12 files changed
+557 -41

No files matched your search

+1 -7
View File
@@ -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.
+8 -5
View File
@@ -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).
+261
View File
@@ -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:?}");
}