@convai/embody 1.0.1 → 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.
- package/CHANGELOG.md +30 -0
- package/README.md +12 -8
- package/dist/character/createCharacter.d.ts +66 -5
- package/dist/character/createCharacter.js +93 -22
- package/dist/character/pipeline.d.ts +9 -5
- package/dist/clips/animations.d.ts +13 -1
- package/dist/clips/animations.js +28 -6
- package/dist/clips/retarget.d.ts +168 -0
- package/dist/clips/retarget.js +856 -0
- package/dist/clips/sources.d.ts +70 -0
- package/dist/clips/sources.js +119 -0
- package/dist/connect/session.d.ts +11 -0
- package/dist/connect/session.js +22 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -0
- package/dist/inspect/inspectModel.d.ts +14 -0
- package/dist/inspect/inspectModel.js +26 -0
- package/dist/inspect/morphMap.d.ts +54 -0
- package/dist/inspect/morphMap.js +140 -0
- package/dist/inspect/report.d.ts +88 -0
- package/dist/inspect/report.js +193 -0
- package/dist/motion/eyeAim.d.ts +94 -0
- package/dist/motion/eyeAim.js +276 -0
- package/dist/motion/eyeGaze.d.ts +17 -0
- package/dist/motion/eyeGaze.js +42 -0
- package/dist/motion/footLock.d.ts +67 -0
- package/dist/motion/footLock.js +176 -0
- package/dist/motion/gaze.d.ts +135 -180
- package/dist/motion/gaze.js +257 -491
- package/dist/motion/hair.d.ts +27 -0
- package/dist/motion/hair.js +127 -0
- package/dist/motion/hairClearance.d.ts +39 -0
- package/dist/motion/hairClearance.js +125 -0
- package/dist/motion/morphNormals.d.ts +38 -0
- package/dist/motion/morphNormals.js +237 -0
- package/dist/motion/types.d.ts +15 -0
- package/dist/react/CharacterModel.d.ts +3 -1
- package/dist/react/CharacterModel.js +9 -1
- package/dist/react/ConvaiCharacter.js +4 -1
- package/dist/react/PortraitLights.d.ts +3 -0
- package/dist/react/PortraitLights.js +44 -0
- package/dist/render/faceMaterials.d.ts +60 -3
- package/dist/render/faceMaterials.js +320 -96
- package/dist/render/portraitRig.d.ts +28 -0
- package/dist/render/portraitRig.js +55 -0
- package/dist/render/surfaces.d.ts +181 -0
- package/dist/render/surfaces.js +1020 -0
- package/dist/rig/resolve.d.ts +8 -2
- package/dist/rig/resolve.js +7 -3
- package/dist/rig/units.d.ts +35 -16
- package/dist/rig/units.js +143 -42
- package/dist/speech/expression.d.ts +20 -0
- package/dist/speech/expression.js +118 -0
- package/dist/speech/lipsync.d.ts +68 -2
- package/dist/speech/lipsync.js +205 -5
- package/dist/speech/mouthFinish.d.ts +46 -0
- package/dist/speech/mouthFinish.js +235 -0
- package/dist/speech/voiceClock.d.ts +94 -0
- package/dist/speech/voiceClock.js +176 -0
- 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
|
|
40
|
-
* material
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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;
|