@genex-ai/cli-demo 0.11.0 → 0.14.2

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 (42) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +203 -4
  3. package/package.json +7 -2
  4. package/templates/controllers/NOTICE.md +65 -0
  5. package/templates/controllers/assets/animation-library.glb +0 -0
  6. package/templates/controllers/assets/character.glb +0 -0
  7. package/templates/controllers/assets/default-avatar.vrm +0 -0
  8. package/templates/controllers/character/character-animations.ts +682 -0
  9. package/templates/controllers/character/character-controller.ts +1636 -0
  10. package/templates/controllers/character/follow-camera.ts +644 -0
  11. package/templates/controllers/character/keyboard-input.ts +277 -0
  12. package/templates/controllers/character/presets.ts +176 -0
  13. package/templates/controllers/character/touch-joystick.ts +387 -0
  14. package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
  15. package/templates/controllers/character/vrm/foot-ik.ts +341 -0
  16. package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
  17. package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
  18. package/templates/controllers/drone/drone-controller.ts +1073 -0
  19. package/templates/controllers/drone/presets.ts +225 -0
  20. package/templates/controllers/interact/enter-exit.ts +502 -0
  21. package/templates/controllers/shared/colliders.ts +456 -0
  22. package/templates/controllers/shared/math.ts +230 -0
  23. package/templates/controllers/shared/physics-world.ts +622 -0
  24. package/templates/controllers/vehicle/presets.ts +297 -0
  25. package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
  26. package/templates/controllers/vehicle/wheel.ts +1200 -0
  27. package/templates/skills/genex-getting-started/SKILL.md +5 -0
  28. package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
  29. package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
  30. package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
  31. package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
  32. package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
  33. package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
  34. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
  35. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  36. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  37. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  38. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  39. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  41. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  42. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,341 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Two-bone foot IK with raycast grounding + pelvis drop + foot-to-slope alignment
