@convai/embody 1.0.1-beta.0 → 1.1.0-beta.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +12 -8
  3. package/dist/character/createCharacter.d.ts +66 -5
  4. package/dist/character/createCharacter.js +93 -22
  5. package/dist/character/pipeline.d.ts +9 -5
  6. package/dist/clips/animations.d.ts +13 -1
  7. package/dist/clips/animations.js +28 -6
  8. package/dist/clips/retarget.d.ts +168 -0
  9. package/dist/clips/retarget.js +856 -0
  10. package/dist/clips/sources.d.ts +70 -0
  11. package/dist/clips/sources.js +119 -0
  12. package/dist/connect/session.d.ts +11 -0
  13. package/dist/connect/session.js +22 -0
  14. package/dist/index.d.ts +22 -1
  15. package/dist/index.js +12 -0
  16. package/dist/inspect/inspectModel.d.ts +14 -0
  17. package/dist/inspect/inspectModel.js +26 -0
  18. package/dist/inspect/morphMap.d.ts +54 -0
  19. package/dist/inspect/morphMap.js +140 -0
  20. package/dist/inspect/report.d.ts +88 -0
  21. package/dist/inspect/report.js +193 -0
  22. package/dist/motion/eyeAim.d.ts +94 -0
  23. package/dist/motion/eyeAim.js +276 -0
  24. package/dist/motion/eyeGaze.d.ts +17 -0
  25. package/dist/motion/eyeGaze.js +42 -0
  26. package/dist/motion/footLock.d.ts +67 -0
  27. package/dist/motion/footLock.js +176 -0
  28. package/dist/motion/gaze.d.ts +135 -180
  29. package/dist/motion/gaze.js +257 -491
  30. package/dist/motion/hair.d.ts +27 -0
  31. package/dist/motion/hair.js +127 -0
  32. package/dist/motion/hairClearance.d.ts +39 -0
  33. package/dist/motion/hairClearance.js +125 -0
  34. package/dist/motion/morphNormals.d.ts +38 -0
  35. package/dist/motion/morphNormals.js +237 -0
  36. package/dist/motion/types.d.ts +15 -0
  37. package/dist/react/CharacterModel.d.ts +3 -1
  38. package/dist/react/CharacterModel.js +9 -1
  39. package/dist/react/ConvaiCharacter.js +4 -1
  40. package/dist/react/PortraitLights.d.ts +3 -0
  41. package/dist/react/PortraitLights.js +44 -0
  42. package/dist/render/faceMaterials.d.ts +60 -3
  43. package/dist/render/faceMaterials.js +320 -96
  44. package/dist/render/portraitRig.d.ts +28 -0
  45. package/dist/render/portraitRig.js +55 -0
  46. package/dist/render/surfaces.d.ts +181 -0
  47. package/dist/render/surfaces.js +1020 -0
  48. package/dist/rig/resolve.d.ts +8 -2
  49. package/dist/rig/resolve.js +7 -3
  50. package/dist/rig/units.d.ts +35 -16
  51. package/dist/rig/units.js +143 -42
  52. package/dist/speech/expression.d.ts +20 -0
  53. package/dist/speech/expression.js +118 -0
  54. package/dist/speech/lipsync.d.ts +68 -2
  55. package/dist/speech/lipsync.js +205 -5
  56. package/dist/speech/mouthFinish.d.ts +46 -0
  57. package/dist/speech/mouthFinish.js +235 -0
  58. package/dist/speech/voiceClock.d.ts +94 -0
  59. package/dist/speech/voiceClock.js +176 -0
  60. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The light `ConvaiCharacter`'s own canvas puts on a character: a portrait
