Skip to content

Commit c323b01

Browse files
committed
feat(render): add squad quaternion-spline helper
1 parent e46679a commit c323b01

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Spherical-quadrangle (squad) quaternion interpolation — Shoemake's C1
3+
* quaternion spline. Given a keyframe and its two neighbors, `squadControl`
4+
* derives the intermediate control quaternion; `squad` blends one segment.
5+
*
6+
* All functions return NEW quaternions (or write into a caller `out`); the
7+
* shared keyframe quaternions are never mutated.
8+
*/
9+
10+
import * as THREE from "three";
11+
12+
/** Ensure `b` is in the same hemisphere as `a` (shortest-path continuity). */
13+
function alignHemisphere(a: THREE.Quaternion, b: THREE.Quaternion): THREE.Quaternion {
14+
const out = b.clone();
15+
if (a.dot(out) < 0) out.set(-out.x, -out.y, -out.z, -out.w);
16+
return out;
17+
}
18+
19+
/** q^-1 for a UNIT quaternion is its conjugate. */
20+
function conjugate(q: THREE.Quaternion): THREE.Quaternion {
21+
return new THREE.Quaternion(-q.x, -q.y, -q.z, q.w);
22+
}
23+
24+
/** Natural log of a unit quaternion → a pure quaternion (w = 0). */
25+
function logUnit(q: THREE.Quaternion): THREE.Quaternion {
26+
const v = new THREE.Vector3(q.x, q.y, q.z);
27+
const vLen = v.length();
28+
const w = THREE.MathUtils.clamp(q.w, -1, 1);
29+
if (vLen < 1e-8) return new THREE.Quaternion(0, 0, 0, 0);
30+
const theta = Math.atan2(vLen, w);
31+
const k = theta / vLen;
32+
return new THREE.Quaternion(v.x * k, v.y * k, v.z * k, 0);
33+
}
34+
35+
/** Exp of a pure quaternion (w = 0) → a unit quaternion. */
36+
function expPure(q: THREE.Quaternion): THREE.Quaternion {
37+
const v = new THREE.Vector3(q.x, q.y, q.z);
38+
const theta = v.length();
39+
if (theta < 1e-8) return new THREE.Quaternion(0, 0, 0, 1);
40+
const s = Math.sin(theta) / theta;
41+
return new THREE.Quaternion(v.x * s, v.y * s, v.z * s, Math.cos(theta));
42+
}
43+
44+
function mul(a: THREE.Quaternion, b: THREE.Quaternion): THREE.Quaternion {
45+
return a.clone().multiply(b);
46+
}
47+
48+
/**
49+
* Shoemake control quaternion for `cur`:
50+
* s = cur * exp( -( log(cur^-1 * next) + log(cur^-1 * prev) ) / 4 )
51+
* Neighbors are hemisphere-aligned to `cur` first for shortest-path continuity.
52+
*/
53+
export function squadControl(
54+
prev: THREE.Quaternion,
55+
cur: THREE.Quaternion,
56+
next: THREE.Quaternion,
57+
): THREE.Quaternion {
58+
const p = alignHemisphere(cur, prev);
59+
const n = alignHemisphere(cur, next);
60+
const inv = conjugate(cur);
61+
const logNext = logUnit(mul(inv, n));
62+
const logPrev = logUnit(mul(inv, p));
63+
const sum = new THREE.Quaternion(
64+
-(logNext.x + logPrev.x) / 4,
65+
-(logNext.y + logPrev.y) / 4,
66+
-(logNext.z + logPrev.z) / 4,
67+
0,
68+
);
69+
return mul(cur, expPure(sum)).normalize();
70+
}
71+
72+
/**
73+
* Squad blend of one segment: slerp(slerp(q0,q1,t), slerp(s0,s1,t), 2t(1-t)).
74+
* Endpoints `q0`,`q1`; their controls `s0`,`s1`. Returns q0 at t=0, q1 at t=1.
75+
*/
76+
export function squad(
77+
q0: THREE.Quaternion,
78+
s0: THREE.Quaternion,
79+
s1: THREE.Quaternion,
80+
q1: THREE.Quaternion,
81+
t: number,
82+
out: THREE.Quaternion = new THREE.Quaternion(),
83+
): THREE.Quaternion {
84+
const q1a = alignHemisphere(q0, q1);
85+
const a = new THREE.Quaternion().slerpQuaternions(q0, q1a, t);
86+
const b = new THREE.Quaternion().slerpQuaternions(s0, alignHemisphere(s0, s1), t);
87+
return out.slerpQuaternions(a, alignHemisphere(a, b), 2 * t * (1 - t));
88+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from "vitest";
2+
import * as THREE from "three";
3+
import { squad, squadControl } from "../src/squad.js";
4+
5+
const q = (x: number, y: number, z: number) =>
6+
new THREE.Quaternion().setFromEuler(new THREE.Euler(x, y, z, "XYZ"));
7+
8+
describe("squad", () => {
9+
it("passes exactly through segment endpoints", () => {
10+
const q0 = q(0, 0, 0);
11+
const q1 = q(0, 1, 0);
12+
const s0 = squadControl(q(0, -0.5, 0), q0, q1);
13+
const s1 = squadControl(q0, q1, q(0, 1.5, 0));
14+
const at0 = squad(q0, s0, s1, q1, 0);
15+
const at1 = squad(q0, s0, s1, q1, 1);
16+
expect(at0.angleTo(q0)).toBeLessThan(1e-6);
17+
expect(at1.angleTo(q1)).toBeLessThan(1e-6);
18+
});
19+
20+
it("is C1-continuous across a shared interior keyframe (slerp is not)", () => {
21+
const k0 = q(0, 0, 0);
22+
const k1 = q(0, 1, 0);
23+
const k2 = q(0, 1.2, 0.8); // direction change at k1
24+
const c_before = squadControl(k0, k1, k2); // control at k1 for both segs
25+
const c0 = squadControl(q(0, -1, 0), k0, k1); // control at k0
26+
const c2 = squadControl(k1, k2, q(0, 0.4, 1.6)); // control at k2
27+
28+
const eps = 1e-3;
29+
const before = squad(k0, c0, c_before, k1, 1 - eps);
30+
const atK1a = squad(k0, c0, c_before, k1, 1);
31+
const atK1b = squad(k1, c_before, c2, k2, 0);
32+
const after = squad(k1, c_before, c2, k2, eps);
33+
34+
const vBefore = atK1a.angleTo(before) / eps;
35+
const vAfter = after.angleTo(atK1b) / eps;
36+
expect(atK1a.angleTo(atK1b)).toBeLessThan(1e-6); // C0
37+
expect(Math.abs(vBefore - vAfter)).toBeLessThan(0.15); // C1 within tolerance
38+
});
39+
40+
it("falls back cleanly when neighbors are identical (no NaN)", () => {
41+
const a = q(0, 0, 0);
42+
const s = squadControl(a, a, a);
43+
const mid = squad(a, s, s, a, 0.5);
44+
expect(Number.isNaN(mid.x)).toBe(false);
45+
expect(mid.angleTo(a)).toBeLessThan(1e-6);
46+
});
47+
});

0 commit comments

Comments
 (0)