@bitmagic/cli 0.1.66-dev.5 → 0.1.66-dev.7

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 (99) hide show
  1. package/README.md +36 -1
  2. package/assets/character-skin-authoring/SKILL.md +43 -0
  3. package/assets/character-skin-authoring/references/exporters.md +134 -0
  4. package/assets/character-skin-authoring/references/eyes.md +215 -0
  5. package/assets/character-skin-authoring/references/materials.md +115 -0
  6. package/assets/character-skin-authoring/references/playground.md +78 -0
  7. package/assets/character-skin-authoring/references/profiles.md +97 -0
  8. package/assets/character-skin-authoring/references/projection.md +190 -0
  9. package/assets/character-skin-authoring/references/regional-detail.md +72 -0
  10. package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
  11. package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
  12. package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
  13. package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
  14. package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
  15. package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
  16. package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
  17. package/assets/character-skin-authoring/scripts/character_build.py +54 -0
  18. package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
  19. package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
  20. package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
  21. package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
  22. package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
  23. package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
  24. package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
  25. package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
  26. package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
  27. package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
  28. package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
  29. package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
  30. package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
  31. package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
  32. package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
  33. package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
  34. package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
  35. package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
  36. package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
  37. package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
  38. package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
  39. package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
  40. package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
  41. package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
  42. package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
  43. package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
  44. package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
  45. package/assets/metahuman/tools/lipsync/make_line.py +181 -0
  46. package/assets/metahuman/tools/metahuman/README.md +21 -0
  47. package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
  48. package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
  49. package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
  50. package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
  51. package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
  52. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
  53. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
  54. package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
  55. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
  56. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
  57. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
  58. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
  59. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
  60. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
  61. package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
  62. package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
  63. package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
  64. package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
  65. package/assets/metahuman/tools/metahuman/package.json +13 -0
  66. package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
  67. package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
  68. package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
  69. package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
  70. package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
  71. package/dist/commands/generate.d.ts +49 -0
  72. package/dist/commands/generate.js +168 -1
  73. package/dist/commands/generate.js.map +1 -1
  74. package/dist/generate/audio-file.d.ts +6 -0
  75. package/dist/generate/audio-file.js +42 -0
  76. package/dist/generate/audio-file.js.map +1 -0
  77. package/dist/generate/music.d.ts +4 -3
  78. package/dist/generate/music.js +6 -23
  79. package/dist/generate/music.js.map +1 -1
  80. package/dist/generate/stream.d.ts +13 -0
  81. package/dist/generate/stream.js +14 -1
  82. package/dist/generate/stream.js.map +1 -1
  83. package/dist/scaffold/asset-pipeline-skill-files.js +2 -0
  84. package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
  85. package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
  86. package/dist/scaffold/character-skin-skill-files.js +49 -0
  87. package/dist/scaffold/character-skin-skill-files.js.map +1 -0
  88. package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
  89. package/dist/scaffold/metahuman-skill-files.js +306 -0
  90. package/dist/scaffold/metahuman-skill-files.js.map +1 -0
  91. package/dist/scaffold/project-files.js +4 -4
  92. package/dist/scaffold/project-files.js.map +1 -1
  93. package/dist/scaffold/project.js +4 -0
  94. package/dist/scaffold/project.js.map +1 -1
  95. package/dist/scaffold/skill-files.js +3 -1
  96. package/dist/scaffold/skill-files.js.map +1 -1
  97. package/dist/tools/bundles.js +8 -0
  98. package/dist/tools/bundles.js.map +1 -1
  99. package/package.json +4 -4
