@convai/embody 1.0.1 → 1.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +12 -8
  3. package/dist/character/createCharacter.d.ts +66 -5
  4. package/dist/character/createCharacter.js +93 -22
  5. package/dist/character/pipeline.d.ts +9 -5
  6. package/dist/clips/animations.d.ts +13 -1
  7. package/dist/clips/animations.js +28 -6
  8. package/dist/clips/retarget.d.ts +168 -0
  9. package/dist/clips/retarget.js +856 -0
  10. package/dist/clips/sources.d.ts +70 -0
  11. package/dist/clips/sources.js +119 -0
  12. package/dist/connect/session.d.ts +11 -0
  13. package/dist/connect/session.js +22 -0
  14. package/dist/index.d.ts +22 -1
  15. package/dist/index.js +12 -0
  16. package/dist/inspect/inspectModel.d.ts +14 -0
  17. package/dist/inspect/inspectModel.js +26 -0
  18. package/dist/inspect/morphMap.d.ts +54 -0
  19. package/dist/inspect/morphMap.js +140 -0
  20. package/dist/inspect/report.d.ts +88 -0
  21. package/dist/inspect/report.js +193 -0
  22. package/dist/motion/eyeAim.d.ts +94 -0
  23. package/dist/motion/eyeAim.js +276 -0
  24. package/dist/motion/eyeGaze.d.ts +17 -0
  25. package/dist/motion/eyeGaze.js +42 -0
  26. package/dist/motion/footLock.d.ts +67 -0
  27. package/dist/motion/footLock.js +176 -0
  28. package/dist/motion/gaze.d.ts +135 -180
  29. package/dist/motion/gaze.js +257 -491
  30. package/dist/motion/hair.d.ts +27 -0
  31. package/dist/motion/hair.js +127 -0
  32. package/dist/motion/hairClearance.d.ts +39 -0
  33. package/dist/motion/hairClearance.js +125 -0
  34. package/dist/motion/morphNormals.d.ts +38 -0
  35. package/dist/motion/morphNormals.js +237 -0
  36. package/dist/motion/types.d.ts +15 -0
  37. package/dist/react/CharacterModel.d.ts +3 -1
  38. package/dist/react/CharacterModel.js +9 -1
  39. package/dist/react/ConvaiCharacter.js +4 -1
  40. package/dist/react/PortraitLights.d.ts +3 -0
  41. package/dist/react/PortraitLights.js +44 -0
  42. package/dist/render/faceMaterials.d.ts +60 -3
  43. package/dist/render/faceMaterials.js +320 -96
  44. package/dist/render/portraitRig.d.ts +28 -0
  45. package/dist/render/portraitRig.js +55 -0
  46. package/dist/render/surfaces.d.ts +181 -0
  47. package/dist/render/surfaces.js +1020 -0
  48. package/dist/rig/resolve.d.ts +8 -2
  49. package/dist/rig/resolve.js +7 -3
  50. package/dist/rig/units.d.ts +35 -16
  51. package/dist/rig/units.js +143 -42
  52. package/dist/speech/expression.d.ts +20 -0
  53. package/dist/speech/expression.js +118 -0
  54. package/dist/speech/lipsync.d.ts +68 -2
  55. package/dist/speech/lipsync.js +205 -5
  56. package/dist/speech/mouthFinish.d.ts +46 -0
  57. package/dist/speech/mouthFinish.js +235 -0
  58. package/dist/speech/voiceClock.d.ts +94 -0
  59. package/dist/speech/voiceClock.js +176 -0
  60. package/package.json +1 -1
@@ -1,34 +1,14 @@
1
1
  import * as THREE from 'three';