4
+ * rig, as the coffee-shop studio lit its films, scaled to the full-body
5
+ * default framing.
6
+ *
7
+ * The old default (a hemisphere at 1.4, an unshadowed key at 2.2, a room
8
+ * environment at 0.6, ACES) flooded the face from every side: the skin's
9
+ * wrap and shade, the shadow work and the eyes' catchlights never showed,
10
+ * and ACES pushed skin orange. This is:
11
+ *
12
+ * · a soft-shadowed key 45° to screen left of the camera and 30° up,
13
+ * following the camera round the character (as a photographer moves the
14
+ * lights with each shot), never more than 70° off the character's front,
15
+ * so a turn to its darker side does not become a black profile;
16
+ * · a fill on the other side at about a ninth of the key, a warm rim
17
+ * behind, a dim hemisphere and a dim room environment for reflections;
18
+ * · Neutral tone mapping at 1.05 (ACES turned skin orange).
19
+ *
20
+ * Only for embody's own canvas: an app with its own `<Canvas>` lights it.
21
+ */
22
+ import { useEffect, useMemo } from 'react';
23
+ import { useFrame, useThree } from '@react-three/fiber';
24
+ import * as THREE from 'three';
25
+ import { createPortraitRig, placePortraitRig, PORTRAIT_TONE } from '../render/portraitRig.js';
26
+ export function PortraitLights({ target }) {
27
+ const gl = useThree((s) => s.gl);
28
+ const lights = useMemo(createPortraitRig, []);
29
+ useEffect(() => {
30
+ const tone = gl.toneMapping, exposure = gl.toneMappingExposure;
31
+ gl.toneMapping = PORTRAIT_TONE.toneMapping;
32
+ gl.toneMappingExposure = PORTRAIT_TONE.exposure;
33
+ return () => {
34
+ gl.toneMapping = tone;
35
+ gl.toneMappingExposure = exposure;
36
+ lights.key.dispose();
37
+ lights.fill.dispose();
38
+ lights.rim.dispose();
39
+ };
40
+ }, [gl, lights]);
41
+ const at = useMemo(() => new THREE.Vector3(...target), [target]);
42
+ useFrame(({ camera }) => placePortraitRig(lights, camera.position, at));
43
+ return (_jsxs(_Fragment, { children: [_jsx("primitive", { object: lights.key }), _jsx("primitive", { object: lights.fill }), _jsx("primitive", { object: lights.rim }), _jsx("primitive", { object: lights.sky })] }));
44
+ }
@@ -36,9 +36,9 @@ import * as THREE from 'three';
36
36
  * character that is still using it, and the failure would appear on an
37
37
  * unrelated character at an unrelated time. Each character gets its own
38
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.
39
+ * 2. Replaced materials are disposed here, not leaked. Swapping the teeth
40
+ * material orphans the original — `dispose()` walks the scene graph and
41
+ * would only ever see the replacement.
42
42
  */
43
43
  /** What this actually needs of `GLTF.parser`, structurally. */