@@ -0,0 +1,580 @@
1
+ /**
2
+ * A MetaHuman standing in the world: loads the original skinned GLB (no
3
+ * voxelization), upgrades its materials, shadows the player's capsule so the
4
+ * default block character can be hidden, and adds a little procedural life —
5
+ * blinks, eye saccades, breathing and a head that turns toward the camera —
6
+ * driven through the export's 51 ARKit blendshapes and the merged skeleton.
7
+ */
8
+ import * as THREE from 'three';
9
+ import type { EngineLike } from 'types/game.js';
10
+ import { upgradeMetaHumanMaterials, type MetaHumanMaterialOptions, type UpgradeReport } from './MetaHumanMaterials.js';
11
+
12
+ export interface MetaHumanActorOptions {
13
+ url: string;
14
+ /** Yaw added to the player's yaw so the model faces gameplay +Z. */
15
+ yawOffset: number;
16
+ materials: Partial<MetaHumanMaterialOptions>;
17
+ life: {
18
+ blink: boolean;
19
+ saccades: boolean;
20
+ breathing: boolean;
21
+ lookAtCamera: boolean;
22
+ /** Max head turn toward the camera, radians. */
23
+ maxHeadTurn: number;
24
+ };
25
+ }
26
+
27
+ interface LoadedGltf { scene: THREE.Group; animations: THREE.AnimationClip[] }
28
+
29
+ export /** The engine CharacterLoader internals the arm rest-pose fix reads and patches (private fields). */
30
+ interface EngineRetargetInternals {
31
+ skinnedRetargetDeltas?: Map<string, THREE.Quaternion> | null;
32
+ skinnedBind?: { rotations: Map<string, THREE.Quaternion> } | null;
33
+ }
34
+
35
+ export type HelperGroup =
36
+ | 'shoulder' | 'thigh' | 'elbow' | 'knee' | 'twistCor' | 'muscle' | 'clavicle' | 'lat' | 'wrist' | 'ankle' | 'finger';
37
+ interface HelperJoint {
38
+ bone: THREE.Bone;
39
+ parent: THREE.Object3D;
40
+ group: HelperGroup;
41
+ /** Bind orientation of the helper and of its parent, in the model's frame. */
42
+ bindModelQ: THREE.Quaternion;
43
+ parentBindModelQ: THREE.Quaternion;
44
+ }
45
+ /** MetaHuman pose-driven helper joints by family. Children of a matched joint inherit its policy. */
46
+ const HELPER_GROUPS: Array<[HelperGroup, RegExp]> = [
47
+ ['shoulder', /^upperarm_correctiveRoot_[lr]$/],
48
+ ['thigh', /^thigh_correctiveRoot_[lr]$/],
49
+ ['elbow', /^lowerarm_correctiveRoot_[lr]$/],
50
+ ['knee', /^calf_correctiveRoot_[lr]$/],
51
+ ['twistCor', /^(upperarm|lowerarm|thigh|calf)_twistCor_0[12]_[lr]$/],
52
+ ['muscle', /^upperarm_(bicep|tricep)_[lr]$/],
53
+ ['clavicle', /^clavicle_(scap|out|pec)_[lr]$/],
54
+ ['lat', /^spine_04_latissimus_[lr]$/],
55
+ ['wrist', /^wrist_(inner|outer)_[lr]$/],
56
+ ['ankle', /^ankle_(fwd|bck)_[lr]$/],
57
+ ['finger', /^(thumb|index|middle|ring|pinky)_\d\d_(bulge|half|in|side_out|side_inn|pip|dip|mcp|palm|palmMid)_[lr]$|^(index|middle|ring|pinky)_metacarpal_slide_[lr]$/],
58
+ ];
59
+
60
+ const _v1 = new THREE.Vector3();
61
+ const _v2 = new THREE.Vector3();
62
+ const _q1 = new THREE.Quaternion();
63
+ const _q2 = new THREE.Quaternion();
64
+ const _q3 = new THREE.Quaternion();
65
+ const IDENTITY_Q = new THREE.Quaternion();
66
+
67
+ /** Rest-pose frame of a bone so we can rotate it about world axes without knowing its local axes. */
68
+ interface BoneRest {
69
+ bone: THREE.Bone;
70
+ restQuat: THREE.Quaternion;
71
+ /** Inverse of the bone's rest WORLD rotation (model-space, root at identity). */
72
+ restWorldInv: THREE.Quaternion;
73
+ }
74
+
75
+ export class MetaHumanActor {
76
+ /** Standalone: our own group. Adopted: the engine-loaded glTF root (moved by the engine). */
77
+ root: THREE.Object3D = new THREE.Group();
78
+ private adopted = false;
79
+ private model: THREE.Object3D | null = null;
80
+ private morphMeshes: THREE.Mesh[] = [];
81
+ private bones = new Map<string, THREE.Bone>();
82
+ private head: BoneRest | null = null;
83
+ private spine: BoneRest[] = [];
84
+ private report: UpgradeReport | null = null;
85
+ private nativeHeight = 0;
86
+
87
+ // follow
88
+ private player: THREE.Object3D | null = null;
89
+ private feetOffsetY = 0;
90
+
91
+ // life state
92
+ private time = 0;
93
+ private blinkTimer = 2.5;
94
+ private blinkPhase = -1; // <0 idle; else seconds into the blink
95
+ private blinkDouble = false;
96
+ private saccadeTimer = 1.0;
97
+ private gazeTarget = new THREE.Vector2();
98
+ private gaze = new THREE.Vector2();
99
+ private headCurrent = new THREE.Quaternion();
100
+ /** Smoothed head-look delta, applied on top of whatever pose the frame already gave the head bone. */
101
+ private headDelta = new THREE.Quaternion();
102
+
103
+ constructor(private readonly engine: EngineLike, private readonly options: MetaHumanActorOptions) {
104
+ this.root.name = 'MetaHumanActor';
105
+ }
106
+
107
+ getReport(): UpgradeReport | null { return this.report; }
108
+ isAdopted(): boolean { return this.adopted; }
109
+
110
+ /** Bind-pose world matrices (model space) per bone, from every skin's inverse bind matrices. */
111
+ private bindWorld = new Map<string, THREE.Matrix4>();
112
+ private buildBindWorldMap(model: THREE.Object3D): void {
113
+ this.bindWorld.clear();
114
+ model.traverse((o) => {
115
+ const m = o as THREE.SkinnedMesh;
116
+ if (!m.isSkinnedMesh || !m.skeleton) return;
117
+ m.skeleton.bones.forEach((b, i) => {
118
+ if (!this.bindWorld.has(b.name) && m.skeleton.boneInverses[i]) this.bindWorld.set(b.name, m.skeleton.boneInverses[i]!.clone().invert());
119
+ });
120
+ });
121
+ }
122
+ /** Bind-pose rotation of a bone in model space; falls back to the bone's current world rotation. */
123
+ private bindModelQuat(bone: THREE.Object3D, modelQInv: THREE.Quaternion): THREE.Quaternion {
124
+ const bw = this.bindWorld.get(bone.name);
125
+ if (bw) return new THREE.Quaternion().setFromRotationMatrix(bw);
126
+ return modelQInv.clone().multiply(bone.getWorldQuaternion(new THREE.Quaternion()));
127
+ }
128
+
129
+ /** Model-space forward direction of the character (the mesh faces +Z in its own space). */
130
+ getFacing(out = new THREE.Vector3()): THREE.Vector3 {
131
+ return out.set(0, 0, 1).applyQuaternion(this.root.getWorldQuaternion(new THREE.Quaternion())).setY(0).normalize();
132
+ }
133
+
134
+ /**
135
+ * Use a model the ENGINE already loaded (the high-res player path, driven by the engine's
136
+ * Mixamo locomotion): upgrade materials, wire morph meshes, take the bones from the real
137
+ * skeleton root (the player group also holds invisible skeleton clones with the same bone
138
+ * names), and parent the hair cards to the head. Must run while the rig is still at rest.
139
+ */
140
+ adopt(model: THREE.Object3D, skeletonRoot: THREE.Object3D, engineLoader: unknown = null): void {
141
+ this.adopted = true;
142
+ this.model = model;
143
+ this.root = model;
144
+ this.engineLoader = engineLoader as EngineRetargetInternals | null;
145
+ this.report = upgradeMetaHumanMaterials(model, this.options.materials);
146
+ skeletonRoot.traverse((o) => {
147
+ const bone = o as THREE.Bone;
148
+ if (bone.isBone && !this.bones.has(bone.name)) this.bones.set(bone.name, bone);
149
+ });
150
+ model.traverse((o) => {
151
+ const mesh = o as THREE.Mesh;
152
+ if (mesh.isMesh && mesh.morphTargetInfluences && mesh.morphTargetDictionary) this.morphMeshes.push(mesh);
153
+ });
154
+ model.updateMatrixWorld(true);
155
+ const box = new THREE.Box3().setFromObject(model);
156
+ this.nativeHeight = box.max.y - box.min.y;
157
+ // Everything "rest pose" below comes from the skins' inverse bind matrices, so it is
158
+ // correct even when the engine has already started animating the rig before we got here.
159
+ this.buildBindWorldMap(model);
160
+ this.captureRest();
161
+ this.captureHelperJoints();
162
+ this.attachCardsToHead(model);
163
+ console.log(
164
+ `[MetaHumanActor] adopted the engine's player model: height ${this.nativeHeight.toFixed(3)} m, ` +
165
+ `${this.morphMeshes.length} morph meshes, ${this.bones.size} bones, materials`, this.report.byPart,
166
+ );
167
+ }
168
+ getModel(): THREE.Object3D | null { return this.model; }
169
+ getBone(name: string): THREE.Bone | null { return this.bones.get(name) ?? null; }
170
+ getNativeHeight(): number { return this.nativeHeight; }
171
+
172
+ private lifeEnabled = true;
173
+ private speechEnergy = 0;
174
+ private readonly _qNod = new THREE.Quaternion();
175
+
176
+ /**
177
+ * MetaHuman corrective helper joints (`*_correctiveRoot_*` and their fwd/bck/in/out children)
178
+ * are positioned by Unreal's pose drivers, never by plain FK. Their skin weights straddle
179
+ * joint boundaries (a neck vertex carries 0.3 of a shoulder corrective), so letting them
180
+ * inherit the full animated rotation of their parent tears the mesh. Each group follows only
181
+ * a fraction of its parent's rotation delta from bind: 0 = pinned to the bind orientation
182
+ * (translates with the parent), 1 = rigid FK.
183
+ */
184
+ private engineLoader: EngineRetargetInternals | null = null;
185
+ private armRestPatched = false;
186
+ /** Rotate the arm chain's bind orientation up to a horizontal T-pose before the engine's per-bone
187
+ * correction is built (MetaHumans bind in an A-pose; the engine's clips rest in a T-pose). */
188
+ armRestFix = true;
189
+
190
+ helperFollow: Record<HelperGroup, number> = {
191
+ // Coordinate-descent result (tools/metahuman/e2e/probes/helper-follow-descent.js) with the arm rest-pose
192
+ // fix in place and the weight-smoothed outfit (shorts 6 / shirt 12 Laplacian iterations):
193
+ // torn shirt/shorts/body edges at the Mixamo idle 600 (rigid FK) → ~270.
194
+ shoulder: 0.5, thigh: 0.5, elbow: 0.5, knee: 0.5, twistCor: 0, muscle: 1, clavicle: 0.5, lat: 1, wrist: 1, ankle: 1, finger: 1,
195
+ };
196
+ private helperJoints: HelperJoint[] = [];
197
+ private readonly _hq = [0, 1, 2, 3, 4, 5, 6].map(() => new THREE.Quaternion());
198
+
199
+ /** 0..1 loudness from the speech system: lifts the brows and nods the head on stressed syllables. */
200
+ setSpeechEnergy(e: number): void { this.speechEnergy = THREE.MathUtils.clamp(e, 0, 1); }
201
+ /** Freeze blinks / gaze / breathing / head-look (deterministic screenshots). */
202
+ setLifeEnabled(on: boolean): void {
203
+ this.lifeEnabled = on;
204
+ if (!on) {
205
+ this.setMorph('eyeBlinkLeft', 0); this.setMorph('eyeBlinkRight', 0);
206
+ for (const n of ['eyeLookOutLeft', 'eyeLookInLeft', 'eyeLookInRight', 'eyeLookOutRight', 'eyeLookUpLeft', 'eyeLookUpRight', 'eyeLookDownLeft', 'eyeLookDownRight']) this.setMorph(n, 0);
207
+ }
208
+ }
209
+
210
+ async load(): Promise<void> {
211
+ const gltf = (await this.engine.loader.loadAsync(this.options.url)) as LoadedGltf;
212
+ const model = gltf.scene;
213
+ this.model = model;
214
+
215
+ this.report = upgradeMetaHumanMaterials(model, this.options.materials);
216
+
217
+ model.traverse((o) => {
218
+ const mesh = o as THREE.Mesh;
219
+ if (mesh.isMesh && mesh.morphTargetInfluences && mesh.morphTargetDictionary) this.morphMeshes.push(mesh);
220
+ const bone = o as THREE.Bone;
221
+ if (bone.isBone && !this.bones.has(bone.name)) this.bones.set(bone.name, bone);
222
+ });
223
+
224
+ this.root.add(model);
225
+ this.engine.scene?.add(this.root);
226
+ this.root.updateMatrixWorld(true);
227
+
228
+ const box = new THREE.Box3().setFromObject(model);
229
+ this.nativeHeight = box.max.y - box.min.y;
230
+ // Feet on the actor origin, whatever the export's tiny offset was.
231
+ model.position.y -= box.min.y;
232
+
233
+ this.captureRest();
234
+ this.attachCardsToHead(model);
235
+ console.log(
236
+ `[MetaHumanActor] loaded ${this.options.url}: height ${this.nativeHeight.toFixed(3)} m, ` +
237
+ `${this.morphMeshes.length} morph meshes, ${this.bones.size} bones, materials`, this.report.byPart,
238
+ 'hidden:', this.report.hiddenMeshes,
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Hair, eyebrow and beard cards are exported as UNSKINNED meshes sitting in the head's
244
+ * rest pose (no JOINTS/WEIGHTS), so a head turn or a breathing spine leaves them behind.
245
+ * Re-parent every non-skinned mesh onto the head bone, keeping its world transform, so
246
+ * the cards ride the skeleton exactly like the skinned face does. Must run while the
247
+ * skeleton is still in its rest pose (before any animation touches the bones).
248
+ */
249
+ private attachCardsToHead(model: THREE.Object3D): void {
250
+ const head = this.bones.get('head');
251
+ if (!head) {
252
+ console.warn('[MetaHumanActor] no "head" bone — hair cards stay static');
253
+ return;
254
+ }
255
+ this.root.updateMatrixWorld(true);
256
+ const cards: THREE.Object3D[] = [];
257
+ model.traverse((o) => {
258
+ const mesh = o as THREE.Mesh;
259
+ if (mesh.isMesh && !(mesh as THREE.SkinnedMesh).isSkinnedMesh) cards.push(mesh);
260
+ });
261
+ const headBind = this.bindWorld.get('head');
262
+ const modelInv = new THREE.Matrix4().copy(model.matrixWorld).invert();
263
+ for (const mesh of cards) {
264
+ if (headBind) {
265
+ // Cards are authored in the head's REST pose: local = inv(headBind) · cardModelMatrix.
266
+ const cardModel = new THREE.Matrix4().copy(modelInv).multiply(mesh.matrixWorld);
267
+ const local = new THREE.Matrix4().copy(headBind).invert().multiply(cardModel);
268
+ mesh.removeFromParent();
269
+ head.add(mesh);
270
+ local.decompose(mesh.position, mesh.quaternion, mesh.scale);
271
+ mesh.updateMatrix();
272
+ } else {
273
+ head.attach(mesh);
274
+ }
275
+ }
276
+ console.log(`[MetaHumanActor] attached ${cards.length} card meshes to the head bone (${headBind ? 'from bind pose' : 'from current pose'}):`, cards.map((c) => c.name));
277
+ }
278
+
279
+ /** Find the corrective-root joints while the rig is at rest and remember their bind orientations. */
280
+ private captureHelperJoints(): void {
281
+ this.helperJoints = [];
282
+ const modelQInv = this.root.getWorldQuaternion(new THREE.Quaternion()).invert();
283
+ for (const [name, bone] of this.bones) {
284
+ const hit = HELPER_GROUPS.find(([, re]) => re.test(name));
285
+ if (!hit || !bone.parent) continue;
286
+ const group = hit[0];
287
+ const bindModelQ = this.bindModelQuat(bone, modelQInv);
288
+ const parentBindModelQ = this.bindModelQuat(bone.parent, modelQInv);
289
+ this.helperJoints.push({ bone, parent: bone.parent, group, bindModelQ, parentBindModelQ });
290
+ }
291
+ const counts: Record<string, number> = {};
292
+ for (const h of this.helperJoints) counts[h.group] = (counts[h.group] ?? 0) + 1;
293
+ console.log('[MetaHumanActor] pose-driven helper joints under policy:', counts);
294
+ }
295
+
296
+ /**
297
+ * The engine retargets a clip by mapping "clip rotation relative to the clip skeleton's rest"
298
+ * onto "rig rotation relative to the rig's bind". Its correction per bone is
299
+ * inv(B_src)·B_rig, which assumes both rests are the same anatomical pose. MetaHumans bind in an
300
+ * A-pose (upper arms 45° down) while the Mixamo clips rest in a T-pose, so every arm pose lands
301
+ * 45° too low and the arms cross into the torso. Fix: rebuild the arm chain's corrections with a
302
+ * virtual T-pose bind, R·B_rig, where R (model space) turns the bind upper-arm direction to
303
+ * horizontal-sideways. Runs once, as soon as the engine has built its map.
304
+ */
305
+ private patchArmRetarget(): void {
306
+ if (this.armRestPatched || !this.armRestFix || !this.engineLoader) return;
307
+ const deltas = this.engineLoader.skinnedRetargetDeltas;
308
+ const bind = this.engineLoader.skinnedBind?.rotations;
309
+ if (!deltas || deltas.size === 0 || !bind) return;
310
+ // Bind positions in model space, from the body skin's inverse bind matrices.
311
+ let skin: THREE.SkinnedMesh | null = null;
312
+ this.model?.traverse((o) => {
313
+ const m = o as THREE.SkinnedMesh;
314
+ if (!skin && m.isSkinnedMesh && m.skeleton.bones.some((b) => b.name === 'lowerarm_l')) skin = m;
315
+ });
316
+ if (!skin) return;
317
+ const sk = (skin as THREE.SkinnedMesh).skeleton;
318
+ const bindPos = (name: string): THREE.Vector3 | null => {
319
+ const i = sk.bones.findIndex((b) => b.name === name);
320
+ if (i < 0) return null;
321
+ return new THREE.Vector3().setFromMatrixPosition(sk.boneInverses[i]!.clone().invert());
322
+ };
323
+ let patched = 0;
324
+ const project = (v: THREE.Vector3, axis: THREE.Vector3) => v.clone().sub(axis.clone().multiplyScalar(v.dot(axis))).normalize();
325
+ for (const side of ['l', 'r'] as const) {
326
+ const shoulder = bindPos(`upperarm_${side}`), elbow = bindPos(`lowerarm_${side}`), wrist = bindPos(`hand_${side}`);
327
+ const knuckle = bindPos(`middle_01_${side}`), thumb = bindPos(`thumb_02_${side}`);
328
+ if (!shoulder || !elbow || !wrist || !knuckle || !thumb) continue;
329
+ // T-pose target: every segment straight out sideways, thumbs forward.
330
+ const dT = new THREE.Vector3(Math.sign(shoulder.x) || 1, 0, 0);
331
+ const rUp = new THREE.Quaternion().setFromUnitVectors(elbow.clone().sub(shoulder).normalize(), dT);
332
+ const dFore = wrist.clone().sub(elbow).normalize().applyQuaternion(rUp);
333
+ const rFore = new THREE.Quaternion().setFromUnitVectors(dFore, dT).multiply(rUp);
334
+ const dHand = knuckle.clone().sub(wrist).normalize().applyQuaternion(rFore);
335
+ const rHandDir = new THREE.Quaternion().setFromUnitVectors(dHand, dT).multiply(rFore);
336
+ // Roll the whole (now straight) arm about its axis so the thumb points forward (+Z).
337
+ const thumbDir = project(thumb.clone().sub(wrist).applyQuaternion(rHandDir), dT);
338
+ const rRoll = new THREE.Quaternion().setFromUnitVectors(thumbDir, new THREE.Vector3(0, 0, 1));
339
+ const R: Array<[RegExp, THREE.Quaternion]> = [
340
+ [new RegExp(`^upperarm_${side}$`), rRoll.clone().multiply(rUp)],
341
+ [new RegExp(`^lowerarm_${side}$`), rRoll.clone().multiply(rFore)],
342
+ [new RegExp(`^(hand|thumb|index|middle|ring|pinky)_(.*_)?${side}$`), rRoll.clone().multiply(rHandDir)],
343
+ ];
344
+ for (const [name, delta] of deltas) {
345
+ const hit = R.find(([re]) => re.test(name));
346
+ const bRig = hit && bind.get(name);
347
+ if (!hit || !bRig) continue;
348
+ const bSrc = bRig.clone().multiply(delta.clone().invert()); // B_rig = B_src · delta
349
+ delta.copy(bSrc.invert().multiply(hit[1]).multiply(bRig)); // inv(B_src) · R · B_rig
350
+ patched++;
351
+ }
352
+ const deg = (q: THREE.Quaternion) => THREE.MathUtils.radToDeg(2 * Math.acos(Math.min(1, Math.abs(q.w)))).toFixed(0);
353
+ console.log(`[MetaHumanActor] arm ${side}: virtual T-pose — upper ${deg(rUp)}°, forearm ${deg(rFore)}°, hand ${deg(rHandDir)}°, roll ${deg(rRoll)}°`);
354
+ }
355
+ this.armRestPatched = true;
356
+ console.log(`[MetaHumanActor] arm rest-pose fix applied to ${patched} retarget corrections`);
357
+ }
358
+
359
+ /** Runs after the engine has posed the rig this frame: partial-follow the corrective roots. */
360
+ private applyHelperJointPolicy(): void {
361
+ if (this.helperJoints.length === 0) return;
362
+ const [modelQ, modelQInv, parentW, parentModel, delta, partial, tmp] = this._hq as [THREE.Quaternion, THREE.Quaternion, THREE.Quaternion, THREE.Quaternion, THREE.Quaternion, THREE.Quaternion, THREE.Quaternion];
363
+ this.root.getWorldQuaternion(modelQ);
364
+ modelQInv.copy(modelQ).invert();
365
+ for (const h of this.helperJoints) {
366
+ const follow = THREE.MathUtils.clamp(this.helperFollow[h.group], 0, 1);
367
+ h.parent.updateWorldMatrix(true, false);
368
+ h.parent.getWorldQuaternion(parentW);
369
+ parentModel.copy(modelQInv).multiply(parentW);
370
+ // The parent's animated rotation relative to its bind, in model space.
371
+ delta.copy(parentModel).multiply(tmp.copy(h.parentBindModelQ).invert());
372
+ partial.identity().slerp(delta, follow);
373
+ // Helper world rotation = partial parent delta applied to the helper's bind orientation.
374
+ const targetWorld = tmp.copy(modelQ).multiply(partial.multiply(h.bindModelQ));
375
+ h.bone.quaternion.copy(parentW.invert().multiply(targetWorld));
376
+ h.bone.updateMatrix();
377
+ }
378
+ }
379
+
380
+ private captureRest(): void {
381
+ const rootQInv = this.root.getWorldQuaternion(new THREE.Quaternion()).invert();
382
+ const rest = (name: string): BoneRest | null => {
383
+ const bone = this.bones.get(name);
384
+ if (!bone) return null;
385
+ bone.updateMatrixWorld(true);
386
+ const bw = this.bindWorld.get(name);
387
+ let restQuat: THREE.Quaternion;
388
+ let wq: THREE.Quaternion;
389
+ if (bw && bone.parent) {
390
+ // Bind world rotation (model space) and the bind LOCAL rotation from the parent's bind.
391
+ wq = new THREE.Quaternion().setFromRotationMatrix(bw);
392
+ const pw = this.bindWorld.get(bone.parent.name);
393
+ restQuat = pw ? new THREE.Quaternion().setFromRotationMatrix(pw).invert().multiply(wq) : bone.quaternion.clone();
394
+ } else {
395
+ wq = bone.getWorldQuaternion(new THREE.Quaternion()).premultiply(rootQInv);
396
+ restQuat = bone.quaternion.clone();
397
+ }
398
+ return { bone, restQuat, restWorldInv: wq.invert() };
399
+ };
400
+ this.head = rest('head');
401
+ if (this.head) this.headCurrent.copy(this.head.restQuat);
402
+ this.spine = ['spine_03', 'spine_04', 'spine_05'].map(rest).filter((r): r is BoneRest => r !== null);
403
+ }
404
+
405
+ /** Follow the player's capsule: feet at the capsule bottom, same yaw. */
406
+ attachToPlayer(player: THREE.Object3D, feetOffsetY: number): void {
407
+ this.player = player;
408
+ this.feetOffsetY = feetOffsetY;
409
+ this.syncToPlayer();
410
+ }
411
+
412
+ private syncToPlayer(): void {
413
+ if (!this.player) return;
414
+ this.root.position.set(this.player.position.x, this.player.position.y + this.feetOffsetY, this.player.position.z);
415
+ this.root.rotation.y = this.player.rotation.y + this.options.yawOffset;
416
+ }
417
+
418
+ setPose(position: THREE.Vector3, yaw: number): void {
419
+ this.player = null;
420
+ this.root.position.copy(position);
421
+ this.root.rotation.y = yaw;
422
+ }
423
+
424
+ /** World position of the head bone (falls back to 1.6 m above the feet). */
425
+ getHeadWorldPosition(out: THREE.Vector3): THREE.Vector3 {
426
+ if (this.head) return this.head.bone.getWorldPosition(out);
427
+ return out.copy(this.root.position).add(_v1.set(0, 1.6, 0));
428
+ }
429
+
430
+ setMorph(name: string, value: number): void {
431
+ for (const mesh of this.morphMeshes) {
432
+ const idx = mesh.morphTargetDictionary![name];
433
+ if (idx !== undefined) mesh.morphTargetInfluences![idx] = value;
434
+ }
435
+ }
436
+
437
+ getMorphNames(): string[] {
438
+ const first = this.morphMeshes[0];
439
+ return first ? Object.keys(first.morphTargetDictionary!) : [];
440
+ }
441
+
442
+ update(deltaTime: number, cameraPosition: THREE.Vector3 | null): void {
443
+ if (!this.model) return;
444
+ const dt = Math.min(deltaTime, 0.1);
445
+ this.time += dt;
446
+ if (!this.adopted) this.syncToPlayer();
447
+ if (this.adopted) {
448
+ this.patchArmRetarget();
449
+ this.applyHelperJointPolicy();
450
+ }
451
+ this.updateSpeechAccents();
452
+ if (!this.lifeEnabled) return;
453
+ const life = this.options.life;
454
+ if (life.blink) this.updateBlink(dt);
455
+ if (life.saccades) this.updateSaccades(dt);
456
+ // The engine's idle clip already breathes when it drives the rig.
457
+ if (life.breathing && !this.adopted) this.updateBreathing();
458
+ if (life.lookAtCamera) this.updateHeadLook(dt, cameraPosition);
459
+ }
460
+
461
+ // --- blinking ----------------------------------------------------------
462
+ private updateBlink(dt: number): void {
463
+ const CLOSE = 0.07, HOLD = 0.03, OPEN = 0.13;
464
+ if (this.blinkPhase < 0) {
465
+ this.blinkTimer -= dt;
466
+ if (this.blinkTimer <= 0) {
467
+ this.blinkPhase = 0;
468
+ this.blinkDouble = Math.random() < 0.15;
469
+ }
470
+ return;
471
+ }
472
+ this.blinkPhase += dt;
473
+ let v: number;
474
+ if (this.blinkPhase < CLOSE) v = this.blinkPhase / CLOSE;
475
+ else if (this.blinkPhase < CLOSE + HOLD) v = 1;
476
+ else if (this.blinkPhase < CLOSE + HOLD + OPEN) v = 1 - (this.blinkPhase - CLOSE - HOLD) / OPEN;
477
+ else {
478
+ v = 0;
479
+ this.blinkPhase = -1;
480
+ this.blinkTimer = this.blinkDouble ? 0.18 : 1.8 + Math.random() * 4.0;
481
+ this.blinkDouble = false;
482
+ }
483
+ const eased = v * v * (3 - 2 * v);
484
+ this.setMorph('eyeBlinkLeft', eased);
485
+ this.setMorph('eyeBlinkRight', eased);
486
+ }
487
+
488
+ // --- eye saccades (ARKit look shapes) ------------------------------------
489
+ private updateSaccades(dt: number): void {
490
+ this.saccadeTimer -= dt;
491
+ if (this.saccadeTimer <= 0) {
492
+ this.saccadeTimer = 0.5 + Math.random() * 2.5;
493
+ // Mostly small drifts, sometimes a larger glance; pitch stays gentle.
494
+ const big = Math.random() < 0.25;
495
+ this.gazeTarget.set(
496
+ (Math.random() * 2 - 1) * (big ? 0.7 : 0.25),
497
+ (Math.random() * 2 - 1) * (big ? 0.35 : 0.15),
498
+ );
499
+ }
500
+ // Saccades are fast: snap most of the way each frame.
501
+ this.gaze.lerp(this.gazeTarget, 1 - Math.exp(-dt * 25));
502
+ const yaw = this.gaze.x, pitch = this.gaze.y;
503
+ // +yaw = character's left. Left eye looks OUT, right eye looks IN (and vice versa).
504
+ this.setMorph('eyeLookOutLeft', Math.max(0, yaw));
505
+ this.setMorph('eyeLookInLeft', Math.max(0, -yaw));
506
+ this.setMorph('eyeLookInRight', Math.max(0, yaw));
507
+ this.setMorph('eyeLookOutRight', Math.max(0, -yaw));
508
+ this.setMorph('eyeLookUpLeft', Math.max(0, pitch));
509
+ this.setMorph('eyeLookUpRight', Math.max(0, pitch));
510
+ this.setMorph('eyeLookDownLeft', Math.max(0, -pitch));
511
+ this.setMorph('eyeLookDownRight', Math.max(0, -pitch));
512
+ }
513
+
514
+ // --- breathing (tiny chest pitch) ----------------------------------------
515
+ private updateBreathing(): void {
516
+ const breath = Math.sin(this.time * (Math.PI * 2) / 4.2); // ~14 breaths / min
517
+ const angle = breath * 0.012;
518
+ for (const s of this.spine) this.rotateAboutWorldAxis(s, _v1.set(1, 0, 0), angle, 1);
519
+ }
520
+
521
+ /** bone = rest * (world-axis rotation expressed in the bone's rest-local frame), blended by `weight`. */
522
+ private rotateAboutWorldAxis(r: BoneRest, worldAxis: THREE.Vector3, angle: number, weight: number): void {
523
+ const localAxis = _v2.copy(worldAxis).applyQuaternion(r.restWorldInv).normalize();
524
+ _q1.setFromAxisAngle(localAxis, angle);
525
+ _q2.copy(r.restQuat).multiply(_q1);
526
+ if (weight >= 1) r.bone.quaternion.copy(_q2);
527
+ else r.bone.quaternion.slerp(_q2, weight);
528
+ }
529
+
530
+ // --- speech accents: brows + a nod on loud syllables -------------------------
531
+ private updateSpeechAccents(): void {
532
+ const e = this.speechEnergy;
533
+ this.setMorph('browInnerUp', e * 0.3);
534
+ this.setMorph('browOuterUpLeft', e * 0.18);
535
+ this.setMorph('browOuterUpRight', e * 0.18);
536
+ }
537
+
538
+ // --- head turns toward the camera ------------------------------------------
539
+ private updateHeadLook(dt: number, cameraPosition: THREE.Vector3 | null): void {
540
+ const h = this.head;
541
+ if (!h) return;
542
+ // Base = whatever this frame already gave the head (the engine's animation pose when
543
+ // adopted, the rest pose otherwise). We only ever ADD a smoothed look delta on top.
544
+ const base = h.bone.quaternion;
545
+ const delta = _q2.identity();
546
+ if (cameraPosition) {
547
+ const headPos = h.bone.getWorldPosition(_v1);
548
+ const toCam = _v2.subVectors(cameraPosition, headPos).normalize();
549
+ // Only when the camera is roughly in front of the character.
550
+ const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(this.root.getWorldQuaternion(_q1));
551
+ const facing = toCam.dot(forward);
552
+ if (facing > 0.15) {
553
+ // Direction to camera in the head's rest-local frame (good enough for the small
554
+ // turns we allow, even while the body animates).
555
+ const rootInv = _q1.invert();
556
+ const toCamLocal = toCam.applyQuaternion(rootInv).applyQuaternion(h.restWorldInv).normalize();
557
+ const fwdLocal = new THREE.Vector3(0, 0, 1).applyQuaternion(h.restWorldInv).normalize();
558
+ delta.setFromUnitVectors(fwdLocal, toCamLocal);
559
+ let angle = 2 * Math.acos(THREE.MathUtils.clamp(delta.w, -1, 1));
560
+ if (angle > Math.PI) angle = 2 * Math.PI - angle;
561
+ const max = this.options.life.maxHeadTurn;
562
+ if (angle > max) delta.slerp(IDENTITY_Q, 1 - max / angle);
563
+ }
564
+ }
565
+ if (this.speechEnergy > 0.02) {
566
+ // Talking heads move: a small pitch nod that follows the loudness envelope.
567
+ const nod = this.speechEnergy * 0.045 * Math.sin(this.time * 9.0);
568
+ const localX = _v2.set(1, 0, 0).applyQuaternion(h.restWorldInv).normalize();
569
+ delta.multiply(this._qNod.setFromAxisAngle(localX, nod));
570
+ }
571
+ this.headDelta.slerp(delta, 1 - Math.exp(-dt * 4));
572
+ h.bone.quaternion.copy(base).multiply(this.headDelta);
573
+ this.headCurrent.copy(h.bone.quaternion);
574
+ }
575
+
576
+ dispose(): void {
577
+ if (!this.adopted) this.engine.scene?.remove(this.root);
578
+ this.model = null;
579
+ }
580
+ }