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/attachments.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
import { getCanonicalBone } from './skeleton.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Attachment points — the Second Life model: every wearable / held object
|
|
7
|
+
// picks a bone on the avatar rig as its parent. The object follows that
|
|
8
|
+
// bone's world transform (plus a tunable local offset), so a sword tracks the
|
|
9
|
+
// right hand, a hat stays on the head, a backpack rides the spine, etc.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export const ATTACHMENT_POINTS = [
|
|
13
|
+
{ bone: 'mHead', label: 'Head / Skull' },
|
|
14
|
+
{ bone: 'mNeck', label: 'Neck' },
|
|
15
|
+
{ bone: 'mChest', label: 'Chest' },
|
|
16
|
+
{ bone: 'mSpine1', label: 'Spine' },
|
|
17
|
+
{ bone: 'mPelvis', label: 'Pelvis' },
|
|
18
|
+
{ bone: 'mShoulderLeft', label: 'L Shoulder' },
|
|
19
|
+
{ bone: 'mShoulderRight', label: 'R Shoulder' },
|
|
20
|
+
{ bone: 'mElbowLeft', label: 'L Forearm' },
|
|
21
|
+
{ bone: 'mElbowRight', label: 'R Forearm' },
|
|
22
|
+
{ bone: 'mWristLeft', label: 'L Hand' },
|
|
23
|
+
{ bone: 'mWristRight', label: 'R Hand' },
|
|
24
|
+
{ bone: 'mHipLeft', label: 'L Hip' },
|
|
25
|
+
{ bone: 'mHipRight', label: 'R Hip' },
|
|
26
|
+
{ bone: 'mKneeLeft', label: 'L Lower Leg' },
|
|
27
|
+
{ bone: 'mKneeRight', label: 'R Lower Leg' },
|
|
28
|
+
{ bone: 'mAnkleLeft', label: 'L Foot' },
|
|
29
|
+
{ bone: 'mAnkleRight', label: 'R Foot' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Built-in sword — a simple low-poly weapon so there's something to try
|
|
34
|
+
// right away without hunting for a GLB. Oriented so the blade points
|
|
35
|
+
// forward (+Z in local space) and the handle sits near the origin.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function buildSwordMesh() {
|
|
39
|
+
const group = new THREE.Group();
|
|
40
|
+
group.name = 'Sword';
|
|
41
|
+
|
|
42
|
+
const metal = new THREE.MeshStandardMaterial({ color: 0xc0c8d4, roughness: 0.22, metalness: 0.92 });
|
|
43
|
+
const darkMetal = new THREE.MeshStandardMaterial({ color: 0x3a3030, roughness: 0.45, metalness: 0.70 });
|
|
44
|
+
const wood = new THREE.MeshStandardMaterial({ color: 0x5c3a1e, roughness: 0.70, metalness: 0.02 });
|
|
45
|
+
const gold = new THREE.MeshStandardMaterial({ color: 0xd4b060, roughness: 0.18, metalness: 0.95 });
|
|
46
|
+
|
|
47
|
+
// Blade (flat box, Z-forward)
|
|
48
|
+
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.015, 0.55), metal);
|
|
49
|
+
blade.position.set(0, 0.04, 0.28);
|
|
50
|
+
group.add(blade);
|
|
51
|
+
|
|
52
|
+
// Guard (crosspiece, X-wide)
|
|
53
|
+
const guard = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.03, 0.04), gold);
|
|
54
|
+
guard.position.set(0, 0.0, 0.02);
|
|
55
|
+
group.add(guard);
|
|
56
|
+
|
|
57
|
+
// Grip
|
|
58
|
+
const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.024, 0.1, 8), wood);
|
|
59
|
+
grip.position.set(0, -0.01, -0.06);
|
|
60
|
+
group.add(grip);
|
|
61
|
+
|
|
62
|
+
// Pommel
|
|
63
|
+
const pommel = new THREE.Mesh(new THREE.SphereGeometry(0.022, 8, 6), darkMetal);
|
|
64
|
+
pommel.position.set(0, -0.01, -0.115);
|
|
65
|
+
group.add(pommel);
|
|
66
|
+
|
|
67
|
+
// Cross-detail rings on guard
|
|
68
|
+
const ringGeo = new THREE.TorusGeometry(0.025, 0.008, 6, 8);
|
|
69
|
+
const ringL = new THREE.Mesh(ringGeo, gold);
|
|
70
|
+
ringL.position.set(-0.09, 0.015, 0.02);
|
|
71
|
+
group.add(ringL);
|
|
72
|
+
const ringR = new THREE.Mesh(ringGeo, gold);
|
|
73
|
+
ringR.position.set(0.09, 0.015, 0.02);
|
|
74
|
+
group.add(ringR);
|
|
75
|
+
|
|
76
|
+
for (const child of group.children) {
|
|
77
|
+
child.castShadow = true;
|
|
78
|
+
child.receiveShadow = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return group;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Hair — a simple bowl/helmet shape that sits on the head
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function buildHairMesh() {
|
|
89
|
+
const group = new THREE.Group();
|
|
90
|
+
group.name = 'Hair';
|
|
91
|
+
|
|
92
|
+
const hairMat = new THREE.MeshStandardMaterial({ color: 0x2a1f14, roughness: 0.55, metalness: 0.05 });
|
|
93
|
+
const highlightMat = new THREE.MeshStandardMaterial({ color: 0x3d2b1a, roughness: 0.45, metalness: 0.08 });
|
|
94
|
+
|
|
95
|
+
// Main dome (scalp)
|
|
96
|
+
const dome = new THREE.Mesh(new THREE.SphereGeometry(0.14, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
|
|
97
|
+
dome.position.set(0, 0.09, 0);
|
|
98
|
+
dome.scale.set(1, 0.85, 0.95);
|
|
99
|
+
group.add(dome);
|
|
100
|
+
|
|
101
|
+
// Back hair (extends down the neck)
|
|
102
|
+
const back = new THREE.Mesh(new THREE.SphereGeometry(0.14, 24, 12, 0, Math.PI * 2, Math.PI * 0.45, Math.PI * 0.35), hairMat);
|
|
103
|
+
back.position.set(0, 0.02, -0.06);
|
|
104
|
+
back.scale.set(1, 1.05, 0.78);
|
|
105
|
+
group.add(back);
|
|
106
|
+
|
|
107
|
+
// Side tufts (left, right)
|
|
108
|
+
for (const sign of [-1, 1]) {
|
|
109
|
+
const side = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.16, 6, 4), hairMat);
|
|
110
|
+
side.position.set(sign * 0.12, 0.04, -0.02);
|
|
111
|
+
side.rotation.z = sign * 0.25;
|
|
112
|
+
side.rotation.x = 0.3;
|
|
113
|
+
group.add(side);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Front bangs
|
|
117
|
+
const bangs = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.05, 0.06, 4, 1, 1), highlightMat);
|
|
118
|
+
bangs.position.set(0, 0.145, 0.08);
|
|
119
|
+
bangs.rotation.x = 0.15;
|
|
120
|
+
group.add(bangs);
|
|
121
|
+
|
|
122
|
+
for (const child of group.children) {
|
|
123
|
+
child.castShadow = true;
|
|
124
|
+
child.receiveShadow = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return group;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Shield — a round knight's shield for the forearm
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function buildShieldMesh() {
|
|
135
|
+
const group = new THREE.Group();
|
|
136
|
+
group.name = 'Shield';
|
|
137
|
+
|
|
138
|
+
const faceMat = new THREE.MeshStandardMaterial({ color: 0x4a6080, roughness: 0.35, metalness: 0.6 });
|
|
139
|
+
const rimMat = new THREE.MeshStandardMaterial({ color: 0x8a7a60, roughness: 0.2, metalness: 0.85 });
|
|
140
|
+
const bossMat = new THREE.MeshStandardMaterial({ color: 0xc0b090, roughness: 0.15, metalness: 0.9 });
|
|
141
|
+
|
|
142
|
+
// Shield face (disc)
|
|
143
|
+
const face = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.02, 32), faceMat);
|
|
144
|
+
group.add(face);
|
|
145
|
+
|
|
146
|
+
// Rim ring
|
|
147
|
+
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.015, 8, 32), rimMat);
|
|
148
|
+
group.add(rim);
|
|
149
|
+
|
|
150
|
+
// Center boss
|
|
151
|
+
const boss = new THREE.Mesh(new THREE.SphereGeometry(0.04, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.45), bossMat);
|
|
152
|
+
boss.position.set(0, 0, 0.012);
|
|
153
|
+
group.add(boss);
|
|
154
|
+
|
|
155
|
+
// Cross rivets
|
|
156
|
+
for (const [x, y] of [[0, 0.09], [0, -0.09], [0.09, 0], [-0.09, 0]]) {
|
|
157
|
+
const rivet = new THREE.Mesh(new THREE.SphereGeometry(0.012, 6, 4), bossMat);
|
|
158
|
+
rivet.position.set(x, y, 0.012);
|
|
159
|
+
group.add(rivet);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const child of group.children) {
|
|
163
|
+
child.castShadow = true;
|
|
164
|
+
child.receiveShadow = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// By default the shield faces forward — parent to forearm and rotate to taste
|
|
168
|
+
return group;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Wings — angel/demon wings that sit on the upper back
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function buildWingsMesh() {
|
|
176
|
+
const group = new THREE.Group();
|
|
177
|
+
group.name = 'Wings';
|
|
178
|
+
|
|
179
|
+
const featherMat = new THREE.MeshStandardMaterial({ color: 0xe8e4dc, roughness: 0.6, metalness: 0.02, side: THREE.DoubleSide });
|
|
180
|
+
const frameMat = new THREE.MeshStandardMaterial({ color: 0xc8c0b4, roughness: 0.4, metalness: 0.1 });
|
|
181
|
+
|
|
182
|
+
function buildWing(side) {
|
|
183
|
+
const wing = new THREE.Group();
|
|
184
|
+
// Main frame (curved upper edge)
|
|
185
|
+
const frame = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 0.55, 6), frameMat);
|
|
186
|
+
frame.position.set(0.18, 0.5, 0);
|
|
187
|
+
frame.rotation.z = -side * 0.7;
|
|
188
|
+
frame.rotation.x = 0.3;
|
|
189
|
+
wing.add(frame);
|
|
190
|
+
|
|
191
|
+
// Feather rows (layered triangles)
|
|
192
|
+
for (let row = 0; row < 4; row++) {
|
|
193
|
+
const y = 0.5 - row * 0.14;
|
|
194
|
+
const width = 0.14 + row * 0.07;
|
|
195
|
+
const height = 0.1 + row * 0.02;
|
|
196
|
+
const xOff = 0.22 + row * 0.07;
|
|
197
|
+
for (let f = 0; f < 3; f++) {
|
|
198
|
+
const feather = new THREE.Mesh(
|
|
199
|
+
new THREE.ConeGeometry(width * 0.4, height, 4, 1),
|
|
200
|
+
featherMat,
|
|
201
|
+
);
|
|
202
|
+
feather.position.set(xOff + f * 0.04, y - f * 0.03, (f - 1) * 0.06);
|
|
203
|
+
feather.rotation.z = -side * (0.5 + row * 0.1);
|
|
204
|
+
feather.rotation.x = 0.15 + row * 0.1;
|
|
205
|
+
feather.scale.set(1, 1, 0.35);
|
|
206
|
+
wing.add(feather);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Tip feathers
|
|
210
|
+
for (let i = 0; i < 2; i++) {
|
|
211
|
+
const tip = new THREE.Mesh(
|
|
212
|
+
new THREE.ConeGeometry(0.06, 0.18, 4, 1),
|
|
213
|
+
featherMat,
|
|
214
|
+
);
|
|
215
|
+
tip.position.set(0.35, 0.58 - i * 0.1, (i - 0.5) * 0.08);
|
|
216
|
+
tip.rotation.z = -side * 0.9;
|
|
217
|
+
tip.rotation.x = 0.2;
|
|
218
|
+
tip.scale.set(1, 1, 0.3);
|
|
219
|
+
wing.add(tip);
|
|
220
|
+
}
|
|
221
|
+
wing.position.x = side * 0.08;
|
|
222
|
+
return wing;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
group.add(buildWing(1)); // right wing
|
|
226
|
+
group.add(buildWing(-1)); // left wing
|
|
227
|
+
|
|
228
|
+
for (const child of group.children) {
|
|
229
|
+
child.traverse((c) => {
|
|
230
|
+
if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; }
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return group;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Built-in presets — ready-to-attach items with suggested bone + offset
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
export const BUILTIN_PRESETS = [
|
|
242
|
+
{
|
|
243
|
+
id: 'sword',
|
|
244
|
+
label: 'Sword',
|
|
245
|
+
bone: 'mWristRight',
|
|
246
|
+
factory: buildSwordMesh,
|
|
247
|
+
offset: { pos: [-0.04, -0.05, 0.06], rot: [0, 0, -Math.PI / 2], scale: [1, 1, 1] },
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'hair',
|
|
251
|
+
label: 'Hair',
|
|
252
|
+
bone: 'mHead',
|
|
253
|
+
factory: buildHairMesh,
|
|
254
|
+
offset: { pos: [0, 0.04, -0.01], rot: [0, 0, 0], scale: [1, 1, 1] },
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'shield',
|
|
258
|
+
label: 'Shield',
|
|
259
|
+
bone: 'mWristLeft',
|
|
260
|
+
factory: buildShieldMesh,
|
|
261
|
+
offset: { pos: [0.04, 0.0, 0.0], rot: [0, Math.PI / 2, 0], scale: [1, 1, 1] },
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'wings',
|
|
265
|
+
label: 'Wings',
|
|
266
|
+
bone: 'mChest',
|
|
267
|
+
factory: buildWingsMesh,
|
|
268
|
+
offset: { pos: [0, 0.05, -0.18], rot: [0, 0, 0], scale: [0.5, 0.5, 0.5] },
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Attachments manager
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
export class Attachments {
|
|
277
|
+
constructor(avatar) {
|
|
278
|
+
this.avatar = avatar;
|
|
279
|
+
/** @type {{ id:number, name:string, boneName:string, object:THREE.Group,
|
|
280
|
+
* offset:{pos:[number,number,number],rot:[number,number,number],scale:[number,number,number]},
|
|
281
|
+
* _url?:string }[]} */
|
|
282
|
+
this.entries = [];
|
|
283
|
+
this._loader = new GLTFLoader();
|
|
284
|
+
this._nextId = 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Attach a built-in procedural mesh. `factory()` must return a THREE.Object3D.
|
|
289
|
+
*/
|
|
290
|
+
attachBuiltin(name, boneName, factory, offset = {}) {
|
|
291
|
+
const bone = getCanonicalBone(this.avatar, boneName);
|
|
292
|
+
if (!bone) throw new Error(`Bone not found: ${boneName}`);
|
|
293
|
+
|
|
294
|
+
const obj = factory();
|
|
295
|
+
const entry = this._createEntry(name, boneName, obj, offset);
|
|
296
|
+
bone.add(obj);
|
|
297
|
+
this.entries.push(entry);
|
|
298
|
+
return entry;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Attach a GLB/glTF file.
|
|
303
|
+
*/
|
|
304
|
+
async attachFile(file, boneName, offset = {}) {
|
|
305
|
+
const bone = getCanonicalBone(this.avatar, boneName);
|
|
306
|
+
if (!bone) throw new Error(`Bone not found: ${boneName}`);
|
|
307
|
+
|
|
308
|
+
const url = URL.createObjectURL(file);
|
|
309
|
+
let gltf;
|
|
310
|
+
try {
|
|
311
|
+
gltf = await this._loader.loadAsync(url);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
URL.revokeObjectURL(url);
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
URL.revokeObjectURL(url);
|
|
317
|
+
|
|
318
|
+
const obj = gltf.scene;
|
|
319
|
+
obj.traverse((child) => {
|
|
320
|
+
if (child.isMesh) {
|
|
321
|
+
child.castShadow = true;
|
|
322
|
+
child.receiveShadow = true;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const entry = this._createEntry(file.name.replace(/\.[^.]+$/, ''), boneName, obj, offset);
|
|
327
|
+
entry._url = url;
|
|
328
|
+
bone.add(obj);
|
|
329
|
+
this.entries.push(entry);
|
|
330
|
+
return entry;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_createEntry(name, boneName, object, offset = {}) {
|
|
334
|
+
const id = this._nextId++;
|
|
335
|
+
const entry = {
|
|
336
|
+
id,
|
|
337
|
+
name,
|
|
338
|
+
boneName,
|
|
339
|
+
object,
|
|
340
|
+
offset: {
|
|
341
|
+
pos: offset.pos ? [...offset.pos] : [0, 0, 0],
|
|
342
|
+
rot: offset.rot ? [...offset.rot] : [0, 0, 0],
|
|
343
|
+
scale: offset.scale ? [...offset.scale] : [1, 1, 1],
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
this._applyOffset(entry);
|
|
347
|
+
return entry;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Remove one attachment by id. */
|
|
351
|
+
remove(id) {
|
|
352
|
+
const idx = this.entries.findIndex((e) => e.id === id);
|
|
353
|
+
if (idx === -1) return false;
|
|
354
|
+
const entry = this.entries[idx];
|
|
355
|
+
entry.object.removeFromParent();
|
|
356
|
+
this.entries.splice(idx, 1);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Update local offset (pos / rot / scale) relative to the parent bone. */
|
|
361
|
+
setOffset(id, offset) {
|
|
362
|
+
const entry = this.entries.find((e) => e.id === id);
|
|
363
|
+
if (!entry) return;
|
|
364
|
+
if (offset.pos) entry.offset.pos = [...offset.pos];
|
|
365
|
+
if (offset.rot) entry.offset.rot = [...offset.rot];
|
|
366
|
+
if (offset.scale) entry.offset.scale = [...offset.scale];
|
|
367
|
+
this._applyOffset(entry);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_applyOffset(entry) {
|
|
371
|
+
const [px, py, pz] = entry.offset.pos;
|
|
372
|
+
const [rx, ry, rz] = entry.offset.rot;
|
|
373
|
+
const [sx, sy, sz] = entry.offset.scale;
|
|
374
|
+
entry.object.position.set(px, py, pz);
|
|
375
|
+
entry.object.rotation.set(rx, ry, rz);
|
|
376
|
+
entry.object.scale.set(sx, sy, sz);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Remove all attachments. */
|
|
380
|
+
clear() {
|
|
381
|
+
for (const entry of [...this.entries]) this.remove(entry.id);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Convenience: create the built-in sword. */
|
|
386
|
+
export function createSword() {
|
|
387
|
+
return buildSwordMesh();
|
|
388
|
+
}
|
package/avatarManager.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { Avatar } from './Avatar.js';
|
|
3
|
+
|
|
4
|
+
// Owns the set of live avatars in a scene and which one is "active" (the one
|
|
5
|
+
// the UI panel and the editor/play tools currently drive). Each entry pairs an
|
|
6
|
+
// Avatar with its scene-graph extras (skeleton helpers) and a bag of UI-only
|
|
7
|
+
// state that isn't stored on the avatar itself (slider positions, sex preset,
|
|
8
|
+
// the Animate-tab layer selection). Everything else — physics, blink, voice,
|
|
9
|
+
// materials, attachments — already lives on the avatar, so switching avatars
|
|
10
|
+
// just repoints the UI at a different instance.
|
|
11
|
+
export class AvatarManager {
|
|
12
|
+
constructor({ scene, basePath, makeUiState }) {
|
|
13
|
+
if (typeof basePath !== 'string' || !basePath) {
|
|
14
|
+
throw new Error('AvatarManager({ basePath }): basePath is required (passed through to Avatar.load).');
|
|
15
|
+
}
|
|
16
|
+
this.scene = scene;
|
|
17
|
+
this.basePath = basePath;
|
|
18
|
+
this._makeUiState = makeUiState ?? (() => ({}));
|
|
19
|
+
this.entries = []; // { id, avatar, helpers, ui }
|
|
20
|
+
this.activeIndex = -1;
|
|
21
|
+
this._nextId = 1;
|
|
22
|
+
this._skeletonsVisible = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get active() { return this.entries[this.activeIndex]?.avatar ?? null; }
|
|
26
|
+
get activeEntry() { return this.entries[this.activeIndex] ?? null; }
|
|
27
|
+
get count() { return this.entries.length; }
|
|
28
|
+
|
|
29
|
+
// Take ownership of an already-loaded avatar (the first one the app builds).
|
|
30
|
+
adopt(avatar, { select = true } = {}) {
|
|
31
|
+
const entry = this._mkEntry(avatar);
|
|
32
|
+
this.entries.push(entry);
|
|
33
|
+
if (select || this.activeIndex < 0) this.activeIndex = this.entries.length - 1;
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build, load, place, and register a new avatar. Avatars fan out along X so
|
|
38
|
+
// they don't overlap unless an explicit position is given.
|
|
39
|
+
async add({ position } = {}) {
|
|
40
|
+
const avatar = new Avatar();
|
|
41
|
+
await avatar.load(this.basePath);
|
|
42
|
+
avatar.group.rotation.y = -Math.PI / 2; // face +Z like the first one
|
|
43
|
+
const i = this.entries.length;
|
|
44
|
+
const fanX = (i % 2 === 1 ? 1 : -1) * Math.ceil(i / 2) * 1.2;
|
|
45
|
+
avatar.group.position.set(position?.x ?? fanX, 0, position?.z ?? 0);
|
|
46
|
+
const entry = this._mkEntry(avatar);
|
|
47
|
+
this.entries.push(entry);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_mkEntry(avatar) {
|
|
52
|
+
const helpers = [];
|
|
53
|
+
for (const part of Object.values(avatar.parts)) {
|
|
54
|
+
const h = new THREE.SkeletonHelper(part.root);
|
|
55
|
+
h.visible = this._skeletonsVisible;
|
|
56
|
+
this.scene.add(h);
|
|
57
|
+
helpers.push(h);
|
|
58
|
+
}
|
|
59
|
+
this.scene.add(avatar.group);
|
|
60
|
+
return { id: this._nextId++, avatar, helpers, ui: this._makeUiState() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
indexOf(entryOrAvatar) {
|
|
64
|
+
return this.entries.findIndex((e) => e === entryOrAvatar || e.avatar === entryOrAvatar);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Resolve a selector (undefined → active, number → index, string → index or
|
|
68
|
+
// matching id) to { entry, index }. Throws on an out-of-range selector.
|
|
69
|
+
resolve(selector) {
|
|
70
|
+
if (selector === undefined || selector === null) {
|
|
71
|
+
return { entry: this.activeEntry, index: this.activeIndex };
|
|
72
|
+
}
|
|
73
|
+
let index = -1;
|
|
74
|
+
if (typeof selector === 'number') index = selector;
|
|
75
|
+
else if (typeof selector === 'string') {
|
|
76
|
+
const byId = this.entries.findIndex((e) => String(e.id) === selector);
|
|
77
|
+
index = byId >= 0 ? byId : Number.parseInt(selector, 10);
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.entries.length) {
|
|
80
|
+
throw new Error(`no such avatar: ${selector} (have ${this.entries.length})`);
|
|
81
|
+
}
|
|
82
|
+
return { entry: this.entries[index], index };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
remove(index) {
|
|
86
|
+
if (this.entries.length <= 1) return false; // never drop the last one
|
|
87
|
+
const entry = this.entries[index];
|
|
88
|
+
if (!entry) return false;
|
|
89
|
+
for (const h of entry.helpers) { this.scene.remove(h); h.dispose?.(); }
|
|
90
|
+
entry.avatar.dispose();
|
|
91
|
+
this.entries.splice(index, 1);
|
|
92
|
+
if (this.activeIndex >= this.entries.length) this.activeIndex = this.entries.length - 1;
|
|
93
|
+
else if (this.activeIndex > index) this.activeIndex -= 1;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setActive(index) {
|
|
98
|
+
if (index < 0 || index >= this.entries.length) return false;
|
|
99
|
+
this.activeIndex = index;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setSkeletonsVisible(on) {
|
|
104
|
+
this._skeletonsVisible = on;
|
|
105
|
+
for (const e of this.entries) for (const h of e.helpers) h.visible = on;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Advance every avatar (active or not) so background avatars keep animating.
|
|
109
|
+
update(dt) { for (const e of this.entries) e.avatar.update(dt); }
|
|
110
|
+
}
|
package/blink.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Procedural eye blinking.
|
|
2
|
+
//
|
|
3
|
+
// Schedules blinks at a randomized interval and plays each as a quick
|
|
4
|
+
// close→open envelope driving RuthAvatar.setBlink. Defaults follow the
|
|
5
|
+
// cadence games use for an idle character: a blink every few seconds, ~120 ms
|
|
6
|
+
// long, with enough interval jitter that it never reads as metronomic.
|
|
7
|
+
|
|
8
|
+
export class Blinker {
|
|
9
|
+
constructor(avatar) {
|
|
10
|
+
this.avatar = avatar;
|
|
11
|
+
this.enabled = false;
|
|
12
|
+
|
|
13
|
+
this.interval = 3.5; // mean seconds between blinks
|
|
14
|
+
this.variation = 0.5; // 0..1 — fraction the interval is randomly jittered
|
|
15
|
+
this.speed = 0.12; // seconds for one full close→open
|
|
16
|
+
|
|
17
|
+
this._timer = 0; // counts down to the next blink
|
|
18
|
+
this._phase = -1; // -1 = idle between blinks; 0..1 = mid-blink
|
|
19
|
+
this._schedule();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
setEnabled(on) {
|
|
23
|
+
this.enabled = on;
|
|
24
|
+
this._phase = -1;
|
|
25
|
+
this._schedule();
|
|
26
|
+
if (!on) this.avatar.setBlink(0); // leave the eyes open when turned off
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Force a blink right now (used by the UI "Blink now" button / on enable).
|
|
30
|
+
blinkNow() {
|
|
31
|
+
if (this._phase < 0) this._phase = 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_schedule() {
|
|
35
|
+
const jitter = 1 + (Math.random() * 2 - 1) * this.variation;
|
|
36
|
+
this._timer = Math.max(0.2, this.interval * jitter);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
update(dt) {
|
|
40
|
+
if (!this.enabled) return;
|
|
41
|
+
|
|
42
|
+
if (this._phase >= 0) {
|
|
43
|
+
this._phase += dt / Math.max(0.04, this.speed);
|
|
44
|
+
if (this._phase >= 1) {
|
|
45
|
+
this._phase = -1;
|
|
46
|
+
this.avatar.setBlink(0);
|
|
47
|
+
this._schedule();
|
|
48
|
+
} else {
|
|
49
|
+
// sin(pi·phase) sweeps 0→1→0 — a smooth, eyelid-like close and open.
|
|
50
|
+
this.avatar.setBlink(Math.sin(Math.PI * this._phase));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this._timer -= dt;
|
|
56
|
+
if (this._timer <= 0) this._phase = 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/bvh.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
|
|
3
|
+
|
|
4
|
+
// BVH joint name -> avatar skeleton bone name.
|
|
5
|
+
//
|
|
6
|
+
// The classic Poser/Avimator/QAvimator BVH rig uses these names. The
|
|
7
|
+
// CMU/MotionBuilder-style aliases are
|
|
8
|
+
// included as well so common free mocap files retarget too.
|
|
9
|
+
const NAME_MAP = {
|
|
10
|
+
// Poser-standard names
|
|
11
|
+
hip: 'mPelvis', abdomen: 'mTorso', chest: 'mChest', neck: 'mNeck', head: 'mHead',
|
|
12
|
+
lCollar: 'mCollarLeft', lShldr: 'mShoulderLeft', lForeArm: 'mElbowLeft', lHand: 'mWristLeft',
|
|
13
|
+
rCollar: 'mCollarRight', rShldr: 'mShoulderRight', rForeArm: 'mElbowRight', rHand: 'mWristRight',
|
|
14
|
+
lThigh: 'mHipLeft', lShin: 'mKneeLeft', lFoot: 'mAnkleLeft', lToe: 'mFootLeft',
|
|
15
|
+
rThigh: 'mHipRight', rShin: 'mKneeRight', rFoot: 'mAnkleRight', rToe: 'mFootRight',
|
|
16
|
+
// Bento fingers (Poser-style naming as used in pirouette.bvh)
|
|
17
|
+
lThumb1: 'mHandThumb1Left', lThumb2: 'mHandThumb2Left',
|
|
18
|
+
lIndex1: 'mHandIndex1Left', lIndex2: 'mHandIndex2Left',
|
|
19
|
+
lMid1: 'mHandMiddle1Left', lMid2: 'mHandMiddle2Left',
|
|
20
|
+
lRing1: 'mHandRing1Left', lRing2: 'mHandRing2Left',
|
|
21
|
+
lPinky1: 'mHandPinky1Left', lPinky2: 'mHandPinky2Left',
|
|
22
|
+
rThumb1: 'mHandThumb1Right', rThumb2: 'mHandThumb2Right',
|
|
23
|
+
rIndex1: 'mHandIndex1Right', rIndex2: 'mHandIndex2Right',
|
|
24
|
+
rMid1: 'mHandMiddle1Right', rMid2: 'mHandMiddle2Right',
|
|
25
|
+
rRing1: 'mHandRing1Right', rRing2: 'mHandRing2Right',
|
|
26
|
+
rPinky1: 'mHandPinky1Right', rPinky2: 'mHandPinky2Right',
|
|
27
|
+
// CMU / MotionBuilder-style aliases
|
|
28
|
+
Hips: 'mPelvis', LowerBack: 'mTorso', Spine: 'mTorso', Spine1: 'mChest',
|
|
29
|
+
Neck: 'mNeck', Neck1: 'mNeck', Head: 'mHead',
|
|
30
|
+
LeftShoulder: 'mCollarLeft', LeftArm: 'mShoulderLeft', LeftForeArm: 'mElbowLeft', LeftHand: 'mWristLeft',
|
|
31
|
+
RightShoulder: 'mCollarRight', RightArm: 'mShoulderRight', RightForeArm: 'mElbowRight', RightHand: 'mWristRight',
|
|
32
|
+
LeftUpLeg: 'mHipLeft', LeftLeg: 'mKneeLeft', LeftFoot: 'mAnkleLeft', LeftToeBase: 'mFootLeft',
|
|
33
|
+
RightUpLeg: 'mHipRight', RightLeg: 'mKneeRight', RightFoot: 'mAnkleRight', RightToeBase: 'mFootRight',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const loader = new BVHLoader();
|
|
37
|
+
|
|
38
|
+
export function parseBVH(text) {
|
|
39
|
+
return loader.parse(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadBVH(url) {
|
|
43
|
+
// Bypass the HTTP cache so freshly edited/regenerated clips always load
|
|
44
|
+
// (the bundled .bvh files change during authoring).
|
|
45
|
+
const text = await (await fetch(url, { cache: 'reload' })).text();
|
|
46
|
+
return parseBVH(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Retarget a BVHLoader result onto the avatar skeleton.
|
|
50
|
+
//
|
|
51
|
+
// Both rigs are "world-aligned": BVH joints carry no rest orientation,
|
|
52
|
+
// and the Avastar-exported Ruth rig stores its bones as pure translations.
|
|
53
|
+
// The frame difference between them: the BVH rig is Y-up facing +Z with
|
|
54
|
+
// arms along ±X (Poser convention), the Ruth armature is Z-up facing +X
|
|
55
|
+
// with arms along ±Y (Ruth convention). That axis change is M = Rz(90°) ∘
|
|
56
|
+
// Rx(90°); every joint rotation is conjugated by it and the hip translation
|
|
57
|
+
// is rotated by it and rescaled from BVH units to meters.
|
|
58
|
+
//
|
|
59
|
+
// Returns a THREE.AnimationClip with tracks named "<bone>.quaternion" and
|
|
60
|
+
// "mPelvis.position" that RuthAvatar.playClip binds by node name.
|
|
61
|
+
export function retargetToRuth(bvh, pelvisRestZ) {
|
|
62
|
+
const C = new THREE.Quaternion()
|
|
63
|
+
.setFromEuler(new THREE.Euler(0, 0, Math.PI / 2))
|
|
64
|
+
.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, 0, 0)));
|
|
65
|
+
const Cinv = C.clone().invert();
|
|
66
|
+
|
|
67
|
+
// Hip rest height (first position keyframe, Y-up) gives us the unit scale.
|
|
68
|
+
let posScale = 1;
|
|
69
|
+
const hipPosTrack = bvh.clip.tracks.find(
|
|
70
|
+
(t) => t.name.endsWith('.position') && NAME_MAP[trackBone(t.name)] === 'mPelvis'
|
|
71
|
+
);
|
|
72
|
+
if (hipPosTrack) {
|
|
73
|
+
const y0 = hipPosTrack.values[1];
|
|
74
|
+
if (y0 > 0.001) posScale = pelvisRestZ / y0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tracks = [];
|
|
78
|
+
const q = new THREE.Quaternion();
|
|
79
|
+
const v = new THREE.Vector3();
|
|
80
|
+
|
|
81
|
+
for (const track of bvh.clip.tracks) {
|
|
82
|
+
const bone = NAME_MAP[trackBone(track.name)];
|
|
83
|
+
if (!bone) continue;
|
|
84
|
+
|
|
85
|
+
if (track.name.endsWith('.quaternion')) {
|
|
86
|
+
const values = new Float32Array(track.values.length);
|
|
87
|
+
for (let i = 0; i < track.values.length; i += 4) {
|
|
88
|
+
q.fromArray(track.values, i);
|
|
89
|
+
q.premultiply(C).multiply(Cinv); // q' = C * q * C^-1
|
|
90
|
+
q.toArray(values, i);
|
|
91
|
+
}
|
|
92
|
+
tracks.push(new THREE.QuaternionKeyframeTrack(`${bone}.quaternion`, track.times, values));
|
|
93
|
+
} else if (track.name.endsWith('.position') && bone === 'mPelvis') {
|
|
94
|
+
const values = new Float32Array(track.values.length);
|
|
95
|
+
for (let i = 0; i < track.values.length; i += 3) {
|
|
96
|
+
v.fromArray(track.values, i).multiplyScalar(posScale).applyQuaternion(C);
|
|
97
|
+
v.toArray(values, i);
|
|
98
|
+
}
|
|
99
|
+
tracks.push(new THREE.VectorKeyframeTrack('mPelvis.position', track.times, values));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return new THREE.AnimationClip(bvh.clip.name || 'bvh', bvh.clip.duration, tracks);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function trackBone(trackName) {
|
|
107
|
+
// BVHLoader names tracks ".bones[name].quaternion"
|
|
108
|
+
const m = trackName.match(/\.bones\[(.+?)\]\./);
|
|
109
|
+
return m ? m[1] : trackName.split('.')[0];
|
|
110
|
+
}
|