44
44
  export type GltfParserLike = {
@@ -65,6 +65,11 @@ export interface FaceMaterialTuning {
65
65
  * 1 is the original's tuning and the default, so nothing silently changes.
66
66
  */
67
67
  teethScale?: number;
68
+ /**
69
+ * Brightness of the eye light reflected in each cornea (WebGL), as from a
70
+ * small panel just above the camera. 0 switches it off. Default 1.2.
71
+ */
72
+ catchlight?: number;
68
73
  }
69
74
  export interface FaceMaterialReport {
70
75
  eyes: number;
@@ -75,6 +80,12 @@ export interface FaceMaterialReport {
75
80
  /** Hidden `Hide` helper materials. */
76
81
  hidden: number;
77
82
  }
83
+ /**
84
+ * An eyeball material: "eye" as a word, camel case included (MetaHuman
85
+ * `Eyes`, re-exports' `EyeSofia`, Character Creator `Std_Eye_R`, Ready
86
+ * Player Me `Wolf3D_Eye`), never the films, lashes and shells around it.
87
+ */
88
+ export declare const isEye: (name: string) => boolean;
78
89
  /**
79
90
  * Pull this model's teeth albedo out of the file, bound or not.
80
91
  *
@@ -96,6 +107,52 @@ export interface FaceMaterialReport {
96
107
  * albedo slot, which reads as a lilac mouth rather than as an error.
97
108
  */
98
109
  export declare function loadTeethTexture(parser: GltfParserLike | undefined): Promise<THREE.Texture | null>;
110
+ /** Iris and pupil radii in an eye texture's UV space, centred at (0.5, 0.5). */
111
+ export interface IrisLayout {
112
+ iris: number;
113
+ pupil: number;
114
+ }
115
+ /**
116
+ * Read the iris and pupil radii off an eye texture, from its radial
117
+ * brightness: the pupil edge where it rises halfway from the pupil to the
118
+ * iris body, the iris edge where it rises halfway from the iris body to the
119
+ * sclera. Measured on the published MetaHumans: 0.047 and 0.155, against the
120
+ * 0.05 and 0.158 the studio set by eye. Null when the image cannot be read
121
+ * (no DOM, a tainted canvas) or does not look like a centred eye, in which
122
+ * case the iris is left as painted.
123
+ */
124
+ export declare function measureIris(image: CanvasImageSource | undefined | null): IrisLayout | null;
125
+ /**
126
+ * The eye light (WebGL): a small soft panel just above and to the side of
127
+ * the camera, as a portrait photographer adds, reflected in the cornea.
128
+ * Under a dim scene a cornea has nothing bright to reflect and the eyes read
129
+ * dead; the studio painted a spot on for its films. A reflection of a light
130
+ * that travels with the view works for any export and any camera, and the
131
+ * eyes look at the camera, so it lands in the iris.
132
+ */
133
+ export declare function patchCatchlight(fs: string): string;
134
+ /** The iris treatment on three's map stage. Unchanged without a map stage. */
135
+ export declare function patchIrisShader(fs: string): string;
136
+ /** Pass the mouth's openness (0 inside, 1 at the front teeth) to the fragment. */
137
+ export declare function patchMouthVertex(vs: string): string;
138
+ /** Rough and without specular where the mouth hides a surface. */
139
+ export declare function patchMouthFragment(fs: string): string;
140
+ /**
141
+ * How much light reaches each vertex of the mouth, from where it sits in the
142
+ * bind pose, as vertex colours (every renderer) and `embodyMouthOpen` (the
143
+ * WebGL specular mask). Measured in the character's own frame (glTF: +Z
144
+ * forward, +Y up), so it holds for any export:
145
+ *
146
+ * · depth: the mouth falls to near black within the first centimetre behind
147
+ * the front teeth (to 0.06, not 0, so a small opening is not a black slit);
148
+ * · height: the lower teeth and gums sit in the lower lip's shade, at half
149
+ * the upper ones;
150
+ * · side: the side teeth sit in the cheeks' shade (the buccal corridor);
151
+ * lit, they flashed white at the mouth corners.
152
+ *
153
+ * Values are the coffee-shop studio's, matched to Convai's Unreal references.
154
+ */
155
+ export declare function mouthOcclusion(mesh: THREE.Mesh, slot?: number): void;
99
156
  /**
100
157
  * Apply the face pass. Synchronous for the material work; the teeth texture
101
158
  * resolves on its own and lands a frame or two later.
@@ -1,5 +1,10 @@
1
1
  import * as THREE from 'three';
2
- const isEye = (name) => /^Eyes?(?!occlusion)/i.test(name);
2
+ /**
3
+ * An eyeball material: "eye" as a word, camel case included (MetaHuman
4
+ * `Eyes`, re-exports' `EyeSofia`, Character Creator `Std_Eye_R`, Ready
5
+ * Player Me `Wolf3D_Eye`), never the films, lashes and shells around it.
6
+ */
7
+ export const isEye = (name) => /(?:^|[^a-z])eyes?(?:[^a-z]|$)/i.test(name.replace(/([a-z])([A-Z])/g, '$1_$2')) && !/occlusion|lash|brow|tear|cornea|shell|socket|lid/i.test(name);
3
8
  /**
4
9
  * Repair what the convai-mha-1 Blender exports get wrong, matched on the
5
10
  * defect rather than the material name wherever the defect is detectable, so
@@ -36,17 +41,7 @@ function repairExport(mat, found) {
36
41
  const sameImage = !!m.map && (m.map === emissiveMap || m.map.source === emissiveMap.source);
37
42
  const lit = emissive.r + emissive.g + emissive.b > 0;
38
43
  if (isEye(name)) {
39
- // Unlit eyes are a deliberate export choice (see `tune`); lit ones get a
40
- // floor of their own albedo.
41
- if ('roughness' in m) {
42
- if (!lit)
43
- emissive.setScalar(0.25);
44
- else
45
- emissive.multiplyScalar(0.25);
46
- if ('emissiveIntensity' in m)
47
- m.emissiveIntensity = 1;
48
- changed = true;
49
- }
44
+ // Replaced by a lit eye with no emissive (see eyeMaterial).
50
45
  }
51
46
  else if (sameImage && lit) {
52
47
  m.emissiveMap = null;
@@ -55,11 +50,6 @@ function repairExport(mat, found) {
55
50
  changed = true;
56
51
  }
57
52
  }
58
- else if (isEye(name) && emissive && m.map && 'roughness' in m) {
59
- m.emissiveMap = m.map;
60
- emissive.setScalar(0.25);
61
- changed = true;
62
- }
63
53
  if (typeof m.roughness === 'number' && m.roughness > 1) {
64
54
  m.roughness = 1;
65
55
  changed = true;
@@ -72,7 +62,7 @@ function repairExport(mat, found) {
72
62
  if (/^(Base)?Head/i.test(name) && 'roughness' in m) {
73
63
  if (m.roughnessMap) {
74
64
  m.roughnessMap = null;
75
- m.roughness = 0.62;
65
+ m.roughness = 0.52;
76
66
  }
77
67
  if ('specularIntensity' in m)
78
68
  m.specularIntensity = 0.35;
@@ -144,67 +134,299 @@ function tune(mat, teethScale, found) {
144
134
  const m = mat;
145
135
  const name = m.name || '';
146
136
  if (isEye(name)) {
147
- // ONLY on a material that has a lighting model.
148
- //
149
- // Measured on the live convai-mha-1 body: its eye materials arrive as
150
- // MeshBasicMaterial — already unlit. Gloss cannot exist there, so
151
- // `roughness`, `clearcoat` and `specularIntensity` would be inert
152
- // properties bolted onto a material that never reads them.
153
- //
154
- // The colour lift is the part that actually does harm. It exists to
155
- // compensate for clearcoat darkening the perceived iris; applied to an
156
- // unlit material it is simply a 25% brightening of the baked eye map, and
157
- // washes out the very colour it was meant to protect. An export that
158
- // already decided eyes are unlit has made this choice deliberately.
159
- if (!('roughness' in m))
160
- return;
161
- m.metalness = 0;
162
- // Wet, glossy sphere — this is what produces a catchlight at all.
163
- m.roughness = 0.12;
164
- if ('clearcoat' in m) {
165
- // The dome over the iris: a second, sharper highlight that reads as
166
- // cornea rather than polished plastic.
167
- m.clearcoat = 1;
168
- m.clearcoatRoughness = 0.02;
169
- }
170
- if ('specularIntensity' in m)
171
- m.specularIntensity = 1.5;
172
- m.envMapIntensity = 1.6;
173
- // The baked eye map darkens the iris; lift it so the colour stays readable
174
- // under the gloss.
175
- m.color?.setRGB(1.25, 1.25, 1.25);
137
+ // Replaced wholesale in applyFaceMaterials (lit, with a cornea and a
138
+ // measured iris).
176
139
  found.eyes++;
140
+ return;
177
141
  }
178
142
  else if (/^Eyeocclusion/i.test(name)) {
179
143
  m.metalness = 0;
180
144
  m.roughness = 1;
181
- // Ships white at full lighting response, which flattens the socket.
182
- // Darkened and unlit it beds the eyeball into the face.
183
- m.color?.setRGB(0.5, 0.45, 0.45);
145
+ // A film that may only ever darken: in MetaHuman it is the socket's
146
+ // shadow, its texture's alpha saying where. Shipped white at full
147
+ // lighting response it flattened the socket; at the old 0.5 grey it still
148
+ // reflected a hard key and drew a pale rim along the upper lash line
149
+ // (seen under a top spot). Black, with no specular, its alpha only shades.
150
+ m.color?.setRGB(0, 0, 0);
151
+ // Strong enough to carry the upper lid's shadow on the iris: at 0.6 the
152
+ // eyes read flat, pasted on (the studio's).
153
+ if (m.transparent)
154
+ m.opacity = 0.8;
184
155
  m.envMapIntensity = 0;
156
+ if ('specularIntensity' in m)
157
+ m.specularIntensity = 0;
185
158
  found.occlusion++;
186
159
  }
187
160
  else if (/^Teeth/i.test(name)) {
188
- m.metalness = 0;
189
- // Enamel, slightly worn — not white, and emphatically not black.
190
- //
191
- // The original drove this to 0.045 trying to stop the teeth reading as a
192
- // bright slab. It worked, and then read as a hole in the face. Albedo was
193
- // the wrong tool: the teeth looked wrong because they did not MOVE with
194
- // the jaw, not because they were the wrong colour. That is fixed properly
195
- // in the lipsync layer, which is why this can go back to being a colour.
196
- m.roughness = 0.55;
197
- m.color?.setRGB(0.46 * teethScale, 0.45 * teethScale, 0.43 * teethScale);
198
- m.envMapIntensity = 0.1;
199
- if ('specularIntensity' in m)
200
- m.specularIntensity = 0.2;
161
+ // Replaced wholesale in applyFaceMaterials (lit, with mouth occlusion).
201
162
  found.teeth++;
163
+ return;
202
164
  }
203
165
  else {
204
166
  return;
205
167
  }
206
168
  m.needsUpdate = true;
207
169
  }
170
+ /**
171
+ * Read the iris and pupil radii off an eye texture, from its radial
172
+ * brightness: the pupil edge where it rises halfway from the pupil to the
173
+ * iris body, the iris edge where it rises halfway from the iris body to the
174
+ * sclera. Measured on the published MetaHumans: 0.047 and 0.155, against the
175
+ * 0.05 and 0.158 the studio set by eye. Null when the image cannot be read
176
+ * (no DOM, a tainted canvas) or does not look like a centred eye, in which
177
+ * case the iris is left as painted.
178
+ */
179
+ export function measureIris(image) {
180
+ if (!image || typeof document === 'undefined')
181
+ return null;
182
+ try {
183
+ const N = 256;
184
+ const canvas = document.createElement('canvas');
185
+ canvas.width = canvas.height = N;
186
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
187
+ if (!ctx)
188
+ return null;
189
+ ctx.drawImage(image, 0, 0, N, N);
190
+ const d = ctx.getImageData(0, 0, N, N).data;
191
+ const BINS = 64;
192
+ const sum = new Float32Array(BINS), count = new Float32Array(BINS);
193
+ for (let j = 0; j < N; j++)
194
+ for (let i = 0; i < N; i++) {
195
+ const r = Math.hypot((i + 0.5) / N - 0.5, (j + 0.5) / N - 0.5);
196
+ const b = Math.floor((r / 0.5) * BINS);
197
+ if (b >= BINS)
198
+ continue;
199
+ const k = (j * N + i) * 4;
200
+ sum[b] += (0.2126 * d[k] + 0.7152 * d[k + 1] + 0.0722 * d[k + 2]) / 255;
201
+ count[b]++;
202
+ }
203
+ const L = Array.from(sum, (v, i) => v / (count[i] || 1));
204
+ const at = (r) => L[Math.min(BINS - 1, Math.floor((r / 0.5) * BINS))];
205
+ const radius = (b) => ((b + 0.5) / BINS) * 0.5;
206
+ const sclera = Math.max(...L);
207
+ const body = at(0.1);
208
+ const firstAbove = (from, level) => {
209
+ for (let b = Math.floor((from / 0.5) * BINS); b < BINS; b++)
210
+ if (L[b] >= level)
211
+ return radius(b);
212
+ return -1;
213
+ };
214
+ const iris = firstAbove(0.05, (body + sclera) / 2);
215
+ const pupil = firstAbove(0, (L[0] + body) / 2);
216
+ if (!(sclera - body > 0.15 && iris > 0.06 && iris < 0.3 && pupil > 0.01 && pupil < iris * 0.7))
217
+ return null;
218
+ return { iris, pupil };
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ }
224
+ /**
225
+ * A living eye under any light (the coffee-shop studio's):
226
+ * · lit, with a wet sclera (roughness 0.32) under a cornea coat at 0.1:
227
+ * not mirror-glassy, since at viewing distance a sharper cornea shrinks
228
+ * every highlight below a pixel and the eyes read dead;
229
+ * · faint reflections (specular and environment 0.25): reflected light
230
+ * ignores shadows, and at full strength the eye on the shadow side glowed
231
+ * as bright as the lit one;
232
+ * · no emissive: the old albedo glow lifted the pupil to grey;
233
+ * · on WebGL, with a measured iris: its colour enriched (saturation 1.4,
234
+ * contrast), a darker limbal ring and a near-black pupil, where exports
235
+ * paint a desaturated grey ring with a soft edge.
236
+ */
237
+ function eyeMaterial(src, catchlight) {
238
+ const m = new THREE.MeshPhysicalMaterial({
239
+ name: src.name,
240
+ side: src.side,
241
+ map: src.map ?? null,
242
+ normalMap: src.normalMap ?? null,
243
+ metalness: 0,
244
+ roughness: 0.32,
245
+ clearcoat: 1,
246
+ clearcoatRoughness: 0.1,
247
+ specularIntensity: 0.25,
248
+ envMapIntensity: 0.25,
249
+ });
250
+ if (src.normalScale)
251
+ m.normalScale.copy(src.normalScale);
252
+ m.userData.embodyEye = true;
253
+ const layout = m.map ? measureIris(src.map?.image) : null;
254
+ if (layout)
255
+ m.userData.embodyIris = layout;
256
+ const uniforms = {
257
+ embodyIris: { value: layout?.iris ?? 0 },
258
+ embodyPupil: { value: layout?.pupil ?? 0 },
259
+ embodyCatchlight: { value: catchlight },
260
+ };
261
+ m.userData.embodyEyeUniforms = uniforms;
262
+ m.onBeforeCompile = (shader) => {
263
+ Object.assign(shader.uniforms, uniforms);
264
+ if (layout)
265
+ shader.fragmentShader = patchIrisShader(shader.fragmentShader);
266
+ shader.fragmentShader = patchCatchlight(shader.fragmentShader);
267
+ };
268
+ m.customProgramCacheKey = () => `embody-eye-${layout ? 1 : 0}`;
269
+ return m;
270
+ }
271
+ /**
272
+ * The eye light (WebGL): a small soft panel just above and to the side of
273
+ * the camera, as a portrait photographer adds, reflected in the cornea.
274
+ * Under a dim scene a cornea has nothing bright to reflect and the eyes read
275
+ * dead; the studio painted a spot on for its films. A reflection of a light
276
+ * that travels with the view works for any export and any camera, and the
277
+ * eyes look at the camera, so it lands in the iris.
278
+ */
279
+ export function patchCatchlight(fs) {
280
+ if (!fs.includes('#include <opaque_fragment>'))
281
+ return fs;
282
+ return fs
283
+ .replace('void main() {', 'uniform float embodyCatchlight;\nvoid main() {')
284
+ .replace('#include <opaque_fragment>', `{
285
+ // A panel ~10° above and to one side of the lens: up and to one side of
286
+ // the pupil. Further out it fell under the upper lid (the open eye
287
+ // shows only the front ~25° of the ball).
288
+ vec3 embodyH = normalize( normalize( vec3( -0.1, 0.13, 1.0 ) ) + vec3( 0.0, 0.0, 1.0 ) );
289
+ float embodySpot = pow( max( dot( normalize( normal ), embodyH ), 0.0 ), 120.0 );
290
+ outgoingLight += vec3( 1.0, 0.97, 0.92 ) * smoothstep( 0.3, 0.7, embodySpot ) * embodyCatchlight;
291
+ }
292
+ #include <opaque_fragment>`);
293
+ }
294
+ /** The iris treatment on three's map stage. Unchanged without a map stage. */
295
+ export function patchIrisShader(fs) {
296
+ if (!fs.includes('#include <map_fragment>'))
297
+ return fs;
298
+ return fs
299
+ .replace('void main() {', 'uniform float embodyIris;\nuniform float embodyPupil;\nvoid main() {')
300
+ .replace('#include <map_fragment>', `#include <map_fragment>
301
+ #ifdef USE_MAP
302
+ {
303
+ float r = length( vMapUv - 0.5 );
304
+ float iris = 1.0 - smoothstep( embodyIris - 0.008, embodyIris + 0.006, r );
305
+ float ring = smoothstep( embodyIris * 0.7, embodyIris, r ) * iris;
306
+ float pupil = 1.0 - smoothstep( embodyPupil - 0.006, embodyPupil + 0.004, r );
307
+ vec3 c = diffuseColor.rgb;
308
+ float lum = dot( c, vec3( 0.2126, 0.7152, 0.0722 ) );
309
+ vec3 rich = pow( max( mix( vec3( lum ), c, 1.4 ), vec3( 0.0 ) ), vec3( 1.2 ) ) * 1.1;
310
+ c = mix( c, rich, iris );
311
+ c *= 1.0 - ring * 0.45;
312
+ c *= 1.0 - pupil * 0.92;
313
+ diffuseColor.rgb = c;
314
+ }
315
+ #endif`);
316
+ }
317
+ /** Enamel under the mouth's light: worn off-white, well under skin brightness. */
318
+ const ENAMEL = new THREE.Color(0.93, 0.9, 0.86).multiplyScalar(0.7);
319
+ /**
320
+ * The lit teeth material. Front faces only: double-sided, the backs of the
321
+ * gums and tongue faced the camera through an open mouth and caught the key
322
+ * as a grey sheet. No environment reflection (there is no sky in a mouth);
323
+ * specular and roughness follow the mouth occlusion on WebGL, so only the
324
+ * front teeth and tongue tip keep a little wet shine and the inner cheeks
325
+ * stop flashing white at grazing angles.
326
+ */
327
+ function teethMaterial(src, teethScale) {
328
+ const m = new THREE.MeshPhysicalMaterial({
329
+ name: src.name || 'Teeth',
330
+ side: THREE.FrontSide,
331
+ map: src.map ?? null,
332
+ color: ENAMEL.clone().multiplyScalar(teethScale),
333
+ metalness: 0,
334
+ roughness: 0.5,
335
+ specularIntensity: 0.42,
336
+ specularColor: new THREE.Color(1, 0.85, 0.82),
337
+ envMapIntensity: 0,
338
+ vertexColors: true,
339
+ });
340
+ m.userData.embodyTeeth = true;
341
+ m.onBeforeCompile = (shader) => {
342
+ shader.vertexShader = patchMouthVertex(shader.vertexShader);
343
+ shader.fragmentShader = patchMouthFragment(shader.fragmentShader);
344
+ };
345
+ m.customProgramCacheKey = () => 'embody-teeth';
346
+ return m;
347
+ }
348
+ /** Pass the mouth's openness (0 inside, 1 at the front teeth) to the fragment. */
349
+ export function patchMouthVertex(vs) {
350
+ if (!vs.includes('#include <begin_vertex>'))
351
+ return vs;
352
+ return vs
353
+ .replace('void main() {', 'attribute float embodyMouthOpen;\nvarying float vEmbodyMouthOpen;\nvoid main() {')
354
+ .replace('#include <begin_vertex>', '#include <begin_vertex>\n\tvEmbodyMouthOpen = embodyMouthOpen;');
355
+ }
356
+ /** Rough and without specular where the mouth hides a surface. */
357
+ export function patchMouthFragment(fs) {
358
+ if (!fs.includes('#include <lights_physical_fragment>') || !fs.includes('#include <roughnessmap_fragment>'))
359
+ return fs;
360
+ return fs
361
+ .replace('void main() {', 'varying float vEmbodyMouthOpen;\nvoid main() {')
362
+ .replace('#include <roughnessmap_fragment>', '#include <roughnessmap_fragment>\n\troughnessFactor = mix( 1.0, roughnessFactor, vEmbodyMouthOpen * vEmbodyMouthOpen );')
363
+ .replace('#include <lights_physical_fragment>', '#include <lights_physical_fragment>\n\tmaterial.specularColor *= vEmbodyMouthOpen;\n\tmaterial.specularF90 *= vEmbodyMouthOpen;');
364
+ }
365
+ const smooth = THREE.MathUtils.smoothstep;
366
+ /**
367
+ * How much light reaches each vertex of the mouth, from where it sits in the
368
+ * bind pose, as vertex colours (every renderer) and `embodyMouthOpen` (the
369
+ * WebGL specular mask). Measured in the character's own frame (glTF: +Z
370
+ * forward, +Y up), so it holds for any export:
371
+ *
372
+ * · depth: the mouth falls to near black within the first centimetre behind
373
+ * the front teeth (to 0.06, not 0, so a small opening is not a black slit);
374
+ * · height: the lower teeth and gums sit in the lower lip's shade, at half
375
+ * the upper ones;
376
+ * · side: the side teeth sit in the cheeks' shade (the buccal corridor);
377
+ * lit, they flashed white at the mouth corners.
378
+ *
379
+ * Values are the coffee-shop studio's, matched to Convai's Unreal references.
380
+ */
381
+ export function mouthOcclusion(mesh, slot = 0) {
382
+ const g = mesh.geometry;
383
+ if (g.userData.embodyMouth || !g.attributes.position)
384
+ return;
385
+ mesh.updateWorldMatrix(true, false);
386
+ const n = g.attributes.position.count;
387
+ // On a multi-material mesh, only the mouth's own triangles set the ranges.
388
+ const mine = new Uint8Array(n);
389
+ const groups = Array.isArray(mesh.material) ? g.groups.filter((gr) => gr.materialIndex === slot) : [];
390
+ if (groups.length) {
391
+ for (const gr of groups)
392
+ for (let k = gr.start; k < gr.start + gr.count; k++)
393
+ mine[g.index ? g.index.getX(k) : k] = 1;
394
+ }
395
+ else
396
+ mine.fill(1);
397
+ const p = new THREE.Vector3();
398
+ const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
399
+ for (let i = 0; i < n; i++) {
400
+ const v = mesh.isSkinnedMesh ? mesh.getVertexPosition(i, p) : p.fromBufferAttribute(g.attributes.position, i);
401
+ v.applyMatrix4(mesh.matrixWorld);
402
+ xs[i] = v.x;
403
+ ys[i] = v.y;
404
+ zs[i] = v.z;
405
+ }
406
+ const unit = (a) => {
407
+ let lo = Infinity, hi = -Infinity;
408
+ a.forEach((x, i) => { if (mine[i]) {
409
+ lo = Math.min(lo, x);
410
+ hi = Math.max(hi, x);
411
+ } });
412
+ return (x) => (x - lo) / (hi - lo || 1);
413
+ };
414
+ const ux = unit(xs), uy = unit(ys), uz = unit(zs);
415
+ const color = new Float32Array(n * 3);
416
+ const open = new Float32Array(n);
417
+ for (let i = 0; i < n; i++) {
418
+ const front = smooth(uz(zs[i]), 0.5, 0.97);
419
+ const cavity = 0.06 + 0.94 * front * front * front;
420
+ const upper = 0.5 + 0.5 * smooth(uy(ys[i]), 0.42, 0.6);
421
+ const corridor = 1 - 0.93 * smooth(Math.abs(ux(xs[i]) * 2 - 1), 0.18, 0.5);
422
+ const light = cavity * upper * corridor;
423
+ color[i * 3] = color[i * 3 + 1] = color[i * 3 + 2] = light;
424
+ open[i] = cavity * corridor;
425
+ }
426
+ g.setAttribute('color', new THREE.BufferAttribute(color, 3));
427
+ g.setAttribute('embodyMouthOpen', new THREE.BufferAttribute(open, 1));
428
+ g.userData.embodyMouth = true;
429
+ }
208
430
  /** How many meshes under `root` reference this exact material instance. */
209
431
  function referenceCount(root, target) {
210
432
  let n = 0;
@@ -241,44 +463,46 @@ export function applyFaceMaterials(root, tuning = {}, parser) {
241
463
  repairExport(m, found);
242
464
  tune(m, teethScale, found);
243
465
  }
244
- const teeth = mats.find((mm) => /^Teeth/i.test(mm.name || ''));
466
+ for (const eye of mats.filter((mm) => isEye(mm.name || '') && !mm.userData.embodyEye)) {
467
+ const lit = eyeMaterial(eye, tuning.catchlight ?? 1.2);
468
+ if (mesh.geometry.attributes.position && !mesh.geometry.attributes.normal)
469
+ mesh.geometry.computeVertexNormals();
470
+ mesh.material = Array.isArray(mesh.material) ? mesh.material.map((m) => (m === eye ? lit : m)) : lit;
471
+ if (referenceCount(root, eye) === 0)
472
+ eye.dispose();
473
+ }
474
+ const teeth = mats.find((mm) => /^Teeth/i.test(mm.name || '') && !mm.userData.embodyTeeth);
245
475
  if (!teeth)
246
476
  return;
247
- // A LIT teeth material is the odd one out: nothing in a scene occludes a
248
- // mouth, so a character standing in sun gets bright teeth and one in shade
249
- // gets dark ones from the same material. Unlit makes every mouth read the
250
- // same wherever the character stands. The TEXTURE is what still separates
251
- // one tooth from another — which is why the rescue above matters, and why
252
- // making them unlit without it produces a flat grey shape.
253
- if (!teeth.isMeshBasicMaterial) {
254
- const flat = new THREE.MeshBasicMaterial({
255
- name: 'Teeth',
256
- color: teeth.color.clone(),
257
- side: teeth.side,
258
- map: teeth.map ?? null,
259
- });
260
- // Replace only the teeth SLOT. A head mesh routinely carries several
261
- // materials — skin, teeth, tongue — and assigning `mesh.material = flat`
262
- // wholesale would collapse that array to one entry and leave every other
263
- // group rendering with the teeth material. Found by a test written for a
264
- // different reason.
265
- if (Array.isArray(mesh.material)) {
266
- mesh.material = mesh.material.map((m) => (m === teeth ? flat : m));
267
- }
268
- else {
269
- mesh.material = flat;
270
- }
271
- // The original is now orphaned: dispose() walks the graph and would only
272
- // ever find the replacement. Dispose it here — but only if nothing else
273
- // under this root still points at it, since one material instance can be
274
- // shared across several meshes.
275
- if (referenceCount(root, teeth) === 0)
276
- teeth.dispose();
277
- mouths.push(flat);
477
+ // Lit, as the rest of the face, and shaded by the mouth around it (see
478
+ // `mouthOcclusion`). Unlit teeth (the export's own choice on some bodies,
479
+ // and this pass's old one) glowed grey in a dark mouth: nothing in a
480
+ // scene's lighting reaches inside a mouth, so the light has to be taken
481
+ // away by where each surface sits.
482
+ const lit = teethMaterial(teeth, teethScale);
483
+ // Exports that ship the teeth unlit ship them without normals too (the
484
+ // published MetaHumans do), and lit without normals they render black.
485
+ if (mesh.geometry.attributes.position && !mesh.geometry.attributes.normal)
486
+ mesh.geometry.computeVertexNormals();
487
+ mouthOcclusion(mesh, mats.indexOf(teeth));
488
+ // Replace only the teeth SLOT. A head mesh routinely carries several
489
+ // materials — skin, teeth, tongue — and assigning `mesh.material = lit`
490
+ // wholesale would collapse that array to one entry and leave every other
491
+ // group rendering with the teeth material. Found by a test written for a
492
+ // different reason.
493
+ if (Array.isArray(mesh.material)) {
494
+ mesh.material = mesh.material.map((m) => (m === teeth ? lit : m));
278
495
  }
279
496
  else {
280
- mouths.push(teeth);
497
+ mesh.material = lit;
281
498
  }
499
+ // The original is now orphaned: dispose() walks the graph and would only
500
+ // ever find the replacement. Dispose it here — but only if nothing else
501
+ // under this root still points at it, since one material instance can be
502
+ // shared across several meshes.
503
+ if (referenceCount(root, teeth) === 0)
504
+ teeth.dispose();
505
+ mouths.push(lit);
282
506
  });
283
507
  if (mouths.length && !mouths.every((m) => m.map)) {
284
508
  void loadTeethTexture(parser).then((tex) => {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A portrait light rig for a character, framework-free: what
3
+ * `ConvaiCharacter` lights its own canvas with, for apps on plain three.js.
4
+ * After the coffee-shop studio's film rig: a soft-shadowed key 45° to screen
5
+ * left of the camera and 30° up, following the camera round the character
6
+ * and never more than 70° off its front (a turn to its darker side is not a
7
+ * black profile); a fill at about a ninth of the key, a warm rim, a dim sky.
8
+ * Pair it with `PORTRAIT_TONE` (ACES pushed skin orange) and a dim
9
+ * environment, and call `placePortraitRig` every frame.
10
+ */
11
+ import * as THREE from 'three';
12
+ /** Tone mapping the rig was tuned under. */
13
+ export declare const PORTRAIT_TONE: {
14
+ readonly toneMapping: 7;
15
+ readonly exposure: 1.05;
16
+ };
17
+ /** Where the key goes for a camera at `camAz` degrees round the character. */
18
+ export declare function keyAzimuth(camAz: number): number;
19
+ export interface PortraitRig {
20
+ key: THREE.DirectionalLight;
21
+ fill: THREE.DirectionalLight;
22
+ rim: THREE.DirectionalLight;
23
+ sky: THREE.HemisphereLight;
24
+ }
25
+ /** The rig's lights: a soft-shadowed key, a fill, a warm rim, a dim sky. */
26
+ export declare function createPortraitRig(): PortraitRig;
27
+ /** Place the rig for a camera at `camera`, lighting the point `at`. */
28
+ export declare function placePortraitRig(rig: PortraitRig, camera: THREE.Vector3, at: THREE.Vector3): void;