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/gltfAnim.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
|
|
4
|
+
// Bone name mapping: UAL1 glTF node name → Ruth avatar bone name.
|
|
5
|
+
// Both skeletons use hierarchy-compatible names but the UAL1 rig uses
|
|
6
|
+
// glTF convention (lowercase, _l/_r suffix) while Ruth uses Second Life
|
|
7
|
+
// bone names (m-prefix, CamelCase). Leaf bones without a Ruth
|
|
8
|
+
// counterpart (finger tips, ball joint, root) are left unmapped and
|
|
9
|
+
// their tracks are silently dropped.
|
|
10
|
+
const UAL1_TO_RUTH = {
|
|
11
|
+
root: null,
|
|
12
|
+
pelvis: 'mPelvis',
|
|
13
|
+
spine_01: 'mSpine1',
|
|
14
|
+
spine_02: 'mSpine2',
|
|
15
|
+
spine_03: 'mSpine3',
|
|
16
|
+
neck_01: 'mNeck',
|
|
17
|
+
Head: 'mHead',
|
|
18
|
+
clavicle_l: 'mCollarLeft',
|
|
19
|
+
upperarm_l: 'mShoulderLeft',
|
|
20
|
+
lowerarm_l: 'mElbowLeft',
|
|
21
|
+
hand_l: 'mWristLeft',
|
|
22
|
+
thumb_01_l: 'mHandThumb1Left',
|
|
23
|
+
thumb_02_l: 'mHandThumb2Left',
|
|
24
|
+
thumb_03_l: 'mHandThumb3Left',
|
|
25
|
+
thumb_04_leaf_l: null, // fingertip — no Ruth bone
|
|
26
|
+
index_01_l: 'mHandIndex1Left',
|
|
27
|
+
index_02_l: 'mHandIndex2Left',
|
|
28
|
+
index_03_l: 'mHandIndex3Left',
|
|
29
|
+
index_04_leaf_l: null,
|
|
30
|
+
middle_01_l: 'mHandMiddle1Left',
|
|
31
|
+
middle_02_l: 'mHandMiddle2Left',
|
|
32
|
+
middle_03_l: 'mHandMiddle3Left',
|
|
33
|
+
middle_04_leaf_l: null,
|
|
34
|
+
ring_01_l: 'mHandRing1Left',
|
|
35
|
+
ring_02_l: 'mHandRing2Left',
|
|
36
|
+
ring_03_l: 'mHandRing3Left',
|
|
37
|
+
ring_04_leaf_l: null,
|
|
38
|
+
pinky_01_l: 'mHandPinky1Left',
|
|
39
|
+
pinky_02_l: 'mHandPinky2Left',
|
|
40
|
+
pinky_03_l: 'mHandPinky3Left',
|
|
41
|
+
pinky_04_leaf_l: null,
|
|
42
|
+
clavicle_r: 'mCollarRight',
|
|
43
|
+
upperarm_r: 'mShoulderRight',
|
|
44
|
+
lowerarm_r: 'mElbowRight',
|
|
45
|
+
hand_r: 'mWristRight',
|
|
46
|
+
thumb_01_r: 'mHandThumb1Right',
|
|
47
|
+
thumb_02_r: 'mHandThumb2Right',
|
|
48
|
+
thumb_03_r: 'mHandThumb3Right',
|
|
49
|
+
thumb_04_leaf_r: null,
|
|
50
|
+
index_01_r: 'mHandIndex1Right',
|
|
51
|
+
index_02_r: 'mHandIndex2Right',
|
|
52
|
+
index_03_r: 'mHandIndex3Right',
|
|
53
|
+
index_04_leaf_r: null,
|
|
54
|
+
middle_01_r: 'mHandMiddle1Right',
|
|
55
|
+
middle_02_r: 'mHandMiddle2Right',
|
|
56
|
+
middle_03_r: 'mHandMiddle3Right',
|
|
57
|
+
middle_04_leaf_r: null,
|
|
58
|
+
ring_01_r: 'mHandRing1Right',
|
|
59
|
+
ring_02_r: 'mHandRing2Right',
|
|
60
|
+
ring_03_r: 'mHandRing3Right',
|
|
61
|
+
ring_04_leaf_r: null,
|
|
62
|
+
pinky_01_r: 'mHandPinky1Right',
|
|
63
|
+
pinky_02_r: 'mHandPinky2Right',
|
|
64
|
+
pinky_03_r: 'mHandPinky3Right',
|
|
65
|
+
pinky_04_leaf_r: null,
|
|
66
|
+
thigh_l: 'mHipLeft',
|
|
67
|
+
calf_l: 'mKneeLeft',
|
|
68
|
+
foot_l: 'mAnkleLeft',
|
|
69
|
+
ball_l: 'mFootLeft',
|
|
70
|
+
ball_leaf_l: null, // toe tip
|
|
71
|
+
thigh_r: 'mHipRight',
|
|
72
|
+
calf_r: 'mKneeRight',
|
|
73
|
+
foot_r: 'mAnkleRight',
|
|
74
|
+
ball_r: 'mFootRight',
|
|
75
|
+
ball_leaf_r: null,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ---- module state (lazy-initialised, per GLB file) ------------------
|
|
79
|
+
|
|
80
|
+
// Each UAL GLB (UAL1, UAL2, …) shares the identical 67-bone skeleton but
|
|
81
|
+
// carries its own animation set, so clips are cached per file path.
|
|
82
|
+
const _cache = new Map(); // glbFile -> Map<glbAnimName, AnimationClip>
|
|
83
|
+
const _loading = new Map(); // glbFile -> Promise (dedupes concurrent loads)
|
|
84
|
+
|
|
85
|
+
const _qTmp = new THREE.Quaternion();
|
|
86
|
+
|
|
87
|
+
// ---- public API ------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
// Load a UAL GLB, retarget every animation in it, and cache the clips.
|
|
90
|
+
// `avatar` supplies the pelvis rest pose/height needed to retarget root
|
|
91
|
+
// motion. Safe to call repeatedly / concurrently for the same file — it
|
|
92
|
+
// loads once.
|
|
93
|
+
export async function initGltfAnim(glbFile, avatar, signal) {
|
|
94
|
+
if (_cache.has(glbFile)) return;
|
|
95
|
+
if (_loading.has(glbFile)) return _loading.get(glbFile);
|
|
96
|
+
const p = _loadAndRetarget(glbFile, avatar, signal).then((clips) => {
|
|
97
|
+
_cache.set(glbFile, clips);
|
|
98
|
+
_loading.delete(glbFile);
|
|
99
|
+
}, (err) => {
|
|
100
|
+
_loading.delete(glbFile);
|
|
101
|
+
throw err;
|
|
102
|
+
});
|
|
103
|
+
_loading.set(glbFile, p);
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Return a retargeted AnimationClip for the named animation in a loaded GLB,
|
|
108
|
+
// or null if not found. Throws if the file hasn't been initialised yet.
|
|
109
|
+
export function getGltfClip(glbFile, glbAnimName) {
|
|
110
|
+
const clips = _cache.get(glbFile);
|
|
111
|
+
if (!clips) throw new Error(`glTF animations for ${glbFile} not initialised — call initGltfAnim(glbFile) first`);
|
|
112
|
+
return clips.get(glbAnimName) ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function _loadAndRetarget(glbFile, avatar, signal) {
|
|
116
|
+
const loader = new GLTFLoader();
|
|
117
|
+
const gltf = await loader.loadAsync(glbFile, undefined, signal ? { signal } : undefined);
|
|
118
|
+
const ctx = _buildXform(gltf.parser.json.nodes, avatar);
|
|
119
|
+
|
|
120
|
+
const clips = new Map();
|
|
121
|
+
for (const glbAnim of gltf.animations) {
|
|
122
|
+
const clip = _retargetGltfClip(glbAnim, ctx);
|
|
123
|
+
if (clip) clips.set(glbAnim.name, clip);
|
|
124
|
+
}
|
|
125
|
+
return clips;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Build the retarget context from a GLB's node list + the target avatar.
|
|
129
|
+
// Returns { xform: Map<glbNodeName, { P, Pinv, restInv }>, pelvisPos } where
|
|
130
|
+
// pelvisPos carries everything needed to retarget root (pelvis) translation.
|
|
131
|
+
function _buildXform(nodes, avatar) {
|
|
132
|
+
// Per-node LOCAL rest rotation (node[].rotation), and child→parent index map.
|
|
133
|
+
const localRest = nodes.map((node) =>
|
|
134
|
+
(node.rotation && node.rotation.length === 4)
|
|
135
|
+
? new THREE.Quaternion(node.rotation[0], node.rotation[1], node.rotation[2], node.rotation[3])
|
|
136
|
+
: new THREE.Quaternion());
|
|
137
|
+
const parentOf = new Array(nodes.length).fill(-1);
|
|
138
|
+
nodes.forEach((node, i) => {
|
|
139
|
+
for (const c of node.children ?? []) parentOf[c] = i;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// WORLD rest orientation per node = product of LOCAL rests from the root
|
|
143
|
+
// down (parent-first). This is the bone's bind orientation in the glTF
|
|
144
|
+
// scene frame — exactly what the BVH rig lacks (it is world-aligned).
|
|
145
|
+
// Resolved per-node up the full chain, so node ordering doesn't matter.
|
|
146
|
+
const worldRest = new Array(nodes.length);
|
|
147
|
+
const resolveWorld = (i) => {
|
|
148
|
+
if (worldRest[i]) return worldRest[i];
|
|
149
|
+
const p = parentOf[i];
|
|
150
|
+
const q = p === -1
|
|
151
|
+
? localRest[i].clone()
|
|
152
|
+
: resolveWorld(p).clone().multiply(localRest[i]);
|
|
153
|
+
worldRest[i] = q;
|
|
154
|
+
return q;
|
|
155
|
+
};
|
|
156
|
+
nodes.forEach((_, i) => resolveWorld(i));
|
|
157
|
+
|
|
158
|
+
// Precompute the per-bone conjugation operator P = C · Wrest[parent] and the
|
|
159
|
+
// source bind inverse, so the per-frame loop is just two quaternion products.
|
|
160
|
+
const xform = new Map();
|
|
161
|
+
nodes.forEach((node, i) => {
|
|
162
|
+
if (!node.name || !UAL1_TO_RUTH[node.name]) return;
|
|
163
|
+
const p = parentOf[i];
|
|
164
|
+
const wrestParent = p === -1 ? _IDENT : worldRest[p];
|
|
165
|
+
const P = _C.clone().multiply(wrestParent);
|
|
166
|
+
xform.set(node.name, {
|
|
167
|
+
P,
|
|
168
|
+
Pinv: P.clone().invert(),
|
|
169
|
+
restInv: localRest[i].clone().invert(),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Root-motion descriptor for the pelvis translation track. The pelvis node's
|
|
174
|
+
// translation lives in its parent (root) authored Z-up frame, so the same P
|
|
175
|
+
// operator that re-frames its rotation also re-frames the translation delta.
|
|
176
|
+
// We scale source units → Ruth units by the pelvis-height ratio and add the
|
|
177
|
+
// result to Ruth's own rest pelvis position (delta-from-bind, so the figure
|
|
178
|
+
// stays planted at rest and only the motion carries over).
|
|
179
|
+
let pelvisPos = null;
|
|
180
|
+
const pelvisX = xform.get('pelvis');
|
|
181
|
+
const pelvisNode = nodes.find((n) => n.name === 'pelvis');
|
|
182
|
+
const ruthRest = avatar?.parts?.body?.rest?.get('mPelvis');
|
|
183
|
+
const t0 = pelvisNode?.translation;
|
|
184
|
+
if (pelvisX && ruthRest && t0 && t0[2] > 1e-4) {
|
|
185
|
+
pelvisPos = {
|
|
186
|
+
P: pelvisX.P, // root-local → Ruth frame
|
|
187
|
+
t0: new THREE.Vector3(t0[0], t0[1], t0[2]), // source bind translation
|
|
188
|
+
scale: avatar.pelvisRestZ / t0[2], // source → Ruth body scale
|
|
189
|
+
restP: ruthRest.p.clone(), // Ruth rest pelvis position
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { xform, pelvisPos };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- retargeting internals -------------------------------------------
|
|
196
|
+
|
|
197
|
+
// glTF → Ruth coordinate-frame change, same as BVH retargeting.
|
|
198
|
+
// glTF uses Y-up; Ruth uses Z-up +X forward (Second Life). The source rig's
|
|
199
|
+
// own Z-up→Y-up tilt lives in its `root` node and is already folded into each
|
|
200
|
+
// bone's Wrest, so C only does the final scene-frame swap.
|
|
201
|
+
const _C = new THREE.Quaternion()
|
|
202
|
+
.setFromEuler(new THREE.Euler(0, 0, Math.PI / 2))
|
|
203
|
+
.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, 0, 0)));
|
|
204
|
+
const _IDENT = new THREE.Quaternion();
|
|
205
|
+
|
|
206
|
+
function _retargetGltfClip(glbAnim, { xform, pelvisPos }) {
|
|
207
|
+
// glTF clip tracks: name = "nodeName.quaternion" (property)
|
|
208
|
+
// We need to re-key them as "ruthBoneName.quaternion" and remap quaternions.
|
|
209
|
+
const tracks = [];
|
|
210
|
+
const v = new THREE.Vector3();
|
|
211
|
+
|
|
212
|
+
for (const track of glbAnim.tracks) {
|
|
213
|
+
// Track name format: "boneName.quaternion" (glTF convention)
|
|
214
|
+
const dotIdx = track.name.lastIndexOf('.');
|
|
215
|
+
const glbNodeName = dotIdx > 0 ? track.name.slice(0, dotIdx) : track.name;
|
|
216
|
+
const prop = dotIdx > 0 ? track.name.slice(dotIdx + 1) : '';
|
|
217
|
+
const ruthName = UAL1_TO_RUTH[glbNodeName];
|
|
218
|
+
if (!ruthName) continue; // unmapped (leaf node, root, etc.)
|
|
219
|
+
|
|
220
|
+
// Pelvis root motion: re-frame the source translation delta and add it to
|
|
221
|
+
// Ruth's rest pelvis position. Only the pelvis carries real translation;
|
|
222
|
+
// every other bone's translation track is just its constant bone offset.
|
|
223
|
+
// (GLTFLoader maps the glTF "translation" path to a ".position" track.)
|
|
224
|
+
if (prop === 'position' && glbNodeName === 'pelvis') {
|
|
225
|
+
if (!pelvisPos) continue; // no avatar pelvis data → keep pelvis at rest
|
|
226
|
+
const values = new Float32Array(track.values.length);
|
|
227
|
+
for (let i = 0; i < track.values.length; i += 3) {
|
|
228
|
+
v.fromArray(track.values, i) // t (root-local)
|
|
229
|
+
.sub(pelvisPos.t0) // Δ = t − bind
|
|
230
|
+
.multiplyScalar(pelvisPos.scale) // source → Ruth units
|
|
231
|
+
.applyQuaternion(pelvisPos.P) // root-local → Ruth frame
|
|
232
|
+
.add(pelvisPos.restP); // + Ruth rest position
|
|
233
|
+
v.toArray(values, i);
|
|
234
|
+
}
|
|
235
|
+
tracks.push(new THREE.VectorKeyframeTrack(
|
|
236
|
+
'mPelvis.position',
|
|
237
|
+
Array.isArray(track.times) ? new Float32Array(track.times) : track.times,
|
|
238
|
+
values,
|
|
239
|
+
));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const x = xform.get(glbNodeName);
|
|
244
|
+
if (!x) continue; // unmapped or missing rest data
|
|
245
|
+
|
|
246
|
+
if (prop === 'quaternion') {
|
|
247
|
+
// Delta-from-bind retarget. The source track holds the FULL local
|
|
248
|
+
// rotation f (which equals the bind rotation at rest), so:
|
|
249
|
+
// Δ = f · restSrc⁻¹ — motion relative to bind, in parent frame
|
|
250
|
+
// local_ruth = P · Δ · P⁻¹ — re-express in Ruth's (world-aligned) frame
|
|
251
|
+
// where P = C · Wrest[parent]. At the source bind pose Δ = identity, so
|
|
252
|
+
// Ruth falls back to its own natural rest. For a world-aligned source
|
|
253
|
+
// (Wrest = restSrc = identity) this collapses to the BVH formula C·f·C⁻¹.
|
|
254
|
+
const values = new Float32Array(track.values.length);
|
|
255
|
+
for (let i = 0; i < track.values.length; i += 4) {
|
|
256
|
+
_qTmp.fromArray(track.values, i); // f
|
|
257
|
+
_qTmp.multiply(x.restInv); // Δ = f · restSrc⁻¹
|
|
258
|
+
_qTmp.premultiply(x.P).multiply(x.Pinv); // P · Δ · P⁻¹
|
|
259
|
+
_qTmp.toArray(values, i);
|
|
260
|
+
}
|
|
261
|
+
tracks.push(new THREE.QuaternionKeyframeTrack(
|
|
262
|
+
`${ruthName}.quaternion`,
|
|
263
|
+
Array.isArray(track.times) ? new Float32Array(track.times) : track.times,
|
|
264
|
+
values,
|
|
265
|
+
));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (tracks.length === 0) return null;
|
|
270
|
+
return new THREE.AnimationClip(glbAnim.name, glbAnim.duration, tracks);
|
|
271
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// metaverse-avatar — public library entry point.
|
|
2
|
+
//
|
|
3
|
+
// The whole avatar is a single class: `new Avatar()` builds a self-contained,
|
|
4
|
+
// independently-controllable figure (skeleton, materials, animation, physics,
|
|
5
|
+
// blinking, lip-sync, attachments, eye look-at). Construct as many as you like
|
|
6
|
+
// and drive them separately. `three` is a peer dependency — the host app
|
|
7
|
+
// provides it (via an import map in the browser, or node_modules under a
|
|
8
|
+
// bundler), so multiple avatars share one THREE instance.
|
|
9
|
+
//
|
|
10
|
+
// import { Avatar } from 'metaverse-avatar';
|
|
11
|
+
// const avatar = await new Avatar().load('models/'); // basePath is required
|
|
12
|
+
// scene.add(avatar.group);
|
|
13
|
+
// // each frame: avatar.update(dt)
|
|
14
|
+
|
|
15
|
+
export { Avatar, RuthAvatar } from './Avatar.js';
|
|
16
|
+
|
|
17
|
+
// Composed capability classes — exported for advanced use / custom wiring.
|
|
18
|
+
// Each `Avatar` already owns one of each (avatar.blinker, .voice, .speech,
|
|
19
|
+
// .attachments); these exports are for building your own.
|
|
20
|
+
export { Blinker } from './blink.js';
|
|
21
|
+
export { VoiceMouth } from './voice.js';
|
|
22
|
+
export { SpeechMouth } from './speech.js';
|
|
23
|
+
// NOTE: locomotion is intentionally NOT a library export — moving a figure
|
|
24
|
+
// through the world is the host app's concern. A ready-made controller lives in
|
|
25
|
+
// examples/common/locomotion.js (used by the studio + simple demos); copy it
|
|
26
|
+
// into your app, or drive avatar.group + the animation methods yourself.
|
|
27
|
+
export {
|
|
28
|
+
Attachments,
|
|
29
|
+
ATTACHMENT_POINTS,
|
|
30
|
+
BUILTIN_PRESETS,
|
|
31
|
+
createSword,
|
|
32
|
+
} from './attachments.js';
|
|
33
|
+
|
|
34
|
+
// Clip loading / retargeting (BVH + glTF → the Ruth rig).
|
|
35
|
+
export { parseBVH, loadBVH, retargetToRuth } from './bvh.js';
|
|
36
|
+
export { initGltfAnim, getGltfClip } from './gltfAnim.js';
|
|
37
|
+
|
|
38
|
+
// Shape sliders + sex presets, and the bone-adjustment solver they feed.
|
|
39
|
+
export { SLIDERS, SEX_PRESETS, computeBoneAdjustments } from './sliders.js';
|
|
40
|
+
|
|
41
|
+
// Nipple protrusion vertex morph (the 'nipple' special slider; Avatar owns one).
|
|
42
|
+
export { buildNippleMorph, applyNippleMorph } from './nipple.js';
|
|
43
|
+
|
|
44
|
+
// PBR material plumbing (per-region map channels, clothing-layer stacks).
|
|
45
|
+
export { PBR_CHANNELS, emptyMapSet, PBRMaterialStack } from './pbr.js';
|
|
46
|
+
|
|
47
|
+
// Lip-sync viseme model (shared by SpeechMouth).
|
|
48
|
+
export { VISEMES, charToViseme, buildVisemeTimeline, sampleViseme } from './visemes.js';
|
|
49
|
+
|
|
50
|
+
// Skeleton helpers — bone lookup / reset across the split avatar parts.
|
|
51
|
+
export {
|
|
52
|
+
partForBone,
|
|
53
|
+
getCanonicalBone,
|
|
54
|
+
listBones,
|
|
55
|
+
getBoneRest,
|
|
56
|
+
resetBone,
|
|
57
|
+
syncBoneToAllParts,
|
|
58
|
+
} from './skeleton.js';
|
|
59
|
+
|
|
60
|
+
// Physics (jiggle/sag soft-body driver; avatars own two instances each).
|
|
61
|
+
export { SoftBodyPhysics } from './physics.js';
|