@convai/embody 0.2.1 → 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 CHANGED
@@ -1,5 +1,55 @@
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
+
3
53
  ## 0.2.1
4
54
 
5
55
  ### Patch Changes
@@ -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 = [];
@@ -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
- const client = new ConvaiClient(config);
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);
@@ -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
- const d = this.gaze.direction;
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
@@ -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. */
@@ -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
  }
@@ -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.2.1",
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>",