Skip to content

Commit d511538

Browse files
committed
Improve procedural animation quality and review labels
1 parent 43827d1 commit d511538

6 files changed

Lines changed: 253 additions & 92 deletions

File tree

packages/posecode-render/src/contacts.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,28 @@ const DEG = Math.PI / 180;
1515
*/
1616
export function alignFloorPalms(
1717
m: Mannequin,
18-
reaches: readonly ReachTarget[],
18+
reaches: readonly (ReachTarget & { weight?: number })[],
1919
pins: readonly PinTarget[],
2020
groundLock: readonly string[] = [],
2121
): void {
22-
const sides = new Set<"left" | "right">();
23-
const collect = (effector: string, target: string) => {
22+
const sides = new Map<"left" | "right", number>();
23+
const collect = (effector: string, target: string, weight = 1) => {
2424
if (target !== "floor") return;
25-
if (effector === "hands" || effector === "hand_left") sides.add("left");
26-
if (effector === "hands" || effector === "hand_right") sides.add("right");
25+
if (effector === "hands" || effector === "hand_left") {
26+
sides.set("left", Math.max(sides.get("left") ?? 0, weight));
27+
}
28+
if (effector === "hands" || effector === "hand_right") {
29+
sides.set("right", Math.max(sides.get("right") ?? 0, weight));
30+
}
2731
};
28-
reaches.forEach((r) => collect(r.effector, r.target));
32+
reaches.forEach((r) => collect(r.effector, r.target, r.weight));
2933
pins.forEach((p) => collect(p.effector, p.anchor));
3034
if (groundLock.includes("hands")) {
31-
sides.add("left");
32-
sides.add("right");
35+
sides.set("left", 1);
36+
sides.set("right", 1);
3337
}
3438

35-
for (const side of sides) {
39+
for (const [side, weight] of sides) {
3640
const wrist = m.bones.get(`wrist_${side}`);
3741
if (!wrist?.parent) continue;
3842
const world = wrist.getWorldQuaternion(new THREE.Quaternion());
@@ -41,6 +45,7 @@ export function alignFloorPalms(
4145
: new THREE.Vector3(-1, 0, 0);
4246
const current = localNormal.applyQuaternion(world).normalize();
4347
const correction = new THREE.Quaternion().setFromUnitVectors(current, DOWN);
48+
if (weight < 1) correction.slerp(new THREE.Quaternion(), 1 - weight);
4449
const desiredWorld = correction.multiply(world);
4550
const parentWorld = wrist.parent.getWorldQuaternion(new THREE.Quaternion());
4651
wrist.quaternion.copy(parentWorld.invert().multiply(desiredWorld));

packages/posecode-render/src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import { eulerRomFor } from "posecode-parser";
1515
import type { PosecodeIR, ReachTarget, PinTarget, GripTarget } from "posecode-parser";
1616
import { buildMannequin, type Mannequin } from "./mannequin.js";
1717
import { applyGroundLock as applyGroundLockTo, groundFigure as groundFigureOf } from "./groundlock.js";
18-
import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js";
18+
import {
19+
buildTimeline,
20+
type BuiltTimeline,
21+
type PhaseSegment,
22+
type WeightedReachTarget,
23+
} from "./timeline.js";
1924
import { solveCCD, type JointLimits } from "./ik.js";
2025
import { buildProps, type PropScene } from "./props.js";
2126
import { loadCharacter, type Character } from "./character.js";
@@ -480,7 +485,7 @@ export function createViewer(
480485
* final root placement. The chain is the arm (hand) or the leg (foot); other
481486
* joints keep their authored FK pose.
482487
*/
483-
function applyReaches(reaches: ReachTarget[]): void {
488+
function applyReaches(reaches: WeightedReachTarget[]): void {
484489
for (const r of reaches) {
485490
const effectorBone = EFFECTOR_BONE[r.effector] ?? r.effector;
486491
const effector = mannequin.bones.get(effectorBone);
@@ -489,7 +494,13 @@ export function createViewer(
489494
if (!target) continue;
490495
const { joints, limits } = reachChain(effectorBone);
491496
if (joints.length === 0) continue;
497+
const before = joints.map((joint) => joint.quaternion.clone());
492498
solveCCD({ joints, limits, effector, target }, 12);
499+
for (let i = 0; i < joints.length; i++) {
500+
const solved = joints[i]!.quaternion.clone();
501+
joints[i]!.quaternion.slerpQuaternions(before[i]!, solved, r.weight);
502+
}
503+
mannequin.root.updateMatrixWorld(true);
493504
}
494505
}
495506

packages/posecode-render/src/timeline.ts

Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
* Turn a PosecodeIR into a looping, eased keyframe timeline.
33
*
44
* Each phase is a keyframe: we accumulate joint overrides forward (a movement
5-
* holds prior joint state unless a later phase changes it), then slerp bone
6-
* quaternions between consecutive keyframes with the destination phase's easing.
7-
* A final wrap segment returns to the base pose so the loop is seamless.
5+
* holds prior joint state unless a later phase changes it), then interpolate
6+
* the DSL's bounded anatomical Euler channels with monotone cubic Hermite
7+
* curves. A final wrap segment returns to the base pose only when necessary.
88
*/
99

1010
import * as THREE from "three";
1111
import type { PosecodeIR, ReachTarget, PinTarget, GripTarget, TimingMode } from "posecode-parser";
1212
import { poseFor, type PoseSpec } from "./poses.js";
13-
import { squad, squadControl } from "./squad.js";
1413

1514
const DEG = Math.PI / 180;
1615

@@ -23,15 +22,12 @@ interface Keyframe {
2322
easing: TimingMode;
2423
/**
2524
* The figure is at rest here (zero boundary velocity), so the spline uses this
26-
* keyframe's own value as its control (no velocity carried across it). True for
27-
* settle/snap phases AND for the two structural anchors — the start pose and
28-
* the loop-reset — which represent the figure standing still at the base pose.
29-
* Those anchors have no real predecessor/successor, so deriving a squad tangent
30-
* from a clamped neighbor yields a backward-biased control that overshoots
31-
* (the "snap to fully-curled" biceps bug); a rest tangent slerps cleanly.
25+
* keyframe receives zero velocity. True for settle/snap phases AND for the
26+
* structural start/reset anchors, which represent the figure at rest.
3227
*/
3328
rest: boolean;
34-
quats: Map<string, THREE.Quaternion>;
29+
/** Authored semantic Euler channels, retained so interpolation follows the DSL. */
30+
eulers: Map<string, EulerDegTuple>;
3531
groundLock: string[];
3632
reaches: ReachTarget[];
3733
pins: PinTarget[];
@@ -50,6 +46,11 @@ export interface PhaseSegment {
5046
cue?: string;
5147
}
5248

49+
/** A reach constraint blended across a phase boundary. */
50+
export interface WeightedReachTarget extends ReachTarget {
51+
weight: number;
52+
}
53+
5354
export interface BuiltTimeline {
5455
duration: number;
5556
repeat: number;
@@ -65,7 +66,7 @@ export interface BuiltTimeline {
6566
phaseName: string;
6667
cue?: string;
6768
groundLock: string[];
68-
reaches: ReachTarget[];
69+
reaches: WeightedReachTarget[];
6970
pins: PinTarget[];
7071
grips: GripTarget[];
7172
/** Interpolated root facing (yaw about world Y, radians). */
@@ -133,6 +134,68 @@ function rootVelocity(
133134
return span > 1e-6 ? (read(next) - read(prev)) / span : 0;
134135
}
135136

137+
/**
138+
* Shape-preserving velocity for an authored Euler channel at an interior
139+
* keyframe. Quaternion splines cannot distinguish a deliberate reversal from
140+
* continuing around the sphere: 0° → 160° → 0° was interpreted as a hidden
141+
* full rotation, holding near neutral before flipping through 180°. The DSL is
142+
* expressed as bounded anatomical Euler channels, so interpolate those scalar
143+
* channels directly and stop at reversals.
144+
*/
145+
function jointVelocity(
146+
prev: Keyframe,
147+
current: Keyframe,
148+
next: Keyframe,
149+
read: (keyframe: Keyframe) => number,
150+
): number {
151+
if (current.rest) return 0;
152+
const beforeSpan = current.time - prev.time;
153+
const afterSpan = next.time - current.time;
154+
if (beforeSpan <= 1e-6 || afterSpan <= 1e-6) return 0;
155+
const before = (read(current) - read(prev)) / beforeSpan;
156+
const after = (read(next) - read(current)) / afterSpan;
157+
// A plateau or direction change is a real anatomical turnaround.
158+
if (before * after <= 0) return 0;
159+
const centered = (read(next) - read(prev)) / (next.time - prev.time);
160+
// Monotone Hermite cap: never let a tangent create an inter-keyframe
161+
// overshoot even when neighboring phase durations differ greatly.
162+
const limit = 3 * Math.min(Math.abs(before), Math.abs(after));
163+
return Math.sign(centered) * Math.min(Math.abs(centered), limit);
164+
}
165+
166+
function posesEqual(
167+
a: Map<string, EulerDegTuple>,
168+
b: Map<string, EulerDegTuple>,
169+
): boolean {
170+
const bones = new Set([...a.keys(), ...b.keys()]);
171+
for (const bone of bones) {
172+
const av = a.get(bone) ?? [0, 0, 0];
173+
const bv = b.get(bone) ?? [0, 0, 0];
174+
if (av.some((value, axis) => Math.abs(value - bv[axis]!) > 1e-6)) return false;
175+
}
176+
return true;
177+
}
178+
179+
function blendReaches(
180+
from: readonly ReachTarget[],
181+
to: readonly ReachTarget[],
182+
t: number,
183+
): WeightedReachTarget[] {
184+
const key = (reach: ReachTarget): string => `${reach.effector}\u0000${reach.target}`;
185+
const previous = new Map(from.map((reach) => [key(reach), reach]));
186+
const next = new Map(to.map((reach) => [key(reach), reach]));
187+
const blended: WeightedReachTarget[] = [];
188+
for (const [id, reach] of previous) {
189+
const weight = next.has(id) ? 1 : 1 - t;
190+
if (weight > 1e-6) blended.push({ ...reach, weight });
191+
}
192+
for (const [id, reach] of next) {
193+
if (previous.has(id)) continue;
194+
if (t > 1e-6) blended.push({ ...reach, weight: t });
195+
}
196+
return blended;
197+
}
198+
136199
export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
137200
const basePose = poseFor(ir.startPose);
138201
const baseJoints = new Map<string, EulerDegTuple>(
@@ -153,7 +216,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
153216
name: ir.startPose ?? "start",
154217
easing: "flow",
155218
rest: true,
156-
quats: snapshot(curr),
219+
eulers: snapshot(curr),
157220
groundLock: [],
158221
reaches: [],
159222
pins: [],
@@ -177,7 +240,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
177240
...(phase.cue ? { cue: phase.cue } : {}),
178241
easing: phase.easing,
179242
rest: REST_MODE[phase.easing],
180-
quats: snapshot(curr),
243+
eulers: snapshot(curr),
181244
groundLock: phase.groundLock,
182245
reaches: phase.reaches,
183246
pins: phase.pins,
@@ -187,20 +250,34 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
187250
});
188251
}
189252

190-
// Wrap back to the base pose (and home position) for a seamless loop. Facing
253+
// Wrap back to the base pose (and home position) for a seamless loop only
254+
// when the author did not already return there. Always adding a first-phase-
255+
// length reset made common out-and-back movements sit idle for roughly a
256+
// third of every repetition. A zero-duration structural reset still gives
257+
// the final real keyframe a rest neighbor without extending the loop.
258+
// Facing
191259
// wraps to the NEAREST FULL TURN to the final yaw, not to 0: a completed 360°
192260
// pirouette then holds its facing through the reset and the loop boundary
193261
// (360°≡0°) is seamless, instead of visibly un-spinning backward. A partial
194262
// turn (e.g. 90°) rounds to 0 and rotates back to front during the reset.
195-
const wrap = ir.phases[0]?.durationSec ?? 1;
263+
const finalPose = snapshot(curr);
264+
const baseSnapshot = snapshot(new Map(baseJoints));
265+
const yawAtHome = Math.abs(currYaw - Math.round(currYaw / 360) * 360) < 1e-6;
266+
const positionAtHome = Math.abs(currPos.x) < 1e-6 && Math.abs(currPos.z) < 1e-6;
267+
const needsWrap = !posesEqual(finalPose, baseSnapshot) || !yawAtHome || !positionAtHome;
268+
const wrap = needsWrap ? (ir.phases[0]?.durationSec ?? 1) : 0;
269+
// With no pose wrap, the structural start is also the cyclic successor of
270+
// the final phase. Seed its reach state from that final phase so a constraint
271+
// shared across the boundary (e.g. cobra palms on the floor) stays planted.
272+
if (!needsWrap) keyframes[0]!.reaches = [...(ir.phases.at(-1)?.reaches ?? [])];
196273
const wrapYaw = Math.round(currYaw / 360) * 360 * DEG;
197274
t += wrap;
198275
keyframes.push({
199276
time: t,
200277
name: "reset",
201278
easing: "flow",
202279
rest: true,
203-
quats: snapshot(new Map(baseJoints)),
280+
eulers: baseSnapshot,
204281
groundLock: [],
205282
reaches: [],
206283
pins: [],
@@ -209,11 +286,11 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
209286
pos: { x: 0, z: 0 },
210287
});
211288

212-
// Fill every keyframe with the full bone set (missing → identity).
213-
const bonesUsed = [...new Set(keyframes.flatMap((k) => [...k.quats.keys()]))];
289+
// Fill every keyframe with the full bone set (missing → neutral Euler).
290+
const bonesUsed = [...new Set(keyframes.flatMap((k) => [...k.eulers.keys()]))];
214291
for (const kf of keyframes) {
215292
for (const bone of bonesUsed) {
216-
if (!kf.quats.has(bone)) kf.quats.set(bone, new THREE.Quaternion());
293+
if (!kf.eulers.has(bone)) kf.eulers.set(bone, [0, 0, 0]);
217294
}
218295
}
219296

@@ -255,25 +332,22 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
255332
const local = THREE.MathUtils.clamp((tt - a.time) / span, 0, 1);
256333
const eased = MODE_EASE[b.easing](local);
257334

258-
// Neighbors for the squad control quaternions (clamp at the ends → the
259-
// segment endpoint itself, giving a one-sided tangent).
335+
// Neighbors for the time-aware semantic-channel tangents.
260336
const kPrev = keyframes[Math.max(0, i - 1)]!;
261337
const kNext = keyframes[Math.min(keyframes.length - 1, i + 2)]!;
262338
for (const bone of bonesUsed) {
263339
const node = bones.get(bone);
264340
if (!node) continue;
265-
const q0 = a.quats.get(bone)!;
266-
const q1 = b.quats.get(bone)!;
267-
// A rest-point keyframe uses its own value as the control (zero tangent
268-
// → the spline comes to / leaves from rest there); otherwise the
269-
// Shoemake control from the neighboring keyframe carries velocity.
270-
const s0 = a.rest
271-
? q0.clone()
272-
: squadControl(kPrev.quats.get(bone)!, q0, q1);
273-
const s1 = b.rest
274-
? q1.clone()
275-
: squadControl(q0, q1, kNext.quats.get(bone)!);
276-
squad(q0, s0, s1, q1, eased, node.quaternion);
341+
const from = a.eulers.get(bone)!;
342+
const to = b.eulers.get(bone)!;
343+
const value = ([0, 1, 2] as const).map((axis) => {
344+
if (b.easing === "linear") return from[axis] + (to[axis] - from[axis]) * eased;
345+
const read = (kf: Keyframe): number => kf.eulers.get(bone)![axis];
346+
const va = jointVelocity(kPrev, a, b, read);
347+
const vb = jointVelocity(a, b, kNext, read);
348+
return hermite(from[axis], to[axis], va, vb, span, eased);
349+
}) as EulerDegTuple;
350+
node.quaternion.copy(eulerToQuat(value));
277351
}
278352
// Root facing/position use the scalar analogue of the joint squad spline:
279353
// cubic Hermite with time-aware centered tangents. This carries velocity
@@ -306,7 +380,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
306380
phaseName: b.name,
307381
...(b.cue ? { cue: b.cue } : {}),
308382
groundLock: b.groundLock,
309-
reaches: b.reaches,
383+
reaches: blendReaches(a.reaches, b.reaches, eased),
310384
pins: b.pins,
311385
grips: b.grips,
312386
rootYaw,
@@ -316,9 +390,9 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
316390
};
317391
}
318392

319-
function snapshot(curr: Map<string, EulerDegTuple>): Map<string, THREE.Quaternion> {
320-
const out = new Map<string, THREE.Quaternion>();
321-
for (const [bone, euler] of curr) out.set(bone, eulerToQuat(euler));
393+
function snapshot(curr: Map<string, EulerDegTuple>): Map<string, EulerDegTuple> {
394+
const out = new Map<string, EulerDegTuple>();
395+
for (const [bone, euler] of curr) out.set(bone, [...euler]);
322396

323397
// Hip-hinge coupling. The `pelvis` is the shared parent of both the torso and
324398
// the legs, so a pelvis X-rotation tips the WHOLE figure forward: torso and
@@ -330,7 +404,7 @@ function snapshot(curr: Map<string, EulerDegTuple>): Map<string, THREE.Quaternio
330404
if (pelvisX !== 0) {
331405
for (const hip of ["hip_left", "hip_right"]) {
332406
const [hx, hy, hz] = curr.get(hip) ?? [0, 0, 0];
333-
out.set(hip, eulerToQuat([hx - pelvisX, hy, hz]));
407+
out.set(hip, [hx - pelvisX, hy, hz]);
334408
}
335409
}
336410
return out;

0 commit comments

Comments
 (0)