@convai/embody 0.2.1 → 0.4.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,82 @@
1
1
  # @convai/embody
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8bfb1ab: Drag to turn the character, scroll to move in and out — on by default.
8
+
9
+ A 3D character you cannot walk around is a picture of one, and
10
+ `<ConvaiCharacter />` exists to be the batteries-included view. Until now
11
+ every consumer that wanted to look at the side of a head had to drop to
12
+ `CharacterModel` and build its own `Canvas`, which is most of what the
13
+ component was there to spare them.
14
+
15
+ `orbit` accepts `false` for a fixed camera, or an object to tune the limits:
16
+ `minDistance`, `maxDistance`, `minPolarAngle`, `maxPolarAngle`, `enablePan`,
17
+ `enableZoom`, `autoRotate`, `autoRotateSpeed`.
18
+
19
+ The defaults are tuned for one subject — a standing human — rather than for a
20
+ general scene viewer. Panning is off, because it only ever slides the
21
+ character off screen, and the vertical sweep stops a few degrees below eye
22
+ level, because orbiting under the floor shows the inside of a head. It uses
23
+ three's own `OrbitControls` rather than `@react-three/drei`, so it costs no
24
+ new dependency: `three` is already a peer and the `dependencies` block stays
25
+ empty.
26
+
27
+ Minor rather than patch because a character that did not move now moves with
28
+ no code change on the consumer's side.
29
+
30
+ ## 0.3.0
31
+
32
+ ### Minor Changes
33
+
34
+ - f9d8f06: Characters now look at you, and their mouths move by default.
35
+
36
+ Four fixes, all of which change what a character does with no code change on
37
+ the consumer's side. Minor rather than patch because default behaviour moves
38
+ and one interface gained a member.
39
+
40
+ **Lipsync worked only if you brought your own client.** `createSession` never
41
+ set `enableLipsync`, and the server sends no blendshape frames unless asked —
42
+ so the zero-config path, `<ConvaiCharacter apiKey characterId />`, connected,
43
+ replied, played audio and never moved its mouth. It worked when an app built
44
+ its own `ConvaiClient` and passed `client`, because then the app supplied the
45
+ flag. That asymmetry was recorded for weeks as "staging emits no blendshapes";
46
+ it was never the environment. `createSession` now defaults `enableLipsync:
47
+ true` and `blendshapeConfig: { format: 'mha' }`, with your config spread last
48
+ so you can still disable it or choose another format.
49
+
50
+ **The head aimed past its target by the rig's rest tilt.** The look angle was
51
+ solved in the head's parent frame but applied composed onto the head's rest
52
+ pose — two different frames. On a rig whose bones rest at identity they
53
+ coincide, which is why no test caught it; on a real MetaHuman the head settled
54
+ 17–20° above a level camera, chin up, while reporting itself on target.
55
+
56
+ **The eyes tracked the head's own turn instead of the target.**
57
+ `EyeGazeSystem` read `GazeSystem.direction`, which is how far the head has
58
+ turned from neutral, when the eyes need how far the head is _still_ off target.
59
+ Those diverge exactly when the head is aimed at you — so the eyes rolled
60
+ further the way the head had already gone, and a character looking straight at
61
+ you stared past you. Measured on a live body, `eyeLookUp` sat pinned at 0.203
62
+ for ever.
63
+
64
+ **New:** a face material pass, on by default. The defect it fixes is in the
65
+ exports rather than in any app: the teeth material arrives with its albedo
66
+ unbound, so glTF loaders never materialise the `Teeth` image the file ships and
67
+ the mouth renders as one flat white slab. The pass fetches it through the glTF
68
+ parser, tags it sRGB, makes the mouth unlit so it reads the same in sun and
69
+ shade, and adds a catchlight to eyeballs that have a lighting model. Pass
70
+ `faceMaterials: false` to leave every material exactly as the GLB shipped it.
71
+
72
+ ### Breaking
73
+
74
+ `GazeSource` gained a required `residual` member. If you implement that
75
+ interface yourself — rather than consuming `GazeSystem` — you now need to
76
+ supply it. `direction` is unchanged, and `BlinkSystem` still reads it: lids
77
+ follow the head's deflection from neutral, eyes follow the residual, and one
78
+ field could not serve both.
79
+
3
80
  ## 0.2.1
