@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -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,1020 @@
1
+ /**
2
+ * Surface shading for skin and hair — how the body takes whatever light the
3
+ * app puts it in.
4
+ *
5
+ * Lighting is the app's. What embody owns is that the materials respond to
6
+ * it the way skin and hair do, under a portrait key, noon sun, a neon club or
7
+ * an overcast sky alike. The convai-mha-1 exports ship neither: the face is a
8
+ * plain standard material (metalness map and all) whose light/shadow edge cuts
9
+ * straight from lit to black, and the hair is roughness 1, IOR 1.1 chalk drawn
10
+ * as a depthless blended shell over hard-cut cards.
11
+ *
12
+ * What changes, and on which renderer:
13
+ *
14
+ * EVERY RENDERER (material properties three's WebGL and WebGPU renderers
15
+ * both honour):
16
+ * · skin → MeshPhysicalMaterial: skin's IOR (1.4) and specular, no metal,
17
+ * and a faint warm sheen at grazing angles where light scatters out;
18
+ * · hair cards and lashes → alpha-to-coverage in the opaque pass: they write
19
+ * depth (so they sort and take shadows) and MSAA softens their edges; the
20
+ * export's blended hair layer stays blended, after the cards, as the soft
21
+ * edge; hair gets a broad, soft keratin sheen in its own colour;
22
+ * · the scalp under the hair is shaded as skin in the hair's shade (vertex
23
+ * colours), so partings and the gaps between cards don't show bare skin;
24
+ * · every texture is sampled anisotropically.
25
+ *
26
+ * WEBGL ONLY (a shader patch WebGPU ignores, leaving the above):
27
+ * · light wraps past the terminator as a warm band, as it does through skin
28
+ * (Green, "Real-time approximations to subsurface scattering", GPU Gems 1
29
+ * ch.16), so the edge is soft instead of clay;
30
+ * · thin tissue (ears, nostril wings) glows when lit from behind
31
+ * (Barré-Brisebois & Bouchard, GDC 2011);
32
+ * · a fine pore normal over the face's own normal map — the published face
33
+ * textures are 1024², too coarse to hold pores under a hard key.
34
+ *
35
+ * Values are the ones the coffee-shop studio arrived at by comparing renders
36
+ * against Unreal MetaHuman references under moving light.
37
+ */
38
+ import * as THREE from 'three';
39
+ import { isEye } from './faceMaterials.js';
40
+ /**
41
+ * The skin response on a male record, where the app sets nothing. The
42
+ * published male heads have the same albedo lightness as the light female
43
+ * ones (face luma 0.023-0.026 against 0.031, measured at the mesh), so this
44
+ * is a look, not a measurement: tighter and less pink, with more pore, after
45
+ * a look-dev review (2026-11-07) of a male head that read waxy and warm beside
46
+ * a female head from the same pipeline. Smaller than it sounds: roughness 0.45
47
+ * went plastic in the same review.
48
+ */
49
+ const MALE_SKIN = {
50
+ roughness: 0.48,
51
+ sheen: 0.04,
52
+ sheenColor: [0.75, 0.52, 0.46],
53
+ specular: 0.5,
54
+ // Pores at 0.3: 0.42 overshot the reference's micro-contrast (0.046
55
+ // against 0.039), 0.2 read smooth.
56
+ detail: 0.3,
57
+ // Less light carried past the terminator: the nose, cheek and jaw hold
58
+ // their form (contrast 0.24 -> 0.27 measured; the reference sits far
59
+ // higher under its own harder key).
60
+ wrap: 0.42,
61
+ subsurface: 0.28,
62
+ translucency: 0.18,
63
+ shadowFloor: [0.1, 0.065, 0.055],
64
+ tint: [0.965, 1, 1.03],
65
+ };
66
+ const SHADOW_FLOOR = [0.12, 0.07, 0.055];
67
+ /**
68
+ * The lash alpha multiplier a male record is capped to: the texture's own
69
+ * alpha, no multiplier. At 1.6 the lashes were still the darkest line on a
70
+ * male face whose brows and stubble are faint, and read as the feature.
71
+ */
72
+ export const LASH_MALE = 1;
73
+ const TEXTURE_KEYS = [
74
+ 'map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'alphaMap',
75
+ 'specularColorMap', 'specularIntensityMap', 'sheenColorMap', 'clearcoatNormalMap',
76
+ ];
77
+ const SKIN_MATERIAL = /^(base)?head|skin|^body/i;
78
+ const HAIR_MESH = /hair|groom|beard|mustache|moustache|brow|fuzz/i;
79
+ const HAIR_MATERIAL = /hair|blind|blend|clip|brow|beard|mustache|fuzz|lash/i;
80
+ /** Base name without the exporter's `.001` suffix. */
81
+ const baseName = (name) => name.replace(/\.\d+$/, '');
82
+ const isHairLike = (mesh, mat) => {
83
+ const alpha = mat.transparent || mat.alphaTest > 0;
84
+ return alpha && (HAIR_MESH.test(mesh.name) || HAIR_MATERIAL.test(baseName(mat.name)));
85
+ };
86
+ const isSkin = (mat) => SKIN_MATERIAL.test(baseName(mat.name)) && 'roughness' in mat && !(mat.transparent || mat.alphaTest > 0);
87
+ /** Mean linear colour of a texture's opaque texels, or null if unreadable. */
88
+ export function meanTextureColor(tex) {
89
+ const img = tex?.image;
90
+ if (!img)
91
+ return null;
92
+ let data = img.data;
93
+ if (!data && typeof document !== 'undefined' && img.width && img.height) {
94
+ const canvas = document.createElement('canvas');
95
+ canvas.width = canvas.height = 32;
96
+ const g = canvas.getContext('2d');
97
+ if (!g)
98
+ return null;
99
+ g.drawImage(img, 0, 0, 32, 32);
100
+ data = g.getImageData(0, 0, 32, 32).data;
101
+ }
102
+ if (!data)
103
+ return null;
104
+ let r = 0, gr = 0, b = 0, n = 0;
105
+ for (let i = 0; i + 3 < data.length; i += 4) {
106
+ if (data[i + 3] < 128)
107
+ continue;
108
+ r += data[i];
109
+ gr += data[i + 1];
110
+ b += data[i + 2];
111
+ n++;
112
+ }
113
+ if (!n)
114
+ return null;
115
+ const c = new THREE.Color(r / n / 255, gr / n / 255, b / n / 255);
116
+ return tex.colorSpace === THREE.SRGBColorSpace ? c.convertSRGBToLinear() : c;
117
+ }
118
+ /**
119
+ * Mean linear colour of a texture where a mesh actually uses it: sampled at
120
+ * every third vertex's UV. A head atlas is mostly empty or not skin (mouth,
121
+ * ears, unused space), so its own mean said nothing about the face (luma
122
+ * 0.013 measured against 0.026 sampled at the mesh). Null if unreadable.
123
+ */
124
+ export function meanSkinColor(mesh, tex) {
125
+ const img = tex?.image;
126
+ const uv = mesh.geometry.attributes.uv;
127
+ if (!img || !uv || !img.width || !img.height)
128
+ return null;
129
+ let data = img.data;
130
+ let w = img.width, h = img.height;
131
+ if (!data && typeof document !== 'undefined') {
132
+ const canvas = document.createElement('canvas');
133
+ canvas.width = canvas.height = 256;
134
+ const g = canvas.getContext('2d');
135
+ if (!g)
136
+ return null;
137
+ g.drawImage(img, 0, 0, 256, 256);
138
+ data = g.getImageData(0, 0, 256, 256).data;
139
+ w = h = 256;
140
+ }
141
+ if (!data)
142
+ return null;
143
+ const wrap = (x) => x - Math.floor(x);
144
+ let r = 0, gr = 0, b = 0, n = 0;
145
+ for (let i = 0; i < uv.count; i += 3) {
146
+ const u = wrap(uv.getX(i)), v = wrap(uv.getY(i));
147
+ const x = Math.min(w - 1, Math.floor(u * w));
148
+ const y = Math.min(h - 1, Math.floor((tex.flipY ? 1 - v : v) * h));
149
+ const p = (y * w + x) * 4;
150
+ if (data[p + 3] < 128)
151
+ continue;
152
+ r += data[p];
153
+ gr += data[p + 1];
154
+ b += data[p + 2];
155
+ n++;
156
+ }
157
+ if (!n)
158
+ return null;
159
+ const c = new THREE.Color(r / n / 255, gr / n / 255, b / n / 255);
160
+ return tex.colorSpace === THREE.SRGBColorSpace ? c.convertSRGBToLinear() : c;
161
+ }
162
+ /**
163
+ * Lashes (and the eye-occlusion film) only get the soft edge. Given hair's
164
+ * specular they caught a top light or the sun and read white.
165
+ */
166
+ const isLash = (mesh, mat) => /lash/i.test(mesh.name) || /lash/i.test(baseName(mat.name));
167
+ function tuneHair(mesh, mat, lash) {
168
+ const m = mat;
169
+ // Every card layer as coverage in the opaque pass: it writes depth, sorts,
170
+ // takes shadows, and MSAA smooths what passes the cut. 0.35 keeps the
171
+ // strands' fine ends (fuller hair); the cards' faint dark-edged texels
172
+ // stay out. (The export's blended twin of the same cards is hidden by
173
+ // applySurfaces: it drew the same hair twice.)
174
+ m.transparent = false;
175
+ m.depthWrite = true;
176
+ m.alphaHash = false;
177
+ m.alphaToCoverage = true;
178
+ m.alphaTest = Math.max(m.alphaTest, lash ? 0.2 : 0.35);
179
+ // Some exports ship hair with opacity above 1, an alpha multiplier for
180
+ // their own blended draw (3.4 on one short male cut, 1 on the long cuts).
181
+ // Drawn as coverage it turns every strand texel past ~0.3 solid: a flat
182
+ // cap with no strand ends. Coverage already follows alpha and restores
183
+ // fullness on the small mips, so the multiplier is dropped. Lashes keep
184
+ // theirs: at 3.5 they read as full lashes, at 1 as a few hairs.
185
+ if (!lash && m.opacity > 1)
186
+ m.opacity = 1;
187
+ if (lash) {
188
+ fadeHairSpecular(m);
189
+ m.needsUpdate = true;
190
+ return;
191
+ }
192
+ // Hair highlights are faint and coloured by the hair, and fade out at the
193
+ // silhouette (the coffee-shop studio's, matched to Unreal references). A
194
+ // white specular at 0.25 read silvery at grazing angles and greyed dark
195
+ // hair; at 0.55 roughness with full white specular it streaked as wet.
196
+ // The export's roughness map is dropped: hair maps are authored for engine
197
+ // hair shaders and mean something else on a physical material.
198
+ const albedo = meanTextureColor(m.map) ?? m.color.clone();
199
+ const tint = albedo.clone().multiplyScalar(1 / Math.max(albedo.r, albedo.g, albedo.b, 1e-3));
200
+ // Lighter hair shows more of the uncoloured primary highlight (the R lobe
201
+ // of a strand reflects the light's colour; the coloured lobes come from
202
+ // inside the strand and dominate dark hair). Dark hair keeps the fully
203
+ // tinted, faint highlight that was matched to the studio films; from
204
+ // mid-brown up a share of white comes in, or a light cut read flat.
205
+ const luma = 0.2126 * albedo.r + 0.7152 * albedo.g + 0.0722 * albedo.b;
206
+ const white = THREE.MathUtils.clamp((luma - 0.12) / 0.5, 0, 0.6);
207
+ if ('roughness' in m) {
208
+ m.roughnessMap = null;
209
+ m.roughness = 0.55 - 0.08 * white;
210
+ m.metalness = 0;
211
+ m.envMapIntensity = 0.35;
212
+ }
213
+ if (m.isMeshPhysicalMaterial) {
214
+ m.ior = 1.55;
215
+ m.specularIntensity = 0.055 + 0.1 * white;
216
+ m.specularColor = tint.clone().lerp(new THREE.Color(1, 1, 1), white);
217
+ m.sheen = 0.08;
218
+ m.sheenRoughness = 0.45;
219
+ m.sheenColor = albedo;
220
+ }
221
+ if ('roughness' in m)
222
+ fadeHairSpecular(m);
223
+ m.needsUpdate = true;
224
+ }
225
+ /** three's physical lighting setup, with hair specular faded at the silhouette. */
226
+ export function patchHairSpecular(fs) {
227
+ if (!fs.includes('#include <lights_physical_fragment>'))
228
+ return fs;
229
+ return fs.replace('#include <lights_physical_fragment>', `#include <lights_physical_fragment>
230
+ {
231
+ // Cards are double-sided: either face toward the eye.
232
+ float embodyFacing = smoothstep( 0.05, 0.45, abs( dot( normal, normalize( vViewPosition ) ) ) );
233
+ material.specularColor *= embodyFacing;
234
+ material.specularF90 *= embodyFacing;
235
+ }`);
236
+ }
237
+ /**
238
+ * Coverage that follows the strand's own alpha. three's WebGL
239
+ * alpha-to-coverage applies the cut as a step (every texel fully in or
240
+ * out), so a fringe read as sparse separate strings with skin between them;
241
+ * coverage proportional to alpha (as WebGPU's is, and as the studio films
242
+ * were rendered) draws faint strand texels faintly and the hair reads full
243
+ * and soft, with alpha scaled back up on the smaller mips. Only
244
+ * near-empty texels are discarded.
245
+ */
246
+ export function patchHairCoverage(fs) {
247
+ if (!fs.includes('#include <alphatest_fragment>'))
248
+ return fs;
249
+ return fs.replace('#include <alphatest_fragment>', `#ifdef USE_MAP
250
+ {
251
+ // Smaller mips average each strand's alpha with the gaps around it,
252
+ // so at a distance strands thin and vanish (forehead showing through
253
+ // a fringe). Scale alpha back up with the mip level sampled.
254
+ vec2 embodyPx = vMapUv * vec2( textureSize( map, 0 ) );
255
+ vec2 embodyDx = dFdx( embodyPx ), embodyDy = dFdy( embodyPx );
256
+ float embodyLod = max( 0.0, 0.5 * log2( max( dot( embodyDx, embodyDx ), dot( embodyDy, embodyDy ) ) ) );
257
+ diffuseColor.a = clamp( diffuseColor.a * ( 1.0 + embodyLod * 0.6 ), 0.0, 1.0 );
258
+ }
259
+ #endif
260
+ if ( diffuseColor.a < 0.02 ) discard;`);
261
+ }
262
+ /** Whether the renderer draws with multisampling, which alpha-to-coverage needs. */
263
+ const multisampled = (renderer) => {
264
+ try {
265
+ return !!renderer?.getContext?.().getContextAttributes()?.antialias;
266
+ }
267
+ catch {
268
+ return false;
269
+ }
270
+ };
271
+ /**
272
+ * WebGL: hair specular fades to nothing at the silhouette, and coverage
273
+ * follows alpha where the canvas is multisampled (without MSAA, coverage is
274
+ * ignored and the cards would draw solid: three's own cut is kept).
275
+ * Chains onto any hook.
276
+ */
277
+ function fadeHairSpecular(m) {
278
+ if (m.userData.embodyHairSpecular)
279
+ return;
280
+ m.userData.embodyHairSpecular = true;
281
+ const before = m.onBeforeCompile;
282
+ const key = m.customProgramCacheKey.bind(m);
283
+ m.onBeforeCompile = (shader, renderer) => {
284
+ before.call(m, shader, renderer);
285
+ shader.fragmentShader = patchHairSpecular(shader.fragmentShader);
286
+ if (m.alphaToCoverage && multisampled(renderer))
287
+ shader.fragmentShader = patchHairCoverage(shader.fragmentShader);
288
+ };
289
+ m.customProgramCacheKey = () => `${key()}|embody-hair-spec${m.alphaToCoverage ? '-a2c' : ''}`;
290
+ }
291
+ /** Eyes, lashes, the mouth, and the films over the eyes. */
292
+ const isFacePart = (mesh, mat) => isEye(baseName(mat.name)) || /teeth|tongue|occlusion|tearline|tear_line|cornea|lash/i.test(`${mesh.name} ${mat.name}`);
293
+ /** Casts nothing, whatever the app sets castShadow to. */
294
+ function noShadow(mesh) {
295
+ const mat = [mesh.material].flat()[0];
296
+ cutShadow(mesh, mat, NO_SHADOW);
297
+ }
298
+ /** Hair and other see-through texels below this coverage cast no shadow. */
299
+ const SHADOW_CUT = 0.5;
300
+ /** Past 1: every fragment is discarded, so the surface casts nothing. */
301
+ const NO_SHADOW = 1.01;
302
+ /**
303
+ * Hair, and every other see-through surface, casts shadow only where it is
304
+ * dense. Without this the skin under it was covered in dark marks:
305
+ *
306
+ * · three's shadow pass copies the drawn material's `alphaTest` onto the
307
+ * depth material every frame, custom or not, and the blended hair layer,
308
+ * brows and face overlays draw at 0 to keep their soft edge; so they cast
309
+ * their whole cards, as solid quads (measured on a Character Creator head:
310
+ * a hard blob over half the face and the collar). The cut is set again in
311
+ * `onBeforeShadow`, which three calls after that copy.
312
+ * · cut at the drawing threshold, every faint wisp threw a long hard line
313
+ * across the face under a key light.
314
+ *
315
+ * A see-through surface with no texture alpha (an eye-occlusion or tear-line
316
+ * film) casts nothing. Each mesh gets its own depth materials, so the cut does
317
+ * not recompile three's shared one. Point lights use the distance material.
318
+ */
319
+ function cutShadow(mesh, mat, force) {
320
+ const map = mat.map ?? null;
321
+ const alphaMap = mat.alphaMap ?? null;
322
+ const cut = force ?? (map || alphaMap ? SHADOW_CUT : NO_SHADOW);
323
+ mesh.customDepthMaterial = new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking, map, alphaMap, alphaTest: cut });
324
+ mesh.customDistanceMaterial = new THREE.MeshDistanceMaterial({ map, alphaMap, alphaTest: cut });
325
+ const before = mesh.onBeforeShadow;
326
+ mesh.onBeforeShadow = function (renderer, object, camera, shadowCamera, geometry, depthMaterial, group) {
327
+ before.call(this, renderer, object, camera, shadowCamera, geometry, depthMaterial, group);
328
+ if (depthMaterial === mesh.customDepthMaterial || depthMaterial === mesh.customDistanceMaterial)
329
+ depthMaterial.alphaTest = cut;
330
+ };
331
+ mesh.userData.embodyShadowCut = cut;
332
+ }
333
+ /** A physical copy of a standard skin material; textures are shared, not cloned. */
334
+ function toPhysicalSkin(src) {
335
+ const m = new THREE.MeshPhysicalMaterial();
336
+ THREE.MeshStandardMaterial.prototype.copy.call(m, src);
337
+ return m;
338
+ }
339
+ /**
340
+ * Skin's response, on any physical skin (converted or shipped physical):
341
+ * dielectric at skin's IOR, a moderate specular, a faint warm sheen at
342
+ * grazing angles. Without a roughness map, 0.52: at a distance the pore
343
+ * detail mips flat, and glossier read as plastic; the pores roughen it
344
+ * further where they are (see the skin shader).
345
+ */
346
+ function skinResponse(m, t = {}) {
347
+ m.metalness = 0;
348
+ m.metalnessMap = null;
349
+ m.ior = 1.4;
350
+ m.specularIntensity = t.specular ?? 0.6;
351
+ m.sheen = t.sheen ?? 0.06;
352
+ m.sheenRoughness = 0.55;
353
+ m.sheenColor = new THREE.Color(...(t.sheenColor ?? [0.9, 0.38, 0.28]));
354
+ if (!m.roughnessMap)
355
+ m.roughness = t.roughness ?? 0.52;
356
+ if (t.tint)
357
+ m.color.multiply(new THREE.Color(...t.tint));
358
+ }
359
+ let detailTexture = null;
360
+ /**
361
+ * Seamless pore and fine-crease detail as a tangent-space normal map,
362
+ * deterministic (seeded) so every load is identical. Shared by every
363
+ * character: it is never stored on a material, so `dispose()` does not free
364
+ * it out from under another body.
365
+ */
366
+ export function poreDetailTexture() {
367
+ if (detailTexture)
368
+ return detailTexture;
369
+ const size = 512;
370
+ let seed = 0x2f6b4e1;
371
+ const rand = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
372
+ const lattice = (n) => {
373
+ const v = new Float32Array(n * n);
374
+ for (let i = 0; i < v.length; i++)
375
+ v[i] = rand();
376
+ return v;
377
+ };
378
+ // Two octaves of wrapping value noise: fine texture over creases.
379
+ const octaves = [[lattice(96), 96, 0.55], [lattice(32), 32, 0.3]];
380
+ const h = new Float32Array(size * size);
381
+ for (const [lat, n, amp] of octaves) {
382
+ for (let y = 0; y < size; y++)
383
+ for (let x = 0; x < size; x++) {
384
+ const fx = (x / size) * n, fy = (y / size) * n;
385
+ const x0 = Math.floor(fx), y0 = Math.floor(fy);
386
+ const tx = fx - x0, ty = fy - y0;
387
+ const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
388
+ const at = (i, j) => lat[((j + n) % n) * n + ((i + n) % n)];
389
+ const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
390
+ const bot = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * sx;
391
+ h[y * size + x] += (top + (bot - top) * sy) * amp;
392
+ }
393
+ }
394
+ // Pores: small soft pits on 1.2% of texels, wrapped at the edges. Noise
395
+ // alone read as sand; pits are what reads as skin.
396
+ const pores = Math.round(size * size * 0.012);
397
+ for (let p = 0; p < pores; p++) {
398
+ const cx = rand() * size, cy = rand() * size, r = 0.8 + rand() * 1.4;
399
+ for (let dy = -3; dy <= 3; dy++)
400
+ for (let dx = -3; dx <= 3; dx++) {
401
+ const d = Math.hypot(dx, dy) / r;
402
+ if (d > 1.6)
403
+ continue;
404
+ const x = (Math.round(cx + dx) + size) % size;
405
+ const y = (Math.round(cy + dy) + size) % size;
406
+ h[y * size + x] -= 0.45 * Math.exp(-d * d * 1.6);
407
+ }
408
+ }
409
+ const data = new Uint8Array(size * size * 4);
410
+ const H = (x, y) => h[((y + size) % size) * size + ((x + size) % size)];
411
+ for (let y = 0; y < size; y++)
412
+ for (let x = 0; x < size; x++) {
413
+ const nx = (H(x - 1, y) - H(x + 1, y)) * 2.2;
414
+ const ny = (H(x, y - 1) - H(x, y + 1)) * 2.2;
415
+ const l = Math.hypot(nx, ny, 1);
416
+ const o = (y * size + x) * 4;
417
+ data[o] = Math.round(((nx / l) * 0.5 + 0.5) * 255);
418
+ data[o + 1] = Math.round(((ny / l) * 0.5 + 0.5) * 255);
419
+ data[o + 2] = Math.round(((1 / l) * 0.5 + 0.5) * 255);
420
+ data[o + 3] = 255;
421
+ }
422
+ const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
423
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
424
+ tex.magFilter = THREE.LinearFilter;
425
+ tex.minFilter = THREE.LinearMipmapLinearFilter;
426
+ tex.generateMipmaps = true;
427
+ tex.colorSpace = THREE.NoColorSpace;
428
+ tex.needsUpdate = true;
429
+ return (detailTexture = tex);
430
+ }
431
+ /** The anchors the WebGL patch hooks; absent in a shader it does not know. */
432
+ const LIGHTS_ANCHOR = '#include <lights_physical_pars_fragment>';
433
+ const NORMAL_ANCHOR = 'mapN.xy *= normalScale;';
434
+ const SHADOW_PARS_ANCHOR = '#include <shadowmap_pars_fragment>';
435
+ const LIGHTS_BEGIN_ANCHOR = '#include <lights_fragment_begin>';
436
+ /** A shadowed light in three's lights_fragment_begin: `directLight.color *= ( … ) ? <shadow> : 1.0;`. */
437
+ const SHADOWED = /directLight\.color \*= (\( directLight\.visible && receiveShadow \) \? [^;]+ : 1\.0);/g;
438
+ /**
439
+ * How skin receives shadow (WebGL): a hair or collar shadow on skin read as
440
+ * a dark mark rather than shade. Two things a real face does that three's
441
+ * receiver does not:
442
+ * · the edge is soft: light scatters a few millimetres under the skin, so
443
+ * a shadow never ends on a texel staircase. The skin filters its shadow
444
+ * over 16 samples, at least `embodyShadowSoft` texels wide, with PCF and
445
+ * with BASIC (three's own: 5 samples at the light's radius with PCF, which
446
+ * widened went grainy without temporal AA, and one hard sample with
447
+ * BASIC, which r182 also gives apps asking for PCFSoftShadowMap);
448
+ * · it is not grey: skin in shade glows faintly red from light carried in
449
+ * from the lit side, so a shadow keeps `embodyShadowFloor` of the light,
450
+ * warm, and never goes to the dull brown that read as dirt.
451
+ * VSM keeps three's filter (already soft) and takes only the floor.
452
+ */
453
+ const SHADOW_RECEIVER = `
454
+ uniform vec3 embodyShadowFloor;
455
+ uniform float embodyShadowSoft;
456
+ vec3 embodyShadowLight( float shadow ) {
457
+ return mix( embodyShadowFloor, vec3( 1.0 ), shadow );
458
+ }
459
+ float embodyIgn( vec2 p ) {
460
+ return fract( 52.9829189 * fract( dot( p, vec2( 0.06711056, 0.00583715 ) ) ) );
461
+ }
462
+ vec2 embodyDisk( int k, int n, float phi ) {
463
+ float r = sqrt( ( float( k ) + 0.5 ) / float( n ) );
464
+ float theta = float( k ) * 2.399963229728653 + phi;
465
+ return vec2( cos( theta ), sin( theta ) ) * r;
466
+ }
467
+ #if defined( USE_SHADOWMAP ) && defined( SHADOWMAP_TYPE_PCF )
468
+ float embodySkinShadow( sampler2DShadow shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) {
469
+ float shadow = 1.0;
470
+ shadowCoord.xyz /= shadowCoord.w;
471
+ shadowCoord.z += shadowBias;
472
+ bool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0;
473
+ if ( inFrustum && shadowCoord.z <= 1.0 ) {
474
+ float radius = max( shadowRadius, embodyShadowSoft ) / shadowMapSize.x;
475
+ float phi = embodyIgn( gl_FragCoord.xy ) * 6.28318530718;
476
+ shadow = 0.0;
477
+ for ( int k = 0; k < 16; k ++ ) {
478
+ shadow += texture( shadowMap, vec3( shadowCoord.xy + embodyDisk( k, 16, phi ) * radius, shadowCoord.z ) );
479
+ }
480
+ shadow /= 16.0;
481
+ }
482
+ return mix( 1.0, shadow, shadowIntensity );
483
+ }
484
+ #elif defined( USE_SHADOWMAP ) && ! defined( SHADOWMAP_TYPE_VSM )
485
+ // BASIC: one hard comparison per pixel in three. Also what an app asking for
486
+ // PCFSoftShadowMap gets in r182: it warns and sets the type on its local light
487
+ // list, not the renderer, so programs see no PCF define. Same filter, compared
488
+ // by hand.
489
+ float embodySkinShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) {
490
+ float shadow = 1.0;
491
+ shadowCoord.xyz /= shadowCoord.w;
492
+ shadowCoord.z += shadowBias;
493
+ bool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0;
494
+ if ( inFrustum && shadowCoord.z <= 1.0 ) {
495
+ float radius = max( shadowRadius, embodyShadowSoft ) / shadowMapSize.x;
496
+ float phi = embodyIgn( gl_FragCoord.xy ) * 6.28318530718;
497
+ shadow = 0.0;
498
+ for ( int k = 0; k < 16; k ++ ) {
499
+ float depth = texture2D( shadowMap, shadowCoord.xy + embodyDisk( k, 16, phi ) * radius ).r;
500
+ #ifdef USE_REVERSED_DEPTH_BUFFER
501
+ shadow += step( depth, shadowCoord.z );
502
+ #else
503
+ shadow += step( shadowCoord.z, depth );
504
+ #endif
505
+ }
506
+ shadow /= 16.0;
507
+ }
508
+ return mix( 1.0, shadow, shadowIntensity );
509
+ }
510
+ #else
511
+ #define embodySkinShadow getShadow
512
+ #endif
513
+ `;
514
+ /**
515
+ * Farthest skin looks up its shadow off the surface, metres. A light's
516
+ * `normalBias` is set for its scene; at 2 cm (common for rooms) the lookup
517
+ * on a neck passed the collar 1 cm away, and the collar's shadow broke into
518
+ * faceted stripes along the neck's triangles. Skin clamps it to face scale.
519
+ */
520
+ const SKIN_MAX_NORMAL_BIAS = 0.004;
521
+ /** three's shadowmap_vertex, with the normal offset clamped to face scale. */
522
+ export function skinShadowVertex(chunk) {
523
+ return chunk.replace(/(\w+LightShadows\[ i \])\.shadowNormalBias/g, `min( $1.shadowNormalBias, ${SKIN_MAX_NORMAL_BIAS.toFixed(3)} )`);
524
+ }
525
+ /**
526
+ * A lit fragment shader receiving shadow as described above. Unchanged when
527
+ * it has no lights (or is already patched).
528
+ */
529
+ export function patchShadowReceiver(fragmentShader) {
530
+ if (!fragmentShader.includes(SHADOW_PARS_ANCHOR) || !fragmentShader.includes(LIGHTS_BEGIN_ANCHOR))
531
+ return fragmentShader;
532
+ return fragmentShader
533
+ .replace(SHADOW_PARS_ANCHOR, `${SHADOW_PARS_ANCHOR}\n${SHADOW_RECEIVER}`)
534
+ .replace(LIGHTS_BEGIN_ANCHOR, skinLightsBegin(THREE.ShaderChunk.lights_fragment_begin));
535
+ }
536
+ /**
537
+ * Every other lit surface on the character (clothing, hair, eyes) receives
538
+ * shadow the same way, soft and at face scale, with no warm floor: a collar
539
+ * on a shirt stair-stepped exactly as it did on the neck. Chains onto any
540
+ * shader hook the material already has.
541
+ */
542
+ function installShadowReceiver(m) {
543
+ if (m.userData.embodyShadow)
544
+ return;
545
+ const uniforms = { embodyShadowFloor: { value: new THREE.Color(0, 0, 0) }, embodyShadowSoft: { value: 3 } };
546
+ m.userData.embodyShadow = uniforms;
547
+ const before = m.onBeforeCompile;
548
+ const key = m.customProgramCacheKey.bind(m);
549
+ m.onBeforeCompile = (shader, renderer) => {
550
+ before.call(m, shader, renderer);
551
+ Object.assign(shader.uniforms, uniforms);
552
+ shader.fragmentShader = patchShadowReceiver(shader.fragmentShader);
553
+ if (shader.vertexShader)
554
+ shader.vertexShader = shader.vertexShader.replace('#include <shadowmap_vertex>', skinShadowVertex(THREE.ShaderChunk.shadowmap_vertex));
555
+ };
556
+ m.customProgramCacheKey = () => `${key()}|embody-shadow`;
557
+ }
558
+ /** three's lights_fragment_begin, with skin's shadow receiver. */
559
+ export function skinLightsBegin(chunk) {
560
+ return chunk
561
+ .replace(/\bgetShadow\(/g, 'embodySkinShadow(')
562
+ .replace(SHADOWED, 'directLight.color *= embodyShadowLight( $1 );');
563
+ }
564
+ /**
565
+ * Patch three's WebGL physical fragment shader for skin. Exported for the
566
+ * test, which runs it on three's real shader source. A shader without the
567
+ * anchors (a future three, a custom material) is returned unchanged.
568
+ */
569
+ export function patchSkinShader(fragmentShader, withDetail) {
570
+ if (!fragmentShader.includes(LIGHTS_ANCHOR))
571
+ return fragmentShader;
572
+ let s = fragmentShader.replace(LIGHTS_ANCHOR, `${LIGHTS_ANCHOR}
573
+ uniform float embodyWrap;
574
+ uniform float embodySubsurface;
575
+ uniform float embodyTranslucency;
576
+ void RE_Direct_EmbodySkin( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {
577
+ RE_Direct_Physical( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );
578
+ float nl = dot( geometryNormal, directLight.direction );
579
+ // Only the band past the terminator is added; the lit side is Lambert's.
580
+ float band = saturate( ( nl + embodyWrap ) / ( 1.0 + embodyWrap ) ) - saturate( nl );
581
+ reflectedLight.directDiffuse += band * directLight.color * material.diffuseColor * vec3( 0.5, 0.4, 0.36 ) * embodySubsurface;
582
+ vec3 through = normalize( directLight.direction + geometryNormal * 0.25 );
583
+ float back = pow( saturate( dot( geometryViewDir, - through ) ), 3.0 ) * embodyTranslucency;
584
+ reflectedLight.directDiffuse += back * directLight.color * material.diffuseColor * vec3( 0.75, 0.3, 0.2 );
585
+ }
586
+ #undef RE_Direct
587
+ #define RE_Direct RE_Direct_EmbodySkin`);
588
+ s = patchShadowReceiver(s);
589
+ // Scalp under hair (shadeScalp's vertex colour, 1 bare to 0.3 under hair):
590
+ // matte and without specular, which caught the light pink through the
591
+ // hair even once darkened.
592
+ if (s.includes('#include <roughnessmap_fragment>') && s.includes('#include <lights_physical_fragment>')) {
593
+ s = s.replace('#include <roughnessmap_fragment>', `#include <roughnessmap_fragment>
594
+ #if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA )
595
+ float embodyUnderHair = clamp( ( 1.0 - vColor.r ) / 0.7, 0.0, 1.0 );
596
+ roughnessFactor = mix( roughnessFactor, 0.85, embodyUnderHair );
597
+ #endif`);
598
+ s = s.replace('#include <lights_physical_fragment>', `#include <lights_physical_fragment>
599
+ #if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA )
600
+ material.specularColor *= 1.0 - 0.8 * embodyUnderHair;
601
+ material.specularF90 *= 1.0 - 0.8 * embodyUnderHair;
602
+ #endif`);
603
+ }
604
+ if (withDetail && s.includes(NORMAL_ANCHOR)) {
605
+ s = s.replace('void main() {', 'uniform sampler2D embodyDetailMap;\nuniform float embodyDetail;\nuniform float embodyDetailTile;\nvoid main() {');
606
+ s = s.replace('#include <roughnessmap_fragment>', `#include <roughnessmap_fragment>
607
+ // Specular breaks up with the pores: pits and creases are rougher.
608
+ roughnessFactor = clamp( roughnessFactor + ( 1.0 - texture2D( embodyDetailMap, vNormalMapUv * embodyDetailTile ).z ) * 0.6, 0.0, 1.0 );`);
609
+ s = s.replace(NORMAL_ANCHOR, `${NORMAL_ANCHOR}
610
+ vec3 embodyPore = texture2D( embodyDetailMap, vNormalMapUv * embodyDetailTile ).xyz * 2.0 - 1.0;
611
+ mapN = normalize( vec3( mapN.xy + embodyPore.xy * embodyDetail, mapN.z * embodyPore.z ) );`);
612
+ }
613
+ return s;
614
+ }
615
+ function installSkinShader(m, tuning) {
616
+ const detail = tuning.detail ?? 0.16;
617
+ const withDetail = detail > 0 && !!m.normalMap;
618
+ const uniforms = {
619
+ embodyWrap: { value: tuning.wrap ?? 0.65 },
620
+ embodySubsurface: { value: tuning.subsurface ?? 0.45 },
621
+ embodyTranslucency: { value: tuning.translucency ?? 0.3 },
622
+ embodyDetailMap: { value: withDetail ? poreDetailTexture() : null },
623
+ embodyDetail: { value: detail },
624
+ embodyDetailTile: { value: tuning.detailTile ?? 30 },
625
+ embodyShadowFloor: { value: new THREE.Color(...(tuning.shadowFloor ?? SHADOW_FLOOR)) },
626
+ embodyShadowSoft: { value: tuning.shadowSoftness ?? 3 },
627
+ };
628
+ m.userData.embodySkin = uniforms;
629
+ m.onBeforeCompile = (shader) => {
630
+ Object.assign(shader.uniforms, uniforms);
631
+ shader.fragmentShader = patchSkinShader(shader.fragmentShader, withDetail);
632
+ if (shader.vertexShader)
633
+ shader.vertexShader = shader.vertexShader.replace('#include <shadowmap_vertex>', skinShadowVertex(THREE.ShaderChunk.shadowmap_vertex));
634
+ };
635
+ m.customProgramCacheKey = () => `embody-skin-${withDetail ? 1 : 0}`;
636
+ }
637
+ /**
638
+ * Darken the head's vertices that sit under hair: within `near` of a hair
639
+ * vertex lying over them (within 60° of the skin's outward normal), above
640
+ * the eyes. Hair beside the skin does not count: at the hairline the fringe
641
+ * lies along the forehead, and counting it tinted a hard brown strip below
642
+ * the hairline (the published heads have ~2.6k vertices, so a tinted row
643
+ * reads as a band). The shade also scales with how much hair is over the
644
+ * vertex, so it fades out through the thinning hairline. Reads skinned positions in WORLD space, so call it
645
+ * after the body is scaled to metres and before anything animates it.
646
+ */
647
+ export function shadeScalp(root, opts = {}) {
648
+ const near = opts.near ?? 0.01;
649
+ const far = opts.far ?? 0.025;
650
+ let head = null;
651
+ let eyes = null;
652
+ const hair = [];
653
+ root.updateMatrixWorld(true);
654
+ root.traverse((o) => {
655
+ const mesh = o;
656
+ if (!mesh.isMesh || !mesh.visible)
657
+ return;
658
+ const mat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material);
659
+ if (!head && mesh.isSkinnedMesh && isSkin(mat) && /head/i.test(baseName(mat.name)))
660
+ head = mesh;
661
+ else if (!eyes && mesh.isSkinnedMesh && /^eyes?$/i.test(baseName(mat.name)))
662
+ eyes = mesh;
663
+ else if (isHairLike(mesh, mat) && HAIR_MESH.test(mesh.name) && !/brow|lash/i.test(mesh.name))
664
+ hair.push(mesh);
665
+ });
666
+ if (!head || !hair.length)
667
+ return 0;
668
+ const headMesh = head;
669
+ const v = new THREE.Vector3();
670
+ const world = (m, i) => m.isSkinnedMesh
671
+ ? m.getVertexPosition(i, v).applyMatrix4(m.matrixWorld)
672
+ : v.fromBufferAttribute(m.geometry.attributes.position, i).applyMatrix4(m.matrixWorld);
673
+ // Hair vertices hashed into `far`-sized cells: every hair vertex within
674
+ // `far` of a point lies in its cell or one of the 26 around it.
675
+ const grid = new Map();
676
+ const cellOf = (x, y, z) => ((Math.floor(x / far) + 512) * 1024 + (Math.floor(y / far) + 512)) * 1024 + (Math.floor(z / far) + 512);
677
+ for (const m of hair) {
678
+ const count = m.geometry.attributes.position.count;
679
+ for (let i = 0; i < count; i += 3) {
680
+ const p = world(m, i);
681
+ const k = cellOf(p.x, p.y, p.z);
682
+ let cell = grid.get(k);
683
+ if (!cell)
684
+ grid.set(k, (cell = []));
685
+ cell.push(p.x, p.y, p.z);
686
+ }
687
+ }
688
+ // Scalp only: hair hanging beside the cheeks must not shade the face. The
689
+ // eyes' height marks the face; without eyes, everything near hair counts.
690
+ let eyeY = -Infinity;
691
+ if (eyes) {
692
+ const e = eyes;
693
+ const count = e.geometry.attributes.position.count;
694
+ let sum = 0;
695
+ for (let i = 0; i < count; i++)
696
+ sum += world(e, i).y;
697
+ eyeY = sum / count;
698
+ }
699
+ const count = headMesh.geometry.attributes.position.count;
700
+ const pos = new Float32Array(count * 3);
701
+ for (let i = 0; i < count; i++)
702
+ world(headMesh, i).toArray(pos, i * 3);
703
+ const normals = posedNormals(headMesh.geometry, pos);
704
+ const colors = new Float32Array(count * 3).fill(1);
705
+ // Scalp seen through hair takes the hair's colour, as a real one does (a
706
+ // fixed skin-grey left a parting reading bald under dark hair). Darker
707
+ // than the hair's mean, never brighter than the old skin shade.
708
+ const hairMap = hair.map((m) => [m.material].flat()[0]).find((m) => m.map);
709
+ const hairColor = meanTextureColor(hairMap?.map)?.multiply(hairMap.color) ?? null;
710
+ const tint = hairColor ? new THREE.Color(Math.min(0.3, hairColor.r * 0.8), Math.min(0.28, hairColor.g * 0.8), Math.min(0.27, hairColor.b * 0.8)) : new THREE.Color(0.3, 0.28, 0.27);
711
+ const p = new THREE.Vector3();
712
+ const n = new THREE.Vector3();
713
+ const near_ = new Float32Array(count).fill(Infinity);
714
+ const over_ = new Float32Array(count);
715
+ for (let i = 0; i < count; i++) {
716
+ p.fromArray(pos, i * 3);
717
+ if (p.y < eyeY + 0.03)
718
+ continue;
719
+ n.fromArray(normals, i * 3);
720
+ const facing = n.lengthSq() > 0;
721
+ let best = Infinity;
722
+ let anyBest = Infinity;
723
+ let over = 0;
724
+ for (let dx = -1; dx <= 1; dx++)
725
+ for (let dy = -1; dy <= 1; dy++)
726
+ for (let dz = -1; dz <= 1; dz++) {
727
+ const cell = grid.get(cellOf(p.x + dx * far, p.y + dy * far, p.z + dz * far));
728
+ if (!cell)
729
+ continue;
730
+ for (let j = 0; j < cell.length; j += 3) {
731
+ const x = cell[j] - p.x, y = cell[j + 1] - p.y, z = cell[j + 2] - p.z;
732
+ const d = x * x + y * y + z * z;
733
+ if (d > far * far)
734
+ continue;
735
+ if (d < anyBest)
736
+ anyBest = d;
737
+ // Over the skin, not beside it: cos(angle to the normal) ≥ 0.5.
738
+ const out = x * n.x + y * n.y + z * n.z;
739
+ if (facing && (out <= 0 || out * out < 0.25 * d))
740
+ continue;
741
+ over++;
742
+ if (d < best)
743
+ best = d;
744
+ }
745
+ }
746
+ // Hair lying over the skin counts on the forehead (where the fringe lies
747
+ // along it); on the scalp any hair near counts (at a parting it lies
748
+ // beside the skin, not over it, and the parting showed bare pink).
749
+ const forward = THREE.MathUtils.smoothstep(n.z, 0.35, 0.75);
750
+ near_[i] = Math.sqrt(forward > 0.5 ? best : anyBest);
751
+ over_[i] = over;
752
+ }
753
+ // How much hair is over a vertex, against this body's own full head of hair
754
+ // (the 90th percentile; point counts depend on how the hair is tessellated).
755
+ // The thinning fringe at the hairline fades out: on CineFemale-type heads it
756
+ // has 0–20 sampled points over it against 20–160 on the scalp.
757
+ const counts = Array.from(over_).filter((c) => c > 0).sort((a, b) => a - b);
758
+ const full = counts.length ? counts[Math.floor(counts.length * 0.9)] : 0;
759
+ let shaded = 0;
760
+ for (let i = 0; i < count; i++) {
761
+ if (near_[i] === Infinity)
762
+ continue;
763
+ p.fromArray(pos, i * 3);
764
+ const aboveFace = eyeY === -Infinity ? 1 : THREE.MathUtils.smoothstep(p.y, eyeY + 0.03, eyeY + 0.05);
765
+ // Only at the forehead hairline (skin facing forward): there the fringe
766
+ // thins out over the skin. Scalp facing up (the crown, a parting) keeps
767
+ // the full shade however few strands cover it, or it showed bare pink.
768
+ // Forward-facing skin (a forehead, however high the hairline: no fixed
769
+ // height holds across heads) counts hair over it, not beside it.
770
+ const forward = THREE.MathUtils.smoothstep(normals[i * 3 + 2], 0.35, 0.75);
771
+ const dense = 1 - forward * (1 - THREE.MathUtils.smoothstep(over_[i], DENSE_FROM * full, DENSE_FULL * full));
772
+ const w = (1 - THREE.MathUtils.smoothstep(near_[i], near, far)) * aboveFace * dense;
773
+ if (w <= 0)
774
+ continue;
775
+ shaded++;
776
+ // Scalp facing up takes the hair's colour; a forehead under hair only
777
+ // darkens, keeping its own hue (hair-tinted, bare forehead under a quiff
778
+ // read as a grey patch).
779
+ const grey = Math.max(0.45, (tint.r + tint.g + tint.b) / 3);
780
+ const r = tint.r + (grey - tint.r) * forward, g = tint.g + (grey - tint.g) * forward, b = tint.b + (grey - tint.b) * forward;
781
+ colors[i * 3] = 1 + (r - 1) * w;
782
+ colors[i * 3 + 1] = 1 + (g - 1) * w;
783
+ colors[i * 3 + 2] = 1 + (b - 1) * w;
784
+ }
785
+ if (!shaded)
786
+ return 0;
787
+ headMesh.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
788
+ for (const m of [headMesh.material].flat()) {
789
+ m.vertexColors = true;
790
+ m.needsUpdate = true;
791
+ }
792
+ return shaded;
793
+ }
794
+ /** Hair over a scalp vertex, as a share of a full head's, where its shade starts and is full. */
795
+ const DENSE_FROM = 0.08;
796
+ const DENSE_FULL = 0.4;
797
+ /**
798
+ * Depth in the hair: strands near the scalp sit under the ones above them and
799
+ * get less light, so a real head of hair is darker at the roots and inside
800
+ * than on its outer shell. Card hair carries none of that unless its texture
801
+ * bakes it in (the long cuts do, a short light cut did not and read as a flat
802
+ * cap). Each hair vertex is darkened by how close it lies to the head,
803
+ * against this hair's own thickness (its 90th-percentile distance from the
804
+ * head), as a vertex colour that multiplies the diffuse: roots to ~45% on
805
+ * light hair, less on dark hair, where the texture already carries the depth
806
+ * and the same darkening would have read as a different cut. Reads skinned
807
+ * positions in WORLD space: call it after the body is scaled to metres and
808
+ * before anything animates it. Returns the number of hair vertices shaded.
809
+ */
810
+ export function shadeHairDepth(root) {
811
+ let head = null;
812
+ const hair = [];
813
+ root.updateMatrixWorld(true);
814
+ root.traverse((o) => {
815
+ const mesh = o;
816
+ if (!mesh.isMesh || !mesh.visible)
817
+ return;
818
+ const mat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material);
819
+ if (!head && mesh.isSkinnedMesh && isSkin(mat) && /head/i.test(baseName(mat.name)))
820
+ head = mesh;
821
+ else if (isHairLike(mesh, mat) && HAIR_MESH.test(mesh.name) && !/brow|lash/i.test(mesh.name))
822
+ hair.push(mesh);
823
+ });
824
+ if (!head || !hair.length)
825
+ return 0;
826
+ const headMesh = head;
827
+ const v = new THREE.Vector3();
828
+ const world = (m, i) => m.isSkinnedMesh
829
+ ? m.getVertexPosition(i, v).applyMatrix4(m.matrixWorld)
830
+ : v.fromBufferAttribute(m.geometry.attributes.position, i).applyMatrix4(m.matrixWorld);
831
+ // Head vertices hashed into cells of `reach`: the nearest head vertex to
832
+ // a hair vertex within `reach` lies in its cell or one of the 26 around it.
833
+ const reach = 0.06;
834
+ const grid = new Map();
835
+ const cellOf = (x, y, z) => ((Math.floor(x / reach) + 512) * 1024 + (Math.floor(y / reach) + 512)) * 1024 + (Math.floor(z / reach) + 512);
836
+ const headCount = headMesh.geometry.attributes.position.count;
837
+ for (let i = 0; i < headCount; i++) {
838
+ const p = world(headMesh, i);
839
+ const k = cellOf(p.x, p.y, p.z);
840
+ let cell = grid.get(k);
841
+ if (!cell)
842
+ grid.set(k, (cell = []));
843
+ cell.push(p.x, p.y, p.z);
844
+ }
845
+ const nearest = (p) => {
846
+ let best = reach * reach;
847
+ for (let dx = -1; dx <= 1; dx++)
848
+ for (let dy = -1; dy <= 1; dy++)
849
+ for (let dz = -1; dz <= 1; dz++) {
850
+ const cell = grid.get(cellOf(p.x + dx * reach, p.y + dy * reach, p.z + dz * reach));
851
+ if (!cell)
852
+ continue;
853
+ for (let j = 0; j < cell.length; j += 3) {
854
+ const x = cell[j] - p.x, y = cell[j + 1] - p.y, z = cell[j + 2] - p.z;
855
+ const d = x * x + y * y + z * z;
856
+ if (d < best)
857
+ best = d;
858
+ }
859
+ }
860
+ return Math.sqrt(best);
861
+ };
862
+ let shaded = 0;
863
+ const p = new THREE.Vector3();
864
+ for (const m of hair) {
865
+ const count = m.geometry.attributes.position.count;
866
+ const depth = new Float32Array(count);
867
+ for (let i = 0; i < count; i++)
868
+ depth[i] = nearest(world(m, i).clone());
869
+ const sorted = Array.from(depth).sort((a, b) => a - b);
870
+ const thick = sorted[Math.floor(sorted.length * 0.9)] ?? 0;
871
+ if (!(thick > 0.004))
872
+ continue; // a cap painted on the scalp has no depth to shade
873
+ const mat = [m.material].flat()[0];
874
+ const albedo = meanTextureColor(mat.map) ?? mat.color.clone();
875
+ const luma = 0.2126 * albedo.r + 0.7152 * albedo.g + 0.0722 * albedo.b;
876
+ // How dark the roots go: light hair 55%, dark hair 25% (its texture
877
+ // already carries the depth).
878
+ const strength = 0.25 + 0.3 * THREE.MathUtils.clamp((luma - 0.08) / 0.4, 0, 1);
879
+ const colors = new Float32Array(count * 3);
880
+ for (let i = 0; i < count; i++) {
881
+ const w = 1 - THREE.MathUtils.smoothstep(depth[i], 0, thick);
882
+ const c = 1 - strength * w * w;
883
+ colors[i * 3] = colors[i * 3 + 1] = colors[i * 3 + 2] = c;
884
+ }
885
+ m.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
886
+ for (const x of [m.material].flat()) {
887
+ x.vertexColors = true;
888
+ x.needsUpdate = true;
889
+ }
890
+ shaded += count;
891
+ }
892
+ return shaded;
893
+ }
894
+ /** Area-weighted vertex normals of `geometry` at the positions `pos`. */
895
+ function posedNormals(geometry, pos) {
896
+ const out = new Float32Array(pos.length);
897
+ const index = geometry.index;
898
+ const tris = index ? index.count : pos.length / 3;
899
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
900
+ for (let t = 0; t + 2 < tris; t += 3) {
901
+ const i = index ? index.getX(t) : t, j = index ? index.getX(t + 1) : t + 1, k = index ? index.getX(t + 2) : t + 2;
902
+ a.fromArray(pos, i * 3);
903
+ b.fromArray(pos, j * 3).sub(a);
904
+ c.fromArray(pos, k * 3).sub(a);
905
+ b.cross(c);
906
+ for (const v of [i, j, k]) {
907
+ out[v * 3] += b.x;
908
+ out[v * 3 + 1] += b.y;
909
+ out[v * 3 + 2] += b.z;
910
+ }
911
+ }
912
+ for (let v = 0; v < out.length; v += 3) {
913
+ const l = Math.hypot(out[v], out[v + 1], out[v + 2]);
914
+ if (l > 0) {
915
+ out[v] /= l;
916
+ out[v + 1] /= l;
917
+ out[v + 2] /= l;
918
+ }
919
+ }
920
+ return out;
921
+ }
922
+ /**
923
+ * Upgrade skin and hair materials in place (skin materials are replaced on
924
+ * their meshes; the originals are disposed, their textures kept). Run once
925
+ * after load. `shadeScalp` is separate because it needs the body in metres.
926
+ */
927
+ export function applySurfaces(root, tuning = {}) {
928
+ const report = { skin: 0, hair: 0, textures: 0, scalp: 0 };
929
+ const anisotropy = tuning.anisotropy ?? 16;
930
+ const lashCap = tuning.lashOpacity ?? (/^male$/i.test(tuning.gender ?? '') ? LASH_MALE : undefined);
931
+ const male = /^male$/i.test(tuning.gender ?? '');
932
+ const skinTuning = tuning.skin === false ? null : { ...(male ? MALE_SKIN : {}), ...(tuning.skin === true || !tuning.skin ? {} : tuning.skin) };
933
+ const replaced = new Map();
934
+ const tunedHair = new Set();
935
+ const textures = new Set();
936
+ // The export ships hair twice, a blended layer and an alpha-tested copy of
937
+ // the same cards: drawn as coverage they look the same, and the second
938
+ // copy halved the frame rate. A blended hair mesh with such a twin is
939
+ // hidden; one without keeps drawing (as coverage).
940
+ const hairCounts = new Set();
941
+ root.traverse((o) => {
942
+ const m = o;
943
+ if (m.isMesh && m.visible && [m.material].flat().some((x) => isHairLike(m, x) && !x.transparent))
944
+ hairCounts.add(m.geometry.attributes.position?.count ?? -1);
945
+ });
946
+ root.traverse((o) => {
947
+ const mesh = o;
948
+ if (!mesh.isMesh)
949
+ return;
950
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
951
+ if (tuning.hair !== false && list.every((x) => isHairLike(mesh, x) && x.transparent) && hairCounts.has(mesh.geometry.attributes.position?.count ?? -2)) {
952
+ mesh.visible = false;
953
+ return;
954
+ }
955
+ // The small face parts: no shadows (lashes, eyes, the socket film and the
956
+ // mouth threw jagged slivers across the face), and never culled (their
957
+ // bounding spheres are the bind pose's, and in close-ups the eyes and
958
+ // mouth blinked out while on screen).
959
+ if (list.some((x) => isFacePart(mesh, x))) {
960
+ noShadow(mesh);
961
+ mesh.frustumCulled = false;
962
+ }
963
+ // A lit surface needs normals; geometry exported for an unlit material
964
+ // often has none, and lit it renders black.
965
+ const g = mesh.geometry;
966
+ if (g.attributes.position && !g.attributes.normal && list.some((m) => m.isMeshStandardMaterial))
967
+ g.computeVertexNormals();
968
+ const next = list.map((mat) => {
969
+ for (const k of TEXTURE_KEYS) {
970
+ const t = mat[k];
971
+ if (t && !textures.has(t)) {
972
+ textures.add(t);
973
+ t.anisotropy = Math.max(t.anisotropy, anisotropy);
974
+ t.needsUpdate = true;
975
+ }
976
+ }
977
+ if (tuning.hair !== false && isHairLike(mesh, mat)) {
978
+ if (!tunedHair.has(mat)) {
979
+ tunedHair.add(mat);
980
+ tuneHair(mesh, mat, isLash(mesh, mat));
981
+ if (isLash(mesh, mat) && lashCap !== undefined && mat.opacity > lashCap)
982
+ mat.opacity = lashCap;
983
+ report.hair++;
984
+ }
985
+ if (!mesh.customDepthMaterial)
986
+ cutShadow(mesh, mat);
987
+ if (mat.isMeshStandardMaterial)
988
+ installShadowReceiver(mat);
989
+ return mat;
990
+ }
991
+ if (skinTuning && isSkin(mat)) {
992
+ const done = replaced.get(mat);
993
+ if (done)
994
+ return done;
995
+ const std = mat;
996
+ const skin = std.isMeshPhysicalMaterial ? std : toPhysicalSkin(std);
997
+ if (skin !== mat)
998
+ skin.name = mat.name;
999
+ skinResponse(skin, skinTuning);
1000
+ installSkinShader(skin, skinTuning);
1001
+ replaced.set(mat, skin);
1002
+ report.skin++;
1003
+ return skin;
1004
+ }
1005
+ // Any other see-through surface (eye-occlusion and tear-line films,
1006
+ // lace, visors): the same cut, or no shadow without texture alpha.
1007
+ if (mat.transparent && !mesh.customDepthMaterial)
1008
+ cutShadow(mesh, mat);
1009
+ if (mat.isMeshStandardMaterial)
1010
+ installShadowReceiver(mat);
1011
+ return mat;
1012
+ });
1013
+ mesh.material = Array.isArray(mesh.material) ? next : next[0];
1014
+ });
1015
+ for (const [old, now] of replaced)
1016
+ if (old !== now)
1017
+ old.dispose();
1018
+ report.textures = textures.size;
1019
+ return report;
1020
+ }