2
- import { setLookQuat } from '../rig/profile.js';
3
- /**
4
- * Head/neck gaze pose solve, ported from `useHeadTracking`
5
- * (convai-web-sdk-internal `examples/react-three-fiber/src/hooks/useHeadTracking.ts`).
6
- *
7
- * Stage 2 — the ONLY system permitted to OVERRIDE the mixer's head/neck
8
- * rotation outright (`.copy(...)`, never `.multiply(...)`): a head that
9
- * blends additively with its own animation clip fights itself every frame.
10
- *
11
- * Task 6 ported the geometric core: world target -> yaw/pitch about the
12
- * rig's own basis -> head/neck split -> time-based, velocity-capped ease ->
13
- * `setLookQuat`. Task 7 layered the aliveness on top of that same solve:
14
- * always-on idle micro-drift, occasional look-away gaze shifts with dwell
15
- * timing, and turn-bank roll — all suppressed under `ctx.reducedMotion`.
16
- * Task 8 (this task) adds the clip-blending layer: a smoothed camera-lock
17
- * strength (`idleLock`/`talkLock`, eased rather than switched — see
18
- * `update()`) blended against the mixer's own clip pose, with a deviation
19
- * pull-back (`deviationLo`/`deviationHi`) that guards large clip excursions
20
- * regardless of lock strength; and `calm`, which de-emphasises the idle
21
- * drift/gaze-shift amplitudes while the (smoothed) lock is high, i.e. while
22
- * speaking — a focused speaker stops glancing around.
23
- *
24
- * The HEAD is the only bone that blends with the clip; the neck bones are
25
- * still overridden outright (Task 6/7 behaviour, unchanged) — see
26
- * `update()`.
27
- *
28
- * See `.superpowers/sdd/2026-09-07-convai-embody-motion-and-staging/task-6-report.md`,
29
- * `task-7-report.md` and `task-8-report.md` for the line-by-line comparison
30
- * against the prior art and the places this deliberately differs from it.
31
- */
2
+ import { EyeAim } from './eyeAim.js';
3
+ /** Minimum-jerk ease: 0→1 with zero velocity and acceleration at both ends. */
4
+ export const minJerk = (s) => {
5
+ const t = THREE.MathUtils.clamp(s, 0, 1);
6
+ return t * t * t * (10 - 15 * t + 6 * t * t);
7
+ };
8
+ const settled = (v) => ({ from: v, to: v, t: 1, dur: 1, delay: 0 });
9
+ const valueOf = (m) => m.from + (m.to - m.from) * minJerk((m.t - m.delay) / m.dur);
10
+ const done = (m) => m.t >= m.delay + m.dur;
11
+ const DEG = Math.PI / 180;
32
12
  export class GazeSystem {
33
13
  constructor(cfg = {}) {
34
14
  this.cfg = cfg;
@@ -38,493 +18,279 @@ export class GazeSystem {
38
18
  right: { horizontal: 0, vertical: 0 },
39
19
  horizontal: 0, vertical: 0,
40
20
  };
41
- /** World-space angle still between the head's forward and the target. */
42
21
  this.residual = { horizontal: 0, vertical: 0 };
43
- this.yaw = 0;
44
- this.pitch = 0;
45
- /** Idle-roll + turn-bank, written by the aliveness layer below. */
46
- this.roll = 0;
47
- // --- Aliveness layer (Task 7) state -------------------------------
48
- /** Idle-drift clock, advanced by `dt * idleSpeed`. Randomly seeded so
49
- * multiple GazeSystem instances on stage together don't drift in
50
- * lockstep. */
51
- this.driftClock = Math.random() * 100;
52
- /** Current eased gaze-shift offsets (the "look-away" the head is
53
- * easing toward / holding). */
54
- this.shiftYaw = 0;
55
- this.shiftPitch = 0;
56
- this.shiftRoll = 0;
57
- /** The look-away target `shift*` is easing toward, re-picked every
58
- * dwell period. */
59
- this.shiftTargetYaw = 0;
60
- this.shiftTargetPitch = 0;
61
- this.shiftTargetRoll = 0;
62
- /** Seconds remaining before the next gaze-shift target is re-picked. */
63
- this.dwell = 0;
64
- // Pre-allocated scratch — zero allocations on the steady-state path.
65
- this.vTarget = new THREE.Vector3();
66
- this.vHead = new THREE.Vector3();
67
- this.vLocal = new THREE.Vector3();
68
- this.mInv = new THREE.Matrix4();
69
- // Scratch for the world-space residual published to EyeGazeSystem.
70
- this.vAim = new THREE.Vector3();
71
- this.vFwdW = new THREE.Vector3();
72
- this.vRightW = new THREE.Vector3();
73
- this.vUpW = new THREE.Vector3();
74
- this.qHeadW = new THREE.Quaternion();
75
- this.qRestInv = new THREE.Quaternion();
76
- /** What the mixer wrote to the head this frame, before we touch it. */
77
- this.qClip = new THREE.Quaternion();
78
- /** The camera-locked look pose computed this frame. */
79
- this.qLook = new THREE.Quaternion();
80
- /** Low-passed (tau ~0.2s) copy of `qClip`. RULING R43 part 2: the
81
- * deviation boost is measured against THIS, not the raw clip pose, so
82
- * one noisy/fast clip frame can't make the boost — and therefore the
83
- * blend — jump within a frame. Ported from useHeadTracking.ts:594-606. */
84
- this.animHead = new THREE.Quaternion();
85
- this.animHeadHas = false;
86
- /** The blended pose, before the output velocity cap below is applied. */
87
- this.qOut = new THREE.Quaternion();
88
- /** The actual pose WRITTEN to the head last frame — the anchor the
89
- * output velocity cap (RULING R43 part 3) rotates from. Ported from
90
- * useHeadTracking.ts:621-633. */
91
- this.lastOutHead = new THREE.Quaternion();
92
- this.lastOutHas = false;
93
- /** RULING R61: the neck bones blend with their OWN clip pose too — same
94
- * low-pass, same shared head-derived `blend`, same output cap — ported
95
- * from useHeadTracking.ts:598-606 (low-pass) and :635-671 (blend +
96
- * cap). One low-pass/cap pair per neck bone, each independent (the
97
- * clip poses and, after the cap, the written poses differ per bone). */
98
- this.animNeck1 = new THREE.Quaternion();
99
- this.animNeck1Has = false;
100
- this.animNeck2 = new THREE.Quaternion();
101
- this.animNeck2Has = false;
102
- this.qOutNeck = new THREE.Quaternion();
103
- this.lastOutNeck1 = new THREE.Quaternion();
104
- this.lastOutNeck1Has = false;
105
- this.lastOutNeck2 = new THREE.Quaternion();
106
- this.lastOutNeck2Has = false;
22
+ /** See `GazeSource.attending`. */
23
+ this.attending = false;
24
+ /** The world point the character is looking at this frame; see `GazeSource.focus`. */
25
+ this.focus = new THREE.Vector3();
26
+ this.headYaw = settled(0);
27
+ this.headPitch = settled(0);
28
+ this.torsoYaw = settled(0);
29
+ this.outside = 0;
30
+ this.twisted = 0;
31
+ this.sway = { yaw: 0, pitch: 0, has: false };
32
+ this.glance = { yaw: 0, pitch: 0, left: 0, next: 3 + Math.random() * 4 };
33
+ this.base = new Map();
34
+ this.out = new Map();
35
+ this.q = new THREE.Quaternion();
36
+ this.pq = new THREE.Quaternion();
37
+ this.up = new THREE.Vector3();
38
+ /** The body's lateral axis turned with the head: pitch nods about it. */
39
+ this.lateral = new THREE.Vector3();
40
+ this.fwd = new THREE.Vector3();
41
+ this.v = new THREE.Vector3();
42
+ this.w = new THREE.Vector3();
43
+ this.headPos = new THREE.Vector3();
44
+ this.m = new THREE.Matrix4();
45
+ this.qHead = new THREE.Quaternion();
107
46
  this.gate = cfg.enabled ?? true;
108
- this.lock = cfg.idleLock ?? 0.9;
109
47
  }
110
- /**
111
- * Live on/off gate, read every frame. While false the system writes
112
- * NOTHING to the rig — the mixer's clips own the head/neck — and eases
113
- * the internal yaw/pitch/roll state toward neutral so re-enabling does
114
- * not snap the head back onto the camera.
115
- */
116
48
  setEnabled(on) { this.gate = on; }
117
49
  /**
118
- * Time-based exponential ease toward `tgt`, velocity-capped so a frame
119
- * hitch can't yank the head. Ported verbatim from
120
- * useHeadTracking.ts:480-484 — NOT the bare `x += (target - x) * lerp`
121
- * form, which is frame-rate dependent by construction (a 144Hz monitor
122
- * would track visibly faster than a 60Hz one). Per RULING R22.
123
- *
124
- * Hoisted to a method (RULING R25) rather than a closure allocated inside
125
- * `update()` — a per-frame closure is exactly the steady-state allocation
126
- * this class's zero-allocation rule forbids. `useHeadTracking.ts` can
127
- * afford the closure because it's a React hook body re-created rarely and
128
- * garbage-collected on unmount; embody's `update()` runs every frame for
129
- * the lifetime of the character.
130
- */
131
- approach(cur, tgt, k, maxStep) {
132
- return cur + THREE.MathUtils.clamp((tgt - cur) * k, -maxStep, maxStep);
133
- }
134
- /**
135
- * Smooth, non-repeating drift in ~[-1, 1] — three incommensurate sines
136
- * (an fbm-lite). Ported VERBATIM from useHeadTracking.ts:144-150
137
- * (function `drift`) — NOT the brief's single-sine placeholder
138
- * (`Math.sin(t * 0.31)`), which is a different, un-sourced waveform.
139
- * The three fixed frequencies/weights are shared by every axis; only
140
- * the per-axis `seed` (and its own amplitude) differs, exactly as in
141
- * the prior art's `drift(t, 1.0|7.3|13.1)` calls at lines 556-559.
142
- *
143
- * Hoisted to a method rather than a closure (RULING R25, same as
144
- * `approach` above) — no steady-state allocation.
50
+ * Put back the clip pose on bones this system wrote and the mixer is not
51
+ * about to rewrite. Registered before the mixer (stage 0): at a loop seam
52
+ * or crossfade the mixer can blend from a bone's current value, which
53
+ * would otherwise still hold last frame's turn.
145
54
  */
146
- drift(t, seed) {
147
- return (Math.sin(t * 0.47 + seed) * 0.55 +
148
- Math.sin(t * 0.83 + seed * 2.13) * 0.3 +
149
- Math.sin(t * 1.71 + seed * 4.7) * 0.15);
55
+ beforeMixer() {
56
+ for (const [bone, was] of this.out)
57
+ if (was.equals(bone.quaternion))
58
+ bone.quaternion.copy(this.base.get(bone));
150
59
  }
151
60
  update(dt, ctx) {
152
61
  const head = ctx.rig.bones.head;
153
- const neck1 = ctx.rig.bones.neck1;
154
- const neck2 = ctx.rig.bones.neck2;
155
- if (!head || !ctx.camera)
62
+ this.attending = false;
63
+ if (!head)
156
64
  return;
157
- const lerpRate = this.cfg.lerpSpeed ?? 0.08;
158
- const k = 1 - Math.exp(-lerpRate * 60 * dt);
159
- const maxStep = 1.4 * dt; // rad — ~80 deg/s retarget speed limit
65
+ if (this.eyes === undefined)
66
+ this.eyes = EyeAim.calibrate(ctx.rig);
67
+ this.restoreClipPose(ctx);
160
68
  if (!this.gate) {
161
- // Ease internal state toward neutral; write NOTHING to the rig.
162
- this.yaw = this.approach(this.yaw, 0, k, maxStep);
163
- this.pitch = this.approach(this.pitch, 0, k, maxStep);
164
- this.roll = this.approach(this.roll, 0, k, maxStep);
165
- // The clip owns the bone while gated — restart the clip low-pass and
166
- // the output-cap anchor from the live pose on re-enable, rather than
167
- // fighting a stale reference (same rationale as
168
- // useHeadTracking.ts:322-324's gate<0.02 branch).
169
- this.animHeadHas = false;
170
- this.lastOutHas = false;
171
- this.animNeck1Has = false;
172
- this.animNeck2Has = false;
173
- this.lastOutNeck1Has = false;
174
- this.lastOutNeck2Has = false;
69
+ // Ease internal state home; the clip owns the bones while gated.
70
+ this.headYaw = settled(0);
71
+ this.headPitch = settled(0);
72
+ this.torsoYaw = settled(0);
73
+ this.out.clear();
175
74
  return;
176
75
  }
177
- // Target direction in the head's PARENT space, so the solve reads off
178
- // the rig's own rest-pose axes and is independent of where the body
179
- // faces in the world. Using the head's actual world position (not its
180
- // parent's origin) matters on a real rig, where the head bone sits
181
- // offset up from neck_02 — see report for why this differs from the
182
- // brief's reference snippet.
183
- ctx.camera.getWorldPosition(this.vTarget);
184
- head.getWorldPosition(this.vHead);
185
- const parent = head.parent ?? ctx.root;
186
- this.mInv.copy(parent.matrixWorld).invert();
187
- this.vLocal.subVectors(this.vTarget, this.vHead).transformDirection(this.mInv);
188
- // Into the head's REST frame. The yaw/pitch solved below are applied as
189
- // `restHead * qLook` (the premultiply further down), i.e. RELATIVE to the
190
- // head's rest orientation — so they must be MEASURED relative to it too.
191
- //
192
- // want: P · restHead · qLook · fwd = aim (P = parent world quat)
193
- // so: qLook solves against restHead⁻¹ · P⁻¹ · aim
194
- //
195
- // `transformDirection(mInv)` above supplies only P⁻¹ · aim. Without the
196
- // restHead⁻¹ the solve measures in one frame and applies in another, and
197
- // the head lands mis-aimed by exactly the rest tilt.
198
- //
199
- // Invisible on a rig whose bones rest at identity — which is every older
200
- // fixture in this file. On the japanese-f fixture the head rests 11.42°
201
- // tilted; measured on the live convai-mha-1 body, a level camera left the
202
- // head aiming 17-20° ABOVE it, chin up, while the solve reported itself on
203
- // target. That is the "it never looks at me directly" symptom.
204
- const headRestFrame = ctx.rig.restPose.head;
205
- if (headRestFrame)
206
- this.vLocal.applyQuaternion(this.qRestInv.copy(headRestFrame).invert());
207
- const basis = ctx.rig.profile.basis;
208
- // Yaw/pitch of the target about the rig's own bone-local axes.
209
- const right = this.vLocal.dot(basis.right);
210
- const up = this.vLocal.dot(basis.up);
211
- const fwd = this.vLocal.dot(basis.fwd);
212
- let targetYaw = Math.atan2(right, fwd);
213
- let targetPitch = -Math.atan2(up, Math.hypot(right, fwd));
214
- const maxTrack = this.cfg.maxTrackAngle ?? Math.PI / 2;
215
- const maxPitch = this.cfg.maxPitchAngle ?? Math.PI / 4;
216
- if (Math.abs(targetYaw) > maxTrack) {
217
- targetYaw = 0;
218
- targetPitch = 0;
219
- }
220
- targetPitch = THREE.MathUtils.clamp(targetPitch, -maxPitch, maxPitch);
221
- this.yaw = this.approach(this.yaw, targetYaw, k, maxStep);
222
- this.pitch = this.approach(this.pitch, targetPitch, k, maxStep);
223
- // --- Clip-blending layer (Task 8): smoothed camera lock ------------
224
- // `ctx.speaking` selects the TARGET lock strength; the lock itself
225
- // eases toward it over ~0.4s (RULING R39) rather than switching
226
- // instantly, using the same time-based exponential form as `approach`
227
- // above (RULING R40, same grounds as R22) — NOT the brief's bare
228
- // `const lock = ctx.speaking ? talkLock : idleLock`, which would pop
229
- // the lock (and therefore the head pose) in a single frame whenever
230
- // speech starts or stops.
231
- const idleLockCfg = this.cfg.idleLock ?? 0.9;
232
- const talkLockCfg = this.cfg.talkLock ?? 0.95;
233
- const lockTarget = ctx.speaking ? talkLockCfg : idleLockCfg;
234
- const lockK = 1 - Math.exp(-0.04 * 60 * dt);
235
- this.lock = this.approach(this.lock, lockTarget, lockK, Infinity);
236
- // `calm`: a focused (highly locked) speaker stops glancing around —
237
- // ported from useHeadTracking.ts:527-533. Derived from the SMOOTHED
238
- // lock (not raw `ctx.speaking`) so it fades in/out with the lock
239
- // transition above rather than popping with it. Applied below to the
240
- // idle-drift and gaze-shift amplitudes (RULING R41) — Task 7 deferred
241
- // this because it depends on this smoothed lock.
242
- const speakBlend = THREE.MathUtils.clamp((this.lock - idleLockCfg) / Math.max(0.001, talkLockCfg - idleLockCfg), 0, 1);
243
- const calm = 1 - 0.65 * speakBlend;
244
- // --- Aliveness layer (Task 7): idle micro-drift + occasional
245
- // look-away gaze shifts. Both are computed here, AFTER the tracking
246
- // ease above and BEFORE the head/neck split below, and folded into
247
- // local `yaw`/`pitch`/`this.roll` — `this.yaw`/`this.pitch` themselves
248
- // stay pure-tracking (Task 6) values, because `direction` below is
249
- // published from them and downstream consumers (blink, eye-gaze) want
250
- // the deliberate look target, not idle jitter.
251
- let driftYaw = 0, driftPitch = 0, driftRoll = 0;
252
- if (!ctx.reducedMotion) {
253
- this.driftClock += dt * (this.cfg.idleSpeed ?? 1.0);
254
- const t = this.driftClock;
255
- // `drift()` ported verbatim from useHeadTracking.ts:144-150; the
256
- // per-axis seeds (1.0 / 7.3 / 13.1) match lines 556, 558 and 559.
257
- driftYaw = this.drift(t, 1.0) * (this.cfg.idleYawAmp ?? 0.03);
258
- driftPitch = this.drift(t, 7.3) * (this.cfg.idlePitchAmp ?? 0.02);
259
- driftRoll = this.drift(t, 13.1) * (this.cfg.idleRollAmp ?? 0.035);
260
- // Occasional look-away, held for a random dwell then re-picked —
261
- // ported from useHeadTracking.ts:507-522 (the "gaze" ref). The
262
- // countdown-timer bookkeeping here (`this.dwell -= dt`) is
263
- // equivalent to the prior art's absolute `nextAt` comparison; the
264
- // pick and dwell-duration formulas themselves are verbatim.
265
- this.dwell -= dt;
266
- if (this.dwell <= 0) {
267
- const min = this.cfg.gazeDwellMin ?? 1.4;
268
- const max = this.cfg.gazeDwellMax ?? 4.0;
269
- this.dwell = min + Math.random() * (max - min);
270
- this.shiftTargetYaw = (Math.random() * 2 - 1) * (this.cfg.gazeYawRange ?? 0.15);
271
- this.shiftTargetPitch = (Math.random() * 2 - 1) * (this.cfg.gazePitchRange ?? 0.09);
272
- this.shiftTargetRoll = (Math.random() * 2 - 1) * (this.cfg.gazeRollRange ?? 0.09);
273
- }
274
- // Time-based exponential ease toward the gaze-shift target — the
275
- // SAME `1 - exp(-rate * 60 * dt)` form as `approach()`'s `k` above
276
- // (RULING R22), ported verbatim from useHeadTracking.ts:519-522.
277
- // NOT the brief's bare `+= (target - cur) * gazeEase`, which is
278
- // frame-rate dependent by construction, same defect R22 already
279
- // ruled out for the main tracking ease.
280
- const gk = 1 - Math.exp(-(this.cfg.gazeEase ?? 0.035) * 60 * dt);
281
- this.shiftYaw += (this.shiftTargetYaw - this.shiftYaw) * gk;
282
- this.shiftPitch += (this.shiftTargetPitch - this.shiftPitch) * gk;
283
- this.shiftRoll += (this.shiftTargetRoll - this.shiftRoll) * gk;
284
- }
285
- else {
286
- // Reduced motion engaged mid-shift: ease any in-flight look-away back
287
- // to centre rather than freezing it there (RULING R34). A hard reset
288
- // would snap the offset to zero in a single frame — itself a motion
289
- // artifact, and exactly what a reduced-motion user should not get.
290
- // Velocity-capped via `approach()` (same k/maxStep as the tracking
291
- // ease above) so the decay is smooth and frame-rate independent.
292
- this.shiftYaw = this.approach(this.shiftYaw, 0, k, maxStep);
293
- this.shiftPitch = this.approach(this.shiftPitch, 0, k, maxStep);
294
- this.shiftRoll = this.approach(this.shiftRoll, 0, k, maxStep);
295
- this.dwell = 0; // re-enabling picks a fresh target, not a stale one
76
+ for (const m of [this.headYaw, this.headPitch, this.torsoYaw])
77
+ m.t += dt;
78
+ const root = ctx.root;
79
+ root.updateMatrixWorld(true);
80
+ this.up.set(0, 1, 0).transformDirection(root.matrixWorld);
81
+ this.fwd.set(0, 0, 1).transformDirection(root.matrixWorld);
82
+ head.getWorldPosition(this.headPos);
83
+ // Steady the head against the clip's fast sway: where a point straight
84
+ // ahead of the body sits from the clip's head pose, minus its slow (4 s)
85
+ // average. The idle clips alone swing the face 15–18°.
86
+ const ahead = this.v.copy(this.headPos).addScaledVector(this.fwd, 10);
87
+ const sway = this.angleTo(ahead, ctx);
88
+ if (!this.sway.has)
89
+ Object.assign(this.sway, { yaw: sway.yaw, pitch: sway.pitch, has: true });
90
+ const ks = 1 - Math.exp(-dt / 4);
91
+ this.sway.yaw += (sway.yaw - this.sway.yaw) * ks;
92
+ this.sway.pitch += (sway.pitch - this.sway.pitch) * ks;
93
+ const steady = this.cfg.steady ?? 0.8;
94
+ const steadyYaw = steady * (sway.yaw - this.sway.yaw);
95
+ const steadyPitch = steady * (sway.pitch - this.sway.pitch);
96
+ // The pose: torso, then the neck unwinding it, then pitch about the
97
+ // head's own turned right axis.
98
+ const torso = valueOf(this.torsoYaw);
99
+ const headYaw = valueOf(this.headYaw);
100
+ const headPitch = valueOf(this.headPitch);
101
+ this.lateral.set(1, 0, 0).transformDirection(root.matrixWorld).applyAxisAngle(this.up, headYaw * DEG);
102
+ const bones = ctx.rig.bones;
103
+ this.turn([[bones.spineWaist, 0.4], [bones.spineChest, 0.6]], this.up, torso);
104
+ this.turn([[bones.neck1, 0.25], [bones.neck2, 0.25], [head, 0.5]], this.up, headYaw - torso + steadyYaw);
105
+ this.turn([[bones.neck1, 0.2], [bones.neck2, 0.3], [head, 0.5]], this.lateral, -(headPitch + steadyPitch));
106
+ root.updateMatrixWorld(true);
107
+ this.rememberOutput();
108
+ // What to look at: the camera while it is within reach of the body,
109
+ // otherwise straight ahead (the character turns home).
110
+ const camera = ctx.camera;
111
+ let attending = false;
112
+ if (camera) {
113
+ camera.getWorldPosition(this.w);
114
+ this.v.subVectors(this.w, this.headPos).projectOnPlane(this.up);
115
+ const offBody = this.v.lengthSq() > 1e-12 ? this.v.angleTo(this.fwd) : 0;
116
+ attending = offBody <= (this.cfg.maxTrackAngle ?? Math.PI / 2);
296
117
  }
297
- // `calm` (Task 8) scales only the idle-drift and gaze-shift
298
- // contributions, never the deliberate tracking (`this.yaw`/`this.pitch`
299
- // themselves) — a focused speaker still looks at the viewer, she just
300
- // stops the micro-life around it.
301
- const yaw = this.yaw + (driftYaw + this.shiftYaw) * calm;
302
- const pitch = this.pitch + (driftPitch + this.shiftPitch) * calm;
303
- // Head CAPS at the head-only zone; the neck takes a share of the excess.
304
- // Ported verbatim from useHeadTracking.ts:450-460 (per RULING R21) — NOT
305
- // `headYaw = yaw - neckYaw`, which lets the head keep rotating past
306
- // `headOnlyAngle` (scaled down by the neck's share) instead of capping
307
- // there outright. That's the exact near-joint-limit pose the 0.44 retune
308
- // (see `headOnlyAngle` doc above) was fixing.
309
- const headOnly = this.cfg.headOnlyAngle ?? 0.44;
310
- const absYaw = Math.abs(yaw);
311
- const yawSign = Math.sign(yaw);
312
- let headYaw;
313
- let neckYaw;
314
- if (absYaw <= headOnly) {
315
- headYaw = yaw;
316
- neckYaw = 0;
118
+ this.attending = attending;
119
+ if (attending) {
120
+ this.planGlances(dt, ctx);
121
+ this.focus.copy(this.w);
122
+ if (this.glance.yaw || this.glance.pitch)
123
+ this.offsetFocus(this.glance.yaw, this.glance.pitch);
317
124
  }
318
125
  else {
319
- const extraAngle = absYaw - headOnly;
320
- headYaw = headOnly * yawSign;
321
- neckYaw = extraAngle * yawSign * (this.cfg.neckYawFactor ?? 0.75);
126
+ this.glance.yaw = this.glance.pitch = 0;
127
+ this.focus.copy(this.headPos).addScaledVector(this.fwd, 10);
322
128
  }
323
- const headPitch = pitch * (this.cfg.headPitchFactor ?? 0.7);
324
- const neckPitch = pitch * (this.cfg.neckPitchFactor ?? 0.3);
325
- // Bank the head into the turn: roll proportional to the (capped) head
326
- // yaw. Ported from useHeadTracking.ts:560-562
327
- // (`bankRoll = -(headYaw + gaze.curYaw * calm) * turnBank`) — NOT a
328
- // positive-sign `yaw * turnBank` off the raw, uncapped yaw, which
329
- // would keep banking further as the neck (not the head) absorbs
330
- // excess yaw beyond `headOnlyAngle`. Per RULING R31, the negative
331
- // sign / capped-`headYaw` base above is confirmed. `calm` (Task 8) is
332
- // already folded into `headYaw` here (it scaled `driftYaw`/`shiftYaw`
333
- // above, before the head/neck split), so `bank` inherits the calming
334
- // for free — no separate `* calm` needed on this line.
335
- //
336
- // Per RULING R30, banking is suppressed under `ctx.reducedMotion`:
337
- // it's decorative roll carrying no information, and head tilt is a
338
- // recognised vestibular trigger — precisely what reduced-motion
339
- // exists to avoid. Tracking yaw/pitch themselves stay (the character
340
- // still looks at you; that's functional), only this stylistic roll
341
- // is gated. The prior art has no reduced-motion concept, so there is
342
- // no upstream formula to defer to here — this gate is judgement, not
343
- // a port.
344
- const bank = ctx.reducedMotion ? 0 : headYaw * (this.cfg.turnBank ?? 0.2);
345
- this.roll = (driftRoll + this.shiftRoll) * calm - bank;
346
- // --- Clip-blending layer (Task 8): camera lock + deviation pull-back
347
- //
348
- // RULING R43: this is a FOUR-part mechanism ported from
349
- // useHeadTracking.ts:594-633, not the two-line `max(lock, linear-pull)`
350
- // form. Each part exists to stabilise the one after it — the low-pass
351
- // stops the boost jittering frame to frame, the boost+lock combination
352
- // is what actually blends, and the output cap catches whatever burst
353
- // still gets through (e.g. a hitch, or a large one-frame clip jump).
354
- //
355
- // What the mixer wrote to the head THIS frame, before we touch it —
356
- // captured now, since nothing above has written to `head.quaternion`
357
- // yet this frame.
358
- this.qClip.copy(head.quaternion);
359
- setLookQuat(this.qLook, basis, headYaw, headPitch, this.roll);
360
- // RULING (2026-09-07 fix wave, C1): compose the look pose ONTO the
361
- // bone's own bind-time local rotation, never treat it as absolute — a
362
- // real rig's head/neck bones are not identity-oriented in their
363
- // parent's frame. Ported from useHeadTracking.ts:590-592
364
- // (`lockedHead = copy(restPose.head).multiply(headLook)`). `premultiply`
365
- // computes exactly `rest * qLook` in place, no extra scratch quaternion.
366
- // Absent rest (a rig this system never resolved) leaves qLook untouched
367
- // — falls back to the pre-fix, identity-relative behaviour rather than
368
- // throwing.
369
- const restHead = ctx.rig.restPose.head;
370
- if (restHead)
371
- this.qLook.premultiply(restHead);
372
- // Part 2 — low-pass the clip pose (tau ~0.2s) BEFORE measuring
373
- // deviation from it. Ported from useHeadTracking.ts:594-606; the prior
374
- // art's own comment gives the reason verbatim: "Deviation is computed
375
- // against the SMOOTHED pose so the boost itself cannot jump within a
376
- // frame." First frame: seed directly from the raw clip (no
377
- // artificial 0.2s ramp-up from an arbitrary identity start).
378
- const kAnim = 1 - Math.exp(-dt / 0.2);
379
- if (!this.animHeadHas) {
380
- this.animHead.copy(this.qClip);
381
- this.animHeadHas = true;
382
- }
383
- else {
384
- this.animHead.slerp(this.qClip, kAnim);
385
- }
386
- // Part 1 — deviation pull-back as a SMOOTHSTEP boost applied ON TOP OF
387
- // the (already time-eased, RULING R39/R40) lock — `lock += (1 - lock)
388
- // * boost` — NOT `max(lock, linear-pull)`. Ported verbatim from
389
- // useHeadTracking.ts:611-618. `deviationHi` is 0.35 (RULING R38:
390
- // verified against `HeadTrackingConfig.DEFAULT_CONFIG.deviationHi`,
391
- // useHeadTracking.ts:135 — NOT the stale interface JSDoc's 0.5,
392
- // useHeadTracking.ts:67, which the implementation itself does not use).
393
- const deviation = this.animHead.angleTo(this.qLook);
394
- const lo = this.cfg.deviationLo ?? 0.12;
395
- const hi = this.cfg.deviationHi ?? 0.35;
396
- const boost = THREE.MathUtils.smoothstep(deviation, lo, hi);
397
- const blend = this.lock + (1 - this.lock) * boost;
398
- // Blend the LOW-PASSED clip pose (never the raw one) toward the look
399
- // pose. Still a `.copy()` onto the bone (never `.multiply()`), per
400
- // this class's contract — `.slerp()` interpolates, it doesn't compose
401
- // with the bone's existing rotation the way `.multiply()` would.
402
- this.qOut.copy(this.animHead).slerp(this.qLook, blend);
403
- // Part 3 — output velocity cap, applied AFTER the blend. Ported from
404
- // useHeadTracking.ts:625-631. The `Math.min(dt, 0.02)` is load-bearing,
405
- // not incidental: the prior art's own comment records the exact bug it
406
- // fixes — "on a hitched frame the window would otherwise expand and
407
- // admit the whole burst as one visible jump (measured 1.6°-in-one-frame
408
- // spikes exactly on dt≈21ms frames)." 55°/s sits above this class's own
409
- // normal motion but compresses a fast clip burst across several
410
- // frames. Skipped on the very first written frame (no prior output to
411
- // rotate from).
412
- const outCap = (55 * Math.PI / 180) * Math.min(dt, 0.02);
413
- if (this.lastOutHas) {
414
- head.quaternion.copy(this.lastOutHead).rotateTowards(this.qOut, outCap);
415
- }
416
- else {
417
- head.quaternion.copy(this.qOut);
129
+ const off = this.angleTo(this.focus, ctx);
130
+ this.pursue(dt, off);
131
+ this.turnTorso(dt, off);
132
+ this.publish(ctx);
133
+ }
134
+ /** Smooth pursuit beyond the eyes' zone, and a quick shift on a large jump. */
135
+ pursue(dt, now) {
136
+ // Against where the head is already going, not where it is mid-move: a
137
+ // shift in flight has already claimed part of the gap, and pursuing that
138
+ // part again overshot the target (35 deg for a camera at 30, which then
139
+ // held the neck past the torso trigger).
140
+ const off = {
141
+ yaw: now.yaw - (this.headYaw.to - valueOf(this.headYaw)),
142
+ pitch: now.pitch - (this.headPitch.to - valueOf(this.headPitch)),
143
+ };
144
+ const zone = this.cfg.eyeZone ?? { yaw: 5, pitch: 4 };
145
+ const shiftZone = this.cfg.shiftZone ?? { yaw: 18, pitch: 12 };
146
+ const limit = this.cfg.headLimit ?? { yaw: 45, pitch: 25 };
147
+ const kf = 1 - Math.exp(-dt / (this.cfg.followTime ?? 0.45));
148
+ const beyond = (v, z) => v - THREE.MathUtils.clamp(v, -z, z);
149
+ const torsoGoal = this.torsoYaw.to;
150
+ const fy = THREE.MathUtils.clamp(beyond(off.yaw, zone.yaw) * kf, torsoGoal - limit.yaw - this.headYaw.to, torsoGoal + limit.yaw - this.headYaw.to);
151
+ const fp = THREE.MathUtils.clamp(beyond(off.pitch, zone.pitch) * kf, -limit.pitch - this.headPitch.to, limit.pitch - this.headPitch.to);
152
+ this.headYaw.from += fy;
153
+ this.headYaw.to += fy;
154
+ this.headPitch.from += fp;
155
+ this.headPitch.to += fp;
156
+ const outside = Math.abs(off.yaw) > shiftZone.yaw || Math.abs(off.pitch) > shiftZone.pitch;
157
+ this.outside = outside ? this.outside + dt : 0;
158
+ if (this.outside > 0.06 && done(this.headYaw) && done(this.headPitch)) {
159
+ const yawNow = valueOf(this.headYaw);
160
+ const pitchNow = valueOf(this.headPitch);
161
+ const torsoNow = this.torsoYaw.to;
162
+ // `off` was measured before this frame's pursuit step, which yawNow
163
+ // already includes; count that step once (counted twice, where the head
164
+ // settled depended on the frame rate: 23.3 deg at 30 fps, 22.3 at 120).
165
+ const remainingYaw = off.yaw - fy;
166
+ const remainingPitch = off.pitch - fp;
167
+ const yaw = THREE.MathUtils.clamp(yawNow + remainingYaw - Math.sign(remainingYaw) * 3, torsoNow - limit.yaw, torsoNow + limit.yaw);
168
+ const pitch = THREE.MathUtils.clamp(pitchNow + remainingPitch - Math.sign(remainingPitch) * 2, -limit.pitch, limit.pitch);
169
+ const dur = 0.22 + Math.hypot(yaw - yawNow, pitch - pitchNow) / 140;
170
+ this.headYaw = { from: yawNow, to: yaw, t: 0, dur, delay: 0.08 };
171
+ this.headPitch = { from: pitchNow, to: pitch, t: 0, dur, delay: 0.08 };
172
+ this.outside = 0;
418
173
  }
419
- this.lastOutHead.copy(head.quaternion);
420
- this.lastOutHas = true;
421
- // RULING R61: the neck bones blend with their own clip pose too — the
422
- // brief (and the first cut of this class) applied the clip-blend
423
- // mechanism to the HEAD only, which meant an authored clip's neck
424
- // motion was discarded outright every frame (a hard `.copy()`
425
- // override, never blended). The prior art low-passes `anim.neck1`/
426
- // `anim.neck2` alongside `anim.head` for exactly this reason
427
- // (useHeadTracking.ts:598-606) and blends both necks with the SAME
428
- // head-derived blend fraction (`blend`, computed above from `lock` and
429
- // the head's own deviation boost) — NOT a separately computed per-neck
430
- // deviation (useHeadTracking.ts:644,
431
- // 663). `this.qClip`/`this.qLook` are reused as scratch here (the
432
- // head's use of them above is finished for this frame) — neck1 is
433
- // fully written before neck2 starts, so this sequential reuse is
434
- // safe and allocation-free.
435
- if (neck1) {
436
- this.qClip.copy(neck1.quaternion);
437
- if (!this.animNeck1Has) {
438
- this.animNeck1.copy(this.qClip);
439
- this.animNeck1Has = true;
440
- }
441
- else {
442
- this.animNeck1.slerp(this.qClip, kAnim);
443
- }
444
- setLookQuat(this.qLook, basis, neckYaw * 0.5, neckPitch * 0.5, 0);
445
- // C1: compose onto neck_01's own rest pose — see the head's identical
446
- // comment above.
447
- const restNeck1 = ctx.rig.restPose.neck1;
448
- if (restNeck1)
449
- this.qLook.premultiply(restNeck1);
450
- this.qOutNeck.copy(this.animNeck1).slerp(this.qLook, blend);
451
- if (this.lastOutNeck1Has) {
452
- neck1.quaternion.copy(this.lastOutNeck1).rotateTowards(this.qOutNeck, outCap);
453
- }
454
- else {
455
- neck1.quaternion.copy(this.qOutNeck);
456
- }
457
- this.lastOutNeck1.copy(neck1.quaternion);
458
- this.lastOutNeck1Has = true;
174
+ }
175
+ /** The torso comes round after the neck has been twisted far for a while, and drifts home. */
176
+ turnTorso(dt, off) {
177
+ if (this.cfg.torso === false)
178
+ return;
179
+ const trigger = this.cfg.torso?.trigger ?? 32;
180
+ const hold = this.cfg.torso?.hold ?? 0.6;
181
+ const limit = this.cfg.torso?.limit ?? 30;
182
+ const twist = valueOf(this.headYaw) - valueOf(this.torsoYaw);
183
+ this.twisted = Math.abs(twist) > trigger ? this.twisted + dt : 0;
184
+ if (!done(this.torsoYaw))
185
+ return;
186
+ let goal = null;
187
+ if (this.twisted > hold)
188
+ goal = valueOf(this.headYaw) - Math.sign(twist) * 12;
189
+ else if (Math.abs(valueOf(this.headYaw) + off.yaw) < 15 && Math.abs(valueOf(this.torsoYaw)) > 2)
190
+ goal = 0;
191
+ if (goal === null)
192
+ return;
193
+ goal = THREE.MathUtils.clamp(goal, -limit, limit);
194
+ const size = Math.abs(goal - valueOf(this.torsoYaw));
195
+ if (size > 1)
196
+ this.torsoYaw = { from: valueOf(this.torsoYaw), to: goal, t: 0, dur: 0.8 + size / 45, delay: 0.25 };
197
+ this.twisted = 0;
198
+ }
199
+ /** Occasional short glances away while listening. */
200
+ planGlances(dt, ctx) {
201
+ const g = this.glance;
202
+ if (this.cfg.lookAways === false || ctx.reducedMotion || ctx.speaking) {
203
+ g.yaw = g.pitch = 0;
204
+ g.left = 0;
205
+ return;
459
206
  }
460
- if (neck2) {
461
- this.qClip.copy(neck2.quaternion);
462
- if (!this.animNeck2Has) {
463
- this.animNeck2.copy(this.qClip);
464
- this.animNeck2Has = true;
465
- }
466
- else {
467
- this.animNeck2.slerp(this.qClip, kAnim);
468
- }
469
- setLookQuat(this.qLook, basis, neckYaw * 0.5, neckPitch * 0.5, 0);
470
- // C1: compose onto neck_02's own rest pose — see the head's identical
471
- // comment above.
472
- const restNeck2 = ctx.rig.restPose.neck2;
473
- if (restNeck2)
474
- this.qLook.premultiply(restNeck2);
475
- this.qOutNeck.copy(this.animNeck2).slerp(this.qLook, blend);
476
- if (this.lastOutNeck2Has) {
477
- neck2.quaternion.copy(this.lastOutNeck2).rotateTowards(this.qOutNeck, outCap);
478
- }
479
- else {
480
- neck2.quaternion.copy(this.qOutNeck);
481
- }
482
- this.lastOutNeck2.copy(neck2.quaternion);
483
- this.lastOutNeck2Has = true;
207
+ if (g.left > 0) {
208
+ g.left -= dt;
209
+ if (g.left <= 0)
210
+ g.yaw = g.pitch = 0;
211
+ return;
484
212
  }
485
- // Publish normalised direction for blink (Task 5) and eye-gaze (Task 9).
486
- const h = THREE.MathUtils.clamp(this.yaw / maxTrack, -1, 1);
487
- const v = THREE.MathUtils.clamp(-this.pitch / maxPitch, -1, 1);
213
+ g.next -= dt;
214
+ if (g.next > 0)
215
+ return;
216
+ const s = Math.random() < 0.5 ? -1 : 1;
217
+ g.yaw = s * (6 + Math.random() * 6);
218
+ g.pitch = (Math.random() * 2 - 1) * 4;
219
+ g.left = 0.6 + Math.random() * 0.6;
220
+ g.next = 3 + Math.random() * 4;
221
+ }
222
+ /** Rotate `focus` about the head by yaw (about up) and pitch (about right), degrees. */
223
+ offsetFocus(yaw, pitch) {
224
+ const d = this.v.subVectors(this.focus, this.headPos);
225
+ d.applyAxisAngle(this.up, yaw * DEG);
226
+ const side = this.w.crossVectors(d, this.up).normalize();
227
+ if (side.lengthSq() > 0)
228
+ d.applyAxisAngle(side, pitch * DEG);
229
+ this.focus.copy(this.headPos).add(d);
230
+ }
231
+ /**
232
+ * Where `target` (world) sits from the face now: yaw (+ toward the
233
+ * character's left) and pitch (+ up), degrees.
234
+ */
235
+ angleTo(target, ctx) {
236
+ if (this.eyes)
237
+ return this.eyes.angleTo(target);
238
+ // Without measurable eyes: the rig basis, whose axes are the head bone's
239
+ // own (so they turn with it). On convai-mha-1 `right` is the character's
240
+ // left; see the profile.
241
+ const head = ctx.rig.bones.head;
242
+ head.updateWorldMatrix(true, false);
243
+ const local = this.v.copy(target).applyMatrix4(this.m.copy(head.matrixWorld).invert());
244
+ const basis = ctx.rig.profile.basis;
245
+ const x = local.dot(basis.right);
246
+ const y = local.dot(basis.up);
247
+ const z = local.dot(basis.fwd);
248
+ return { yaw: Math.atan2(x, z) / DEG, pitch: Math.atan2(y, Math.hypot(x, z)) / DEG };
249
+ }
250
+ publish(ctx) {
251
+ const maxTrack = this.cfg.maxTrackAngle ?? Math.PI / 2;
252
+ const limit = this.cfg.headLimit ?? { yaw: 45, pitch: 25 };
253
+ const h = THREE.MathUtils.clamp((valueOf(this.headYaw) * DEG) / maxTrack, -1, 1);
254
+ const v = THREE.MathUtils.clamp(valueOf(this.headPitch) / limit.pitch, -1, 1);
488
255
  this.direction.horizontal = h;
489
256
  this.direction.vertical = v;
490
257
  this.direction.left.horizontal = h;
491
258
  this.direction.left.vertical = v;
492
259
  this.direction.right.horizontal = h;
493
260
  this.direction.right.vertical = v;
494
- // The EYES need a different quantity, measured in WORLD space from the
495
- // head's ACTUAL orientation after the writes above — not from
496
- // `this.yaw`/`this.pitch`, which are bone-space CORRECTION angles.
497
- //
498
- // Those correction angles also carry the head's own rest tilt and the
499
- // neck chain's orientation. On a rig whose bones rest at identity the
500
- // two coincide, which is why this was invisible in every test here. On a
501
- // real MetaHuman it is badly wrong: measured in a browser on the live
502
- // convai-mha-1 body, a camera 3cm above the head (true angle 0.48°)
503
- // published vertical = 0.456, 42x too large, because the head bone rests
504
- // ~11.5° tilted and the solve must undo that before it aims anywhere.
505
- // EyeGazeSystem read that as "looking up at 46% of maximum" and pinned
506
- // eyeLookUp at 0.20 — the eyes never tracked, they were stuck up, and a
507
- // character looking straight at you appeared to stare over your shoulder.
508
- //
509
- // `direction` above is deliberately NOT changed: BlinkSystem wants the
510
- // head's deflection from neutral (lids follow the head), and two tests
511
- // pin that meaning. One field cannot serve both, so the eyes get their
512
- // own. `getWorldQuaternion` refreshes the matrix chain, so this reads
513
- // this frame's pose rather than last frame's.
514
- head.getWorldQuaternion(this.qHeadW);
515
- head.getWorldPosition(this.vHead);
516
- ctx.camera.getWorldPosition(this.vTarget);
517
- this.vAim.subVectors(this.vTarget, this.vHead);
518
- if (this.vAim.lengthSq() > 1e-12) {
519
- this.vAim.normalize();
520
- this.vFwdW.copy(basis.fwd).applyQuaternion(this.qHeadW);
521
- this.vRightW.copy(basis.right).applyQuaternion(this.qHeadW);
522
- this.vUpW.copy(basis.up).applyQuaternion(this.qHeadW);
523
- const aimR = this.vAim.dot(this.vRightW);
524
- const aimU = this.vAim.dot(this.vUpW);
525
- const aimF = this.vAim.dot(this.vFwdW);
526
- this.residual.horizontal = THREE.MathUtils.clamp(Math.atan2(aimR, aimF) / maxTrack, -1, 1);
527
- this.residual.vertical = THREE.MathUtils.clamp(Math.atan2(aimU, Math.hypot(aimR, aimF)) / maxPitch, -1, 1);
261
+ // What the eyes still have to cover, normalised: for the fallback eye path.
262
+ const off = this.angleTo(this.focus, ctx);
263
+ this.residual.horizontal = THREE.MathUtils.clamp((off.yaw * DEG) / maxTrack, -1, 1);
264
+ this.residual.vertical = THREE.MathUtils.clamp((off.pitch * DEG) / (Math.PI / 4), -1, 1);
265
+ }
266
+ /** Bones the clip did not rewrite this frame still hold our output: put the clip pose back. */
267
+ restoreClipPose(ctx) {
268
+ const b = ctx.rig.bones;
269
+ for (const bone of [b.spineWaist, b.spineChest, b.neck1, b.neck2, b.head]) {
270
+ if (!bone)
271
+ continue;
272
+ const was = this.out.get(bone);
273
+ if (was && was.equals(bone.quaternion))
274
+ bone.quaternion.copy(this.base.get(bone));
275
+ else
276
+ this.base.set(bone, (this.base.get(bone) ?? new THREE.Quaternion()).copy(bone.quaternion));
277
+ }
278
+ }
279
+ rememberOutput() {
280
+ for (const bone of this.base.keys())
281
+ this.out.set(bone, (this.out.get(bone) ?? new THREE.Quaternion()).copy(bone.quaternion));
282
+ }
283
+ /** Rotate a chain of bones by `deg` about a world axis, shared by weight. */
284
+ turn(chain, worldAxis, deg) {
285
+ if (Math.abs(deg) < 1e-4)
286
+ return;
287
+ for (const [bone, weight] of chain) {
288
+ if (!bone?.parent)
289
+ continue;
290
+ bone.parent.getWorldQuaternion(this.pq);
291
+ const local = this.w.copy(worldAxis).applyQuaternion(this.pq.invert()).normalize();
292
+ bone.quaternion.premultiply(this.q.setFromAxisAngle(local, deg * weight * DEG));
293
+ bone.updateMatrixWorld(true);
528
294
  }
529
295
  }
530
296
  }