4
81
 
5
82
  ### 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
  }
@@ -1,4 +1,5 @@
1
1
  import { type CharacterModelProps } from './CharacterModel.js';
2
+ import { type OrbitOptions } from './OrbitRig.js';
2
3
  export interface ConvaiCharacterProps extends CharacterModelProps {
3
4
  /** Wrapper style. Defaults to filling the parent. */
4
5
  style?: React.CSSProperties;
@@ -14,8 +15,16 @@ export interface ConvaiCharacterProps extends CharacterModelProps {
14
15
  fov?: number;
15
16
  target?: [number, number, number];
16
17
  };
18
+ /**
19
+ * Drag to turn the character, scroll to move in and out. On by default: a
20
+ * 3D character you cannot walk around is a picture of one, and this
21
+ * component's whole job is the batteries-included view.
22
+ *
23
+ * Pass `false` for a fixed camera, or an object to tune the limits.
24
+ */
25
+ orbit?: boolean | OrbitOptions;
17
26
  /** Rendered while the body is still loading. */
18
27
  fallback?: React.ReactNode;
19
28
  }
20
- export declare function ConvaiCharacter({ style, className, background, camera, fallback, ...character }: ConvaiCharacterProps): import("react").JSX.Element;
29
+ export declare function ConvaiCharacter({ style, className, background, camera, orbit, fallback, ...character }: ConvaiCharacterProps): import("react").JSX.Element;
21
30
  export default ConvaiCharacter;
@@ -24,9 +24,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  import { Suspense } from 'react';
25
25
  import { Canvas } from '@react-three/fiber';
26
26
  import { CharacterModel } from './CharacterModel.js';
27
+ import { OrbitRig } from './OrbitRig.js';
27
28
  const DEFAULT_POSITION = [0, 1.45, 3.6];
28
29
  const DEFAULT_TARGET = [0, 0.92, 0];
