@genex-ai/cli-demo 0.12.1 → 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.
- package/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- 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
|
+
}
|