metaverse-avatar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ASSET_LICENSES.md +122 -0
- package/Avatar.js +860 -0
- package/LICENSE.md +21 -0
- package/README.md +407 -0
- package/anims/UAL1_Standard.glb +0 -0
- package/anims/UAL2_Standard.glb +0 -0
- package/anims/pirouette.bvh +867 -0
- package/attachments.js +388 -0
- package/avatarManager.js +110 -0
- package/blink.js +58 -0
- package/bvh.js +110 -0
- package/gltfAnim.js +271 -0
- package/index.js +61 -0
- package/licenses/AGPL-3.0.txt +661 -0
- package/models/body.glb +0 -0
- package/models/eyes.glb +0 -0
- package/models/feet.glb +0 -0
- package/models/hands.glb +0 -0
- package/models/head.glb +0 -0
- package/models/textures/android_face.png +0 -0
- package/models/textures/android_face_ao.jpg +0 -0
- package/models/textures/android_face_metallic.jpg +0 -0
- package/models/textures/android_face_normal.jpg +0 -0
- package/models/textures/android_face_roughness.jpg +0 -0
- package/models/textures/android_lower.png +0 -0
- package/models/textures/android_lower_ao.jpg +0 -0
- package/models/textures/android_lower_metallic.jpg +0 -0
- package/models/textures/android_lower_normal.jpg +0 -0
- package/models/textures/android_lower_roughness.jpg +0 -0
- package/models/textures/android_upper.png +0 -0
- package/models/textures/android_upper_ao.jpg +0 -0
- package/models/textures/android_upper_metallic.jpg +0 -0
- package/models/textures/android_upper_normal.jpg +0 -0
- package/models/textures/android_upper_roughness.jpg +0 -0
- package/models/textures/blue_eyes.png +0 -0
- package/models/textures/layers/cute_pants.png +0 -0
- package/models/textures/layers/cute_shirt.png +0 -0
- package/nipple.js +218 -0
- package/package.json +48 -0
- package/pbr.js +225 -0
- package/physics.js +313 -0
- package/skeleton.js +70 -0
- package/sliders.js +590 -0
- package/speech.js +130 -0
- package/visemes.js +66 -0
- package/voice.js +75 -0
package/Avatar.js
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
import { computeBoneAdjustments } from './sliders.js';
|
|
4
|
+
import { buildNippleMorph, applyNippleMorph } from './nipple.js';
|
|
5
|
+
import { SoftBodyPhysics } from './physics.js';
|
|
6
|
+
import { PBRMaterialStack } from './pbr.js';
|
|
7
|
+
import { Blinker } from './blink.js';
|
|
8
|
+
import { VoiceMouth } from './voice.js';
|
|
9
|
+
import { SpeechMouth } from './speech.js';
|
|
10
|
+
import { Attachments } from './attachments.js';
|
|
11
|
+
|
|
12
|
+
const SKIN = new THREE.MeshStandardMaterial({
|
|
13
|
+
color: 0xd9a78b,
|
|
14
|
+
roughness: 0.6,
|
|
15
|
+
metalness: 0.0,
|
|
16
|
+
side: THREE.DoubleSide, // hides the neck-seam backfaces where head and body overlap
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const EYE = new THREE.MeshStandardMaterial({
|
|
20
|
+
color: 0xf2f2f5,
|
|
21
|
+
roughness: 0.25,
|
|
22
|
+
metalness: 0.0,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Ruth avatars are textured per region (head / upper incl. hands / lower incl.
|
|
26
|
+
// feet / eyes); the dae material slot names tell us which region each face
|
|
27
|
+
// group belongs to. Default MIT-licensed maps in models/textures/; replace via drop slots.
|
|
28
|
+
// Each region seeds the matching PBR channels (albedo + normal/roughness/
|
|
29
|
+
// metallic/ao where shipped); the eyes only have an albedo map.
|
|
30
|
+
const DEFAULT_SKIN = {
|
|
31
|
+
face: {
|
|
32
|
+
albedo: 'textures/android_face.png',
|
|
33
|
+
normal: 'textures/android_face_normal.jpg',
|
|
34
|
+
roughness: 'textures/android_face_roughness.jpg',
|
|
35
|
+
metallic: 'textures/android_face_metallic.jpg',
|
|
36
|
+
ao: 'textures/android_face_ao.jpg',
|
|
37
|
+
},
|
|
38
|
+
upper: {
|
|
39
|
+
albedo: 'textures/android_upper.png',
|
|
40
|
+
normal: 'textures/android_upper_normal.jpg',
|
|
41
|
+
roughness: 'textures/android_upper_roughness.jpg',
|
|
42
|
+
metallic: 'textures/android_upper_metallic.jpg',
|
|
43
|
+
ao: 'textures/android_upper_ao.jpg',
|
|
44
|
+
},
|
|
45
|
+
lower: {
|
|
46
|
+
albedo: 'textures/android_lower.png',
|
|
47
|
+
normal: 'textures/android_lower_normal.jpg',
|
|
48
|
+
roughness: 'textures/android_lower_roughness.jpg',
|
|
49
|
+
metallic: 'textures/android_lower_metallic.jpg',
|
|
50
|
+
ao: 'textures/android_lower_ao.jpg',
|
|
51
|
+
},
|
|
52
|
+
eyes: {
|
|
53
|
+
albedo: 'textures/blue_eyes.png',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const TEXTURE_SIZE = 1024;
|
|
58
|
+
|
|
59
|
+
// Eye look-at: clamp how far the eyeballs can swing from rest so they never
|
|
60
|
+
// roll back into the skull when the target is behind / extreme.
|
|
61
|
+
const EYE_LOOK_MAX_YAW = 0.55;
|
|
62
|
+
const EYE_LOOK_MAX_PITCH = 0.4;
|
|
63
|
+
const _lookM4 = new THREE.Matrix4();
|
|
64
|
+
const _lookV1 = new THREE.Vector3();
|
|
65
|
+
const _lookV2 = new THREE.Vector3();
|
|
66
|
+
const _lookQ0 = new THREE.Quaternion();
|
|
67
|
+
const _lookQ1 = new THREE.Quaternion();
|
|
68
|
+
const _lookE = new THREE.Euler();
|
|
69
|
+
const _EYE_FWD = new THREE.Vector3(1, 0, 0);
|
|
70
|
+
|
|
71
|
+
// Regions that accept stacked clothing layers (face/eyes are skin-only).
|
|
72
|
+
export const LAYERED_REGIONS = ['upper', 'lower'];
|
|
73
|
+
|
|
74
|
+
// Per-region PBR stack defaults. Skin tone base color shows through when an
|
|
75
|
+
// albedo map is cleared; eyes start glossier than skin.
|
|
76
|
+
const REGION_DEFAULTS = {
|
|
77
|
+
face: { layered: false, baseColor: 0xd9a78b, roughness: 0.55, metalness: 0.0 },
|
|
78
|
+
upper: { layered: true, baseColor: 0xd9a78b, roughness: 0.55, metalness: 0.0 },
|
|
79
|
+
lower: { layered: true, baseColor: 0xd9a78b, roughness: 0.55, metalness: 0.0 },
|
|
80
|
+
eyes: { layered: false, baseColor: 0xf2f2f5, roughness: 0.25, metalness: 0.0 },
|
|
81
|
+
};
|
|
82
|
+
// Global roughness/metalness sliders drive the skin regions (not the eyes).
|
|
83
|
+
const SKIN_REGIONS = ['face', 'upper', 'lower'];
|
|
84
|
+
|
|
85
|
+
function loadImage(url) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const img = new Image();
|
|
88
|
+
img.crossOrigin = 'anonymous';
|
|
89
|
+
img.onload = () => resolve(img);
|
|
90
|
+
img.onerror = () => reject(new Error('failed to load image: ' + url));
|
|
91
|
+
img.src = url;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function regionForMaterial(materialName) {
|
|
96
|
+
const n = materialName.toLowerCase();
|
|
97
|
+
if (n.includes('eye')) return 'eyes';
|
|
98
|
+
if (n.includes('head')) return 'face';
|
|
99
|
+
if (n.includes('upper') || n.includes('hand')) return 'upper';
|
|
100
|
+
return 'lower'; // mat_body_lower, mat_feet_*
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The Ruth2 RC3 avatar is split into separate rigged glTF part files (converted
|
|
104
|
+
// from the original Collada). Each file carries its own copy of the (relevant
|
|
105
|
+
// subset of the) avatar skeleton, so we keep one skeleton per part and drive them
|
|
106
|
+
// all in sync by bone name.
|
|
107
|
+
// Default part files (override per-call via load(basePath, { parts })). The .glb
|
|
108
|
+
// exports carry the Z-up Ruth armature; pass your own .glb/.gltf URLs to swap
|
|
109
|
+
// meshes (same rig → same sliders/physics/anim).
|
|
110
|
+
const DEFAULT_PARTS = {
|
|
111
|
+
body: 'body.glb', // Release3_BothLowerUpper_15
|
|
112
|
+
hands: 'hands.glb', // Release3_Hands_15 (Bento fingers)
|
|
113
|
+
feet: 'feet.glb', // Release3_FlatFeet_15
|
|
114
|
+
head: 'head.glb', // Ruth2v4Head (RC3 has no head; the v4 head fits the RC3 body)
|
|
115
|
+
eyes: 'eyes.glb', // Ruth2v4Eyeballs
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const _gltfLoader = new GLTFLoader();
|
|
119
|
+
|
|
120
|
+
// Load one part file (glTF/GLB). The exports ship the armature collapsed (bind
|
|
121
|
+
// pose only in the inverse-bind matrices), so the caller recovers it with
|
|
122
|
+
// poseFromBind.
|
|
123
|
+
async function loadPartScene(url) {
|
|
124
|
+
return (await _gltfLoader.loadAsync(url)).scene;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Resolve a part/texture entry against basePath. An absolute URL — a scheme
|
|
128
|
+
// (https:, data:, blob:…), protocol-relative (//…), or root-relative (/…) — is
|
|
129
|
+
// used as-is, so a bundler/CDN can hand back fingerprinted per-file URLs (e.g.
|
|
130
|
+
// `import bodyUrl from './body.glb'`). A plain relative name joins basePath.
|
|
131
|
+
const isAbsoluteUrl = (s) => /^([a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(s);
|
|
132
|
+
const resolveUrl = (basePath, file) => (isAbsoluteUrl(file) ? file : basePath + file);
|
|
133
|
+
|
|
134
|
+
// Like THREE.Skeleton.pose(), but across all of a part's skins at once and
|
|
135
|
+
// ignoring placeholder bind matrices. The exports ship the armature collapsed
|
|
136
|
+
// (the real rest pose lives only in the inverse-bind matrices), and a part with
|
|
137
|
+
// multiple skins (the v4 eyeballs) puts every bone in every skeleton while only
|
|
138
|
+
// the mesh's own joints get real inverse binds — the rest are identity, so
|
|
139
|
+
// calling skeleton.pose() per mesh would snap those bones to the origin.
|
|
140
|
+
function poseFromBind(root) {
|
|
141
|
+
const identity = new THREE.Matrix4();
|
|
142
|
+
const bindWorld = new Map(); // Bone -> bind-pose world matrix
|
|
143
|
+
root.updateMatrixWorld(true);
|
|
144
|
+
root.traverse((obj) => {
|
|
145
|
+
if (!obj.isSkinnedMesh) return;
|
|
146
|
+
obj.skeleton.bones.forEach((bone, i) => {
|
|
147
|
+
const inv = obj.skeleton.boneInverses[i];
|
|
148
|
+
if (bindWorld.has(bone) || inv.equals(identity)) return;
|
|
149
|
+
bindWorld.set(bone, inv.clone().invert());
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
const local = new THREE.Matrix4();
|
|
153
|
+
const parentInv = new THREE.Matrix4();
|
|
154
|
+
for (const [bone, world] of bindWorld) {
|
|
155
|
+
local.copy(world);
|
|
156
|
+
if (bone.parent && bone.parent.isBone) {
|
|
157
|
+
parentInv.copy(bindWorld.get(bone.parent) ?? bone.parent.matrixWorld).invert();
|
|
158
|
+
local.premultiply(parentInv);
|
|
159
|
+
}
|
|
160
|
+
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
161
|
+
}
|
|
162
|
+
root.updateMatrixWorld(true);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A fully self-contained, independently-controllable avatar. Each `new Avatar()`
|
|
166
|
+
// owns its own scene graph (`group`), skeleton, materials, animation state, and
|
|
167
|
+
// the per-avatar capabilities — pec/glute physics, procedural blinking,
|
|
168
|
+
// microphone + TTS lip-sync, prop attachments, and eye look-at. Nothing is
|
|
169
|
+
// shared between instances, so two avatars can be loaded into the same scene and
|
|
170
|
+
// driven completely separately:
|
|
171
|
+
//
|
|
172
|
+
// const a = await new Avatar().load('models/');
|
|
173
|
+
// const b = await new Avatar().load('models/');
|
|
174
|
+
// scene.add(a.group, b.group);
|
|
175
|
+
// // ...each frame:
|
|
176
|
+
// a.update(dt); b.update(dt);
|
|
177
|
+
//
|
|
178
|
+
// `update(dt)` advances everything the avatar does on its own (animation,
|
|
179
|
+
// physics, blinking, lip-sync, look-at). Locomotion is intentionally NOT part
|
|
180
|
+
// of the avatar — how a figure moves around is the game's concern. Move it by
|
|
181
|
+
// driving `avatar.group` + the animation methods (playClip/crossFadeTo/…)
|
|
182
|
+
// directly, or copy the standalone `Locomotion` helper (examples/common/locomotion.js).
|
|
183
|
+
export class Avatar {
|
|
184
|
+
constructor() {
|
|
185
|
+
this.group = new THREE.Group();
|
|
186
|
+
this.parts = {}; // name -> { root, bones: Map<name, Bone>, rest: Map<name, {p,q,s}>, mixer, action }
|
|
187
|
+
this.pelvisRestZ = 1.067; // overwritten from the loaded rig
|
|
188
|
+
this.clip = null;
|
|
189
|
+
this.paused = false;
|
|
190
|
+
this.timeScale = 1;
|
|
191
|
+
this._partClips = new Map(); // clip -> Map(part -> per-part AnimationClip|null), cached
|
|
192
|
+
this._fades = []; // actions ramping out; { action, mixer, clip, t } — stopped at t<=0
|
|
193
|
+
this._regions = {}; // region -> PBRMaterialStack
|
|
194
|
+
this._textured = true;
|
|
195
|
+
this.pecPhysics = new SoftBodyPhysics(this);
|
|
196
|
+
// Glute jiggle/sag — single BUTT collision volume, sag aimed by the pelvis.
|
|
197
|
+
this.glutePhysics = new SoftBodyPhysics(this, { bones: ['BUTT'], trackBones: ['mPelvis'] });
|
|
198
|
+
|
|
199
|
+
// ---- composed per-avatar capabilities ----
|
|
200
|
+
// Each takes this avatar and holds its own state, so they're independent
|
|
201
|
+
// per instance. update() ticks them every frame.
|
|
202
|
+
this.blinker = new Blinker(this); // procedural eye blinks
|
|
203
|
+
this.voice = new VoiceMouth(this); // microphone-driven jaw
|
|
204
|
+
this.speech = new SpeechMouth(this); // TTS-clip lip-sync (visemes)
|
|
205
|
+
this.attachments = new Attachments(this); // props parented to bones
|
|
206
|
+
|
|
207
|
+
// Eye look-at — each avatar tracks its own world-space target, so several
|
|
208
|
+
// avatars can each gaze somewhere different. update() aims the eyeballs.
|
|
209
|
+
this.lookAt = { enabled: false, target: new THREE.Vector3(), _was: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async load(basePath, { parts = DEFAULT_PARTS } = {}) {
|
|
213
|
+
if (typeof basePath !== 'string' || !basePath) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
'Avatar.load(basePath): basePath is required — pass the URL/path to the ' +
|
|
216
|
+
"directory holding the model files (e.g. 'models/', '../../models/', or " +
|
|
217
|
+
"new URL('models/', import.meta.url).href). It must end with a slash."
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
this._basePath = basePath;
|
|
221
|
+
|
|
222
|
+
// One PBR material stack per region; seed each with its default albedo.
|
|
223
|
+
for (const [region, cfg] of Object.entries(REGION_DEFAULTS)) {
|
|
224
|
+
this._regions[region] = new PBRMaterialStack({ size: TEXTURE_SIZE, ...cfg });
|
|
225
|
+
}
|
|
226
|
+
await Promise.all(
|
|
227
|
+
Object.entries(DEFAULT_SKIN).flatMap(([region, channels]) =>
|
|
228
|
+
Object.entries(channels).map(async ([channel, file]) => {
|
|
229
|
+
this._regions[region]?.setSkinMap(channel, await loadImage(resolveUrl(basePath, file)));
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const loads = Object.entries(parts).map(async ([name, file]) => {
|
|
235
|
+
const url = resolveUrl(basePath, file);
|
|
236
|
+
const root = await loadPartScene(url);
|
|
237
|
+
root.updateMatrixWorld(true);
|
|
238
|
+
root.traverse((obj) => {
|
|
239
|
+
if (obj.isSkinnedMesh) {
|
|
240
|
+
// aoMap samples the second UV set; reuse the primary UVs so AO
|
|
241
|
+
// works without a dedicated lightmap channel.
|
|
242
|
+
const uv = obj.geometry.getAttribute('uv');
|
|
243
|
+
if (uv && !obj.geometry.getAttribute('uv1')) obj.geometry.setAttribute('uv1', uv);
|
|
244
|
+
const orig = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
245
|
+
const textured = orig.map((m) => this._materialForRegion(regionForMaterial(m.name || '')));
|
|
246
|
+
const plain = orig.map(() => (name === 'eyes' ? EYE : SKIN));
|
|
247
|
+
obj.userData.materialSets = {
|
|
248
|
+
textured: Array.isArray(obj.material) ? textured : textured[0],
|
|
249
|
+
plain: Array.isArray(obj.material) ? plain : plain[0],
|
|
250
|
+
};
|
|
251
|
+
obj.material = obj.userData.materialSets.textured;
|
|
252
|
+
obj.castShadow = true;
|
|
253
|
+
obj.receiveShadow = true;
|
|
254
|
+
// Bone-scale sliders and BVH clips move verts well outside the
|
|
255
|
+
// static bounding box; never let three.js cull the avatar away.
|
|
256
|
+
obj.frustumCulled = false;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// The exports' visual-scene node transforms don't match the skin bind
|
|
260
|
+
// pose; recover the true rest pose from the inverse bind matrices
|
|
261
|
+
// before we capture it below.
|
|
262
|
+
poseFromBind(root);
|
|
263
|
+
// Stand the rig up. The Ruth armature is Z-up in bone-local space, and our
|
|
264
|
+
// glTF parts are exported Z-up (Blender export_yup=false) to keep those
|
|
265
|
+
// bone-local rests — so they arrive lying down. Detect that (the rig's up,
|
|
266
|
+
// local +Z, still pointing at world +Z) and rotate the root -90°X. Bone-local
|
|
267
|
+
// rests — and therefore the sliders/physics/retarget — are unaffected.
|
|
268
|
+
const rigUp = new THREE.Vector3(0, 0, 1).transformDirection(root.matrixWorld);
|
|
269
|
+
if (rigUp.z > 0.7) { root.rotateX(-Math.PI / 2); root.updateMatrixWorld(true); }
|
|
270
|
+
const bones = new Map();
|
|
271
|
+
const rest = new Map();
|
|
272
|
+
root.traverse((obj) => {
|
|
273
|
+
if (obj.isBone) {
|
|
274
|
+
if (bones.has(obj.name)) return;
|
|
275
|
+
bones.set(obj.name, obj);
|
|
276
|
+
rest.set(obj.name, {
|
|
277
|
+
p: obj.position.clone(),
|
|
278
|
+
q: obj.quaternion.clone(),
|
|
279
|
+
s: obj.scale.clone(),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
this.parts[name] = { root, bones, rest, mixer: null, action: null };
|
|
284
|
+
this.group.add(root);
|
|
285
|
+
});
|
|
286
|
+
await Promise.all(loads);
|
|
287
|
+
|
|
288
|
+
const pelvis = this.parts.body.rest.get('mPelvis');
|
|
289
|
+
if (pelvis) this.pelvisRestZ = pelvis.p.z;
|
|
290
|
+
|
|
291
|
+
// Nipple morph: locate the body skinned mesh and precompute the apex/falloff
|
|
292
|
+
// (no-op on rigs without pec-weighted geometry). Driven by the 'nipple' slider.
|
|
293
|
+
this.parts.body.root.traverse((obj) => {
|
|
294
|
+
if (obj.isSkinnedMesh && !this._nippleMorph) this._nippleMorph = buildNippleMorph(obj);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
this.graftHeadParts();
|
|
298
|
+
this.pecPhysics.captureBasePositions();
|
|
299
|
+
this.glutePhysics.captureBasePositions();
|
|
300
|
+
return this;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The Ruth2 v4 head and eyeball exports have flat skeletons: their bones
|
|
304
|
+
// (NECK, HEAD, mFaceRoot / mEyeLeft, mEyeRight) hang directly off the
|
|
305
|
+
// armature root with no spine chain, so on their own they can't follow
|
|
306
|
+
// the body. Graft them into the body's skeleton instead: synthesize the
|
|
307
|
+
// missing mHead bone under the body's mNeck (Ruth skeleton offset), then
|
|
308
|
+
// re-parent each head/eye root bone into the chain. attach() preserves
|
|
309
|
+
// world transforms, and the SkinnedMeshes keep referencing the same Bone
|
|
310
|
+
// objects, so the skins are unaffected — they just inherit body motion.
|
|
311
|
+
graftHeadParts() {
|
|
312
|
+
const body = this.parts.body;
|
|
313
|
+
const bodyNeck = body?.bones.get('mNeck');
|
|
314
|
+
if (!bodyNeck) return;
|
|
315
|
+
|
|
316
|
+
const mHead = new THREE.Bone();
|
|
317
|
+
mHead.name = 'mHead';
|
|
318
|
+
bodyNeck.add(mHead);
|
|
319
|
+
mHead.position.set(0, 0, 0.076); // avatar_skeleton.xml: mHead offset from mNeck
|
|
320
|
+
body.bones.set('mHead', mHead);
|
|
321
|
+
body.rest.set('mHead', {
|
|
322
|
+
p: mHead.position.clone(),
|
|
323
|
+
q: mHead.quaternion.clone(),
|
|
324
|
+
s: mHead.scale.clone(),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
this.group.updateMatrixWorld(true);
|
|
328
|
+
for (const name of ['head', 'eyes']) {
|
|
329
|
+
const part = this.parts[name];
|
|
330
|
+
if (!part) continue;
|
|
331
|
+
part.grafted = true;
|
|
332
|
+
for (const bone of part.bones.values()) {
|
|
333
|
+
if (bone.parent && bone.parent.isBone) continue; // only armature-root bones
|
|
334
|
+
(bone.name === 'NECK' ? bodyNeck : mHead).attach(bone);
|
|
335
|
+
}
|
|
336
|
+
// re-capture rest transforms: attach() rewrote the root bones' locals
|
|
337
|
+
for (const [boneName, bone] of part.bones) {
|
|
338
|
+
part.rest.set(boneName, {
|
|
339
|
+
p: bone.position.clone(),
|
|
340
|
+
q: bone.quaternion.clone(),
|
|
341
|
+
s: bone.scale.clone(),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
setPartVisible(name, visible) {
|
|
348
|
+
const part = this.parts[name];
|
|
349
|
+
if (part) part.root.visible = visible;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
setTextured(textured) {
|
|
353
|
+
this._textured = textured;
|
|
354
|
+
this._applySurfaceMaterials();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- PBR textures --------------------------------------------------
|
|
358
|
+
// region is one of 'face' | 'upper' | 'lower' | 'eyes'; channel is one of
|
|
359
|
+
// 'albedo' | 'normal' | 'roughness' | 'metallic' | 'ao'. Clothing layers
|
|
360
|
+
// (upper/lower only) stack on top of the skin, masked by their albedo.
|
|
361
|
+
|
|
362
|
+
getRegions() {
|
|
363
|
+
return Object.keys(this._regions);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
getDefaultSkinLabel(region) {
|
|
367
|
+
const file = DEFAULT_SKIN[region]?.albedo;
|
|
368
|
+
return file ? file.split('/').pop() : '';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
setSkinMap(region, channel, img) {
|
|
372
|
+
this._regions[region]?.setSkinMap(channel, img ?? null);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
getSkinMap(region, channel) {
|
|
376
|
+
return this._regions[region]?.skin[channel] ?? null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
getClothingLayers(region) {
|
|
380
|
+
return this._regions[region]?.layers ?? [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
addClothingLayer(region) {
|
|
384
|
+
return this._regions[region]?.addLayer() ?? -1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
removeClothingLayer(region, i) {
|
|
388
|
+
this._regions[region]?.removeLayer(i);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
moveClothingLayer(region, i, dir) {
|
|
392
|
+
this._regions[region]?.moveLayer(i, dir);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
setLayerMap(region, i, channel, img) {
|
|
396
|
+
this._regions[region]?.setLayerMap(i, channel, img ?? null);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
getLayerMap(region, i, channel) {
|
|
400
|
+
return this._regions[region]?.layers[i]?.maps[channel] ?? null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
setLayerVisible(region, i, visible) {
|
|
404
|
+
this._regions[region]?.setLayerVisible(i, visible);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Roughness/metalness as a constant value where a region has no such map.
|
|
408
|
+
setGlobalRoughness(v) {
|
|
409
|
+
for (const r of SKIN_REGIONS) this._regions[r]?.setGlobalRoughness(v);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
setGlobalMetalness(v) {
|
|
413
|
+
for (const r of SKIN_REGIONS) this._regions[r]?.setGlobalMetalness(v);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_materialForRegion(region) {
|
|
417
|
+
return this._regions[region]?.material;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- lip sync ------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
// Open the jaw, t in [0, 1]. Rotates mFaceJaw about the same axis the
|
|
423
|
+
// "Mouth Open" shape slider uses, relative to its captured rest pose.
|
|
424
|
+
setMouthOpen(t) {
|
|
425
|
+
t = THREE.MathUtils.clamp(t, 0, 1);
|
|
426
|
+
if (!this._jaw) {
|
|
427
|
+
for (const part of Object.values(this.parts)) {
|
|
428
|
+
const bone = part.bones.get('mFaceJaw');
|
|
429
|
+
if (bone) { this._jaw = { bone, rest: part.rest.get('mFaceJaw') }; break; }
|
|
430
|
+
}
|
|
431
|
+
this._jawEuler = new THREE.Euler();
|
|
432
|
+
this._jawQuat = new THREE.Quaternion();
|
|
433
|
+
}
|
|
434
|
+
if (!this._jaw?.bone || !this._jaw.rest) return;
|
|
435
|
+
this._jawEuler.set(0, 0.6 * t, 0);
|
|
436
|
+
this._jaw.bone.quaternion.copy(this._jaw.rest.q).multiply(this._jawQuat.setFromEuler(this._jawEuler));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Blink, t in [0, 1]: 0 = eyes open (rest), 1 = fully closed. Rotates the
|
|
440
|
+
// Bento eyelid bones with the same Y-axis amounts the "Eye Closed" shape
|
|
441
|
+
// sliders use (upper lids 0.55, lower lids -0.15), relative to each lid's
|
|
442
|
+
// captured rest pose — same approach as setMouthOpen.
|
|
443
|
+
setBlink(t) {
|
|
444
|
+
t = THREE.MathUtils.clamp(t, 0, 1);
|
|
445
|
+
if (!this._lids) {
|
|
446
|
+
this._lids = [];
|
|
447
|
+
this._blinkEuler = new THREE.Euler();
|
|
448
|
+
this._blinkQuat = new THREE.Quaternion();
|
|
449
|
+
const spec = [
|
|
450
|
+
['mFaceEyeLidUpperLeft', 0.55], ['mFaceEyeLidLowerLeft', -0.15],
|
|
451
|
+
['mFaceEyeLidUpperRight', 0.55], ['mFaceEyeLidLowerRight', -0.15],
|
|
452
|
+
];
|
|
453
|
+
for (const [name, ry] of spec) {
|
|
454
|
+
for (const part of Object.values(this.parts)) {
|
|
455
|
+
const bone = part.bones.get(name);
|
|
456
|
+
if (bone) { this._lids.push({ bone, rest: part.rest.get(name), ry }); break; }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const lid of this._lids) {
|
|
461
|
+
if (!lid.bone || !lid.rest) continue;
|
|
462
|
+
this._blinkEuler.set(0, lid.ry * t, 0);
|
|
463
|
+
lid.bone.quaternion.copy(lid.rest.q).multiply(this._blinkQuat.setFromEuler(this._blinkEuler));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Viseme mouth shaping for speech lip-sync. Controls are each 0..1:
|
|
468
|
+
// open — jaw drop (vowels)
|
|
469
|
+
// round — lip pucker / protrude (o, u, w)
|
|
470
|
+
// wide — lip spread (e, i)
|
|
471
|
+
// Magnitudes reuse the matching shape sliders (mouth_open jaw rot 0.45,
|
|
472
|
+
// mouth_width corner ±0.008, lip protrusion lips +0.005), applied relative to
|
|
473
|
+
// each bone's rest — so all-zero restores the resting (closed) mouth.
|
|
474
|
+
setMouth({ open = 0, round = 0, wide = 0 } = {}) {
|
|
475
|
+
open = THREE.MathUtils.clamp(open, 0, 1);
|
|
476
|
+
round = THREE.MathUtils.clamp(round, 0, 1);
|
|
477
|
+
wide = THREE.MathUtils.clamp(wide, 0, 1);
|
|
478
|
+
if (!this._mouth) {
|
|
479
|
+
this._mouthEuler = new THREE.Euler();
|
|
480
|
+
this._mouthQuat = new THREE.Quaternion();
|
|
481
|
+
const find = (name) => {
|
|
482
|
+
for (const part of Object.values(this.parts)) {
|
|
483
|
+
const bone = part.bones.get(name);
|
|
484
|
+
if (bone) return { bone, rest: part.rest.get(name) };
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
};
|
|
488
|
+
this._mouth = { jaw: find('mFaceJaw'), corners: [], lips: [] };
|
|
489
|
+
// corner sign: +1 = left lip corner (moves +Y to widen), -1 = right
|
|
490
|
+
for (const [name, sign] of [['mFaceLipCornerLeft', 1], ['mFaceLipCornerRight', -1]]) {
|
|
491
|
+
const f = find(name);
|
|
492
|
+
if (f) this._mouth.corners.push({ ...f, sign });
|
|
493
|
+
}
|
|
494
|
+
for (const name of ['mFaceLipUpperLeft', 'mFaceLipUpperRight', 'mFaceLipUpperCenter',
|
|
495
|
+
'mFaceLipLowerLeft', 'mFaceLipLowerRight', 'mFaceLipLowerCenter']) {
|
|
496
|
+
const f = find(name);
|
|
497
|
+
if (f) this._mouth.lips.push(f);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const m = this._mouth;
|
|
501
|
+
if (m.jaw?.bone && m.jaw.rest) {
|
|
502
|
+
this._mouthEuler.set(0, 0.5 * open, 0);
|
|
503
|
+
m.jaw.bone.quaternion.copy(m.jaw.rest.q).multiply(this._mouthQuat.setFromEuler(this._mouthEuler));
|
|
504
|
+
}
|
|
505
|
+
for (const c of m.corners) {
|
|
506
|
+
if (!c.rest) continue;
|
|
507
|
+
// widen on `wide`, pull in + forward on `round`
|
|
508
|
+
const y = c.sign * (0.008 * wide - 0.006 * round);
|
|
509
|
+
c.bone.position.set(c.rest.p.x + 0.004 * round, c.rest.p.y + y, c.rest.p.z);
|
|
510
|
+
}
|
|
511
|
+
for (const l of m.lips) {
|
|
512
|
+
if (!l.rest) continue;
|
|
513
|
+
l.bone.position.set(l.rest.p.x + 0.005 * round, l.rest.p.y, l.rest.p.z); // protrude
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
_applySurfaceMaterials() {
|
|
518
|
+
for (const part of Object.values(this.parts)) {
|
|
519
|
+
part.root.traverse((obj) => {
|
|
520
|
+
if (!obj.isSkinnedMesh || !obj.userData.materialSets) return;
|
|
521
|
+
obj.material = this._textured
|
|
522
|
+
? obj.userData.materialSets.textured
|
|
523
|
+
: obj.userData.materialSets.plain;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---- shape sliders -------------------------------------------------
|
|
529
|
+
|
|
530
|
+
applyShape(state) {
|
|
531
|
+
const adj = computeBoneAdjustments(state);
|
|
532
|
+
const height = state.height ?? 0;
|
|
533
|
+
this.group.scale.setScalar(1 + 0.15 * height);
|
|
534
|
+
|
|
535
|
+
const euler = new THREE.Euler();
|
|
536
|
+
const q = new THREE.Quaternion();
|
|
537
|
+
for (const part of Object.values(this.parts)) {
|
|
538
|
+
for (const [name, bone] of part.bones) {
|
|
539
|
+
const rest = part.rest.get(name);
|
|
540
|
+
if (!rest) continue;
|
|
541
|
+
const a = adj.get(name);
|
|
542
|
+
if (a) {
|
|
543
|
+
bone.scale.set(rest.s.x * a.scale[0], rest.s.y * a.scale[1], rest.s.z * a.scale[2]);
|
|
544
|
+
bone.position.set(rest.p.x + a.offset[0], rest.p.y + a.offset[1], rest.p.z + a.offset[2]);
|
|
545
|
+
// rotation sliders (jaw etc.) — pose-like sliders on un-animated face bones
|
|
546
|
+
if (a.rot[0] || a.rot[1] || a.rot[2] || this._rotBones?.has(name)) {
|
|
547
|
+
euler.set(a.rot[0], a.rot[1], a.rot[2]);
|
|
548
|
+
bone.quaternion.copy(rest.q).multiply(q.setFromEuler(euler));
|
|
549
|
+
(this._rotBones ??= new Set()).add(name);
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
bone.scale.copy(rest.s);
|
|
553
|
+
bone.position.copy(rest.p);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
applyNippleMorph(this._nippleMorph, state.nipple ?? 0);
|
|
558
|
+
|
|
559
|
+
this.pecPhysics.captureBasePositions();
|
|
560
|
+
this.glutePhysics.captureBasePositions();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---- animation -----------------------------------------------------
|
|
564
|
+
|
|
565
|
+
// Play a single clip as the whole stack (replaces any layers). Retained as
|
|
566
|
+
// the simple entry point used by the BVH/glTF play paths and MCP.
|
|
567
|
+
playClip(clip) {
|
|
568
|
+
this.setLayerStack([{ id: 'base', clip, loop: true }]);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Set the animation layer stack. `layers` is ordered HIGHEST priority first
|
|
572
|
+
// (index 0 = topmost); each is { id, clip, loop }. Priority is resolved
|
|
573
|
+
// per track: the highest layer that animates a given bone owns it, and
|
|
574
|
+
// lower layers only fill bones the ones above them leave untouched. So a
|
|
575
|
+
// hand-only clip on top overrides just the hand while a full-body clip
|
|
576
|
+
// below drives everything else.
|
|
577
|
+
setLayerStack(layers) {
|
|
578
|
+
this._teardownLayers();
|
|
579
|
+
this.layers = (layers ?? []).filter((l) => l && l.clip);
|
|
580
|
+
|
|
581
|
+
// Per-track ownership: first (highest-priority) layer to claim a track
|
|
582
|
+
// name wins; lower layers' copies of that track are dropped so no two
|
|
583
|
+
// actions ever target the same bone (avoids three.js weight-blending).
|
|
584
|
+
const owner = new Map(); // track.name -> layer index
|
|
585
|
+
this.layers.forEach((layer, i) => {
|
|
586
|
+
for (const track of layer.clip.tracks) {
|
|
587
|
+
if (!owner.has(track.name)) owner.set(track.name, i);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
this.layers.forEach((layer, i) => {
|
|
592
|
+
for (const part of Object.values(this.parts)) {
|
|
593
|
+
if (part.grafted) continue; // bones live in the body's tree
|
|
594
|
+
const tracks = layer.clip.tracks.filter(
|
|
595
|
+
(t) => owner.get(t.name) === i && part.bones.has(t.name.split('.')[0]),
|
|
596
|
+
);
|
|
597
|
+
if (tracks.length === 0) continue;
|
|
598
|
+
part.mixer ??= new THREE.AnimationMixer(part.root);
|
|
599
|
+
const partClip = new THREE.AnimationClip(`${layer.clip.name}#${layer.id}`, layer.clip.duration, tracks);
|
|
600
|
+
const action = part.mixer.clipAction(partClip);
|
|
601
|
+
action.setLoop(layer.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
|
|
602
|
+
action.clampWhenFinished = layer.loop === false;
|
|
603
|
+
action.reset();
|
|
604
|
+
action.setEffectiveWeight(1);
|
|
605
|
+
action.play();
|
|
606
|
+
(part.actions ??= []).push(action);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
this.clip = this.layers.length ? this.layers[0].clip : null;
|
|
611
|
+
this.setPaused(false);
|
|
612
|
+
this.setSpeed(this.timeScale);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Per-part sub-clip for `clip` (only the tracks whose bones live on `part`),
|
|
616
|
+
// cached so repeated transitions reuse the same AnimationClip — and therefore
|
|
617
|
+
// the same cached action — instead of leaking a new action into the mixer.
|
|
618
|
+
_subClip(part, clip) {
|
|
619
|
+
let perClip = this._partClips.get(clip);
|
|
620
|
+
if (!perClip) { perClip = new Map(); this._partClips.set(clip, perClip); }
|
|
621
|
+
if (!perClip.has(part)) {
|
|
622
|
+
const tracks = clip.tracks.filter((t) => part.bones.has(t.name.split('.')[0]));
|
|
623
|
+
perClip.set(part, tracks.length
|
|
624
|
+
? new THREE.AnimationClip(`${clip.name}#${part.root?.name || 'p'}`, clip.duration, tracks)
|
|
625
|
+
: null);
|
|
626
|
+
}
|
|
627
|
+
return perClip.get(part);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Smoothly blend from whatever is playing to `clip` over `duration` seconds,
|
|
631
|
+
// instead of the hard cut setLayerStack does. Each part fades its current
|
|
632
|
+
// action(s) out while the new one fades in; the mixer slerp-blends the
|
|
633
|
+
// overlap. Falls back to a hard play when nothing is playing yet.
|
|
634
|
+
crossFadeTo(clip, duration = 0.25, loop = true) {
|
|
635
|
+
if (!clip) return;
|
|
636
|
+
if (!this.clip || duration <= 0) { this.playClip(clip); return; }
|
|
637
|
+
if (clip === this.clip) return;
|
|
638
|
+
for (const part of Object.values(this.parts)) {
|
|
639
|
+
if (part.grafted) continue;
|
|
640
|
+
const partClip = this._subClip(part, clip);
|
|
641
|
+
if (!partClip) continue;
|
|
642
|
+
part.mixer ??= new THREE.AnimationMixer(part.root);
|
|
643
|
+
const action = part.mixer.clipAction(partClip);
|
|
644
|
+
// if we're fading back to a clip that's still fading out, reclaim its
|
|
645
|
+
// action instead of letting the queued stop kill it.
|
|
646
|
+
this._fades = this._fades.filter((f) => f.action !== action);
|
|
647
|
+
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
|
|
648
|
+
action.clampWhenFinished = !loop;
|
|
649
|
+
action.reset();
|
|
650
|
+
action.play();
|
|
651
|
+
action.fadeIn(duration);
|
|
652
|
+
for (const old of part.actions ?? []) {
|
|
653
|
+
if (old === action) continue;
|
|
654
|
+
old.fadeOut(duration);
|
|
655
|
+
this._fades.push({ action: old, mixer: part.mixer, clip: old.getClip(), t: duration });
|
|
656
|
+
}
|
|
657
|
+
part.actions = [action];
|
|
658
|
+
}
|
|
659
|
+
this.clip = clip;
|
|
660
|
+
this.layers = [{ id: 'base', clip, loop }];
|
|
661
|
+
this.setPaused(false);
|
|
662
|
+
this.setSpeed(this.timeScale);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
stop() {
|
|
666
|
+
this._teardownLayers();
|
|
667
|
+
this.clip = null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
_teardownLayers() {
|
|
671
|
+
this._fades.length = 0;
|
|
672
|
+
this._partClips.clear(); // cached sub-clips reference the about-to-die mixers
|
|
673
|
+
for (const part of Object.values(this.parts)) {
|
|
674
|
+
if (part.mixer) {
|
|
675
|
+
part.mixer.stopAllAction();
|
|
676
|
+
part.mixer.uncacheRoot(part.root); // drop cached clips so rebuilds don't leak
|
|
677
|
+
}
|
|
678
|
+
part.mixer = null;
|
|
679
|
+
part.action = null;
|
|
680
|
+
part.actions = [];
|
|
681
|
+
}
|
|
682
|
+
this.layers = [];
|
|
683
|
+
this.pecPhysics.reset();
|
|
684
|
+
this.glutePhysics.reset();
|
|
685
|
+
// restore rest rotations (positions/scales are owned by the sliders);
|
|
686
|
+
// the pelvis position is pose, not shape, so a clip's root motion must be
|
|
687
|
+
// undone here too or the figure stays where the animation last left it.
|
|
688
|
+
for (const part of Object.values(this.parts)) {
|
|
689
|
+
for (const [name, bone] of part.bones) {
|
|
690
|
+
const rest = part.rest.get(name);
|
|
691
|
+
if (!rest) continue;
|
|
692
|
+
bone.quaternion.copy(rest.q);
|
|
693
|
+
if (name === 'mPelvis') bone.position.copy(rest.p);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
setPaused(paused) {
|
|
699
|
+
this.paused = paused;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
setSpeed(s) {
|
|
703
|
+
this.timeScale = s;
|
|
704
|
+
for (const part of Object.values(this.parts)) {
|
|
705
|
+
if (part.mixer) part.mixer.timeScale = s;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
get playing() {
|
|
710
|
+
return this.clip !== null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
update(dt) {
|
|
714
|
+
if (this.paused) return;
|
|
715
|
+
for (const part of Object.values(this.parts)) {
|
|
716
|
+
if (part.mixer) part.mixer.update(dt);
|
|
717
|
+
}
|
|
718
|
+
// retire actions that have finished fading out so they stop consuming the
|
|
719
|
+
// mixer (and free their cached clip) once their weight has reached zero.
|
|
720
|
+
if (this._fades.length) {
|
|
721
|
+
for (const f of this._fades) f.t -= dt;
|
|
722
|
+
this._fades = this._fades.filter((f) => {
|
|
723
|
+
if (f.t > 0) return true;
|
|
724
|
+
f.action.stop();
|
|
725
|
+
f.mixer.uncacheAction(f.clip);
|
|
726
|
+
return false;
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
this.pecPhysics.update(dt);
|
|
730
|
+
this.glutePhysics.update(dt);
|
|
731
|
+
|
|
732
|
+
// Expressive systems run on top of the resolved pose each frame. They are
|
|
733
|
+
// no-ops while inactive/disabled.
|
|
734
|
+
this.blinker.update(dt);
|
|
735
|
+
this.voice.update(dt); // microphone jaw
|
|
736
|
+
this.speech.update(dt); // TTS playback jaw / visemes
|
|
737
|
+
this._applyLookAt(); // aim the eyeballs at the look-at target
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ---- eye look-at ---------------------------------------------------
|
|
741
|
+
|
|
742
|
+
// Aim/track a world-space point with the eyes, and/or toggle tracking. All
|
|
743
|
+
// fields optional. Enabling with no target yet seeds one in front of the head.
|
|
744
|
+
setLookAt({ enabled, x, y, z } = {}) {
|
|
745
|
+
const la = this.lookAt;
|
|
746
|
+
if (typeof x === 'number') la.target.x = x;
|
|
747
|
+
if (typeof y === 'number') la.target.y = y;
|
|
748
|
+
if (typeof z === 'number') la.target.z = z;
|
|
749
|
+
if (typeof enabled === 'boolean') {
|
|
750
|
+
if (enabled && la.target.lengthSq() === 0) this._seedLookTarget();
|
|
751
|
+
la.enabled = enabled;
|
|
752
|
+
}
|
|
753
|
+
return this.getLookAt();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
getLookAt() {
|
|
757
|
+
return { enabled: this.lookAt.enabled, position: this.lookAt.target.toArray() };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Default look-at point: ~1 m in front of the head (matches the editor handle).
|
|
761
|
+
_seedLookTarget() {
|
|
762
|
+
const head = this._findBone('mHead');
|
|
763
|
+
if (!head) return;
|
|
764
|
+
this.group.updateMatrixWorld(true);
|
|
765
|
+
head.getWorldPosition(this.lookAt.target);
|
|
766
|
+
this.lookAt.target.x += 1.0;
|
|
767
|
+
this.lookAt.target.z += 0.08;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
_findBone(name) {
|
|
771
|
+
for (const part of Object.values(this.parts)) {
|
|
772
|
+
const b = part.bones.get(name);
|
|
773
|
+
if (b) return b;
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
_applyLookAt() {
|
|
779
|
+
const la = this.lookAt;
|
|
780
|
+
const part = this.parts.eyes;
|
|
781
|
+
if (!part) return;
|
|
782
|
+
if (la.enabled) {
|
|
783
|
+
this.group.updateMatrixWorld(true);
|
|
784
|
+
for (const name of ['mEyeLeft', 'mEyeRight']) {
|
|
785
|
+
const bone = part.bones.get(name);
|
|
786
|
+
const rest = part.rest.get(name);
|
|
787
|
+
if (bone && rest) this._aimEyeAt(bone, rest, la.target);
|
|
788
|
+
}
|
|
789
|
+
la._was = true;
|
|
790
|
+
} else if (la._was) {
|
|
791
|
+
// tracking just turned off — return the eyes to rest once
|
|
792
|
+
for (const name of ['mEyeLeft', 'mEyeRight']) {
|
|
793
|
+
const bone = part.bones.get(name);
|
|
794
|
+
const rest = part.rest.get(name);
|
|
795
|
+
if (bone && rest) bone.quaternion.copy(rest.q);
|
|
796
|
+
}
|
|
797
|
+
la._was = false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Rotate an eye bone from rest so its local +X (forward) points at the target.
|
|
802
|
+
_aimEyeAt(bone, rest, targetWorld) {
|
|
803
|
+
if (!bone.parent) return;
|
|
804
|
+
bone.parent.updateMatrixWorld(true);
|
|
805
|
+
_lookM4.copy(bone.parent.matrixWorld).invert();
|
|
806
|
+
_lookV1.copy(targetWorld).applyMatrix4(_lookM4); // target in the bone's parent frame
|
|
807
|
+
_lookV2.copy(_lookV1).sub(rest.p);
|
|
808
|
+
const len = _lookV2.length();
|
|
809
|
+
if (len < 1e-6) return;
|
|
810
|
+
_lookV2.divideScalar(len);
|
|
811
|
+
_lookQ0.setFromUnitVectors(_EYE_FWD, _lookV2);
|
|
812
|
+
_lookE.setFromQuaternion(_lookQ0, 'YXZ');
|
|
813
|
+
_lookE.x = 0;
|
|
814
|
+
_lookE.y = THREE.MathUtils.clamp(_lookE.y, -EYE_LOOK_MAX_YAW, EYE_LOOK_MAX_YAW);
|
|
815
|
+
_lookE.z = THREE.MathUtils.clamp(_lookE.z, -EYE_LOOK_MAX_PITCH, EYE_LOOK_MAX_PITCH);
|
|
816
|
+
bone.quaternion.copy(rest.q).multiply(_lookQ1.setFromEuler(_lookE));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ---- capability facades -------------------------------------------
|
|
820
|
+
// Thin convenience wrappers over the composed members, so common actions
|
|
821
|
+
// read as avatar verbs. The members themselves stay public for full control.
|
|
822
|
+
|
|
823
|
+
// Microphone lip-sync.
|
|
824
|
+
startMic() { return this.voice.start(); }
|
|
825
|
+
stopMic() { this.voice.stop(); }
|
|
826
|
+
|
|
827
|
+
// Play a TTS audio clip and lip-sync to it (per-viseme if visemeUrl given).
|
|
828
|
+
speak(url, visemeUrl) { return this.speech.play(url, visemeUrl); }
|
|
829
|
+
stopSpeaking() { this.speech.stop(); }
|
|
830
|
+
|
|
831
|
+
// Procedural blinking.
|
|
832
|
+
setBlinking(on) { this.blinker.setEnabled(on); }
|
|
833
|
+
blinkNow() { this.blinker.blinkNow(); }
|
|
834
|
+
|
|
835
|
+
// Attach a prop to a bone (built-in factory or loaded GLB file).
|
|
836
|
+
attachBuiltin(name, boneName, factory, offset) {
|
|
837
|
+
return this.attachments.attachBuiltin(name, boneName, factory, offset);
|
|
838
|
+
}
|
|
839
|
+
attachFile(file, boneName, offset) {
|
|
840
|
+
return this.attachments.attachFile(file, boneName, offset);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Free every GPU resource this avatar owns. After dispose() the instance is
|
|
844
|
+
// dead; remove its group from the scene first.
|
|
845
|
+
dispose() {
|
|
846
|
+
this.stop();
|
|
847
|
+
this.voice.stop();
|
|
848
|
+
this.speech.stop();
|
|
849
|
+
this.attachments.clear();
|
|
850
|
+
for (const region of Object.values(this._regions)) region.dispose?.();
|
|
851
|
+
this.group.traverse((obj) => {
|
|
852
|
+
if (obj.isSkinnedMesh || obj.isMesh) obj.geometry?.dispose?.();
|
|
853
|
+
});
|
|
854
|
+
this.group.removeFromParent();
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Deprecated alias — the class was renamed RuthAvatar → Avatar when the
|
|
859
|
+
// per-avatar capabilities were folded in. Kept so existing imports keep working.
|
|
860
|
+
export { Avatar as RuthAvatar };
|