29
- export function ConvaiCharacter({ style, className, background = '#17181c', camera, fallback = null, ...character }) {
30
+ export function ConvaiCharacter({ style, className, background = '#17181c', camera, orbit = true, fallback = null, ...character }) {
30
31
  const position = camera?.position ?? DEFAULT_POSITION;
31
32
  const target = camera?.target ?? DEFAULT_TARGET;
32
33
  return (_jsxs("div", { className: className, style: { width: '100%', height: '100%', minHeight: '100vh', ...style }, children: [_jsxs(Canvas, { camera: { position, fov: camera?.fov ?? 35, near: 0.05, far: 100 },
@@ -34,7 +35,9 @@ export function ConvaiCharacter({ style, className, background = '#17181c', came
34
35
  // existing page content rather than in a box of its own.
35
36
  gl: { antialias: true, alpha: background === null },
36
37
  // Aim at the character's head rather than the origin at its feet;
37
- // r3f points the camera at [0,0,0] otherwise.
38
- onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
38
+ // r3f points the camera at [0,0,0] otherwise. Harmless when orbit is
39
+ // on — OrbitRig takes the same target and drives the camera from
40
+ // there — and load-bearing when it is off.
41
+ onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), orbit !== false && (_jsx(OrbitRig, { target: target, options: orbit === true ? undefined : orbit })), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
39
42
  }
40
43
  export default ConvaiCharacter;
@@ -0,0 +1,30 @@
1
+ export interface OrbitOptions {
2
+ /** Closest the camera may come, in metres. */
3
+ minDistance?: number;
4
+ /** Furthest the camera may retreat, in metres. */
5
+ maxDistance?: number;
6
+ /** Highest the camera may rise, in radians from straight up. */
7
+ minPolarAngle?: number;
8
+ /** Lowest the camera may sink. Past `PI/2` it starts looking up from below. */
9
+ maxPolarAngle?: number;
10
+ /** Drag the subject around the frame. Off by default — it loses the character. */
11
+ enablePan?: boolean;
12
+ /** Scroll to dolly in and out. */
13
+ enableZoom?: boolean;
14
+ /** Turn the character slowly on its own, for an idle showcase. */
15
+ autoRotate?: boolean;
16
+ /** Degrees per second when `autoRotate` is on. */
17
+ autoRotateSpeed?: number;
18
+ }
19
+ export interface OrbitRigProps {
20
+ /** The point orbited around — the character's head, not its feet. */
21
+ target: [number, number, number];
22
+ options?: OrbitOptions;
23
+ }
24
+ /**
25
+ * Renders nothing. Lives inside `<Canvas>` so it can reach the camera and the
26
+ * canvas element, which is also why it cannot simply be set up in `onCreated`
27
+ * — damping needs a per-frame `update()`.
28
+ */
29
+ export declare function OrbitRig({ target, options }: OrbitRigProps): null;
30
+ export default OrbitRig;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Drag-to-orbit for the character view.
3
+ *
4
+ * Uses three's own `OrbitControls` rather than `@react-three/drei` so this
5
+ * costs no new dependency: `three` is already a peer, and embody ships with
6
+ * an empty `dependencies` block that is worth keeping empty.
7
+ *
8
+ * The defaults are tuned for one subject — a standing human — rather than for
9
+ * a general scene viewer. Panning is off and the vertical sweep stops just
10
+ * below eye level, because in a character viewer both of those only ever lose
11
+ * the character: pan slides it off screen, and orbiting under the floor shows
12
+ * the inside of a head.
13
+ */
14
+ import { useFrame, useThree } from '@react-three/fiber';
15
+ import { useEffect, useRef, useState } from 'react';
16
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
17
+ /**
18
+ * Roughly 20 degrees above the head down to a few degrees below eye level.
19
+ * `PI/2` is dead level with the orbit target, so the small overshoot buys a
20
+ * slight up-angle without ever reaching the floor.
21
+ */
22
+ const DEFAULTS = {
23
+ minDistance: 0.6,
24
+ maxDistance: 8,
25
+ minPolarAngle: Math.PI * 0.12,
26
+ maxPolarAngle: Math.PI * 0.52,
27
+ enablePan: false,
28
+ enableZoom: true,
29
+ autoRotate: false,
30
+ autoRotateSpeed: 1.2,
31
+ };
32
+ /**
33
+ * Renders nothing. Lives inside `<Canvas>` so it can reach the camera and the
34
+ * canvas element, which is also why it cannot simply be set up in `onCreated`
35
+ * — damping needs a per-frame `update()`.
36
+ */
37
+ export function OrbitRig({ target, options }) {
38
+ const camera = useThree((s) => s.camera);
39
+ const domElement = useThree((s) => s.gl.domElement);
40
+ // Created and disposed in the SAME effect, never in useMemo. Disposal
41
+ // removes the listeners OrbitControls put on the canvas — without it a
42
+ // remounted character leaves a live controller behind and two fight over
43
+ // every drag. But a memoised instance disposed in a cleanup is a trap:
44
+ // React StrictMode runs mount → cleanup → mount in dev, and the second
45
+ // mount reused the same, already-disposed instance. The rig then rendered
46
+ // fine and did nothing until something recreated the controls — a resize,
47
+ // an HMR reload — which is exactly "orbit comes and goes". Pairing
48
+ // construction with disposal means the remount gets a live instance.
49
+ const [controls, setControls] = useState(null);
50
+ const controlsRef = useRef(null);
51
+ useEffect(() => {
52
+ const next = new OrbitControls(camera, domElement);
53
+ controlsRef.current = next;
54
+ setControls(next);
55
+ return () => {
56
+ next.dispose();
57
+ if (controlsRef.current === next)
58
+ controlsRef.current = null;
59
+ };
60
+ }, [camera, domElement]);
61
+ const [tx, ty, tz] = target;
62
+ const { minDistance, maxDistance, minPolarAngle, maxPolarAngle, enablePan, enableZoom, autoRotate, autoRotateSpeed, } = { ...DEFAULTS, ...options };
63
+ useEffect(() => {
64
+ if (!controls)
65
+ return;
66
+ controls.target.set(tx, ty, tz);
67
+ controls.minDistance = minDistance;
68
+ controls.maxDistance = maxDistance;
69
+ controls.minPolarAngle = minPolarAngle;
70
+ controls.maxPolarAngle = maxPolarAngle;
71
+ controls.enablePan = enablePan;
72
+ controls.enableZoom = enableZoom;
73
+ controls.autoRotate = autoRotate;
74
+ controls.autoRotateSpeed = autoRotateSpeed;
75
+ // Damping is what makes a drag feel like weight rather than a jump cut.
76
+ controls.enableDamping = true;
77
+ controls.dampingFactor = 0.08;
78
+ controls.update();
79
+ }, [
80
+ controls, tx, ty, tz,
81
+ minDistance, maxDistance, minPolarAngle, maxPolarAngle,
82
+ enablePan, enableZoom, autoRotate, autoRotateSpeed,
83
+ ]);
84
+ useFrame(() => {
85
+ // The ref, not the state: a frame can run between dispose and the
86
+ // re-render that clears the state, and updating a disposed controller
87
+ // is harmless but pointless.
88
+ controlsRef.current?.update();
89
+ });
90
+ return null;
91
+ }
92
+ export default OrbitRig;
@@ -31,6 +31,8 @@ export { ConvaiCharacter, default } from './ConvaiCharacter.js';
31
31
  export type { ConvaiCharacterProps } from './ConvaiCharacter.js';
32
32
  export { CharacterModel } from './CharacterModel.js';
33
33
  export type { CharacterModelProps } from './CharacterModel.js';
34
+ export { OrbitRig } from './OrbitRig.js';
35
+ export type { OrbitOptions, OrbitRigProps } from './OrbitRig.js';
34
36
  export { useWidgetClient } from './useWidgetClient.js';
35
37
  export type { WidgetClient } from './useWidgetClient.js';
36
38
  export { ConvaiWidget, useConvaiClient, AudioRenderer, AudioContext } from '@convai/web-sdk/react';
@@ -28,6 +28,9 @@
28
28
  export { useCharacter } from './useCharacter.js';
29
29
  export { ConvaiCharacter, default } from './ConvaiCharacter.js';
30
30
  export { CharacterModel } from './CharacterModel.js';
31
+ // Exported for apps that build their own <Canvas> around CharacterModel and
32
+ // still want the orbit behaviour ConvaiCharacter gives for free.
33
+ export { OrbitRig } from './OrbitRig.js';
31
34
  export { useWidgetClient } from './useWidgetClient.js';
32
35
  // ── Chat / conversation surface, re-exported from @convai/web-sdk/react ────
33
36
  export { ConvaiWidget, useConvaiClient, AudioRenderer, AudioContext } from '@convai/web-sdk/react';
@@ -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.4.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>",
@@ -79,6 +79,7 @@
79
79
  "@types/react-dom": "^19.2.7",
80
80
  "@types/react-test-renderer": "18.3.1",
81
81
  "@types/three": "^0.182.0",
82
+ "jsdom": "^25.0.1",
82
83
  "react": "^18.3.1",
83
84
  "react-dom": "^18.3.1",
84
85
  "react-test-renderer": "18.3.1",