@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.
- package/CHANGELOG.md +38 -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,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
|
+
}
|