@lovelace_lol/loom3 1.0.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/README.md +1667 -0
- package/dist/index.cjs +6226 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2384 -0
- package/dist/index.d.ts +2384 -0
- package/dist/index.js +6160 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
# Loom3
|
|
2
|
+
|
|
3
|
+
The missing character controller for Three.js, allowing you to bring humanoid and animal characters to life. Loom3 is based on the Facial Action Coding System (FACS) as the basis of its mappings, providing a morph and bone mapping library for controlling high-definition 3D characters in Three.js.
|
|
4
|
+
|
|
5
|
+
Loom3 provides mappings that connect [Facial Action Coding System (FACS)](https://en.wikipedia.org/wiki/Facial_Action_Coding_System) Action Units to the morph targets and bone transforms found in Character Creator 4 (CC4) characters. Instead of manually figuring out which blend shapes correspond to which facial movements, you can simply say `setAU(12, 0.8)` and the library handles the rest.
|
|
6
|
+
|
|
7
|
+
> **Note:** If you previously used the `loomlarge` npm package, it has been renamed to `loom3`.
|
|
8
|
+
|
|
9
|
+
> **Screenshot placeholder:** Add a hero image showing a character with facial expressions controlled by Loom3
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
1. [Installation & Setup](#1-installation--setup)
|
|
16
|
+
2. [Using Presets](#2-using-presets)
|
|
17
|
+
3. [Getting to Know Your Character](#3-getting-to-know-your-character)
|
|
18
|
+
4. [Extending & Custom Presets](#4-extending--custom-presets)
|
|
19
|
+
5. [Creating Skeletal Animation Presets](#5-creating-skeletal-animation-presets)
|
|
20
|
+
6. [Action Unit Control](#6-action-unit-control)
|
|
21
|
+
7. [Mix Weight System](#7-mix-weight-system)
|
|
22
|
+
8. [Composite Rotation System](#8-composite-rotation-system)
|
|
23
|
+
9. [Continuum Pairs](#9-continuum-pairs)
|
|
24
|
+
10. [Direct Morph Control](#10-direct-morph-control)
|
|
25
|
+
11. [Viseme System](#11-viseme-system)
|
|
26
|
+
12. [Transition System](#12-transition-system)
|
|
27
|
+
13. [Playback & State Control](#13-playback--state-control)
|
|
28
|
+
14. [Hair Physics](#14-hair-physics)
|
|
29
|
+
15. [Baked Animations](#15-baked-animations)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. Installation & Setup
|
|
34
|
+
|
|
35
|
+
> **Screenshot placeholder:** Add a screenshot of a project structure with Loom3 installed
|
|
36
|
+
|
|
37
|
+
### Install the package
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install loom3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Peer dependency
|
|
44
|
+
|
|
45
|
+
Loom3 requires Three.js as a peer dependency:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install three
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Basic setup
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import * as THREE from 'three';
|
|
55
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
56
|
+
import { Loom3, collectMorphMeshes, CC4_PRESET } from 'loom3';
|
|
57
|
+
|
|
58
|
+
// 1. Create the Loom3 controller with a preset
|
|
59
|
+
const loom = new Loom3({ profile: CC4_PRESET });
|
|
60
|
+
|
|
61
|
+
// 2. Set up your Three.js scene
|
|
62
|
+
const scene = new THREE.Scene();
|
|
63
|
+
const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 100);
|
|
64
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
65
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
66
|
+
document.body.appendChild(renderer.domElement);
|
|
67
|
+
|
|
68
|
+
// 3. Load your character model
|
|
69
|
+
const loader = new GLTFLoader();
|
|
70
|
+
loader.load('/character.glb', (gltf) => {
|
|
71
|
+
scene.add(gltf.scene);
|
|
72
|
+
|
|
73
|
+
// 4. Collect all meshes that have morph targets
|
|
74
|
+
const meshes = collectMorphMeshes(gltf.scene);
|
|
75
|
+
|
|
76
|
+
// 5. Initialize Loom3 with the meshes and model
|
|
77
|
+
loom.onReady({ meshes, model: gltf.scene });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 6. In your animation loop, call loom.update(deltaSeconds)
|
|
81
|
+
// This drives all transitions and animations
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If you’re implementing a custom renderer, target the `LoomLarge` interface exported from `loom3`.
|
|
85
|
+
|
|
86
|
+
### Quick start examples
|
|
87
|
+
|
|
88
|
+
Once your character is loaded, you can control facial expressions immediately:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// Make the character smile
|
|
92
|
+
loom.setAU(12, 0.8);
|
|
93
|
+
|
|
94
|
+
// Raise eyebrows
|
|
95
|
+
loom.setAU(1, 0.6);
|
|
96
|
+
loom.setAU(2, 0.6);
|
|
97
|
+
|
|
98
|
+
// Blink
|
|
99
|
+
loom.setAU(45, 1.0);
|
|
100
|
+
|
|
101
|
+
// Open jaw
|
|
102
|
+
loom.setAU(26, 0.5);
|
|
103
|
+
|
|
104
|
+
// Turn head left
|
|
105
|
+
loom.setAU(51, 0.4);
|
|
106
|
+
|
|
107
|
+
// Look up
|
|
108
|
+
loom.setAU(63, 0.6);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Animate smoothly with transitions:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// Smile over 200ms
|
|
115
|
+
await loom.transitionAU(12, 0.8, 200).promise;
|
|
116
|
+
|
|
117
|
+
// Then fade back to neutral
|
|
118
|
+
await loom.transitionAU(12, 0, 300).promise;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### The `collectMorphMeshes` helper
|
|
122
|
+
|
|
123
|
+
This utility function traverses a Three.js scene and returns all meshes that have `morphTargetInfluences` (i.e., blend shapes). It's the recommended way to gather meshes for Loom3:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { collectMorphMeshes } from 'loom3';
|
|
127
|
+
|
|
128
|
+
const meshes = collectMorphMeshes(gltf.scene);
|
|
129
|
+
// Returns: Array of THREE.Mesh objects with morph targets
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
> **Screenshot placeholder:** Add a screenshot of a loaded character in the Three.js scene
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 2. Using Presets
|
|
137
|
+
|
|
138
|
+
> **Screenshot placeholder:** Add a diagram showing how presets connect AUs to morphs and bones
|
|
139
|
+
|
|
140
|
+
Presets define how FACS Action Units map to your character's morph targets and bones. Loom3 ships with `CC4_PRESET` for Character Creator 4 characters.
|
|
141
|
+
|
|
142
|
+
### What's in a preset?
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { CC4_PRESET } from 'loom3';
|
|
146
|
+
|
|
147
|
+
// CC4_PRESET contains:
|
|
148
|
+
{
|
|
149
|
+
auToMorphs: {
|
|
150
|
+
// AU number → morph target names split by side
|
|
151
|
+
1: { left: ['Brow_Raise_Inner_L'], right: ['Brow_Raise_Inner_R'], center: [] },
|
|
152
|
+
12: { left: ['Mouth_Smile_L'], right: ['Mouth_Smile_R'], center: [] },
|
|
153
|
+
45: { left: ['Eye_Blink_L'], right: ['Eye_Blink_R'], center: [] },
|
|
154
|
+
// ... 87 AUs total
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
auToBones: {
|
|
158
|
+
// AU number → array of bone bindings
|
|
159
|
+
51: [{ node: 'HEAD', channel: 'ry', scale: -1, maxDegrees: 30 }],
|
|
160
|
+
61: [{ node: 'EYE_L', channel: 'rz', scale: 1, maxDegrees: 25 }],
|
|
161
|
+
// ... 32 bone bindings
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
boneNodes: {
|
|
165
|
+
// Logical bone name → actual node name in skeleton
|
|
166
|
+
'HEAD': 'CC_Base_Head',
|
|
167
|
+
'JAW': 'CC_Base_JawRoot',
|
|
168
|
+
'EYE_L': 'CC_Base_L_Eye',
|
|
169
|
+
'EYE_R': 'CC_Base_R_Eye',
|
|
170
|
+
'TONGUE': 'CC_Base_Tongue01',
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
visemeKeys: [
|
|
174
|
+
// 15 viseme morph names for lip-sync
|
|
175
|
+
'V_EE', 'V_Er', 'V_IH', 'V_Ah', 'V_Oh',
|
|
176
|
+
'V_W_OO', 'V_S_Z', 'V_Ch_J', 'V_F_V', 'V_TH',
|
|
177
|
+
'V_T_L_D_N', 'V_B_M_P', 'V_K_G_H_NG', 'V_AE', 'V_R'
|
|
178
|
+
],
|
|
179
|
+
|
|
180
|
+
morphToMesh: {
|
|
181
|
+
// Routes morph categories to specific meshes
|
|
182
|
+
'face': ['CC_Base_Body'],
|
|
183
|
+
'tongue': ['CC_Base_Tongue'],
|
|
184
|
+
'eye': ['CC_Base_EyeOcclusion_L', 'CC_Base_EyeOcclusion_R'],
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
auMixDefaults: {
|
|
188
|
+
// Default morph/bone blend weights (0 = morph, 1 = bone)
|
|
189
|
+
26: 0.5, // Jaw drop: 50% morph, 50% bone
|
|
190
|
+
51: 0.7, // Head turn: 70% bone
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
auInfo: {
|
|
194
|
+
// Metadata about each AU
|
|
195
|
+
'12': {
|
|
196
|
+
name: 'Lip Corner Puller',
|
|
197
|
+
muscularBasis: 'zygomaticus major',
|
|
198
|
+
faceArea: 'Lower',
|
|
199
|
+
facePart: 'Mouth',
|
|
200
|
+
},
|
|
201
|
+
// ...
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Passing a preset to Loom3
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { Loom3, CC4_PRESET } from 'loom3';
|
|
210
|
+
|
|
211
|
+
const loom = new Loom3({ profile: CC4_PRESET });
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
You can also resolve presets by name and apply overrides without cloning the full preset:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { Loom3 } from 'loom3';
|
|
218
|
+
|
|
219
|
+
const loom = new Loom3({
|
|
220
|
+
presetType: 'cc4',
|
|
221
|
+
profile: {
|
|
222
|
+
auToMorphs: {
|
|
223
|
+
12: { left: ['MySmile_Left'], right: ['MySmile_Right'], center: [] },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Profiles (preset overrides)
|
|
230
|
+
|
|
231
|
+
A **profile** is a partial override object that extends a base preset. Use it to customize a single character without copying the full preset:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import type { Profile } from 'loom3';
|
|
235
|
+
import { Loom3 } from 'loom3';
|
|
236
|
+
|
|
237
|
+
const DAISY_PROFILE: Profile = {
|
|
238
|
+
morphToMesh: { face: ['Object_9'] },
|
|
239
|
+
annotationRegions: [
|
|
240
|
+
{ name: 'face', bones: ['CC_Base_Head'] },
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const loom = new Loom3({
|
|
245
|
+
presetType: 'cc4',
|
|
246
|
+
profile: DAISY_PROFILE,
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
> **Screenshot placeholder:** Add a screenshot showing the preset being applied to a character
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 3. Getting to Know Your Character
|
|
255
|
+
|
|
256
|
+
> **Screenshot placeholder:** Add a screenshot of the console output showing mesh and morph target information
|
|
257
|
+
|
|
258
|
+
Before customizing presets or extending mappings, it's helpful to understand what's actually in your character model. Loom3 provides several methods to inspect meshes, morph targets, and bones.
|
|
259
|
+
|
|
260
|
+
### Listing meshes
|
|
261
|
+
|
|
262
|
+
Get all meshes in your character with their visibility and morph target counts:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
const meshes = loom.getMeshList();
|
|
266
|
+
console.log(meshes);
|
|
267
|
+
// [
|
|
268
|
+
// { name: 'CC_Base_Body', visible: true, morphCount: 142 },
|
|
269
|
+
// { name: 'CC_Base_Tongue', visible: true, morphCount: 12 },
|
|
270
|
+
// { name: 'CC_Base_EyeOcclusion_L', visible: true, morphCount: 8 },
|
|
271
|
+
// { name: 'CC_Base_EyeOcclusion_R', visible: true, morphCount: 8 },
|
|
272
|
+
// { name: 'Male_Bushy_1', visible: true, morphCount: 142 },
|
|
273
|
+
// ...
|
|
274
|
+
// ]
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Listing morph targets
|
|
278
|
+
|
|
279
|
+
Get all morph target names grouped by mesh:
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
const morphs = loom.getMorphTargets();
|
|
283
|
+
console.log(morphs);
|
|
284
|
+
// {
|
|
285
|
+
// 'CC_Base_Body': [
|
|
286
|
+
// 'A01_Brow_Inner_Up', 'A02_Brow_Down_Left', 'A02_Brow_Down_Right',
|
|
287
|
+
// 'A04_Brow_Outer_Up_Left', 'A04_Brow_Outer_Up_Right',
|
|
288
|
+
// 'Mouth_Smile_L', 'Mouth_Smile_R', 'Eye_Blink_L', 'Eye_Blink_R',
|
|
289
|
+
// ...
|
|
290
|
+
// ],
|
|
291
|
+
// 'CC_Base_Tongue': [
|
|
292
|
+
// 'V_Tongue_Out', 'V_Tongue_Up', 'V_Tongue_Down', ...
|
|
293
|
+
// ],
|
|
294
|
+
// ...
|
|
295
|
+
// }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
This is invaluable when creating custom presets—you need to know the exact morph target names your character uses.
|
|
299
|
+
|
|
300
|
+
### Listing bones
|
|
301
|
+
|
|
302
|
+
Get all resolved bones with their current positions and rotations (in degrees):
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
const bones = loom.getBones();
|
|
306
|
+
console.log(bones);
|
|
307
|
+
// {
|
|
308
|
+
// 'HEAD': { position: [0, 156.2, 0], rotation: [0, 0, 0] },
|
|
309
|
+
// 'JAW': { position: [0, 154.1, 2.3], rotation: [0, 0, 0] },
|
|
310
|
+
// 'EYE_L': { position: [-3.2, 160.5, 8.1], rotation: [0, 0, 0] },
|
|
311
|
+
// 'EYE_R': { position: [3.2, 160.5, 8.1], rotation: [0, 0, 0] },
|
|
312
|
+
// 'TONGUE': { position: [0, 152.3, 1.8], rotation: [0, 0, 0] },
|
|
313
|
+
// }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Validation & analysis
|
|
317
|
+
|
|
318
|
+
Loom3 includes validation and analysis helpers so you can verify presets against a model and generate corrections:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import {
|
|
322
|
+
extractModelData,
|
|
323
|
+
analyzeModel,
|
|
324
|
+
validateMappings,
|
|
325
|
+
generateMappingCorrections,
|
|
326
|
+
resolvePreset,
|
|
327
|
+
} from 'loom3';
|
|
328
|
+
|
|
329
|
+
const preset = resolvePreset('cc4');
|
|
330
|
+
const modelData = extractModelData({ model, meshes, animations });
|
|
331
|
+
const analysis = analyzeModel(modelData, { preset });
|
|
332
|
+
const validation = validateMappings(modelData, preset);
|
|
333
|
+
const corrections = generateMappingCorrections(modelData, preset);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Use these helpers to:
|
|
337
|
+
- Find missing morphs/bones and mesh mismatches
|
|
338
|
+
- Score preset compatibility
|
|
339
|
+
- Suggest corrections before you ship a profile
|
|
340
|
+
|
|
341
|
+
### Controlling mesh visibility
|
|
342
|
+
|
|
343
|
+
Hide or show individual meshes:
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
// Hide hair mesh
|
|
347
|
+
loom.setMeshVisible('Side_part_wavy_1', false);
|
|
348
|
+
|
|
349
|
+
// Show it again
|
|
350
|
+
loom.setMeshVisible('Side_part_wavy_1', true);
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Adjusting material properties
|
|
354
|
+
|
|
355
|
+
Fine-tune render order, transparency, and blending for each mesh:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// Get current material config
|
|
359
|
+
const config = loom.getMeshMaterialConfig('CC_Base_Body');
|
|
360
|
+
console.log(config);
|
|
361
|
+
// {
|
|
362
|
+
// renderOrder: 0,
|
|
363
|
+
// transparent: false,
|
|
364
|
+
// opacity: 1,
|
|
365
|
+
// depthWrite: true,
|
|
366
|
+
// depthTest: true,
|
|
367
|
+
// blending: 'Normal'
|
|
368
|
+
// }
|
|
369
|
+
|
|
370
|
+
// Set custom material config
|
|
371
|
+
loom.setMeshMaterialConfig('CC_Base_EyeOcclusion_L', {
|
|
372
|
+
renderOrder: 10,
|
|
373
|
+
transparent: true,
|
|
374
|
+
opacity: 0.8,
|
|
375
|
+
blending: 'Normal' // 'Normal', 'Additive', 'Subtractive', 'Multiply', 'None'
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
This is especially useful for:
|
|
380
|
+
- Fixing render order issues (eyebrows behind hair, etc.)
|
|
381
|
+
- Making meshes semi-transparent for debugging
|
|
382
|
+
- Adjusting blending modes for special effects
|
|
383
|
+
|
|
384
|
+
> **Screenshot placeholder:** Add before/after screenshots showing render order adjustments
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## 4. Extending & Custom Presets
|
|
389
|
+
|
|
390
|
+
> **Screenshot placeholder:** Add a diagram showing preset inheritance/extension
|
|
391
|
+
|
|
392
|
+
### Extending an existing preset
|
|
393
|
+
|
|
394
|
+
Use `mergePreset` to override specific mappings while keeping the rest:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { CC4_PRESET, mergePreset } from 'loom3';
|
|
398
|
+
|
|
399
|
+
const MY_PRESET = mergePreset(CC4_PRESET, {
|
|
400
|
+
|
|
401
|
+
// Override AU12 (smile) with custom morph names
|
|
402
|
+
auToMorphs: {
|
|
403
|
+
12: { left: ['MySmile_Left'], right: ['MySmile_Right'], center: [] },
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// Add a new bone binding
|
|
407
|
+
auToBones: {
|
|
408
|
+
99: [{ node: 'CUSTOM_BONE', channel: 'ry', scale: 1, maxDegrees: 45 }],
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
// Update bone node paths
|
|
412
|
+
boneNodes: {
|
|
413
|
+
'CUSTOM_BONE': 'MyRig_CustomBone',
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const loom = new Loom3({ profile: MY_PRESET });
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Creating a preset from scratch
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
import { Profile } from 'loom3';
|
|
424
|
+
|
|
425
|
+
const CUSTOM_PRESET: Profile = {
|
|
426
|
+
auToMorphs: {
|
|
427
|
+
1: { left: ['brow_inner_up_L'], right: ['brow_inner_up_R'], center: [] },
|
|
428
|
+
2: { left: ['brow_outer_up_L'], right: ['brow_outer_up_R'], center: [] },
|
|
429
|
+
12: { left: ['mouth_smile_L'], right: ['mouth_smile_R'], center: [] },
|
|
430
|
+
45: { left: ['eye_blink_L'], right: ['eye_blink_R'], center: [] },
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
auToBones: {
|
|
434
|
+
51: [{ node: 'HEAD', channel: 'ry', scale: -1, maxDegrees: 30 }],
|
|
435
|
+
52: [{ node: 'HEAD', channel: 'ry', scale: 1, maxDegrees: 30 }],
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
boneNodes: {
|
|
439
|
+
'HEAD': 'head_bone',
|
|
440
|
+
'JAW': 'jaw_bone',
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
visemeKeys: ['aa', 'ee', 'ih', 'oh', 'oo'],
|
|
444
|
+
|
|
445
|
+
morphToMesh: {
|
|
446
|
+
'face': ['body_mesh'],
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Changing presets at runtime
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// Switch to a different preset
|
|
455
|
+
loom.setProfile(ANOTHER_PRESET);
|
|
456
|
+
|
|
457
|
+
// Get current mappings
|
|
458
|
+
const current = loom.getProfile();
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
> **Screenshot placeholder:** Add a screenshot showing custom preset in action
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## 5. Creating Skeletal Animation Presets
|
|
466
|
+
|
|
467
|
+
> **Screenshot placeholder:** Add a screenshot showing the fish model with labeled bones
|
|
468
|
+
|
|
469
|
+
Loom3 isn't limited to humanoid characters with morph targets. You can create presets for any 3D model that uses skeletal animation, such as fish, animals, or fantasy creatures. This section explains how to create a preset for a betta fish model that has no morph targets—only bone-driven animation.
|
|
470
|
+
|
|
471
|
+
### Understanding skeletal-only models
|
|
472
|
+
|
|
473
|
+
Some models (like fish) rely entirely on bone rotations for animation:
|
|
474
|
+
- **No morph targets:** All movement is skeletal
|
|
475
|
+
- **Hierarchical bones:** Fins and body parts follow parent rotations
|
|
476
|
+
- **Custom "Action Units":** Instead of FACS AUs, you define model-specific actions
|
|
477
|
+
|
|
478
|
+
### Example: Betta Fish Preset
|
|
479
|
+
|
|
480
|
+
Here's a complete example of a preset for a betta fish:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
import type { BoneBinding, AUInfo, CompositeRotation } from 'loom3';
|
|
484
|
+
|
|
485
|
+
// Define semantic bone mappings
|
|
486
|
+
export const FISH_BONE_NODES = {
|
|
487
|
+
ROOT: 'Armature_rootJoint',
|
|
488
|
+
BODY_ROOT: 'Bone_Armature',
|
|
489
|
+
HEAD: 'Bone001_Armature',
|
|
490
|
+
BODY_FRONT: 'Bone002_Armature',
|
|
491
|
+
BODY_MID: 'Bone003_Armature',
|
|
492
|
+
BODY_BACK: 'Bone004_Armature',
|
|
493
|
+
TAIL_BASE: 'Bone005_Armature',
|
|
494
|
+
|
|
495
|
+
// Pectoral fins (side fins)
|
|
496
|
+
PECTORAL_L: 'Bone046_Armature',
|
|
497
|
+
PECTORAL_R: 'Bone047_Armature',
|
|
498
|
+
|
|
499
|
+
// Dorsal fin (top fin)
|
|
500
|
+
DORSAL_ROOT: 'Bone006_Armature',
|
|
501
|
+
|
|
502
|
+
// Eyes (single mesh for both)
|
|
503
|
+
EYE_L: 'EYES_0',
|
|
504
|
+
EYE_R: 'EYES_0',
|
|
505
|
+
} as const;
|
|
506
|
+
|
|
507
|
+
// Define custom fish actions (analogous to FACS AUs)
|
|
508
|
+
export enum FishAction {
|
|
509
|
+
// Body orientation
|
|
510
|
+
TURN_LEFT = 2,
|
|
511
|
+
TURN_RIGHT = 3,
|
|
512
|
+
PITCH_UP = 4,
|
|
513
|
+
PITCH_DOWN = 5,
|
|
514
|
+
ROLL_LEFT = 6,
|
|
515
|
+
ROLL_RIGHT = 7,
|
|
516
|
+
|
|
517
|
+
// Tail movements
|
|
518
|
+
TAIL_SWEEP_LEFT = 12,
|
|
519
|
+
TAIL_SWEEP_RIGHT = 13,
|
|
520
|
+
TAIL_FIN_SPREAD = 14,
|
|
521
|
+
TAIL_FIN_CLOSE = 15,
|
|
522
|
+
|
|
523
|
+
// Pectoral fins
|
|
524
|
+
PECTORAL_L_UP = 20,
|
|
525
|
+
PECTORAL_L_DOWN = 21,
|
|
526
|
+
PECTORAL_R_UP = 22,
|
|
527
|
+
PECTORAL_R_DOWN = 23,
|
|
528
|
+
|
|
529
|
+
// Eye rotation
|
|
530
|
+
EYE_LEFT = 61,
|
|
531
|
+
EYE_RIGHT = 62,
|
|
532
|
+
EYE_UP = 63,
|
|
533
|
+
EYE_DOWN = 64,
|
|
534
|
+
}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Defining bone bindings for movement
|
|
538
|
+
|
|
539
|
+
Map each action to bone rotations:
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
export const FISH_BONE_BINDINGS: Record<number, BoneBinding[]> = {
|
|
543
|
+
// Turn the fish left - affects head, front body, and mid body
|
|
544
|
+
[FishAction.TURN_LEFT]: [
|
|
545
|
+
{ node: 'HEAD', channel: 'ry', scale: 1, maxDegrees: 30 },
|
|
546
|
+
{ node: 'BODY_FRONT', channel: 'ry', scale: 1, maxDegrees: 14 },
|
|
547
|
+
{ node: 'BODY_MID', channel: 'ry', scale: 1, maxDegrees: 5 },
|
|
548
|
+
],
|
|
549
|
+
|
|
550
|
+
// Tail sweep left - cascading motion through tail bones
|
|
551
|
+
[FishAction.TAIL_SWEEP_LEFT]: [
|
|
552
|
+
{ node: 'BODY_BACK', channel: 'rz', scale: 1, maxDegrees: 15 },
|
|
553
|
+
{ node: 'TAIL_BASE', channel: 'rz', scale: 1, maxDegrees: 30 },
|
|
554
|
+
{ node: 'TAIL_TOP', channel: 'rz', scale: 1, maxDegrees: 20 },
|
|
555
|
+
{ node: 'TAIL_MID', channel: 'rz', scale: 1, maxDegrees: 20 },
|
|
556
|
+
],
|
|
557
|
+
|
|
558
|
+
// Pectoral fin movements
|
|
559
|
+
[FishAction.PECTORAL_L_UP]: [
|
|
560
|
+
{ node: 'PECTORAL_L', channel: 'rz', scale: 1, maxDegrees: 40 },
|
|
561
|
+
{ node: 'PECTORAL_L_MID', channel: 'rz', scale: 1, maxDegrees: 20 },
|
|
562
|
+
],
|
|
563
|
+
|
|
564
|
+
// Eye rotation
|
|
565
|
+
[FishAction.EYE_LEFT]: [
|
|
566
|
+
{ node: 'EYE_L', channel: 'ry', scale: 1, maxDegrees: 25 },
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### Composite rotations for multi-axis control
|
|
572
|
+
|
|
573
|
+
Define how multiple AUs combine for smooth rotation:
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
export const FISH_COMPOSITE_ROTATIONS: CompositeRotation[] = [
|
|
577
|
+
{
|
|
578
|
+
node: 'HEAD',
|
|
579
|
+
pitch: {
|
|
580
|
+
aus: [FishAction.PITCH_UP, FishAction.PITCH_DOWN],
|
|
581
|
+
axis: 'rx',
|
|
582
|
+
negative: FishAction.PITCH_DOWN,
|
|
583
|
+
positive: FishAction.PITCH_UP
|
|
584
|
+
},
|
|
585
|
+
yaw: {
|
|
586
|
+
aus: [FishAction.TURN_LEFT, FishAction.TURN_RIGHT],
|
|
587
|
+
axis: 'ry',
|
|
588
|
+
negative: FishAction.TURN_LEFT,
|
|
589
|
+
positive: FishAction.TURN_RIGHT
|
|
590
|
+
},
|
|
591
|
+
roll: null,
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
node: 'TAIL_BASE',
|
|
595
|
+
pitch: null,
|
|
596
|
+
yaw: null,
|
|
597
|
+
roll: {
|
|
598
|
+
aus: [FishAction.TAIL_SWEEP_LEFT, FishAction.TAIL_SWEEP_RIGHT],
|
|
599
|
+
axis: 'rz',
|
|
600
|
+
negative: FishAction.TAIL_SWEEP_RIGHT,
|
|
601
|
+
positive: FishAction.TAIL_SWEEP_LEFT
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
node: 'EYE_L',
|
|
606
|
+
pitch: {
|
|
607
|
+
aus: [FishAction.EYE_UP, FishAction.EYE_DOWN],
|
|
608
|
+
axis: 'rx',
|
|
609
|
+
negative: FishAction.EYE_DOWN,
|
|
610
|
+
positive: FishAction.EYE_UP
|
|
611
|
+
},
|
|
612
|
+
yaw: {
|
|
613
|
+
aus: [FishAction.EYE_LEFT, FishAction.EYE_RIGHT],
|
|
614
|
+
axis: 'ry',
|
|
615
|
+
negative: FishAction.EYE_RIGHT,
|
|
616
|
+
positive: FishAction.EYE_LEFT
|
|
617
|
+
},
|
|
618
|
+
roll: null,
|
|
619
|
+
},
|
|
620
|
+
];
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### Action metadata for UI and debugging
|
|
624
|
+
|
|
625
|
+
```typescript
|
|
626
|
+
export const FISH_AU_INFO: Record<string, AUInfo> = {
|
|
627
|
+
'2': { id: '2', name: 'Turn Left', facePart: 'Body Orientation' },
|
|
628
|
+
'3': { id: '3', name: 'Turn Right', facePart: 'Body Orientation' },
|
|
629
|
+
'4': { id: '4', name: 'Pitch Up', facePart: 'Body Orientation' },
|
|
630
|
+
'5': { id: '5', name: 'Pitch Down', facePart: 'Body Orientation' },
|
|
631
|
+
'12': { id: '12', name: 'Tail Sweep Left', facePart: 'Tail' },
|
|
632
|
+
'13': { id: '13', name: 'Tail Sweep Right', facePart: 'Tail' },
|
|
633
|
+
'20': { id: '20', name: 'Pectoral L Up', facePart: 'Pectoral Fins' },
|
|
634
|
+
'61': { id: '61', name: 'Eyes Left', facePart: 'Eyes' },
|
|
635
|
+
// ... more actions
|
|
636
|
+
};
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Continuum pairs for bidirectional sliders
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
export const FISH_CONTINUUM_PAIRS_MAP: Record<number, {
|
|
643
|
+
pairId: number;
|
|
644
|
+
isNegative: boolean;
|
|
645
|
+
axis: 'pitch' | 'yaw' | 'roll';
|
|
646
|
+
node: string;
|
|
647
|
+
}> = {
|
|
648
|
+
[FishAction.TURN_LEFT]: {
|
|
649
|
+
pairId: FishAction.TURN_RIGHT,
|
|
650
|
+
isNegative: true,
|
|
651
|
+
axis: 'yaw',
|
|
652
|
+
node: 'HEAD'
|
|
653
|
+
},
|
|
654
|
+
[FishAction.TURN_RIGHT]: {
|
|
655
|
+
pairId: FishAction.TURN_LEFT,
|
|
656
|
+
isNegative: false,
|
|
657
|
+
axis: 'yaw',
|
|
658
|
+
node: 'HEAD'
|
|
659
|
+
},
|
|
660
|
+
[FishAction.TAIL_SWEEP_LEFT]: {
|
|
661
|
+
pairId: FishAction.TAIL_SWEEP_RIGHT,
|
|
662
|
+
isNegative: true,
|
|
663
|
+
axis: 'roll',
|
|
664
|
+
node: 'TAIL_BASE'
|
|
665
|
+
},
|
|
666
|
+
// ... more pairs
|
|
667
|
+
};
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
### Creating the final preset config
|
|
671
|
+
|
|
672
|
+
```typescript
|
|
673
|
+
export const FISH_AU_MAPPING_CONFIG = {
|
|
674
|
+
auToBones: FISH_BONE_BINDINGS,
|
|
675
|
+
boneNodes: FISH_BONE_NODES,
|
|
676
|
+
auToMorphs: {} as Record<number, { left: string[]; right: string[]; center: string[] }>, // No morph targets
|
|
677
|
+
morphToMesh: {} as Record<string, string[]>,
|
|
678
|
+
visemeKeys: [] as string[], // Fish don't speak!
|
|
679
|
+
auInfo: FISH_AU_INFO,
|
|
680
|
+
compositeRotations: FISH_COMPOSITE_ROTATIONS,
|
|
681
|
+
eyeMeshNodes: { LEFT: 'EYES_0', RIGHT: 'EYES_0' },
|
|
682
|
+
};
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Using the fish preset
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
import { Loom3 } from 'loom3';
|
|
689
|
+
import { FISH_AU_MAPPING_CONFIG, FishAction } from './presets/bettaFish';
|
|
690
|
+
|
|
691
|
+
const fishController = new Loom3({
|
|
692
|
+
profile: FISH_AU_MAPPING_CONFIG
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Load the fish model
|
|
696
|
+
loader.load('/characters/betta/scene.gltf', (gltf) => {
|
|
697
|
+
const meshes = collectMorphMeshes(gltf.scene); // Will be empty for fish
|
|
698
|
+
fishController.onReady({ meshes, model: gltf.scene });
|
|
699
|
+
|
|
700
|
+
// Control the fish!
|
|
701
|
+
fishController.setAU(FishAction.TURN_LEFT, 0.5); // Turn left
|
|
702
|
+
fishController.setAU(FishAction.TAIL_SWEEP_LEFT, 0.8); // Sweep tail
|
|
703
|
+
fishController.setAU(FishAction.PECTORAL_L_UP, 0.6); // Raise left fin
|
|
704
|
+
|
|
705
|
+
// Smooth transitions
|
|
706
|
+
await fishController.transitionAU(FishAction.TURN_RIGHT, 1.0, 500).promise;
|
|
707
|
+
});
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
### Creating swimming animations
|
|
711
|
+
|
|
712
|
+
Use continuum controls for natural swimming motion:
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
// Use setContinuum for paired actions
|
|
716
|
+
fishController.setContinuum(
|
|
717
|
+
FishAction.TURN_LEFT,
|
|
718
|
+
FishAction.TURN_RIGHT,
|
|
719
|
+
0.3 // Slight turn right
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// Animate swimming with oscillating tail
|
|
723
|
+
async function swimCycle() {
|
|
724
|
+
while (true) {
|
|
725
|
+
await fishController.transitionContinuum(
|
|
726
|
+
FishAction.TAIL_SWEEP_LEFT,
|
|
727
|
+
FishAction.TAIL_SWEEP_RIGHT,
|
|
728
|
+
0.8, // Sweep right
|
|
729
|
+
300
|
|
730
|
+
).promise;
|
|
731
|
+
|
|
732
|
+
await fishController.transitionContinuum(
|
|
733
|
+
FishAction.TAIL_SWEEP_LEFT,
|
|
734
|
+
FishAction.TAIL_SWEEP_RIGHT,
|
|
735
|
+
-0.8, // Sweep left
|
|
736
|
+
300
|
|
737
|
+
).promise;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
> **Screenshot placeholder:** Add a GIF showing the fish swimming animation
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## 6. Action Unit Control
|
|
747
|
+
|
|
748
|
+
> **Screenshot placeholder:** Add a screenshot showing a character with different AU values
|
|
749
|
+
|
|
750
|
+
Action Units are the core of FACS. Each AU represents a specific muscular movement of the face.
|
|
751
|
+
|
|
752
|
+
### Setting an AU immediately
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
// Set AU12 (smile) to 80% intensity
|
|
756
|
+
loom.setAU(12, 0.8);
|
|
757
|
+
|
|
758
|
+
// Set AU45 (blink) to full intensity
|
|
759
|
+
loom.setAU(45, 1.0);
|
|
760
|
+
|
|
761
|
+
// Set to 0 to deactivate
|
|
762
|
+
loom.setAU(12, 0);
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Transitioning an AU over time
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
// Animate AU12 to 0.8 over 200ms
|
|
769
|
+
const handle = loom.transitionAU(12, 0.8, 200);
|
|
770
|
+
|
|
771
|
+
// Wait for completion
|
|
772
|
+
await handle.promise;
|
|
773
|
+
|
|
774
|
+
// Or chain transitions
|
|
775
|
+
loom.transitionAU(12, 1.0, 200).promise.then(() => {
|
|
776
|
+
loom.transitionAU(12, 0, 300); // Fade out
|
|
777
|
+
});
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### Getting the current AU value
|
|
781
|
+
|
|
782
|
+
```typescript
|
|
783
|
+
const smileAmount = loom.getAU(12);
|
|
784
|
+
console.log(`Current smile: ${smileAmount}`);
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### Asymmetric control with balance
|
|
788
|
+
|
|
789
|
+
Many AUs have left and right variants (e.g., `Mouth_Smile_L` and `Mouth_Smile_R`). The `balance` parameter lets you control them independently:
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
// Balance range: -1 (left only) to +1 (right only), 0 = both equal
|
|
793
|
+
|
|
794
|
+
// Smile on both sides equally
|
|
795
|
+
loom.setAU(12, 0.8, 0);
|
|
796
|
+
|
|
797
|
+
// Smile only on left side
|
|
798
|
+
loom.setAU(12, 0.8, -1);
|
|
799
|
+
|
|
800
|
+
// Smile only on right side
|
|
801
|
+
loom.setAU(12, 0.8, 1);
|
|
802
|
+
|
|
803
|
+
// 70% left, 30% right
|
|
804
|
+
loom.setAU(12, 0.8, -0.4);
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
### String-based side selection
|
|
808
|
+
|
|
809
|
+
You can also specify the side directly in the AU ID:
|
|
810
|
+
|
|
811
|
+
```typescript
|
|
812
|
+
// These are equivalent:
|
|
813
|
+
loom.setAU('12L', 0.8); // Left side only
|
|
814
|
+
loom.setAU(12, 0.8, -1); // Left side only
|
|
815
|
+
|
|
816
|
+
loom.setAU('12R', 0.8); // Right side only
|
|
817
|
+
loom.setAU(12, 0.8, 1); // Right side only
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
---
|
|
821
|
+
|
|
822
|
+
## 7. Mix Weight System
|
|
823
|
+
|
|
824
|
+
> **Screenshot placeholder:** Add a comparison showing morph-only vs bone-only vs mixed weights
|
|
825
|
+
|
|
826
|
+
Some AUs can be driven by both morph targets (blend shapes) AND bone rotations. The mix weight controls the blend between them.
|
|
827
|
+
|
|
828
|
+
### Why mix weights?
|
|
829
|
+
|
|
830
|
+
Take jaw opening (AU26) as an example:
|
|
831
|
+
- **Morph-only (weight 0)**: Vertices deform to show open mouth, but jaw bone doesn't move
|
|
832
|
+
- **Bone-only (weight 1)**: Jaw bone rotates down, but no soft tissue deformation
|
|
833
|
+
- **Mixed (weight 0.5)**: Both contribute equally for realistic results
|
|
834
|
+
|
|
835
|
+
### Setting mix weights
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
// Get the default mix weight for AU26
|
|
839
|
+
const weight = loom.getAUMixWeight(26); // e.g., 0.5
|
|
840
|
+
|
|
841
|
+
// Set to pure morph
|
|
842
|
+
loom.setAUMixWeight(26, 0);
|
|
843
|
+
|
|
844
|
+
// Set to pure bone
|
|
845
|
+
loom.setAUMixWeight(26, 1);
|
|
846
|
+
|
|
847
|
+
// Set to 70% bone, 30% morph
|
|
848
|
+
loom.setAUMixWeight(26, 0.7);
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
### Which AUs support mixing?
|
|
852
|
+
|
|
853
|
+
Only AUs that have both `auToMorphs` AND `auToBones` entries support mixing. Common examples:
|
|
854
|
+
- AU26 (Jaw Drop)
|
|
855
|
+
- AU27 (Mouth Stretch)
|
|
856
|
+
- AU51-56 (Head movements)
|
|
857
|
+
- AU61-64 (Eye movements)
|
|
858
|
+
|
|
859
|
+
```typescript
|
|
860
|
+
import { isMixedAU } from 'loom3';
|
|
861
|
+
|
|
862
|
+
if (isMixedAU(26)) {
|
|
863
|
+
console.log('AU26 supports morph/bone mixing');
|
|
864
|
+
}
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## 8. Composite Rotation System
|
|
870
|
+
|
|
871
|
+
> **Screenshot placeholder:** Add a diagram showing the pitch/yaw/roll axes on a head
|
|
872
|
+
|
|
873
|
+
Bones like the head and eyes need multi-axis rotation (pitch, yaw, roll). The composite rotation system handles this automatically.
|
|
874
|
+
|
|
875
|
+
### How it works
|
|
876
|
+
|
|
877
|
+
When you set an AU that affects a bone rotation, Loom3:
|
|
878
|
+
1. Queues the rotation update in `pendingCompositeNodes`
|
|
879
|
+
2. At the end of `update()`, calls `flushPendingComposites()`
|
|
880
|
+
3. Applies all three axes (pitch, yaw, roll) together to prevent gimbal issues
|
|
881
|
+
|
|
882
|
+
### Supported bones and their axes
|
|
883
|
+
|
|
884
|
+
| Bone | Pitch (X) | Yaw (Y) | Roll (Z) |
|
|
885
|
+
|------|-----------|---------|----------|
|
|
886
|
+
| HEAD | AU53 (up) / AU54 (down) | AU51 (left) / AU52 (right) | AU55 (tilt left) / AU56 (tilt right) |
|
|
887
|
+
| EYE_L | AU63 (up) / AU64 (down) | AU61 (left) / AU62 (right) | - |
|
|
888
|
+
| EYE_R | AU63 (up) / AU64 (down) | AU61 (left) / AU62 (right) | - |
|
|
889
|
+
| JAW | AU25-27 (open) | AU30 (left) / AU35 (right) | - |
|
|
890
|
+
| TONGUE | AU37 (up) / AU38 (down) | AU39 (left) / AU40 (right) | AU41 / AU42 (tilt) |
|
|
891
|
+
|
|
892
|
+
### Example: Moving the head
|
|
893
|
+
|
|
894
|
+
```typescript
|
|
895
|
+
// Turn head left 50%
|
|
896
|
+
loom.setAU(51, 0.5);
|
|
897
|
+
|
|
898
|
+
// Turn head right 50%
|
|
899
|
+
loom.setAU(52, 0.5);
|
|
900
|
+
|
|
901
|
+
// Tilt head up 30%
|
|
902
|
+
loom.setAU(53, 0.3);
|
|
903
|
+
|
|
904
|
+
// Combine: turn left AND tilt up
|
|
905
|
+
loom.setAU(51, 0.5);
|
|
906
|
+
loom.setAU(53, 0.3);
|
|
907
|
+
// Both are applied together in a single composite rotation
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Example: Eye gaze
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
// Look left
|
|
914
|
+
loom.setAU(61, 0.7);
|
|
915
|
+
|
|
916
|
+
// Look right
|
|
917
|
+
loom.setAU(62, 0.7);
|
|
918
|
+
|
|
919
|
+
// Look up
|
|
920
|
+
loom.setAU(63, 0.5);
|
|
921
|
+
|
|
922
|
+
// Look down-right (combined)
|
|
923
|
+
loom.setAU(62, 0.6);
|
|
924
|
+
loom.setAU(64, 0.4);
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
---
|
|
928
|
+
|
|
929
|
+
## 9. Continuum Pairs
|
|
930
|
+
|
|
931
|
+
> **Screenshot placeholder:** Add a screenshot showing a continuum slider UI
|
|
932
|
+
|
|
933
|
+
Continuum pairs are bidirectional AU pairs that represent opposite directions on the same axis. They're linked so that activating one should deactivate the other.
|
|
934
|
+
|
|
935
|
+
### Pair mappings
|
|
936
|
+
|
|
937
|
+
| Pair | Description |
|
|
938
|
+
|------|-------------|
|
|
939
|
+
| AU51 ↔ AU52 | Head turn left / right |
|
|
940
|
+
| AU53 ↔ AU54 | Head up / down |
|
|
941
|
+
| AU55 ↔ AU56 | Head tilt left / right |
|
|
942
|
+
| AU61 ↔ AU62 | Eyes look left / right |
|
|
943
|
+
| AU63 ↔ AU64 | Eyes look up / down |
|
|
944
|
+
| AU30 ↔ AU35 | Jaw shift left / right |
|
|
945
|
+
| AU37 ↔ AU38 | Tongue up / down |
|
|
946
|
+
| AU39 ↔ AU40 | Tongue left / right |
|
|
947
|
+
| AU73 ↔ AU74 | Tongue narrow / wide |
|
|
948
|
+
| AU76 ↔ AU77 | Tongue tip up / down |
|
|
949
|
+
|
|
950
|
+
### Negative value shorthand (recommended)
|
|
951
|
+
|
|
952
|
+
The simplest way to work with continuum pairs is using **negative values**. When you pass a negative value to `setAU()` or `transitionAU()`, the engine automatically activates the paired AU instead:
|
|
953
|
+
|
|
954
|
+
```typescript
|
|
955
|
+
// Head looking left at 50% (AU51 is "head left")
|
|
956
|
+
loom.setAU(51, 0.5);
|
|
957
|
+
|
|
958
|
+
// Head looking right at 50% - just use a negative value!
|
|
959
|
+
loom.setAU(51, -0.5); // Automatically activates AU52 at 0.5
|
|
960
|
+
|
|
961
|
+
// This is equivalent to manually setting the pair:
|
|
962
|
+
loom.setAU(51, 0);
|
|
963
|
+
loom.setAU(52, 0.5);
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
This works for transitions too:
|
|
967
|
+
|
|
968
|
+
```typescript
|
|
969
|
+
// Animate head from left to right over 500ms
|
|
970
|
+
await loom.transitionAU(51, 0.5, 250).promise; // Turn left
|
|
971
|
+
await loom.transitionAU(51, -0.5, 500).promise; // Turn right (activates AU52)
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
### The setContinuum method
|
|
975
|
+
|
|
976
|
+
For explicit continuum control, use `setContinuum()` with a single value from -1 to +1:
|
|
977
|
+
|
|
978
|
+
```typescript
|
|
979
|
+
// setContinuum(negativeAU, positiveAU, value)
|
|
980
|
+
// value: -1 = full negative, 0 = neutral, +1 = full positive
|
|
981
|
+
|
|
982
|
+
// Head centered
|
|
983
|
+
loom.setContinuum(51, 52, 0);
|
|
984
|
+
|
|
985
|
+
// Head 50% left
|
|
986
|
+
loom.setContinuum(51, 52, -0.5);
|
|
987
|
+
|
|
988
|
+
// Head 70% right
|
|
989
|
+
loom.setContinuum(51, 52, 0.7);
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
With smooth animation:
|
|
993
|
+
|
|
994
|
+
```typescript
|
|
995
|
+
// Animate head from current position to 80% right over 300ms
|
|
996
|
+
await loom.transitionContinuum(51, 52, 0.8, 300).promise;
|
|
997
|
+
|
|
998
|
+
// Animate eyes to look left over 200ms
|
|
999
|
+
await loom.transitionContinuum(61, 62, -0.6, 200).promise;
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
### Manual pair management
|
|
1003
|
+
|
|
1004
|
+
You can also manually manage pairs by setting each AU individually:
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
// Head looking left at 50%
|
|
1008
|
+
loom.setAU(51, 0.5);
|
|
1009
|
+
loom.setAU(52, 0); // Right should be 0
|
|
1010
|
+
|
|
1011
|
+
// Head looking right at 70%
|
|
1012
|
+
loom.setAU(51, 0); // Left should be 0
|
|
1013
|
+
loom.setAU(52, 0.7);
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
### The CONTINUUM_PAIRS_MAP
|
|
1017
|
+
|
|
1018
|
+
You can access pair information programmatically:
|
|
1019
|
+
|
|
1020
|
+
```typescript
|
|
1021
|
+
import { CONTINUUM_PAIRS_MAP } from 'loom3';
|
|
1022
|
+
|
|
1023
|
+
const pair = CONTINUUM_PAIRS_MAP[51];
|
|
1024
|
+
// { pairId: 52, isNegative: true, axis: 'yaw', node: 'HEAD' }
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
## 10. Direct Morph Control
|
|
1030
|
+
|
|
1031
|
+
> **Screenshot placeholder:** Add a screenshot of a morph target being controlled directly
|
|
1032
|
+
|
|
1033
|
+
Sometimes you need to control morph targets directly by name, bypassing the AU system.
|
|
1034
|
+
|
|
1035
|
+
### Setting a morph immediately
|
|
1036
|
+
|
|
1037
|
+
```typescript
|
|
1038
|
+
// Set a specific morph to 50%
|
|
1039
|
+
loom.setMorph('Mouth_Smile_L', 0.5);
|
|
1040
|
+
|
|
1041
|
+
// Set on specific meshes only
|
|
1042
|
+
loom.setMorph('Mouth_Smile_L', 0.5, ['CC_Base_Body']);
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
### Transitioning a morph
|
|
1046
|
+
|
|
1047
|
+
```typescript
|
|
1048
|
+
// Animate morph over 200ms
|
|
1049
|
+
const handle = loom.transitionMorph('Mouth_Smile_L', 0.8, 200);
|
|
1050
|
+
|
|
1051
|
+
// With mesh targeting
|
|
1052
|
+
loom.transitionMorph('Eye_Blink_L', 1.0, 100, ['CC_Base_Body']);
|
|
1053
|
+
|
|
1054
|
+
// Wait for completion
|
|
1055
|
+
await handle.promise;
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### Reading current morph value
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
const value = loom.getMorphValue('Mouth_Smile_L');
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
### Morph caching
|
|
1065
|
+
|
|
1066
|
+
Loom3 caches morph target lookups for performance. The first time you access a morph, it searches all meshes and caches the index. Subsequent accesses are O(1).
|
|
1067
|
+
|
|
1068
|
+
---
|
|
1069
|
+
|
|
1070
|
+
## 11. Viseme System
|
|
1071
|
+
|
|
1072
|
+
> **Screenshot placeholder:** Add a grid showing all 15 viseme mouth shapes
|
|
1073
|
+
|
|
1074
|
+
Visemes are mouth shapes used for lip-sync. Loom3 includes 15 visemes with automatic jaw coupling.
|
|
1075
|
+
|
|
1076
|
+
### The 15 visemes
|
|
1077
|
+
|
|
1078
|
+
| Index | Key | Phoneme Example |
|
|
1079
|
+
|-------|-----|-----------------|
|
|
1080
|
+
| 0 | EE | "b**ee**" |
|
|
1081
|
+
| 1 | Er | "h**er**" |
|
|
1082
|
+
| 2 | IH | "s**i**t" |
|
|
1083
|
+
| 3 | Ah | "f**a**ther" |
|
|
1084
|
+
| 4 | Oh | "g**o**" |
|
|
1085
|
+
| 5 | W_OO | "t**oo**" |
|
|
1086
|
+
| 6 | S_Z | "**s**un, **z**oo" |
|
|
1087
|
+
| 7 | Ch_J | "**ch**ip, **j**ump" |
|
|
1088
|
+
| 8 | F_V | "**f**un, **v**an" |
|
|
1089
|
+
| 9 | TH | "**th**ink" |
|
|
1090
|
+
| 10 | T_L_D_N | "**t**op, **l**ip, **d**og, **n**o" |
|
|
1091
|
+
| 11 | B_M_P | "**b**at, **m**an, **p**op" |
|
|
1092
|
+
| 12 | K_G_H_NG | "**k**ite, **g**o, **h**at, si**ng**" |
|
|
1093
|
+
| 13 | AE | "c**a**t" |
|
|
1094
|
+
| 14 | R | "**r**ed" |
|
|
1095
|
+
|
|
1096
|
+
### Setting a viseme
|
|
1097
|
+
|
|
1098
|
+
```typescript
|
|
1099
|
+
// Set viseme 3 (Ah) to full intensity
|
|
1100
|
+
loom.setViseme(3, 1.0);
|
|
1101
|
+
|
|
1102
|
+
// With jaw scale (0-1, default 1)
|
|
1103
|
+
loom.setViseme(3, 1.0, 0.5); // Half jaw opening
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
### Transitioning visemes
|
|
1107
|
+
|
|
1108
|
+
Viseme transitions default to 80ms and use the standard `easeInOutQuad` easing when no duration is provided.
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
// Animate to a viseme using the default 80ms duration
|
|
1112
|
+
const handle = loom.transitionViseme(3, 1.0);
|
|
1113
|
+
|
|
1114
|
+
// Disable jaw coupling (duration can be omitted to use the 80ms default)
|
|
1115
|
+
loom.transitionViseme(3, 1.0, 80, 0);
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
### Automatic jaw coupling
|
|
1119
|
+
|
|
1120
|
+
Each viseme has a predefined jaw opening amount. When you set a viseme, the jaw automatically opens proportionally:
|
|
1121
|
+
|
|
1122
|
+
| Viseme | Jaw Amount |
|
|
1123
|
+
|--------|------------|
|
|
1124
|
+
| EE | 0.15 |
|
|
1125
|
+
| Ah | 0.70 |
|
|
1126
|
+
| Oh | 0.50 |
|
|
1127
|
+
| B_M_P | 0.20 |
|
|
1128
|
+
|
|
1129
|
+
The `jawScale` parameter multiplies this amount:
|
|
1130
|
+
- `jawScale = 1.0`: Normal jaw opening
|
|
1131
|
+
- `jawScale = 0.5`: Half jaw opening
|
|
1132
|
+
- `jawScale = 0`: No jaw movement (viseme only)
|
|
1133
|
+
|
|
1134
|
+
### Lip-sync example
|
|
1135
|
+
|
|
1136
|
+
```typescript
|
|
1137
|
+
async function speak(phonemes: number[]) {
|
|
1138
|
+
for (const viseme of phonemes) {
|
|
1139
|
+
// Clear previous viseme
|
|
1140
|
+
for (let i = 0; i < 15; i++) loom.setViseme(i, 0);
|
|
1141
|
+
|
|
1142
|
+
// Transition to new viseme
|
|
1143
|
+
await loom.transitionViseme(viseme, 1.0, 80).promise;
|
|
1144
|
+
|
|
1145
|
+
// Hold briefly
|
|
1146
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Return to neutral
|
|
1150
|
+
for (let i = 0; i < 15; i++) loom.setViseme(i, 0);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// "Hello" approximation
|
|
1154
|
+
speak([5, 0, 10, 4]);
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
---
|
|
1158
|
+
|
|
1159
|
+
## 12. Transition System
|
|
1160
|
+
|
|
1161
|
+
> **Screenshot placeholder:** Add a diagram showing transition timeline with easing
|
|
1162
|
+
|
|
1163
|
+
All animated changes in Loom3 go through the transition system, which provides smooth interpolation with easing.
|
|
1164
|
+
|
|
1165
|
+
### TransitionHandle
|
|
1166
|
+
|
|
1167
|
+
Every transition method returns a `TransitionHandle`:
|
|
1168
|
+
|
|
1169
|
+
```typescript
|
|
1170
|
+
interface TransitionHandle {
|
|
1171
|
+
promise: Promise<void>; // Resolves when transition completes
|
|
1172
|
+
pause(): void; // Pause this transition
|
|
1173
|
+
resume(): void; // Resume this transition
|
|
1174
|
+
cancel(): void; // Cancel immediately
|
|
1175
|
+
}
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
### Using handles
|
|
1179
|
+
|
|
1180
|
+
```typescript
|
|
1181
|
+
// Start a transition
|
|
1182
|
+
const handle = loom.transitionAU(12, 1.0, 500);
|
|
1183
|
+
|
|
1184
|
+
// Pause it
|
|
1185
|
+
handle.pause();
|
|
1186
|
+
|
|
1187
|
+
// Resume later
|
|
1188
|
+
handle.resume();
|
|
1189
|
+
|
|
1190
|
+
// Or cancel entirely
|
|
1191
|
+
handle.cancel();
|
|
1192
|
+
|
|
1193
|
+
// Wait for completion
|
|
1194
|
+
await handle.promise;
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
### Combining multiple transitions
|
|
1198
|
+
|
|
1199
|
+
When you call `transitionAU`, it may create multiple internal transitions (one per morph target). The returned handle controls all of them:
|
|
1200
|
+
|
|
1201
|
+
```typescript
|
|
1202
|
+
// AU12 might affect Mouth_Smile_L and Mouth_Smile_R
|
|
1203
|
+
const handle = loom.transitionAU(12, 1.0, 200);
|
|
1204
|
+
|
|
1205
|
+
// Pausing the handle pauses both morph transitions
|
|
1206
|
+
handle.pause();
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
### Easing
|
|
1210
|
+
|
|
1211
|
+
The default easing is `easeInOutQuad`. Custom easing can be provided when using the Animation system directly:
|
|
1212
|
+
|
|
1213
|
+
```typescript
|
|
1214
|
+
// The AnimationThree class supports custom easing
|
|
1215
|
+
animation.addTransition(
|
|
1216
|
+
'custom',
|
|
1217
|
+
0,
|
|
1218
|
+
1,
|
|
1219
|
+
200,
|
|
1220
|
+
(v) => console.log(v),
|
|
1221
|
+
(t) => t * t // Custom ease-in quadratic
|
|
1222
|
+
);
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
### Active transition count
|
|
1226
|
+
|
|
1227
|
+
```typescript
|
|
1228
|
+
const count = loom.getActiveTransitionCount();
|
|
1229
|
+
console.log(`${count} transitions in progress`);
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
### Clearing all transitions
|
|
1233
|
+
|
|
1234
|
+
```typescript
|
|
1235
|
+
// Cancel everything immediately
|
|
1236
|
+
loom.clearTransitions();
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
---
|
|
1240
|
+
|
|
1241
|
+
## 13. Playback & State Control
|
|
1242
|
+
|
|
1243
|
+
> **Screenshot placeholder:** Add a screenshot showing pause/resume controls in a UI
|
|
1244
|
+
|
|
1245
|
+
### Pausing and resuming
|
|
1246
|
+
|
|
1247
|
+
```typescript
|
|
1248
|
+
// Pause all animation updates
|
|
1249
|
+
loom.pause();
|
|
1250
|
+
|
|
1251
|
+
// Check pause state
|
|
1252
|
+
if (loom.getPaused()) {
|
|
1253
|
+
console.log('Animation is paused');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Resume
|
|
1257
|
+
loom.resume();
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
When paused, `loom.update()` stops processing transitions, but you can still call `setAU()` for immediate changes.
|
|
1261
|
+
|
|
1262
|
+
### Resetting to neutral
|
|
1263
|
+
|
|
1264
|
+
```typescript
|
|
1265
|
+
// Reset everything to rest state
|
|
1266
|
+
loom.resetToNeutral();
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
This:
|
|
1270
|
+
- Clears all AU values to 0
|
|
1271
|
+
- Cancels all active transitions
|
|
1272
|
+
- Resets all morph targets to 0
|
|
1273
|
+
- Returns all bones to their original position/rotation
|
|
1274
|
+
|
|
1275
|
+
### Mesh visibility
|
|
1276
|
+
|
|
1277
|
+
```typescript
|
|
1278
|
+
// Get list of all meshes
|
|
1279
|
+
const meshes = loom.getMeshList();
|
|
1280
|
+
// Returns: [{ name: 'CC_Base_Body', visible: true, morphCount: 80 }, ...]
|
|
1281
|
+
|
|
1282
|
+
// Hide a mesh
|
|
1283
|
+
loom.setMeshVisible('CC_Base_Hair', false);
|
|
1284
|
+
|
|
1285
|
+
// Show it again
|
|
1286
|
+
loom.setMeshVisible('CC_Base_Hair', true);
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
### Cleanup
|
|
1290
|
+
|
|
1291
|
+
```typescript
|
|
1292
|
+
// When done, dispose of resources
|
|
1293
|
+
loom.dispose();
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
---
|
|
1297
|
+
|
|
1298
|
+
## 14. Hair Physics (Mixer-Driven)
|
|
1299
|
+
|
|
1300
|
+
> **Screenshot placeholder:** Add a GIF showing hair physics responding to head movement
|
|
1301
|
+
|
|
1302
|
+
Loom3 includes a built-in hair physics system that drives morph targets through the AnimationMixer.
|
|
1303
|
+
It is **mixer-only** (no per-frame morph LERP), and it reacts to **head rotation** coming from AUs.
|
|
1304
|
+
|
|
1305
|
+
### How it works
|
|
1306
|
+
|
|
1307
|
+
Hair motion is decomposed into three clip families:
|
|
1308
|
+
|
|
1309
|
+
1. **Idle/Wind loop** - continuous sway and optional wind.
|
|
1310
|
+
2. **Impulse clips** - short oscillations triggered by *changes* in head yaw/pitch.
|
|
1311
|
+
3. **Gravity clip** - a single clip that is **scrubbed** by head pitch (up/down).
|
|
1312
|
+
|
|
1313
|
+
All clips are created with `buildClip` and applied to the mixer.
|
|
1314
|
+
When you update head AUs (e.g. `setAU`, `setContinuum`, `transitionAU`), hair updates automatically.
|
|
1315
|
+
|
|
1316
|
+
### Basic setup
|
|
1317
|
+
|
|
1318
|
+
```typescript
|
|
1319
|
+
const loom = new Loom3({ presetType: 'cc4' });
|
|
1320
|
+
|
|
1321
|
+
loader.load('/character.glb', (gltf) => {
|
|
1322
|
+
const meshes = collectMorphMeshes(gltf.scene);
|
|
1323
|
+
loom.onReady({ meshes, model: gltf.scene });
|
|
1324
|
+
|
|
1325
|
+
// Register hair + eyebrow meshes (filters using CC4_MESHES category tags)
|
|
1326
|
+
const allObjects: Object3D[] = [];
|
|
1327
|
+
gltf.scene.traverse((obj) => allObjects.push(obj));
|
|
1328
|
+
loom.registerHairObjects(allObjects);
|
|
1329
|
+
|
|
1330
|
+
// Enable physics (starts idle + gravity + impulse clips)
|
|
1331
|
+
loom.setHairPhysicsEnabled(true);
|
|
1332
|
+
});
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
### Configuration (profile defaults)
|
|
1336
|
+
|
|
1337
|
+
Hair physics defaults live in the preset/profile and are applied automatically at init:
|
|
1338
|
+
|
|
1339
|
+
```typescript
|
|
1340
|
+
import type { Profile } from 'loom3';
|
|
1341
|
+
|
|
1342
|
+
const profile: Profile = {
|
|
1343
|
+
// ...all your usual AU mappings...
|
|
1344
|
+
hairPhysics: {
|
|
1345
|
+
stiffness: 7.5,
|
|
1346
|
+
damping: 0.18,
|
|
1347
|
+
inertia: 3.5,
|
|
1348
|
+
gravity: 12,
|
|
1349
|
+
responseScale: 2.5,
|
|
1350
|
+
idleSwayAmount: 0.12,
|
|
1351
|
+
idleSwaySpeed: 1.0,
|
|
1352
|
+
windStrength: 0,
|
|
1353
|
+
windDirectionX: 1.0,
|
|
1354
|
+
windDirectionZ: 0,
|
|
1355
|
+
windTurbulence: 0.3,
|
|
1356
|
+
windFrequency: 1.4,
|
|
1357
|
+
idleClipDuration: 10,
|
|
1358
|
+
impulseClipDuration: 1.4,
|
|
1359
|
+
direction: {
|
|
1360
|
+
yawSign: -1,
|
|
1361
|
+
pitchSign: -1,
|
|
1362
|
+
},
|
|
1363
|
+
morphTargets: {
|
|
1364
|
+
swayLeft: { key: 'L_Hair_Left', axis: 'yaw' },
|
|
1365
|
+
swayRight: { key: 'L_Hair_Right', axis: 'yaw' },
|
|
1366
|
+
swayFront: { key: 'L_Hair_Front', axis: 'pitch' },
|
|
1367
|
+
fluffRight: { key: 'Fluffy_Right', axis: 'yaw' },
|
|
1368
|
+
fluffBottom: { key: 'Fluffy_Bottom_ALL', axis: 'pitch' },
|
|
1369
|
+
headUp: {
|
|
1370
|
+
Hairline_High_ALL: { value: 0.45, axis: 'pitch' },
|
|
1371
|
+
Length_Short: { value: 0.65, axis: 'pitch' },
|
|
1372
|
+
},
|
|
1373
|
+
headDown: {
|
|
1374
|
+
L_Hair_Front: { value: 2.0, axis: 'pitch' },
|
|
1375
|
+
Fluffy_Bottom_ALL: { value: 1.0, axis: 'pitch' },
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
};
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
### Configuration (runtime overrides)
|
|
1383
|
+
|
|
1384
|
+
```typescript
|
|
1385
|
+
loom.setHairPhysicsConfig({
|
|
1386
|
+
stiffness: 7.5,
|
|
1387
|
+
damping: 0.18,
|
|
1388
|
+
inertia: 3.5,
|
|
1389
|
+
gravity: 12,
|
|
1390
|
+
responseScale: 2.5,
|
|
1391
|
+
idleSwayAmount: 0.12,
|
|
1392
|
+
idleSwaySpeed: 1.0,
|
|
1393
|
+
windStrength: 0,
|
|
1394
|
+
windDirectionX: 1.0,
|
|
1395
|
+
windDirectionZ: 0,
|
|
1396
|
+
windTurbulence: 0.3,
|
|
1397
|
+
windFrequency: 1.4,
|
|
1398
|
+
idleClipDuration: 10,
|
|
1399
|
+
impulseClipDuration: 1.4,
|
|
1400
|
+
|
|
1401
|
+
// Direction mapping (signs) – adjust if hair goes the wrong way.
|
|
1402
|
+
direction: {
|
|
1403
|
+
yawSign: -1, // hair lags opposite head yaw
|
|
1404
|
+
pitchSign: -1, // head down drives forward hair motion
|
|
1405
|
+
},
|
|
1406
|
+
|
|
1407
|
+
// Morph target mapping (override per character/rig)
|
|
1408
|
+
morphTargets: {
|
|
1409
|
+
swayLeft: 'L_Hair_Left',
|
|
1410
|
+
swayRight: 'L_Hair_Right',
|
|
1411
|
+
swayFront: 'L_Hair_Front',
|
|
1412
|
+
fluffRight: 'Fluffy_Right',
|
|
1413
|
+
fluffBottom: 'Fluffy_Bottom_ALL',
|
|
1414
|
+
headUp: {
|
|
1415
|
+
Hairline_High_ALL: 0.45,
|
|
1416
|
+
Length_Short: 0.65,
|
|
1417
|
+
},
|
|
1418
|
+
headDown: {
|
|
1419
|
+
L_Hair_Front: 2.0,
|
|
1420
|
+
Fluffy_Bottom_ALL: 1.0,
|
|
1421
|
+
},
|
|
1422
|
+
},
|
|
1423
|
+
});
|
|
1424
|
+
```
|
|
1425
|
+
|
|
1426
|
+
### Validation
|
|
1427
|
+
|
|
1428
|
+
```typescript
|
|
1429
|
+
const missing = loom.validateHairMorphTargets();
|
|
1430
|
+
if (missing.length > 0) {
|
|
1431
|
+
console.warn('Missing hair morph targets:', missing);
|
|
1432
|
+
}
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
Loom3 also logs a warning the first time it encounters a missing hair morph key.
|
|
1436
|
+
|
|
1437
|
+
### Notes
|
|
1438
|
+
|
|
1439
|
+
- **Head rotation input** comes from AUs (e.g. 51/52 yaw, 53/54 pitch).
|
|
1440
|
+
Hair updates when those AUs change.
|
|
1441
|
+
- **Mesh selection** comes from the preset (`CC4_MESHES` categories).
|
|
1442
|
+
Hair morph target *names* live in the preset/profile (`Profile.hairPhysics`) and can be overridden at runtime.
|
|
1443
|
+
- **Direction/morphs are explicit** so you can expose a clean, user-friendly API.
|
|
1444
|
+
|
|
1445
|
+
### Troubleshooting
|
|
1446
|
+
|
|
1447
|
+
- Hair moves the wrong direction → flip `direction.yawSign` or `direction.pitchSign`.
|
|
1448
|
+
- Wrong morphs are moving → override `morphTargets` with your rig’s names.
|
|
1449
|
+
- Need stronger response → increase `responseScale` or the `headDown/headUp` values.
|
|
1450
|
+
|
|
1451
|
+
---
|
|
1452
|
+
|
|
1453
|
+
## 15. Baked Animations
|
|
1454
|
+
|
|
1455
|
+
Loom3 can play baked skeletal animations from your GLB/GLTF files using Three.js AnimationMixer. This allows you to combine pre-made animations (idle, walk, gestures) with real-time facial control.
|
|
1456
|
+
|
|
1457
|
+
### Loading animations
|
|
1458
|
+
|
|
1459
|
+
After loading your model, pass the animations array to Loom3:
|
|
1460
|
+
|
|
1461
|
+
```typescript
|
|
1462
|
+
const loader = new GLTFLoader();
|
|
1463
|
+
loader.load('/character.glb', (gltf) => {
|
|
1464
|
+
scene.add(gltf.scene);
|
|
1465
|
+
|
|
1466
|
+
const meshes = collectMorphMeshes(gltf.scene);
|
|
1467
|
+
loom.onReady({ meshes, model: gltf.scene });
|
|
1468
|
+
|
|
1469
|
+
// Load baked animations from the GLB file
|
|
1470
|
+
loom.loadAnimationClips(gltf.animations);
|
|
1471
|
+
|
|
1472
|
+
// Start the internal update loop
|
|
1473
|
+
loom.start();
|
|
1474
|
+
});
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
### Listing available animations
|
|
1478
|
+
|
|
1479
|
+
```typescript
|
|
1480
|
+
const clips = loom.getAnimationClips();
|
|
1481
|
+
console.log(clips);
|
|
1482
|
+
// [
|
|
1483
|
+
// { name: 'Idle', duration: 4.0, trackCount: 52 },
|
|
1484
|
+
// { name: 'Walk', duration: 1.2, trackCount: 52 },
|
|
1485
|
+
// { name: 'Wave', duration: 2.5, trackCount: 24 },
|
|
1486
|
+
// ]
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
### Playing animations
|
|
1490
|
+
|
|
1491
|
+
```typescript
|
|
1492
|
+
// Play an animation with default settings (looping)
|
|
1493
|
+
loom.playAnimation('Idle');
|
|
1494
|
+
|
|
1495
|
+
// Play with options
|
|
1496
|
+
const handle = loom.playAnimation('Wave', {
|
|
1497
|
+
speed: 1.0, // Playback speed (1.0 = normal)
|
|
1498
|
+
intensity: 1.0, // Weight/intensity (0-1)
|
|
1499
|
+
loop: false, // Don't loop
|
|
1500
|
+
loopMode: 'once', // 'repeat', 'pingpong', or 'once'
|
|
1501
|
+
clampWhenFinished: true, // Hold last frame when done
|
|
1502
|
+
startTime: 0, // Start from beginning
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
// Wait for non-looping animation to finish
|
|
1506
|
+
await handle.finished;
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
### Mixer clip playback for curves
|
|
1510
|
+
|
|
1511
|
+
Loom3 can convert AU/morph curves into AnimationMixer clips for smooth, mixer-only playback. This is the preferred path for high-frequency animation agencies (eye/head tracking, visemes, prosody) because it avoids per-keyframe transitions.
|
|
1512
|
+
|
|
1513
|
+
Key APIs:
|
|
1514
|
+
- `snippetToClip(name, curves, options)` builds an AnimationClip from curves.
|
|
1515
|
+
- `playClip(clip, options)` returns a ClipHandle you can pause/resume/stop.
|
|
1516
|
+
- `clipHandle.stop()` now resolves cleanly (no rejected promise).
|
|
1517
|
+
|
|
1518
|
+
```typescript
|
|
1519
|
+
const clip = loom.snippetToClip('gaze', {
|
|
1520
|
+
'61': [{ time: 0, intensity: 0 }, { time: 0.4, intensity: 0.6 }],
|
|
1521
|
+
'62': [{ time: 0, intensity: 0 }, { time: 0.4, intensity: 0 }],
|
|
1522
|
+
}, { loop: false });
|
|
1523
|
+
|
|
1524
|
+
if (clip) {
|
|
1525
|
+
const handle = loom.playClip(clip, { loop: false, speed: 1 });
|
|
1526
|
+
await handle.finished;
|
|
1527
|
+
}
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
### Controlling playback
|
|
1531
|
+
|
|
1532
|
+
The handle returned from `playAnimation()` provides full control:
|
|
1533
|
+
|
|
1534
|
+
```typescript
|
|
1535
|
+
const handle = loom.playAnimation('Idle');
|
|
1536
|
+
|
|
1537
|
+
// Pause and resume
|
|
1538
|
+
handle.pause();
|
|
1539
|
+
handle.resume();
|
|
1540
|
+
|
|
1541
|
+
// Adjust speed in real-time
|
|
1542
|
+
handle.setSpeed(0.5); // Half speed
|
|
1543
|
+
handle.setSpeed(2.0); // Double speed
|
|
1544
|
+
|
|
1545
|
+
// Adjust intensity/weight
|
|
1546
|
+
handle.setWeight(0.5); // 50% influence
|
|
1547
|
+
|
|
1548
|
+
// Seek to specific time
|
|
1549
|
+
handle.seekTo(1.5); // Jump to 1.5 seconds
|
|
1550
|
+
|
|
1551
|
+
// Get current state
|
|
1552
|
+
const state = handle.getState();
|
|
1553
|
+
console.log(state);
|
|
1554
|
+
// {
|
|
1555
|
+
// name: 'Idle',
|
|
1556
|
+
// isPlaying: true,
|
|
1557
|
+
// isPaused: false,
|
|
1558
|
+
// time: 1.5,
|
|
1559
|
+
// duration: 4.0,
|
|
1560
|
+
// speed: 1.0,
|
|
1561
|
+
// weight: 1.0,
|
|
1562
|
+
// isLooping: true
|
|
1563
|
+
// }
|
|
1564
|
+
|
|
1565
|
+
// Stop the animation
|
|
1566
|
+
handle.stop();
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
### Crossfading between animations
|
|
1570
|
+
|
|
1571
|
+
Smoothly transition from one animation to another:
|
|
1572
|
+
|
|
1573
|
+
```typescript
|
|
1574
|
+
// Start with idle
|
|
1575
|
+
loom.playAnimation('Idle');
|
|
1576
|
+
|
|
1577
|
+
// Later, crossfade to walk over 0.3 seconds
|
|
1578
|
+
loom.crossfadeTo('Walk', 0.3);
|
|
1579
|
+
|
|
1580
|
+
// Or use the handle
|
|
1581
|
+
const idleHandle = loom.playAnimation('Idle');
|
|
1582
|
+
idleHandle.crossfadeTo('Walk', 0.5);
|
|
1583
|
+
```
|
|
1584
|
+
|
|
1585
|
+
### Global animation control
|
|
1586
|
+
|
|
1587
|
+
Control all animations at once:
|
|
1588
|
+
|
|
1589
|
+
```typescript
|
|
1590
|
+
// Stop all animations
|
|
1591
|
+
loom.stopAllAnimations();
|
|
1592
|
+
|
|
1593
|
+
// Pause all animations
|
|
1594
|
+
loom.pauseAllAnimations();
|
|
1595
|
+
|
|
1596
|
+
// Resume all paused animations
|
|
1597
|
+
loom.resumeAllAnimations();
|
|
1598
|
+
|
|
1599
|
+
// Set global time scale (affects all animations)
|
|
1600
|
+
loom.setAnimationTimeScale(0.5); // Everything at half speed
|
|
1601
|
+
|
|
1602
|
+
// Get all currently playing animations
|
|
1603
|
+
const playing = loom.getPlayingAnimations();
|
|
1604
|
+
```
|
|
1605
|
+
|
|
1606
|
+
### Direct control by name
|
|
1607
|
+
|
|
1608
|
+
You can also control animations directly without using handles:
|
|
1609
|
+
|
|
1610
|
+
```typescript
|
|
1611
|
+
loom.playAnimation('Idle');
|
|
1612
|
+
|
|
1613
|
+
// Later...
|
|
1614
|
+
loom.setAnimationSpeed('Idle', 1.5);
|
|
1615
|
+
loom.setAnimationIntensity('Idle', 0.8);
|
|
1616
|
+
loom.pauseAnimation('Idle');
|
|
1617
|
+
loom.resumeAnimation('Idle');
|
|
1618
|
+
loom.stopAnimation('Idle');
|
|
1619
|
+
|
|
1620
|
+
// Get state of specific animation
|
|
1621
|
+
const state = loom.getAnimationState('Idle');
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
### Combining with facial animation
|
|
1625
|
+
|
|
1626
|
+
Baked animations and facial AU control work together seamlessly. The AnimationMixer updates automatically when you call `loom.update()` or use `loom.start()`:
|
|
1627
|
+
|
|
1628
|
+
```typescript
|
|
1629
|
+
loom.loadAnimationClips(gltf.animations);
|
|
1630
|
+
loom.start(); // Starts internal RAF loop
|
|
1631
|
+
|
|
1632
|
+
// Play a body animation
|
|
1633
|
+
loom.playAnimation('Idle');
|
|
1634
|
+
|
|
1635
|
+
// Control facial expressions on top
|
|
1636
|
+
loom.setAU(12, 0.8); // Smile
|
|
1637
|
+
loom.transitionAU(45, 1.0, 100); // Blink
|
|
1638
|
+
|
|
1639
|
+
// Both update together - no separate render loop needed
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
### Animation types
|
|
1643
|
+
|
|
1644
|
+
| Option | Type | Default | Description |
|
|
1645
|
+
|--------|------|---------|-------------|
|
|
1646
|
+
| `speed` | number | 1.0 | Playback speed multiplier |
|
|
1647
|
+
| `intensity` | number | 1.0 | Animation weight (0-1) |
|
|
1648
|
+
| `loop` | boolean | true | Whether to loop |
|
|
1649
|
+
| `loopMode` | string | 'repeat' | 'repeat', 'pingpong', or 'once' |
|
|
1650
|
+
| `crossfadeDuration` | number | 0 | Fade in duration (seconds) |
|
|
1651
|
+
| `clampWhenFinished` | boolean | true | Hold last frame when done |
|
|
1652
|
+
| `startTime` | number | 0 | Initial playback position |
|
|
1653
|
+
|
|
1654
|
+
---
|
|
1655
|
+
|
|
1656
|
+
## Resources
|
|
1657
|
+
|
|
1658
|
+
> **Screenshot placeholder:** Add logos or screenshots from the resources below
|
|
1659
|
+
|
|
1660
|
+
- [FACS on Wikipedia](https://en.wikipedia.org/wiki/Facial_Action_Coding_System)
|
|
1661
|
+
- [Paul Ekman Group - FACS](https://www.paulekman.com/facial-action-coding-system/)
|
|
1662
|
+
- [Character Creator 4](https://www.reallusion.com/character-creator/)
|
|
1663
|
+
- [Three.js Documentation](https://threejs.org/docs/)
|
|
1664
|
+
|
|
1665
|
+
## License
|
|
1666
|
+
|
|
1667
|
+
MIT License - see LICENSE file for details.
|