@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.
- package/CHANGELOG.md +30 -0
- package/README.md +12 -8
- package/dist/character/createCharacter.d.ts +66 -5
- package/dist/character/createCharacter.js +93 -22
- package/dist/character/pipeline.d.ts +9 -5
- package/dist/clips/animations.d.ts +13 -1
- package/dist/clips/animations.js +28 -6
- package/dist/clips/retarget.d.ts +168 -0
- package/dist/clips/retarget.js +856 -0
- package/dist/clips/sources.d.ts +70 -0
- package/dist/clips/sources.js +119 -0
- package/dist/connect/session.d.ts +11 -0
- package/dist/connect/session.js +22 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -0
- package/dist/inspect/inspectModel.d.ts +14 -0
- package/dist/inspect/inspectModel.js +26 -0
- package/dist/inspect/morphMap.d.ts +54 -0
- package/dist/inspect/morphMap.js +140 -0
- package/dist/inspect/report.d.ts +88 -0
- package/dist/inspect/report.js +193 -0
- package/dist/motion/eyeAim.d.ts +94 -0
- package/dist/motion/eyeAim.js +276 -0
- package/dist/motion/eyeGaze.d.ts +17 -0
- package/dist/motion/eyeGaze.js +42 -0
- package/dist/motion/footLock.d.ts +67 -0
- package/dist/motion/footLock.js +176 -0
- package/dist/motion/gaze.d.ts +135 -180
- package/dist/motion/gaze.js +257 -491
- package/dist/motion/hair.d.ts +27 -0
- package/dist/motion/hair.js +127 -0
- package/dist/motion/hairClearance.d.ts +39 -0
- package/dist/motion/hairClearance.js +125 -0
- package/dist/motion/morphNormals.d.ts +38 -0
- package/dist/motion/morphNormals.js +237 -0
- package/dist/motion/types.d.ts +15 -0
- package/dist/react/CharacterModel.d.ts +3 -1
- package/dist/react/CharacterModel.js +9 -1
- package/dist/react/ConvaiCharacter.js +4 -1
- package/dist/react/PortraitLights.d.ts +3 -0
- package/dist/react/PortraitLights.js +44 -0
- package/dist/render/faceMaterials.d.ts +60 -3
- package/dist/render/faceMaterials.js +320 -96
- package/dist/render/portraitRig.d.ts +28 -0
- package/dist/render/portraitRig.js +55 -0
- package/dist/render/surfaces.d.ts +181 -0
- package/dist/render/surfaces.js +1020 -0
- package/dist/rig/resolve.d.ts +8 -2
- package/dist/rig/resolve.js +7 -3
- package/dist/rig/units.d.ts +35 -16
- package/dist/rig/units.js +143 -42
- package/dist/speech/expression.d.ts +20 -0
- package/dist/speech/expression.js +118 -0
- package/dist/speech/lipsync.d.ts +68 -2
- package/dist/speech/lipsync.js +205 -5
- package/dist/speech/mouthFinish.d.ts +46 -0
- package/dist/speech/mouthFinish.js +235 -0
- package/dist/speech/voiceClock.d.ts +94 -0
- package/dist/speech/voiceClock.js +176 -0
- package/package.json +1 -1
package/dist/motion/gaze.js
CHANGED
|
@@ -1,34 +1,14 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
|
-
import {
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
44
|
-
this.
|
|
45
|
-
/**
|
|
46
|
-
this.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
if (!head || !ctx.camera)
|
|
62
|
+
this.attending = false;
|
|
63
|
+
if (!head)
|
|
156
64
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
162
|
-
this.
|
|
163
|
-
this.
|
|
164
|
-
this.
|
|
165
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
head
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// the
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
let
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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 (
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if (
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
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
|
}
|