@convai/embody 0.2.0 → 0.3.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/CHANGELOG.md +65 -0
- package/README.md +9 -2
- package/dist/character/createCharacter.d.ts +13 -0
- package/dist/character/createCharacter.js +7 -0
- package/dist/connect/session.js +15 -1
- package/dist/motion/eyeGaze.js +7 -1
- package/dist/motion/gaze.d.ts +9 -1
- package/dist/motion/gaze.js +63 -0
- package/dist/motion/types.d.ts +18 -0
- package/dist/render/faceMaterials.d.ts +103 -0
- package/dist/render/faceMaterials.js +210 -0
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# @convai/embody
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9d8f06: Characters now look at you, and their mouths move by default.
|
|
8
|
+
|
|
9
|
+
Four fixes, all of which change what a character does with no code change on
|
|
10
|
+
the consumer's side. Minor rather than patch because default behaviour moves
|
|
11
|
+
and one interface gained a member.
|
|
12
|
+
|
|
13
|
+
**Lipsync worked only if you brought your own client.** `createSession` never
|
|
14
|
+
set `enableLipsync`, and the server sends no blendshape frames unless asked —
|
|
15
|
+
so the zero-config path, `<ConvaiCharacter apiKey characterId />`, connected,
|
|
16
|
+
replied, played audio and never moved its mouth. It worked when an app built
|
|
17
|
+
its own `ConvaiClient` and passed `client`, because then the app supplied the
|
|
18
|
+
flag. That asymmetry was recorded for weeks as "staging emits no blendshapes";
|
|
19
|
+
it was never the environment. `createSession` now defaults `enableLipsync:
|
|
20
|
+
true` and `blendshapeConfig: { format: 'mha' }`, with your config spread last
|
|
21
|
+
so you can still disable it or choose another format.
|
|
22
|
+
|
|
23
|
+
**The head aimed past its target by the rig's rest tilt.** The look angle was
|
|
24
|
+
solved in the head's parent frame but applied composed onto the head's rest
|
|
25
|
+
pose — two different frames. On a rig whose bones rest at identity they
|
|
26
|
+
coincide, which is why no test caught it; on a real MetaHuman the head settled
|
|
27
|
+
17–20° above a level camera, chin up, while reporting itself on target.
|
|
28
|
+
|
|
29
|
+
**The eyes tracked the head's own turn instead of the target.**
|
|
30
|
+
`EyeGazeSystem` read `GazeSystem.direction`, which is how far the head has
|
|
31
|
+
turned from neutral, when the eyes need how far the head is _still_ off target.
|
|
32
|
+
Those diverge exactly when the head is aimed at you — so the eyes rolled
|
|
33
|
+
further the way the head had already gone, and a character looking straight at
|
|
34
|
+
you stared past you. Measured on a live body, `eyeLookUp` sat pinned at 0.203
|
|
35
|
+
for ever.
|
|
36
|
+
|
|
37
|
+
**New:** a face material pass, on by default. The defect it fixes is in the
|
|
38
|
+
exports rather than in any app: the teeth material arrives with its albedo
|
|
39
|
+
unbound, so glTF loaders never materialise the `Teeth` image the file ships and
|
|
40
|
+
the mouth renders as one flat white slab. The pass fetches it through the glTF
|
|
41
|
+
parser, tags it sRGB, makes the mouth unlit so it reads the same in sun and
|
|
42
|
+
shade, and adds a catchlight to eyeballs that have a lighting model. Pass
|
|
43
|
+
`faceMaterials: false` to leave every material exactly as the GLB shipped it.
|
|
44
|
+
|
|
45
|
+
### Breaking
|
|
46
|
+
|
|
47
|
+
`GazeSource` gained a required `residual` member. If you implement that
|
|
48
|
+
interface yourself — rather than consuming `GazeSystem` — you now need to
|
|
49
|
+
supply it. `direction` is unchanged, and `BlinkSystem` still reads it: lids
|
|
50
|
+
follow the head's deflection from neutral, eyes follow the residual, and one
|
|
51
|
+
field could not serve both.
|
|
52
|
+
|
|
53
|
+
## 0.2.1
|
|
54
|
+
|
|
55
|
+
### Patch Changes
|
|
56
|
+
|
|
57
|
+
- Add a `default` condition to the package exports map.
|
|
58
|
+
|
|
59
|
+
The map previously declared only `types` and `import`, so any resolver using
|
|
60
|
+
CommonJS semantics could not resolve the package at all — Jest's default
|
|
61
|
+
resolver failed to resolve `@convai/embody/react` even to mock it, and
|
|
62
|
+
Next.js's `next/dynamic` build transform (which resolves its target via
|
|
63
|
+
`require.resolveWeak`) failed with "Package path './react' is not exported".
|
|
64
|
+
The first real consumer needed three separate workarounds for this one
|
|
65
|
+
omission. `default` points at the same ESM files; resolvers that only need
|
|
66
|
+
resolution now succeed, and runtimes with `require(esm)` can load them.
|
|
67
|
+
|
|
3
68
|
## 0.2.0
|
|
4
69
|
|
|
5
70
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -184,6 +184,7 @@ Design, plan and decision history live in [`docs/`](./docs):
|
|
|
184
184
|
| [motion-implementation-plan.md](./docs/motion-implementation-plan.md) | the 12-task TDD plan |
|
|
185
185
|
| [motion-decisions.md](./docs/motion-decisions.md) | 73 rulings, each with its cost if wrong |
|
|
186
186
|
| [motion-execution-ledger.md](./docs/motion-execution-ledger.md) | the full execution record |
|
|
187
|
+
| [testing-spec.md](./docs/testing-spec.md) | standing policy: the module → test manifest CI enforces |
|
|
187
188
|
|
|
188
189
|
## Release channels
|
|
189
190
|
|
|
@@ -216,13 +217,19 @@ pnpm check:hosts # auto-detects the current branch
|
|
|
216
217
|
|
|
217
218
|
```bash
|
|
218
219
|
pnpm install
|
|
219
|
-
pnpm test #
|
|
220
|
+
pnpm test # 183 tests
|
|
220
221
|
pnpm typecheck
|
|
222
|
+
pnpm check:tests # every src/ module has a named test — see docs/testing-spec.md
|
|
221
223
|
pnpm build # emits dist/ with .d.ts
|
|
222
|
-
pnpm verify # hosts + typecheck + test + build
|
|
224
|
+
pnpm verify # hosts + test manifest + typecheck + test + build
|
|
223
225
|
pnpm dev # the demo (see demo/verify.md)
|
|
224
226
|
```
|
|
225
227
|
|
|
228
|
+
Every module in `src/` has a row in [`docs/testing-spec.md`](./docs/testing-spec.md)
|
|
229
|
+
naming its test and what that test must prove, and `pnpm check:tests` enforces
|
|
230
|
+
it in CI. A new file under `src/` with no row fails the build, so adding a
|
|
231
|
+
feature and adding its test are the same commit.
|
|
232
|
+
|
|
226
233
|
## Roadmap
|
|
227
234
|
|
|
228
235
|
- **Zero requests of our own.** Return the body manifest from the connection
|
|
@@ -16,6 +16,7 @@ import { type ResolvedRig } from '../rig/resolve.js';
|
|
|
16
16
|
import { GazeSystem, type GazeConfig } from '../motion/gaze.js';
|
|
17
17
|
import { type BreathingConfig } from '../motion/breathe.js';
|
|
18
18
|
import { type EyeGazeConfig } from '../motion/eyeGaze.js';
|
|
19
|
+
import { type FaceMaterialTuning } from '../render/faceMaterials.js';
|
|
19
20
|
import { type BlinkConfig } from '../motion/blink.js';
|
|
20
21
|
import { type ReducedMotion } from '../motion/reducedMotion.js';
|
|
21
22
|
/**
|
|
@@ -110,6 +111,18 @@ export interface CreateCharacterOptions {
|
|
|
110
111
|
* systems — no gaze, breathing, eye-look or blink. Default `true`.
|
|
111
112
|
*/
|
|
112
113
|
motion?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Eye, teeth and socket shading. On by default, because the defect it fixes
|
|
116
|
+
* is in the exports rather than in any app: eyeballs ship with no catchlight
|
|
117
|
+
* and read as plastic, and the teeth material arrives with its albedo
|
|
118
|
+
* unbound so the mouth renders as one flat white slab.
|
|
119
|
+
*
|
|
120
|
+
* Pass `false` to leave every material exactly as the GLB shipped it — for a
|
|
121
|
+
* pipeline that does its own look development, or a rig whose materials are
|
|
122
|
+
* not named on the MetaHuman convention this matches (`Eyes*`, `Teeth*`,
|
|
123
|
+
* `Eyeocclusion*`).
|
|
124
|
+
*/
|
|
125
|
+
faceMaterials?: boolean | FaceMaterialTuning;
|
|
113
126
|
}
|
|
114
127
|
export interface CharacterHandle {
|
|
115
128
|
root: THREE.Object3D;
|
|
@@ -12,6 +12,7 @@ import { resolveRig } from '../rig/resolve.js';
|
|
|
12
12
|
import { GazeSystem } from '../motion/gaze.js';
|
|
13
13
|
import { BreathingSystem } from '../motion/breathe.js';
|
|
14
14
|
import { EyeGazeSystem } from '../motion/eyeGaze.js';
|
|
15
|
+
import { applyFaceMaterials } from '../render/faceMaterials.js';
|
|
15
16
|
import { BlinkSystem } from '../motion/blink.js';
|
|
16
17
|
import { createReducedMotion } from '../motion/reducedMotion.js';
|
|
17
18
|
/**
|
|
@@ -196,6 +197,12 @@ export async function createCharacter(opts) {
|
|
|
196
197
|
draco.dispose();
|
|
197
198
|
}
|
|
198
199
|
const root = modelGltf.scene;
|
|
200
|
+
// Shading pass, before anything reads the scene. Not awaited: the teeth
|
|
201
|
+
// texture it rescues resolves on its own and lands a frame or two later,
|
|
202
|
+
// and the mouth is correctly shaped and coloured in the meantime.
|
|
203
|
+
if (opts.faceMaterials !== false) {
|
|
204
|
+
applyFaceMaterials(root, opts.faceMaterials === true || opts.faceMaterials === undefined ? {} : opts.faceMaterials, modelGltf.parser);
|
|
205
|
+
}
|
|
199
206
|
// gather every clip whose name the manifest/overrides declared
|
|
200
207
|
const wanted = new Set(clipUrls.keys());
|
|
201
208
|
const clips = [];
|
package/dist/connect/session.js
CHANGED
|
@@ -23,7 +23,21 @@ import { AudioRenderer } from '@convai/web-sdk/vanilla';
|
|
|
23
23
|
*/
|
|
24
24
|
export async function createSession(opts) {
|
|
25
25
|
const { audio = true, ...config } = opts;
|
|
26
|
-
|
|
26
|
+
// Ask for the frames embody exists to render. The server sends NO
|
|
27
|
+
// blendshapes unless `enableLipsync` is set, and embody's only registered
|
|
28
|
+
// rig is convai-mha-1, which wants MHA-format frames — so the zero-config
|
|
29
|
+
// path `<ConvaiCharacter apiKey characterId />` produced a character that
|
|
30
|
+
// connected, replied, played audio and never moved its mouth. It only
|
|
31
|
+
// worked when an app built its own client and passed `client`, because
|
|
32
|
+
// then the APP supplied these, which is exactly what this demo used to do.
|
|
33
|
+
//
|
|
34
|
+
// `config` spreads LAST so an app can still disable lipsync or choose
|
|
35
|
+
// another blendshape format.
|
|
36
|
+
const client = new ConvaiClient({
|
|
37
|
+
enableLipsync: true,
|
|
38
|
+
blendshapeConfig: { format: 'mha' },
|
|
39
|
+
...config,
|
|
40
|
+
});
|
|
27
41
|
await client.connect();
|
|
28
42
|
const renderer = audio ? new AudioRenderer(client.room) : null;
|
|
29
43
|
return wrapSession(client, renderer, true);
|
package/dist/motion/eyeGaze.js
CHANGED
|
@@ -36,7 +36,13 @@ export class EyeGazeSystem {
|
|
|
36
36
|
const lerpSpeed = this.cfg.lerpSpeed ?? 0.2;
|
|
37
37
|
const verticalOffset = this.cfg.verticalOffset ?? -0.15;
|
|
38
38
|
const gain = this.cfg.morphGain ?? 0.65;
|
|
39
|
-
|
|
39
|
+
// RESIDUAL, not `direction`. `direction` is how far the HEAD has turned
|
|
40
|
+
// from neutral; the eyes need how far the head is STILL off target. They
|
|
41
|
+
// differ exactly when the head is on target, which is most of the time:
|
|
42
|
+
// driving the eyes from `direction` rolls them further the way the head
|
|
43
|
+
// already went, so a character looking straight at you stares past you.
|
|
44
|
+
// On the live convai-mha-1 body that pinned eyeLookUp at 0.20 forever.
|
|
45
|
+
const d = this.gaze.residual;
|
|
40
46
|
const targetH = ctx.reducedMotion ? 0 : THREE.MathUtils.clamp(d.horizontal, -1, 1);
|
|
41
47
|
const targetV = ctx.reducedMotion
|
|
42
48
|
? 0
|
package/dist/motion/gaze.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FrameContext, System } from '../character/pipeline.js';
|
|
2
|
-
import type { GazeDirection, GazeSource } from './types.js';
|
|
2
|
+
import type { EyeGazeDirection, GazeDirection, GazeSource } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Config surface for `GazeSystem`. Task 6 landed the geometric core —
|
|
5
5
|
* target -> yaw/pitch -> head/neck split -> time-based, velocity-capped
|
|
@@ -110,6 +110,8 @@ export declare class GazeSystem implements System, GazeSource {
|
|
|
110
110
|
private readonly cfg;
|
|
111
111
|
readonly stage: 2;
|
|
112
112
|
readonly direction: GazeDirection;
|
|
113
|
+
/** World-space angle still between the head's forward and the target. */
|
|
114
|
+
readonly residual: EyeGazeDirection;
|
|
113
115
|
private gate;
|
|
114
116
|
private yaw;
|
|
115
117
|
private pitch;
|
|
@@ -141,6 +143,12 @@ export declare class GazeSystem implements System, GazeSource {
|
|
|
141
143
|
private readonly vHead;
|
|
142
144
|
private readonly vLocal;
|
|
143
145
|
private readonly mInv;
|
|
146
|
+
private readonly vAim;
|
|
147
|
+
private readonly vFwdW;
|
|
148
|
+
private readonly vRightW;
|
|
149
|
+
private readonly vUpW;
|
|
150
|
+
private readonly qHeadW;
|
|
151
|
+
private readonly qRestInv;
|
|
144
152
|
/** What the mixer wrote to the head this frame, before we touch it. */
|
|
145
153
|
private readonly qClip;
|
|
146
154
|
/** The camera-locked look pose computed this frame. */
|
package/dist/motion/gaze.js
CHANGED
|
@@ -38,6 +38,8 @@ export class GazeSystem {
|
|
|
38
38
|
right: { horizontal: 0, vertical: 0 },
|
|
39
39
|
horizontal: 0, vertical: 0,
|
|
40
40
|
};
|
|
41
|
+
/** World-space angle still between the head's forward and the target. */
|
|
42
|
+
this.residual = { horizontal: 0, vertical: 0 };
|
|
41
43
|
this.yaw = 0;
|
|
42
44
|
this.pitch = 0;
|
|
43
45
|
/** Idle-roll + turn-bank, written by the aliveness layer below. */
|
|
@@ -64,6 +66,13 @@ export class GazeSystem {
|
|
|
64
66
|
this.vHead = new THREE.Vector3();
|
|
65
67
|
this.vLocal = new THREE.Vector3();
|
|
66
68
|
this.mInv = new THREE.Matrix4();
|
|
69
|
+
// Scratch for the world-space residual published to EyeGazeSystem.
|
|
70
|
+
this.vAim = new THREE.Vector3();
|
|
71
|
+
this.vFwdW = new THREE.Vector3();
|
|
72
|
+
this.vRightW = new THREE.Vector3();
|
|
73
|
+
this.vUpW = new THREE.Vector3();
|
|
74
|
+
this.qHeadW = new THREE.Quaternion();
|
|
75
|
+
this.qRestInv = new THREE.Quaternion();
|
|
67
76
|
/** What the mixer wrote to the head this frame, before we touch it. */
|
|
68
77
|
this.qClip = new THREE.Quaternion();
|
|
69
78
|
/** The camera-locked look pose computed this frame. */
|
|
@@ -176,6 +185,25 @@ export class GazeSystem {
|
|
|
176
185
|
const parent = head.parent ?? ctx.root;
|
|
177
186
|
this.mInv.copy(parent.matrixWorld).invert();
|
|
178
187
|
this.vLocal.subVectors(this.vTarget, this.vHead).transformDirection(this.mInv);
|
|
188
|
+
// Into the head's REST frame. The yaw/pitch solved below are applied as
|
|
189
|
+
// `restHead * qLook` (the premultiply further down), i.e. RELATIVE to the
|
|
190
|
+
// head's rest orientation — so they must be MEASURED relative to it too.
|
|
191
|
+
//
|
|
192
|
+
// want: P · restHead · qLook · fwd = aim (P = parent world quat)
|
|
193
|
+
// so: qLook solves against restHead⁻¹ · P⁻¹ · aim
|
|
194
|
+
//
|
|
195
|
+
// `transformDirection(mInv)` above supplies only P⁻¹ · aim. Without the
|
|
196
|
+
// restHead⁻¹ the solve measures in one frame and applies in another, and
|
|
197
|
+
// the head lands mis-aimed by exactly the rest tilt.
|
|
198
|
+
//
|
|
199
|
+
// Invisible on a rig whose bones rest at identity — which is every older
|
|
200
|
+
// fixture in this file. On the japanese-f fixture the head rests 11.42°
|
|
201
|
+
// tilted; measured on the live convai-mha-1 body, a level camera left the
|
|
202
|
+
// head aiming 17-20° ABOVE it, chin up, while the solve reported itself on
|
|
203
|
+
// target. That is the "it never looks at me directly" symptom.
|
|
204
|
+
const headRestFrame = ctx.rig.restPose.head;
|
|
205
|
+
if (headRestFrame)
|
|
206
|
+
this.vLocal.applyQuaternion(this.qRestInv.copy(headRestFrame).invert());
|
|
179
207
|
const basis = ctx.rig.profile.basis;
|
|
180
208
|
// Yaw/pitch of the target about the rig's own bone-local axes.
|
|
181
209
|
const right = this.vLocal.dot(basis.right);
|
|
@@ -463,5 +491,40 @@ export class GazeSystem {
|
|
|
463
491
|
this.direction.left.vertical = v;
|
|
464
492
|
this.direction.right.horizontal = h;
|
|
465
493
|
this.direction.right.vertical = v;
|
|
494
|
+
// The EYES need a different quantity, measured in WORLD space from the
|
|
495
|
+
// head's ACTUAL orientation after the writes above — not from
|
|
496
|
+
// `this.yaw`/`this.pitch`, which are bone-space CORRECTION angles.
|
|
497
|
+
//
|
|
498
|
+
// Those correction angles also carry the head's own rest tilt and the
|
|
499
|
+
// neck chain's orientation. On a rig whose bones rest at identity the
|
|
500
|
+
// two coincide, which is why this was invisible in every test here. On a
|
|
501
|
+
// real MetaHuman it is badly wrong: measured in a browser on the live
|
|
502
|
+
// convai-mha-1 body, a camera 3cm above the head (true angle 0.48°)
|
|
503
|
+
// published vertical = 0.456, 42x too large, because the head bone rests
|
|
504
|
+
// ~11.5° tilted and the solve must undo that before it aims anywhere.
|
|
505
|
+
// EyeGazeSystem read that as "looking up at 46% of maximum" and pinned
|
|
506
|
+
// eyeLookUp at 0.20 — the eyes never tracked, they were stuck up, and a
|
|
507
|
+
// character looking straight at you appeared to stare over your shoulder.
|
|
508
|
+
//
|
|
509
|
+
// `direction` above is deliberately NOT changed: BlinkSystem wants the
|
|
510
|
+
// head's deflection from neutral (lids follow the head), and two tests
|
|
511
|
+
// pin that meaning. One field cannot serve both, so the eyes get their
|
|
512
|
+
// own. `getWorldQuaternion` refreshes the matrix chain, so this reads
|
|
513
|
+
// this frame's pose rather than last frame's.
|
|
514
|
+
head.getWorldQuaternion(this.qHeadW);
|
|
515
|
+
head.getWorldPosition(this.vHead);
|
|
516
|
+
ctx.camera.getWorldPosition(this.vTarget);
|
|
517
|
+
this.vAim.subVectors(this.vTarget, this.vHead);
|
|
518
|
+
if (this.vAim.lengthSq() > 1e-12) {
|
|
519
|
+
this.vAim.normalize();
|
|
520
|
+
this.vFwdW.copy(basis.fwd).applyQuaternion(this.qHeadW);
|
|
521
|
+
this.vRightW.copy(basis.right).applyQuaternion(this.qHeadW);
|
|
522
|
+
this.vUpW.copy(basis.up).applyQuaternion(this.qHeadW);
|
|
523
|
+
const aimR = this.vAim.dot(this.vRightW);
|
|
524
|
+
const aimU = this.vAim.dot(this.vUpW);
|
|
525
|
+
const aimF = this.vAim.dot(this.vFwdW);
|
|
526
|
+
this.residual.horizontal = THREE.MathUtils.clamp(Math.atan2(aimR, aimF) / maxTrack, -1, 1);
|
|
527
|
+
this.residual.vertical = THREE.MathUtils.clamp(Math.atan2(aimU, Math.hypot(aimR, aimF)) / maxPitch, -1, 1);
|
|
528
|
+
}
|
|
466
529
|
}
|
|
467
530
|
}
|
package/dist/motion/types.d.ts
CHANGED
|
@@ -12,5 +12,23 @@ export interface GazeDirection {
|
|
|
12
12
|
}
|
|
13
13
|
/** Anything that can tell a downstream system where the character is looking. */
|
|
14
14
|
export interface GazeSource {
|
|
15
|
+
/**
|
|
16
|
+
* How far the HEAD has turned from its neutral, normalised by the tracking
|
|
17
|
+
* limits. This is what "the character is looking up/right" means, and it is
|
|
18
|
+
* what BlinkSystem wants: lids follow the head's own deflection.
|
|
19
|
+
*
|
|
20
|
+
* It is NOT what the eyes want. See `residual`.
|
|
21
|
+
*/
|
|
15
22
|
readonly direction: GazeDirection;
|
|
23
|
+
/**
|
|
24
|
+
* How far the head STILL points away from the gaze target, in world space,
|
|
25
|
+
* after this frame's turn. Zero when the head is aimed at the target.
|
|
26
|
+
*
|
|
27
|
+
* This is what EyeGazeSystem wants, and the two genuinely differ: a head
|
|
28
|
+
* that has finished turning to face you has a large `direction` (it turned
|
|
29
|
+
* a long way) and a `residual` of zero (it is now on target). Driving the
|
|
30
|
+
* eyes from `direction` rolls them further in the direction the head has
|
|
31
|
+
* already gone, so a character looking straight at you stares past you.
|
|
32
|
+
*/
|
|
33
|
+
readonly residual: EyeGazeDirection;
|
|
16
34
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Face material pass — the half of "the eyes and teeth look wrong" that no
|
|
4
|
+
* amount of animation can fix.
|
|
5
|
+
*
|
|
6
|
+
* Ported from the pagoda-act scene, where every value below was arrived at by
|
|
7
|
+
* looking at a rendered face rather than by reasoning about shading. The
|
|
8
|
+
* lipsync layer in `src/speech/lipsync.ts` is the other half of the same work;
|
|
9
|
+
* neither is sufficient alone, and the teeth in particular need both:
|
|
10
|
+
*
|
|
11
|
+
* SHAPE — the arches must retract and tuck as the jaw moves. That lives in
|
|
12
|
+
* the lipsync layer (teethRetract / teethUpperLift / teethLowerTuck)
|
|
13
|
+
* and is already here.
|
|
14
|
+
* SHADE — the arches must be textured and lit like a mouth interior. That is
|
|
15
|
+
* this file, and without it the mouth renders as one flat slab.
|
|
16
|
+
*
|
|
17
|
+
* Two distinct defects, both shading:
|
|
18
|
+
*
|
|
19
|
+
* · THE EYES READ DEAD. Exports ship the eyeball as a matte-ish dielectric
|
|
20
|
+
* with no wet dome, so there is no catchlight. A real eye is a glossy
|
|
21
|
+
* sphere under a near-mirror cornea; with no specular highlight the viewer
|
|
22
|
+
* reads plastic no matter how well the gaze tracks.
|
|
23
|
+
* · THE TEETH READ TOO BRIGHT. A real mouth sits in shadow. Darkening the
|
|
24
|
+
* enamel and cutting its lighting response is what finally allows an honest
|
|
25
|
+
* jaw opening — open vowels then show a shadowed glimpse of the upper row
|
|
26
|
+
* instead of a bright white band.
|
|
27
|
+
*
|
|
28
|
+
* TWO DELIBERATE DEVIATIONS from the original, both because this is a library
|
|
29
|
+
* and that was one scene:
|
|
30
|
+
*
|
|
31
|
+
* 1. No cross-character texture sharing. The original keeps a module-level
|
|
32
|
+
* `sharedTeethMap` so a character whose export ships no teeth image can
|
|
33
|
+
* borrow one from whoever else is mounted. Two characters in one scene can
|
|
34
|
+
* afford that. A package cannot: `CharacterHandle.dispose()` frees every
|
|
35
|
+
* texture it finds, so a borrowed map would be disposed out from under a
|
|
36
|
+
* character that is still using it, and the failure would appear on an
|
|
37
|
+
* unrelated character at an unrelated time. Each character gets its own
|
|
38
|
+
* map or none.
|
|
39
|
+
* 2. Replaced materials are disposed here, not leaked. Swapping a lit teeth
|
|
40
|
+
* material for an unlit one orphans the original — `dispose()` walks the
|
|
41
|
+
* scene graph and would only ever see the replacement.
|
|
42
|
+
*/
|
|
43
|
+
/** What this actually needs of `GLTF.parser`, structurally. */
|
|
44
|
+
export type GltfParserLike = {
|
|
45
|
+
json?: {
|
|
46
|
+
images?: Array<{
|
|
47
|
+
name?: string;
|
|
48
|
+
}>;
|
|
49
|
+
textures?: Array<{
|
|
50
|
+
source?: number;
|
|
51
|
+
}>;
|
|
52
|
+
};
|
|
53
|
+
getDependency?: (type: string, index: number) => Promise<unknown>;
|
|
54
|
+
};
|
|
55
|
+
export interface FaceMaterialTuning {
|
|
56
|
+
/**
|
|
57
|
+
* Per-character multiplier on the teeth albedo.
|
|
58
|
+
*
|
|
59
|
+
* Exists because characters stand in different light and nothing occludes a
|
|
60
|
+
* mouth. In the original scene the two characters' teeth materials were
|
|
61
|
+
* measured IDENTICAL and only the lighting differed, so one shared value
|
|
62
|
+
* could not satisfy both — every attempt to find one made whichever
|
|
63
|
+
* character was currently wrong, right, and the other one wrong.
|
|
64
|
+
*
|
|
65
|
+
* 1 is the original's tuning and the default, so nothing silently changes.
|
|
66
|
+
*/
|
|
67
|
+
teethScale?: number;
|
|
68
|
+
}
|
|
69
|
+
export interface FaceMaterialReport {
|
|
70
|
+
eyes: number;
|
|
71
|
+
teeth: number;
|
|
72
|
+
occlusion: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Pull this model's teeth albedo out of the file, bound or not.
|
|
76
|
+
*
|
|
77
|
+
* THE V2 EXPORTS ORPHAN IT. The GLB ships a `Teeth` image — alongside
|
|
78
|
+
* Teeth_spec, Teeth_normal and Teeth_roughness — and a texture entry pointing
|
|
79
|
+
* at it. What it does not ship is a material that USES it: the teeth material
|
|
80
|
+
* arrives with `baseColorFactor: [0,0,0,1]` and no `baseColorTexture`. glTF
|
|
81
|
+
* loaders only materialise textures some material references, so the map is
|
|
82
|
+
* present in the file, never becomes a THREE.Texture, and the mouth renders as
|
|
83
|
+
* one flat untextured slab with no tooth separation. That is the "everything
|
|
84
|
+
* is white" report.
|
|
85
|
+
*
|
|
86
|
+
* `parser.getDependency('texture', i)` loads any texture entry, referenced or
|
|
87
|
+
* not, which is exactly the hole.
|
|
88
|
+
*
|
|
89
|
+
* Matches the image name EXACTLY — `Teeth`, never `Teeth_spec`,
|
|
90
|
+
* `Teeth_normal` or `Teeth_roughness`, all three of which sit next to it and
|
|
91
|
+
* none of which is a colour map. Getting that wrong puts a normal map in the
|
|
92
|
+
* albedo slot, which reads as a lilac mouth rather than as an error.
|
|
93
|
+
*/
|
|
94
|
+
export declare function loadTeethTexture(parser: GltfParserLike | undefined): Promise<THREE.Texture | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Apply the face pass. Synchronous for the material work; the teeth texture
|
|
97
|
+
* resolves on its own and lands a frame or two later.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately not awaited by the caller: the teeth are correctly shaped and
|
|
100
|
+
* correctly coloured in the meantime, they simply have no tooth separation
|
|
101
|
+
* until the image decodes.
|
|
102
|
+
*/
|
|
103
|
+
export declare function applyFaceMaterials(root: THREE.Object3D, tuning?: FaceMaterialTuning, parser?: GltfParserLike): FaceMaterialReport;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Pull this model's teeth albedo out of the file, bound or not.
|
|
4
|
+
*
|
|
5
|
+
* THE V2 EXPORTS ORPHAN IT. The GLB ships a `Teeth` image — alongside
|
|
6
|
+
* Teeth_spec, Teeth_normal and Teeth_roughness — and a texture entry pointing
|
|
7
|
+
* at it. What it does not ship is a material that USES it: the teeth material
|
|
8
|
+
* arrives with `baseColorFactor: [0,0,0,1]` and no `baseColorTexture`. glTF
|
|
9
|
+
* loaders only materialise textures some material references, so the map is
|
|
10
|
+
* present in the file, never becomes a THREE.Texture, and the mouth renders as
|
|
11
|
+
* one flat untextured slab with no tooth separation. That is the "everything
|
|
12
|
+
* is white" report.
|
|
13
|
+
*
|
|
14
|
+
* `parser.getDependency('texture', i)` loads any texture entry, referenced or
|
|
15
|
+
* not, which is exactly the hole.
|
|
16
|
+
*
|
|
17
|
+
* Matches the image name EXACTLY — `Teeth`, never `Teeth_spec`,
|
|
18
|
+
* `Teeth_normal` or `Teeth_roughness`, all three of which sit next to it and
|
|
19
|
+
* none of which is a colour map. Getting that wrong puts a normal map in the
|
|
20
|
+
* albedo slot, which reads as a lilac mouth rather than as an error.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadTeethTexture(parser) {
|
|
23
|
+
const json = parser?.json;
|
|
24
|
+
if (!json?.images || !json.textures || !parser?.getDependency)
|
|
25
|
+
return null;
|
|
26
|
+
const image = json.images.findIndex((im) => /^teeth$/i.test(im?.name ?? ''));
|
|
27
|
+
if (image < 0)
|
|
28
|
+
return null;
|
|
29
|
+
const texture = json.textures.findIndex((t) => t?.source === image);
|
|
30
|
+
if (texture < 0)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const tex = (await parser.getDependency('texture', texture));
|
|
34
|
+
if (!tex)
|
|
35
|
+
return null;
|
|
36
|
+
// getDependency hands back the raw texture. Colour space is normally
|
|
37
|
+
// applied by the material assignment that never happened here, and an
|
|
38
|
+
// albedo left in linear renders washed out — which would look like a
|
|
39
|
+
// PARTIAL fix and be much harder to spot than no texture at all.
|
|
40
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
41
|
+
tex.needsUpdate = true;
|
|
42
|
+
return tex;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Mutate in place, and feature-test every property.
|
|
50
|
+
*
|
|
51
|
+
* The original's first attempt built a fresh MeshPhysicalMaterial and
|
|
52
|
+
* `copy()`d the old one into it, to get clearcoat. That threw
|
|
53
|
+
* "Cannot read properties of undefined" out of the renderer and took the whole
|
|
54
|
+
* canvas down: on a node-material path these are node materials, and a
|
|
55
|
+
* hand-rolled swap loses internals the renderer then dereferences. Mutating
|
|
56
|
+
* the material the loader made keeps its maps, its node graph and its
|
|
57
|
+
* skinning/morph bindings — and anything unsupported is simply skipped.
|
|
58
|
+
*/
|
|
59
|
+
function tune(mat, teethScale, found) {
|
|
60
|
+
const m = mat;
|
|
61
|
+
const name = m.name || '';
|
|
62
|
+
if (/^Eyes/i.test(name)) {
|
|
63
|
+
// ONLY on a material that has a lighting model.
|
|
64
|
+
//
|
|
65
|
+
// Measured on the live convai-mha-1 body: its eye materials arrive as
|
|
66
|
+
// MeshBasicMaterial — already unlit. Gloss cannot exist there, so
|
|
67
|
+
// `roughness`, `clearcoat` and `specularIntensity` would be inert
|
|
68
|
+
// properties bolted onto a material that never reads them.
|
|
69
|
+
//
|
|
70
|
+
// The colour lift is the part that actually does harm. It exists to
|
|
71
|
+
// compensate for clearcoat darkening the perceived iris; applied to an
|
|
72
|
+
// unlit material it is simply a 25% brightening of the baked eye map, and
|
|
73
|
+
// washes out the very colour it was meant to protect. An export that
|
|
74
|
+
// already decided eyes are unlit has made this choice deliberately.
|
|
75
|
+
if (!('roughness' in m))
|
|
76
|
+
return;
|
|
77
|
+
m.metalness = 0;
|
|
78
|
+
// Wet, glossy sphere — this is what produces a catchlight at all.
|
|
79
|
+
m.roughness = 0.12;
|
|
80
|
+
if ('clearcoat' in m) {
|
|
81
|
+
// The dome over the iris: a second, sharper highlight that reads as
|
|
82
|
+
// cornea rather than polished plastic.
|
|
83
|
+
m.clearcoat = 1;
|
|
84
|
+
m.clearcoatRoughness = 0.02;
|
|
85
|
+
}
|
|
86
|
+
if ('specularIntensity' in m)
|
|
87
|
+
m.specularIntensity = 1.5;
|
|
88
|
+
m.envMapIntensity = 1.6;
|
|
89
|
+
// The baked eye map darkens the iris; lift it so the colour stays readable
|
|
90
|
+
// under the gloss.
|
|
91
|
+
m.color?.setRGB(1.25, 1.25, 1.25);
|
|
92
|
+
found.eyes++;
|
|
93
|
+
}
|
|
94
|
+
else if (/^Eyeocclusion/i.test(name)) {
|
|
95
|
+
m.metalness = 0;
|
|
96
|
+
m.roughness = 1;
|
|
97
|
+
// Ships white at full lighting response, which flattens the socket.
|
|
98
|
+
// Darkened and unlit it beds the eyeball into the face.
|
|
99
|
+
m.color?.setRGB(0.5, 0.45, 0.45);
|
|
100
|
+
m.envMapIntensity = 0;
|
|
101
|
+
found.occlusion++;
|
|
102
|
+
}
|
|
103
|
+
else if (/^Teeth/i.test(name)) {
|
|
104
|
+
m.metalness = 0;
|
|
105
|
+
// Enamel, slightly worn — not white, and emphatically not black.
|
|
106
|
+
//
|
|
107
|
+
// The original drove this to 0.045 trying to stop the teeth reading as a
|
|
108
|
+
// bright slab. It worked, and then read as a hole in the face. Albedo was
|
|
109
|
+
// the wrong tool: the teeth looked wrong because they did not MOVE with
|
|
110
|
+
// the jaw, not because they were the wrong colour. That is fixed properly
|
|
111
|
+
// in the lipsync layer, which is why this can go back to being a colour.
|
|
112
|
+
m.roughness = 0.55;
|
|
113
|
+
m.color?.setRGB(0.46 * teethScale, 0.45 * teethScale, 0.43 * teethScale);
|
|
114
|
+
m.envMapIntensity = 0.1;
|
|
115
|
+
if ('specularIntensity' in m)
|
|
116
|
+
m.specularIntensity = 0.2;
|
|
117
|
+
found.teeth++;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
m.needsUpdate = true;
|
|
123
|
+
}
|
|
124
|
+
/** How many meshes under `root` reference this exact material instance. */
|
|
125
|
+
function referenceCount(root, target) {
|
|
126
|
+
let n = 0;
|
|
127
|
+
root.traverse((obj) => {
|
|
128
|
+
const mesh = obj;
|
|
129
|
+
if (!mesh.isMesh || !mesh.material)
|
|
130
|
+
return;
|
|
131
|
+
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
132
|
+
for (const m of mats)
|
|
133
|
+
if (m === target)
|
|
134
|
+
n++;
|
|
135
|
+
});
|
|
136
|
+
return n;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Apply the face pass. Synchronous for the material work; the teeth texture
|
|
140
|
+
* resolves on its own and lands a frame or two later.
|
|
141
|
+
*
|
|
142
|
+
* Deliberately not awaited by the caller: the teeth are correctly shaped and
|
|
143
|
+
* correctly coloured in the meantime, they simply have no tooth separation
|
|
144
|
+
* until the image decodes.
|
|
145
|
+
*/
|
|
146
|
+
export function applyFaceMaterials(root, tuning = {}, parser) {
|
|
147
|
+
const teethScale = tuning.teethScale ?? 1;
|
|
148
|
+
const found = { eyes: 0, teeth: 0, occlusion: 0 };
|
|
149
|
+
/** Every teeth material on THIS model, for the map to land on once loaded. */
|
|
150
|
+
const mouths = [];
|
|
151
|
+
root.traverse((obj) => {
|
|
152
|
+
const mesh = obj;
|
|
153
|
+
if (!mesh.isMesh || !mesh.material)
|
|
154
|
+
return;
|
|
155
|
+
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
156
|
+
for (const m of mats)
|
|
157
|
+
tune(m, teethScale, found);
|
|
158
|
+
const teeth = mats.find((mm) => /^Teeth/i.test(mm.name || ''));
|
|
159
|
+
if (!teeth)
|
|
160
|
+
return;
|
|
161
|
+
// A LIT teeth material is the odd one out: nothing in a scene occludes a
|
|
162
|
+
// mouth, so a character standing in sun gets bright teeth and one in shade
|
|
163
|
+
// gets dark ones from the same material. Unlit makes every mouth read the
|
|
164
|
+
// same wherever the character stands. The TEXTURE is what still separates
|
|
165
|
+
// one tooth from another — which is why the rescue above matters, and why
|
|
166
|
+
// making them unlit without it produces a flat grey shape.
|
|
167
|
+
if (!teeth.isMeshBasicMaterial) {
|
|
168
|
+
const flat = new THREE.MeshBasicMaterial({
|
|
169
|
+
name: 'Teeth',
|
|
170
|
+
color: teeth.color.clone(),
|
|
171
|
+
side: teeth.side,
|
|
172
|
+
map: teeth.map ?? null,
|
|
173
|
+
});
|
|
174
|
+
// Replace only the teeth SLOT. A head mesh routinely carries several
|
|
175
|
+
// materials — skin, teeth, tongue — and assigning `mesh.material = flat`
|
|
176
|
+
// wholesale would collapse that array to one entry and leave every other
|
|
177
|
+
// group rendering with the teeth material. Found by a test written for a
|
|
178
|
+
// different reason.
|
|
179
|
+
if (Array.isArray(mesh.material)) {
|
|
180
|
+
mesh.material = mesh.material.map((m) => (m === teeth ? flat : m));
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
mesh.material = flat;
|
|
184
|
+
}
|
|
185
|
+
// The original is now orphaned: dispose() walks the graph and would only
|
|
186
|
+
// ever find the replacement. Dispose it here — but only if nothing else
|
|
187
|
+
// under this root still points at it, since one material instance can be
|
|
188
|
+
// shared across several meshes.
|
|
189
|
+
if (referenceCount(root, teeth) === 0)
|
|
190
|
+
teeth.dispose();
|
|
191
|
+
mouths.push(flat);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
mouths.push(teeth);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
if (mouths.length && !mouths.every((m) => m.map)) {
|
|
198
|
+
void loadTeethTexture(parser).then((tex) => {
|
|
199
|
+
if (!tex)
|
|
200
|
+
return;
|
|
201
|
+
for (const m of mouths) {
|
|
202
|
+
if (!m.map) {
|
|
203
|
+
m.map = tex;
|
|
204
|
+
m.needsUpdate = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return found;
|
|
210
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@convai/embody",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Give a Convai character a body: resolve 3D characters by characterId and drive them with lipsync and animation. Layered on @convai/web-sdk.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Convai <support@convai.com>",
|
|
@@ -33,11 +33,13 @@
|
|
|
33
33
|
"exports": {
|
|
34
34
|
".": {
|
|
35
35
|
"types": "./dist/index.d.ts",
|
|
36
|
-
"import": "./dist/index.js"
|
|
36
|
+
"import": "./dist/index.js",
|
|
37
|
+
"default": "./dist/index.js"
|
|
37
38
|
},
|
|
38
39
|
"./react": {
|
|
39
40
|
"types": "./dist/react/index.d.ts",
|
|
40
|
-
"import": "./dist/react/index.js"
|
|
41
|
+
"import": "./dist/react/index.js",
|
|
42
|
+
"default": "./dist/react/index.js"
|
|
41
43
|
},
|
|
42
44
|
"./package.json": "./package.json"
|
|
43
45
|
},
|
|
@@ -96,12 +98,13 @@
|
|
|
96
98
|
"typecheck": "tsc --noEmit",
|
|
97
99
|
"test": "vitest run",
|
|
98
100
|
"test:watch": "vitest",
|
|
99
|
-
"verify": "pnpm check:hosts && pnpm typecheck && pnpm test && pnpm build",
|
|
101
|
+
"verify": "pnpm check:hosts && pnpm check:tests && pnpm typecheck && pnpm test && pnpm build",
|
|
100
102
|
"changeset": "changeset",
|
|
101
103
|
"version": "changeset version",
|
|
102
104
|
"release": "pnpm build:prod && changeset publish",
|
|
103
105
|
"release:beta": "pnpm build:beta && changeset publish --tag beta",
|
|
104
106
|
"check:hosts": "node scripts/check-hosts.mjs",
|
|
107
|
+
"check:tests": "node scripts/check-tests.mjs",
|
|
105
108
|
"build:stg": "pnpm clean && BRANCH=stg node scripts/check-hosts.mjs && pnpm build",
|
|
106
109
|
"release:stg": "pnpm build:stg && changeset publish --tag stg",
|
|
107
110
|
"docs": "typedoc",
|