3
+ // (Genex AG-747). Renderer/physics-agnostic: you inject a ground query (wrap your
4
+ // Rapier `world.castRayAndGetNormal`) and it plants the feet — so on stairs and
5
+ // slopes each sole sits FLAT on the step under it instead of clipping or floating
6
+ // toes-down. OPT-IN: locomotion + punch work without it; enable after per-avatar QA.
7
+ //
8
+ // It does what a naive ankle-raise cannot, matching a good reference planter:
9
+ // 1. lowers the PELVIS toward the lower foot so both legs can reach without
10
+ // over-stretching (essential on steps),
11
+ // 2. two-bone-solves each leg so the ankle reaches its grounded target while the
12
+ // foot keeps its animated orientation, then
13
+ // 3. tilts each PLANTED foot to lie flat on the ground normal (lifted/mid-stride
14
+ // feet keep the animation, weighted by how high off the ground they are).
15
+ //
16
+ // ORDER MATTERS: this poses the VRM's NORMALIZED bones, which `vrm.update(dt)` then
17
+ // copies to the raw (rendered) rig. Run it AFTER the animation mixer has posed the
18
+ // frame but BEFORE `vrm.update(dt)` — after the copy it has no visible effect until
19
+ // the next frame, where the mixer overwrites it first.
20
+ //
21
+ // const footIK = new FootIK(vrm, (foot) => {
22
+ // const hit = world.castRayAndGetNormal(
23
+ // new RAPIER.Ray({ x: foot.x, y: foot.y + 0.5, z: foot.z }, { x: 0, y: -1, z: 0 }),
24
+ // 1.0, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, undefined, undefined, character.body);
25
+ // return hit ? { y: foot.y + 0.5 - hit.timeOfImpact, normal: hit.normal } : null;
26
+ // }, { isActive: () => character.isOnGround });
27
+ // // each frame, in this order:
28
+ // anims.update(character, dt); // mixer poses the normalized rig
29
+ // footIK.update(dt); // plant the feet on the normalized rig
30
+ // vrm.update(dt); // copy normalized -> raw, run spring bones
31
+ import * as THREE from "three";
32
+ import { VRMHumanBoneName } from "@pixiv/three-vrm";
33
+ import type { VRM } from "@pixiv/three-vrm";
34
+
35
+ /** A grounded contact under a foot: world-Y of the surface + (optional) its world normal. */
36
+ export interface GroundSample {
37
+ /** Ground world-Y directly below the foot. */
38
+ y: number;
39
+ /** Surface normal (world, unit). Omit for flat-only grounding (feet stay level). */
40
+ normal?: THREE.Vector3;
41
+ }
42
+
43
+ /**
44
+ * Ground query below a foot. Return the contact ({@link GroundSample} — or a bare
45
+ * world-Y number for the simple flat case), or null when there's nothing to plant
46
+ * on (airborne, a gap, a too-far drop) so the foot keeps its animated pose.
47
+ */
48
+ export type GroundQuery = (footWorldPos: THREE.Vector3) => GroundSample | number | null;
49
+
50
+ export interface FootIKOptions {
51
+ /** Max metres an ankle is raised/lowered toward the ground. Default 0.4. */
52
+ maxOffset?: number;
53
+ /**
54
+ * Extra clearance between the sole and the ground contact (m). The grounding
55
+ * target is the TERRAIN under the foot relative to the body root, so 0 lands
56
+ * the sole flush; raise it a hair if a particular avatar's sole clips in.
57
+ * Default 0.
58
+ */
59
+ soleClearance?: number;
60
+ /** Per-frame offset smoothing rate (higher = snappier). Default 14. */
61
+ smoothing?: number;
62
+ /** Lower the pelvis toward the lower foot so both legs reach. Default true. */
63
+ pelvisDrop?: boolean;
64
+ /** Tilt planted feet to lie flat on the ground normal. Default true. */
65
+ alignFeet?: boolean;
66
+ /**
67
+ * Gate: return false to fade the effect out (e.g. `() => character.isOnGround`
68
+ * so airborne legs keep their jump pose). Default: always active.
69
+ */
70
+ isActive?: () => boolean;
71
+ }
72
+
73
+ // three-vrm's own node type — same THREE.Object3D at runtime, but using the
74
+ // library's return type keeps this file's storage self-consistent under any
75
+ // three typings the game happens to resolve.
76
+ type BoneNode = NonNullable<ReturnType<VRM["humanoid"]["getNormalizedBoneNode"]>>;
77
+ type VrmHumanBone = (typeof VRMHumanBoneName)[keyof typeof VRMHumanBoneName];
78
+
79
+ interface Leg {
80
+ upper: BoneNode;
81
+ lower: BoneNode;
82
+ foot: BoneNode;
83
+ /** Smoothed vertical grounding offset (world-Y delta from the animated ankle). */
84
+ offset: number;
85
+ /** Smoothed world ground normal under this foot. */
86
+ normal: THREE.Vector3;
87
+ /** Animated ankle world position, captured before any IK this frame. */
88
+ animFootPos: THREE.Vector3;
89
+ }
90
+
91
+ const UP = new THREE.Vector3(0, 1, 0);
92
+
93
+ // Foot-plant tuning (mirrors the reference planter's feel).
94
+ const WEIGHT_DAMPING = 8; // global fade in/out when isActive flips
95
+ const NORMAL_DAMPING = 12; // ground-normal smoothing
96
+ const PLANTED_LIFT_MIN = 0.04; // below this lift the foot is fully planted (align at full weight)
97
+ const PLANTED_LIFT_MAX = 0.16; // above this lift the foot is fully lifted (no align)
98
+ const MAX_FOOT_TILT = 0.6; // clamp foot-to-slope tilt (rad)
99
+ const MIN_BONE_LENGTH = 1e-4;
100
+ const IK_EPSILON = 1e-4;
101
+
102
+ const _a = new THREE.Vector3();
103
+ const _b = new THREE.Vector3();
104
+ const _c = new THREE.Vector3();
105
+ const _target = new THREE.Vector3();
106
+ const _rootPos = new THREE.Vector3();
107
+ const _normalTarget = new THREE.Vector3();
108
+ const _toTarget = new THREE.Vector3();
109
+ const _kneeToHip = new THREE.Vector3();
110
+ const _kneeToAnkle = new THREE.Vector3();
111
+ const _bendAxis = new THREE.Vector3();
112
+ const _fallbackAxis = new THREE.Vector3();
113
+ const _currentDir = new THREE.Vector3();
114
+ const _targetDir = new THREE.Vector3();
115
+ const _tiltAxis = new THREE.Vector3();
116
+ const _worldPos = new THREE.Vector3();
117
+ const _footWorldQuat = new THREE.Quaternion();
118
+ const _deltaQuat = new THREE.Quaternion();
119
+ const _parentQuat = new THREE.Quaternion();
120
+ const _localDelta = new THREE.Quaternion();
121
+ const _modelQuat = new THREE.Quaternion();
122
+ const _parentInverse = new THREE.Matrix4();
123
+
124
+ /**
125
+ * Foot IK over a VRM's normalized leg bones. Constructed once; call `update()`
126
+ * each frame AFTER the animation mixer and BEFORE `vrm.update()` (see file header).
127
+ */
128
+ export class FootIK {
129
+ #legs: Leg[] = [];
130
+ #hips: BoneNode | null;
131
+ #modelRoot: THREE.Object3D;
132
+ #query: GroundQuery;
133
+ #maxOffset: number;
134
+ #soleClearance: number;
135
+ #smoothing: number;
136
+ #pelvisDrop: boolean;
137
+ #alignFeet: boolean;
138
+ #isActive: (() => boolean) | undefined;
139
+ #enabled = true;
140
+ #weight = 0;
141
+ #restFootHeight = 0;
142
+
143
+ constructor(vrm: VRM, groundQuery: GroundQuery, options: FootIKOptions = {}) {
144
+ this.#query = groundQuery;
145
+ this.#maxOffset = options.maxOffset ?? 0.4;
146
+ this.#soleClearance = options.soleClearance ?? 0;
147
+ this.#smoothing = options.smoothing ?? 14;
148
+ this.#pelvisDrop = options.pelvisDrop ?? true;
149
+ this.#alignFeet = options.alignFeet ?? true;
150
+ this.#isActive = options.isActive;
151
+ this.#modelRoot = vrm.scene;
152
+
153
+ const h = vrm.humanoid;
154
+ this.#hips = h.getNormalizedBoneNode(VRMHumanBoneName.Hips);
155
+ const mk = (u: VrmHumanBone, l: VrmHumanBone, f: VrmHumanBone): Leg | null => {
156
+ const upper = h.getNormalizedBoneNode(u);
157
+ const lower = h.getNormalizedBoneNode(l);
158
+ const foot = h.getNormalizedBoneNode(f);
159
+ return upper && lower && foot
160
+ ? { upper, lower, foot, offset: 0, normal: new THREE.Vector3(0, 1, 0), animFootPos: new THREE.Vector3() }
161
+ : null;
162
+ };
163
+ const left = mk(VRMHumanBoneName.LeftUpperLeg, VRMHumanBoneName.LeftLowerLeg, VRMHumanBoneName.LeftFoot);
164
+ const right = mk(VRMHumanBoneName.RightUpperLeg, VRMHumanBoneName.RightLowerLeg, VRMHumanBoneName.RightFoot);
165
+ if (left) this.#legs.push(left);
166
+ if (right) this.#legs.push(right);
167
+ this.#restFootHeight = this.#measureRestFootHeight();
168
+ }
169
+
170
+ setEnabled(enabled: boolean): void {
171
+ this.#enabled = enabled;
172
+ }
173
+
174
+ update(dt = 1 / 60): void {
175
+ if (this.#legs.length === 0) return;
176
+
177
+ // Global weight fades the whole effect in/out (toggle or airborne) so nothing pops.
178
+ const active = this.#enabled && (this.#isActive?.() ?? true);
179
+ this.#weight += ((active ? 1 : 0) - this.#weight) * (1 - Math.exp(-WEIGHT_DAMPING * dt));
180
+ if (!active && this.#weight < 1e-3) {
181
+ for (const leg of this.#legs) {
182
+ leg.offset = 0;
183
+ leg.normal.copy(UP);
184
+ }
185
+ return;
186
+ }
187
+
188
+ this.#modelRoot.updateWorldMatrix(true, true);
189
+ this.#modelRoot.getWorldPosition(_rootPos);
190
+ this.#modelRoot.getWorldQuaternion(_modelQuat);
191
+ _fallbackAxis.set(1, 0, 0).applyQuaternion(_modelQuat); // knee hinge fallback (model X)
192
+
193
+ const k = 1 - Math.exp(-this.#smoothing * dt);
194
+ const kNormal = 1 - Math.exp(-NORMAL_DAMPING * dt);
195
+
196
+ // 1. Sample the ground under each ANIMATED foot; smooth offset + normal.
197
+ // The offset is TERRAIN-relative — the ground height under the foot vs the
198
+ // body root (the VRM origin = its floor/sole level), NOT vs the animated
199
+ // foot. That's the load-bearing choice: it's independent of the foot's
200
+ // stride phase, so a lifted swing foot keeps its animation (no dragging /
201
+ // sinking while running) while a planted foot still lands on its step, and
202
+ // it self-corrects any residual capsule-float gap (feet reach true ground).
203
+ for (const leg of this.#legs) {
204
+ leg.foot.getWorldPosition(leg.animFootPos);
205
+ const sample = active ? this.#query(leg.animFootPos) : null;
206
+ const groundY = sample === null ? null : typeof sample === "number" ? sample : sample.y;
207
+ _normalTarget.copy(
208
+ sample !== null && typeof sample !== "number" && sample.normal ? sample.normal : UP,
209
+ );
210
+ const desired =
211
+ groundY === null
212
+ ? 0
213
+ : THREE.MathUtils.clamp(
214
+ groundY + this.#soleClearance - _rootPos.y,
215
+ -this.#maxOffset,
216
+ this.#maxOffset,
217
+ );
218
+ leg.offset += (desired - leg.offset) * k;
219
+ leg.normal.lerp(_normalTarget, kNormal).normalize();
220
+ }
221
+
222
+ // 2. Pelvis drop: sink the hips toward the LOWER foot (most negative offset)
223
+ // so the downhill leg reaches without the uphill one hyper-extending.
224
+ if (this.#pelvisDrop && this.#hips && this.#legs.length === 2) {
225
+ let minOffset = 0;
226
+ for (const leg of this.#legs) minOffset = Math.min(minOffset, leg.offset);
227
+ const pelvisOffset = minOffset * this.#weight;
228
+ if (Math.abs(pelvisOffset) > IK_EPSILON) this.#shiftWorldY(this.#hips, pelvisOffset);
229
+ }
230
+
231
+ // 3. Per-leg two-bone IK to the grounded target, then flatten planted feet.
232
+ for (const leg of this.#legs) {
233
+ _target.copy(leg.animFootPos);
234
+ _target.y += leg.offset * this.#weight;
235
+ leg.foot.getWorldPosition(_c);
236
+ if (_c.distanceToSquared(_target) > IK_EPSILON * IK_EPSILON) {
237
+ this.#solve(leg, _target);
238
+ }
239
+ if (this.#alignFeet) this.#alignFootToGround(leg, _rootPos.y);
240
+ }
241
+ }
242
+
243
+ /** Analytic two-bone IK: bend the knee + swing the hip so the ankle reaches
244
+ * `targetWorld`, preserving the foot's animated world orientation. */
245
+ #solve(leg: Leg, targetWorld: THREE.Vector3): void {
246
+ leg.upper.getWorldPosition(_a);
247
+ leg.lower.getWorldPosition(_b);
248
+ leg.foot.getWorldPosition(_c);
249
+ const upperLen = _a.distanceTo(_b);
250
+ const lowerLen = _b.distanceTo(_c);
251
+ _toTarget.subVectors(targetWorld, _a);
252
+ if (upperLen < MIN_BONE_LENGTH || lowerLen < MIN_BONE_LENGTH || _toTarget.lengthSq() < MIN_BONE_LENGTH ** 2) {
253
+ return;
254
+ }
255
+
256
+ // Preserve the animated foot orientation across the solve (we re-tilt it in step 3).
257
+ leg.foot.getWorldQuaternion(_footWorldQuat);
258
+
259
+ const dist = THREE.MathUtils.clamp(
260
+ _toTarget.length(),
261
+ Math.abs(upperLen - lowerLen) + IK_EPSILON,
262
+ upperLen + lowerLen - IK_EPSILON,
263
+ );
264
+ const cosKnee = (upperLen * upperLen + lowerLen * lowerLen - dist * dist) / (2 * upperLen * lowerLen);
265
+ const desiredKnee = Math.acos(THREE.MathUtils.clamp(cosKnee, -1, 1));
266
+
267
+ _kneeToHip.subVectors(_a, _b);
268
+ _kneeToAnkle.subVectors(_c, _b);
269
+ const currentKnee = _kneeToHip.angleTo(_kneeToAnkle);
270
+ _bendAxis.crossVectors(_kneeToAnkle, _kneeToHip);
271
+ if (_bendAxis.lengthSq() < 1e-10) _bendAxis.copy(_fallbackAxis);
272
+ _bendAxis.normalize();
273
+
274
+ const bendDelta = currentKnee - desiredKnee;
275
+ if (Math.abs(bendDelta) > 1e-6) {
276
+ _deltaQuat.setFromAxisAngle(_bendAxis, bendDelta);
277
+ this.#applyWorldRotationDelta(leg.lower, _deltaQuat);
278
+ leg.foot.getWorldPosition(_c);
279
+ }
280
+
281
+ // Swing the whole limb so the ankle points at the target.
282
+ _currentDir.subVectors(_c, _a).normalize();
283
+ _targetDir.copy(_toTarget).normalize();
284
+ _deltaQuat.setFromUnitVectors(_currentDir, _targetDir);
285
+ this.#applyWorldRotationDelta(leg.upper, _deltaQuat);
286
+
287
+ // Restore the animated foot orientation (step 3 tilts it onto the slope).
288
+ this.#setWorldQuaternion(leg.foot, _footWorldQuat);
289
+ }
290
+
291
+ /** Tilt a PLANTED foot to lie flat on its ground normal (skipped for lifted feet). */
292
+ #alignFootToGround(leg: Leg, rootY: number): void {
293
+ const lift = leg.animFootPos.y - rootY - this.#restFootHeight;
294
+ const planted = 1 - THREE.MathUtils.smoothstep(lift, PLANTED_LIFT_MIN, PLANTED_LIFT_MAX);
295
+ const tilt = Math.min(leg.normal.angleTo(UP), MAX_FOOT_TILT) * this.#weight * planted;
296
+ if (tilt < 1e-3) return;
297
+ _tiltAxis.crossVectors(UP, leg.normal);
298
+ if (_tiltAxis.lengthSq() < 1e-10) return;
299
+ _deltaQuat.setFromAxisAngle(_tiltAxis.normalize(), tilt);
300
+ this.#applyWorldRotationDelta(leg.foot, _deltaQuat);
301
+ }
302
+
303
+ #measureRestFootHeight(): number {
304
+ if (this.#legs.length === 0) return 0;
305
+ this.#modelRoot.updateWorldMatrix(true, true);
306
+ this.#modelRoot.getWorldPosition(_rootPos);
307
+ let total = 0;
308
+ for (const leg of this.#legs) total += leg.foot.getWorldPosition(_worldPos).y - _rootPos.y;
309
+ return total / this.#legs.length;
310
+ }
311
+
312
+ /** Rotate a bone by a WORLD-space quaternion delta, then refresh its subtree. */
313
+ #applyWorldRotationDelta(bone: BoneNode, worldDelta: THREE.Quaternion): void {
314
+ const parent = bone.parent;
315
+ if (!parent) return;
316
+ parent.getWorldQuaternion(_parentQuat);
317
+ _localDelta.copy(_parentQuat).invert().multiply(worldDelta).multiply(_parentQuat);
318
+ bone.quaternion.premultiply(_localDelta);
319
+ bone.updateWorldMatrix(false, true);
320
+ }
321
+
322
+ /** Set a bone's WORLD orientation, then refresh its subtree. */
323
+ #setWorldQuaternion(bone: BoneNode, worldQuat: THREE.Quaternion): void {
324
+ const parent = bone.parent;
325
+ if (!parent) return;
326
+ parent.getWorldQuaternion(_parentQuat);
327
+ bone.quaternion.copy(_parentQuat).invert().multiply(worldQuat);
328
+ bone.updateWorldMatrix(false, true);
329
+ }
330
+
331
+ /** Translate a bone vertically in WORLD space, then refresh its subtree. */
332
+ #shiftWorldY(bone: BoneNode, deltaY: number): void {
333
+ const parent = bone.parent;
334
+ if (!parent) return;
335
+ bone.getWorldPosition(_worldPos);
336
+ _worldPos.y += deltaY;
337
+ _parentInverse.copy(parent.matrixWorld).invert();
338
+ bone.position.copy(_worldPos.applyMatrix4(_parentInverse));
339
+ bone.updateWorldMatrix(false, true);
340
+ }
341
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // VRM loading for the character controller (Genex AG-747). Wraps three's
3
+ // GLTFLoader with @pixiv/three-vrm's VRMLoaderPlugin, normalizes VRM 0.x
4
+ // orientation, and runs the standard perf cleanup — so the rest of the
5
+ // controller treats every avatar (VRM 0.x or 1.0) identically.
6
+ //
7
+ // Needs `npm i @pixiv/three-vrm` (peer of three, which the scaffold already has).
8
+ import * as THREE from "three";
9
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
10
+ import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
11
+ import type { VRM } from "@pixiv/three-vrm";
12
+
13
+ export interface LoadedVrm {
14
+ /** The renderable root — add THIS to your character root / the scene. */
15
+ scene: THREE.Group;
16
+ /** The VRM instance — call `vrm.update(dt)` once per frame (spring bones). */
17
+ vrm: VRM;
18
+ }
19
+
20
+ /**
21
+ * Load a `.vrm` and return its scene + VRM instance, ready to animate.
22
+ *
23
+ * `VRMUtils.rotateVRM0` bakes the VRM 0.x 180° flip into BOTH the model and its
24
+ * humanoid rig, so the avatar faces -Z (three's forward, same as every other
25
+ * model) and the UAL retargeter (vrm-retarget.ts) needs no per-version handling.
26
+ */
27
+ export async function loadVrm(url: string): Promise<LoadedVrm> {
28
+ const loader = new GLTFLoader();
29
+ loader.register((parser) => new VRMLoaderPlugin(parser));
30
+
31
+ const gltf = await loader.loadAsync(url);
32
+ const vrm = gltf.userData.vrm as VRM;
33
+
34
+ VRMUtils.rotateVRM0(vrm);
35
+ VRMUtils.removeUnnecessaryVertices(vrm.scene);
36
+ VRMUtils.combineSkeletons(vrm.scene);
37
+
38
+ // Skinned avatars can pop out at glancing camera angles otherwise.
39
+ vrm.scene.traverse((obj) => {
40
+ obj.frustumCulled = false;
41
+ });
42
+
43
+ return { scene: vrm.scene, vrm };
44
+ }
@@ -0,0 +1,195 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Retarget Quaternius Universal Animation Library clips (Blender Rigify `DEF-`
3
+ // rig) onto a three-vrm normalized humanoid rig (Genex AG-747). Adapted from the
4
+ // official three-vrm Mixamo retarget recipe (@pixiv/three-vrm examples, MIT):
5
+ // rewrite each bone track into the VRM's normalized-bone local space using the
6
+ // SOURCE rig's rest-pose world rotations, and scale the hips translation by the
7
+ // height ratio. Because vrm-loader calls VRMUtils.rotateVRM0, VRM 0.x and 1.0
8
+ // share this one path — no per-version flip.
9
+ //
10
+ // The HIPS POSITION track is kept (delta from the source rest pose, scaled by
11
+ // the hips-height ratio, like the Mixamo recipe). Dropping it pins the hips at
12
+ // bind height, so any pose that lowers the hips (idle stance, walk contact,
13
+ // punches) lifts the feet off the floor instead — the UAL idle alone holds the
14
+ // hips ~4.5 cm below rest. All other position tracks are still dropped: the
15
+ // physics controller owns whole-body translation, and UAL clips are in-place,
16
+ // so the hips delta is pure pose (crouch/bob/sway), not root motion.
17
+ //
18
+ // The output AnimationClips target the VRM's normalized bone nodes, so they feed
19
+ // CharacterAnimations + buildClipMap unchanged:
20
+ //
21
+ // const { scene, vrm } = await loadVrm("./assets/avatar.vrm");
22
+ // const lib = await new GLTFLoader().loadAsync("./assets/animation-library.glb");
23
+ // const clips = retargetClips(vrm, lib.scene, lib.animations);
24
+ // const anims = new CharacterAnimations(scene, clips);
25
+ import * as THREE from "three";
26
+ import { VRMHumanBoneName } from "@pixiv/three-vrm";
27
+ import type { VRM } from "@pixiv/three-vrm";
28
+
29
+ type VrmBone = (typeof VRMHumanBoneName)[keyof typeof VRMHumanBoneName];
30
+
31
+ // Quaternius UAL Rigify deform bone -> VRM humanoid bone. Fingers are omitted
32
+ // (the clips barely animate them and not every avatar rigs them); the body chain
33
+ // is what locomotion + gestures need.
34
+ const DEF_TO_VRM: Record<string, VrmBone> = {
35
+ "DEF-hips": VRMHumanBoneName.Hips,
36
+ "DEF-spine.001": VRMHumanBoneName.Spine,
37
+ "DEF-spine.002": VRMHumanBoneName.Chest,
38
+ "DEF-spine.003": VRMHumanBoneName.UpperChest,
39
+ "DEF-neck": VRMHumanBoneName.Neck,
40
+ "DEF-head": VRMHumanBoneName.Head,
41
+ "DEF-shoulder.L": VRMHumanBoneName.LeftShoulder,
42
+ "DEF-upper_arm.L": VRMHumanBoneName.LeftUpperArm,
43
+ "DEF-forearm.L": VRMHumanBoneName.LeftLowerArm,
44
+ "DEF-hand.L": VRMHumanBoneName.LeftHand,
45
+ "DEF-shoulder.R": VRMHumanBoneName.RightShoulder,
46
+ "DEF-upper_arm.R": VRMHumanBoneName.RightUpperArm,
47
+ "DEF-forearm.R": VRMHumanBoneName.RightLowerArm,
48
+ "DEF-hand.R": VRMHumanBoneName.RightHand,
49
+ "DEF-thigh.L": VRMHumanBoneName.LeftUpperLeg,
50
+ "DEF-shin.L": VRMHumanBoneName.LeftLowerLeg,
51
+ "DEF-foot.L": VRMHumanBoneName.LeftFoot,
52
+ "DEF-toe.L": VRMHumanBoneName.LeftToes,
53
+ "DEF-thigh.R": VRMHumanBoneName.RightUpperLeg,
54
+ "DEF-shin.R": VRMHumanBoneName.RightLowerLeg,
55
+ "DEF-foot.R": VRMHumanBoneName.RightFoot,
56
+ "DEF-toe.R": VRMHumanBoneName.RightToes,
57
+ };
58
+
59
+ /**
60
+ * Retarget UAL clips onto `vrm`.
61
+ * @param vrm the loaded VRM (already through {@link loadVrm}).
62
+ * @param animationRoot the animation-library GLB's scene — its `DEF-` bones in
63
+ * rest pose supply the source frame the tracks are relative to.
64
+ * @param clips that GLB's animations (all 46 UAL clips).
65
+ * @returns new clips whose tracks target the VRM's normalized humanoid bones.
66
+ */
67
+ export function retargetClips(
68
+ vrm: VRM,
69
+ animationRoot: THREE.Object3D,
70
+ clips: THREE.AnimationClip[],
71
+ ): THREE.AnimationClip[] {
72
+ animationRoot.updateWorldMatrix(true, true);
73
+ vrm.scene.updateWorldMatrix(true, true);
74
+
75
+ // three's GLTFLoader SANITIZES node names in animation track targets
76
+ // (PropertyBinding strips `[].:/ ` and turns spaces into `_`), so a Rigify bone
77
+ // "DEF-upper_arm.L" shows up in tracks as "DEF-upper_armL". Map those sanitized
78
+ // names back to the real bones, whose actual names carry the dots DEF_TO_VRM
79
+ // keys on. (Mixamo names are dotless, so the upstream recipe never needed this.)
80
+ const sanitize = (name: string): string => name.replace(/\s/g, "_").replace(/[[\]./:]/g, "");
81
+ const sourceByTrackName = new Map<string, THREE.Object3D>();
82
+ animationRoot.traverse((o) => {
83
+ if (o.name) sourceByTrackName.set(sanitize(o.name), o);
84
+ });
85
+ // glTF load sanitizes bone names too, so `source.name` is already dot-stripped —
86
+ // key the VRM-bone lookup by the sanitized DEF name, matching the track's nodeName.
87
+ const sanitizedDefToVrm: Record<string, VrmBone> = {};
88
+ for (const [def, bone] of Object.entries(DEF_TO_VRM)) sanitizedDefToVrm[sanitize(def)] = bone;
89
+
90
+ // Reusable bind-pose quaternions. Both rigs are at rest here (nothing has
91
+ // animated them yet), so getWorldQuaternion reads the bind pose.
92
+ const srcParentBindWorld = new THREE.Quaternion();
93
+ const srcBindWorldInv = new THREE.Quaternion();
94
+ const tgtBindWorld = new THREE.Quaternion();
95
+ const tgtParentBindWorldInv = new THREE.Quaternion();
96
+ const q = new THREE.Quaternion();
97
+
98
+ // Hips-position retarget setup (see the header note). We reproduce the source
99
+ // hips' vertical bob/crouch on the target hips as a rest-relative DELTA, so
100
+ // the physics controller still owns the whole-body base translation while the
101
+ // pose keeps the pelvis (and therefore the feet) at the right height.
102
+ const HIPS_SANITIZED = sanitize("DEF-hips");
103
+ const srcHips = sourceByTrackName.get(HIPS_SANITIZED) ?? null;
104
+ const tgtHips = vrm.humanoid.getNormalizedBoneNode(VRMHumanBoneName.Hips);
105
+ const hipsSrcParentWorld = new THREE.Quaternion();
106
+ const hipsTgtParentWorldInv = new THREE.Quaternion();
107
+ const hipsSrcRestLocal = new THREE.Vector3();
108
+ const hipsTgtRestLocal = new THREE.Vector3();
109
+ let hipsHeightRatio = 1;
110
+ if (srcHips && tgtHips) {
111
+ if (srcHips.parent) srcHips.parent.getWorldQuaternion(hipsSrcParentWorld);
112
+ if (tgtHips.parent) tgtHips.parent.getWorldQuaternion(hipsTgtParentWorldInv).invert();
113
+ hipsSrcRestLocal.copy(srcHips.position);
114
+ hipsTgtRestLocal.copy(tgtHips.position);
115
+ // Scale the crouch/bob by leg-length proportion (hips rest height ratio) so
116
+ // a tall avatar bobs more than a short one, matching the source clip's feel.
117
+ const srcY = srcHips.getWorldPosition(new THREE.Vector3()).y;
118
+ const tgtY = tgtHips.getWorldPosition(new THREE.Vector3()).y;
119
+ if (Math.abs(srcY) > 1e-4) hipsHeightRatio = tgtY / srcY;
120
+ }
121
+ const hipsDelta = new THREE.Vector3();
122
+
123
+ const out: THREE.AnimationClip[] = [];
124
+ for (const clip of clips) {
125
+ const tracks: THREE.KeyframeTrack[] = [];
126
+ for (const track of clip.tracks) {
127
+ const lastDot = track.name.lastIndexOf(".");
128
+ const nodeName = track.name.slice(0, lastDot);
129
+ const prop = track.name.slice(lastDot + 1);
130
+ const source = sourceByTrackName.get(nodeName);
131
+ const vrmBone = sanitizedDefToVrm[nodeName];
132
+ if (!source || !vrmBone) continue;
133
+
134
+ const target = vrm.humanoid.getNormalizedBoneNode(vrmBone);
135
+ if (!target) continue;
136
+
137
+ // HIPS POSITION: keep it, as a rest-relative delta (see header note). Every
138
+ // OTHER position track is dropped — the physics controller owns whole-body
139
+ // translation, and the UAL clips are in-place, so only the hips carry pose
140
+ // height (crouch/bob) worth reproducing.
141
+ if (prop === "position" && track instanceof THREE.VectorKeyframeTrack) {
142
+ if (vrmBone !== VRMHumanBoneName.Hips || !srcHips || !tgtHips) continue;
143
+ const values = Array.from(track.values);
144
+ for (let i = 0; i < values.length; i += 3) {
145
+ // delta = (frameLocal - srcRestLocal) → world → scale → target-parent-local,
146
+ // then re-anchor on the target hips' own rest local position.
147
+ hipsDelta.fromArray(values, i).sub(hipsSrcRestLocal);
148
+ hipsDelta.applyQuaternion(hipsSrcParentWorld);
149
+ hipsDelta.multiplyScalar(hipsHeightRatio);
150
+ hipsDelta.applyQuaternion(hipsTgtParentWorldInv).add(hipsTgtRestLocal);
151
+ hipsDelta.toArray(values, i);
152
+ }
153
+ tracks.push(
154
+ new THREE.VectorKeyframeTrack(`${tgtHips.name}.position`, Array.from(track.times), values),
155
+ );
156
+ continue;
157
+ }
158
+
159
+ if (prop !== "quaternion" || !(track instanceof THREE.QuaternionKeyframeTrack)) continue;
160
+
161
+ // Full bind-pose retarget: reproduce the SOURCE bone's world-space motion
162
+ // on the TARGET bone, accounting for BOTH rigs' bind orientations, then
163
+ // express it in the target's local space. Unlike the simplified Mixamo
164
+ // recipe (source-rest only), this also uses the VRM normalized bone's bind
165
+ // world rotation — necessary because T-pose limbs are far from identity,
166
+ // which is what left arms pointing straight up before. Reduces to identity
167
+ // (the normalized rest pose) when the source is at its own bind pose.
168
+ source.getWorldQuaternion(srcBindWorldInv).invert();
169
+ if (source.parent) source.parent.getWorldQuaternion(srcParentBindWorld);
170
+ else srcParentBindWorld.identity();
171
+ target.getWorldQuaternion(tgtBindWorld);
172
+ if (target.parent) {
173
+ target.parent.getWorldQuaternion(tgtParentBindWorldInv);
174
+ tgtParentBindWorldInv.invert();
175
+ } else {
176
+ tgtParentBindWorldInv.identity();
177
+ }
178
+
179
+ const values = Array.from(track.values);
180
+ for (let i = 0; i < values.length; i += 4) {
181
+ // q_target_local = tgtParentBindWorld⁻¹ · srcParentBindWorld · q · srcBindWorld⁻¹ · tgtBindWorld
182
+ q.fromArray(values, i);
183
+ q.premultiply(srcParentBindWorld);
184
+ q.multiply(srcBindWorldInv).multiply(tgtBindWorld);
185
+ q.premultiply(tgtParentBindWorldInv);
186
+ q.toArray(values, i);
187
+ }
188
+ tracks.push(
189
+ new THREE.QuaternionKeyframeTrack(`${target.name}.quaternion`, Array.from(track.times), values),
190
+ );
191
+ }
192
+ if (tracks.length > 0) out.push(new THREE.AnimationClip(clip.name, clip.duration, tracks));
193
+ }
194
+ return out;
195
+ }