1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
|
import * as THREE from 'three';
import { drawEyes, drawMouth, drawHeadCoverWrap, canvasLayout } from './faceArt.js';
// The hair plate's texture is drawn at this multiple of the plate's natural UV
// size. The hairline crosses the head over only a fraction of that height, so at
// 1x its edge comes out visibly jagged; doubling it (with the finer curve
// subdivision in `drawHeadCoverWrap`) keeps the edge smooth.
const HAIR_SCALE = 2;
/**
* Owns the two procedural textures (eyes and mouth) and keeps them in sync with
* the face parameters.
*
* The artwork is carried by the model's face plates, but those plates are shells
* of the *whole* front half of the body, so their UVs cover far more than the
* 0..1 window the drawing lives in. `remapPlate` rewrites each plate's UVs onto
* 0..1 and the canvas is enlarged to match, which is what removes the
* clamp-to-edge smear at the border and what gives a moved tear, brow or mouth
* room to move (see `canvasLayout` in src/faceArt.js).
*
* Both parts can independently fall back to the original hand-drawn texture
* that ships inside the GLB.
*/
export class Face {
constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) {
this.eyeMesh = eyeMesh;
this.mouthMesh = mouthMesh;
// 頭の模様 can be carried by a plate that wraps right round the head (the
// `hair-plate`), so hair is visible from behind as well. Older models have no
// such plate and draw the covering on the front one instead.
this.hairMesh = hairMesh;
// Rewrite each plate's UVs so its own range fills 0..1, and work out the
// canvas that keeps the artwork exactly where the 2022 textures put it.
this.eyeLayout = remapPlate(eyeMesh);
this.mouthLayout = remapPlate(mouthMesh);
this.hairLayout = hairMesh ? remapPlate(hairMesh) : null;
// The hand-drawn eye textures bake the face colour into their background,
// which is invisible in the lit "real" style but shows up as a flat purple
// patch in the flat and line-art styles. Key it out once, here. Both kinds
// of original are window-sized artwork, so they belong *in* the window, not
// stretched across the enlarged canvas.
this.originals = {
eyes: Object.fromEntries(
Object.entries(originals.eyes).map(([key, texture]) => [key, keyOutBackground(texture, this.eyeLayout)]),
),
mouth: placeInWindowTexture(originals.mouth, this.mouthLayout),
};
this.customNames = new Map();
// Drawings supplied for a beard kind (assets/beards/<shape>.png). Loaded by the
// app; a kind without one falls back to the built-in drawing (see drawBeard).
this.beardImages = {};
// Drawings for the brows (assets/brows/<kind>.png), loaded the same way.
this.browImages = {};
this.eyeCanvas = makeCanvas(this.eyeLayout);
this.mouthCanvas = makeCanvas(this.mouthLayout);
this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null;
this.eyeCtx = this.eyeCanvas.getContext('2d');
this.mouthCtx = this.mouthCanvas.getContext('2d');
this.hairCtx = this.hairCanvas ? this.hairCanvas.getContext('2d') : null;
this.eyeTexture = makeTexture(this.eyeCanvas);
this.mouthTexture = makeTexture(this.mouthCanvas);
this.hairTexture = this.hairCanvas ? makeTexture(this.hairCanvas) : null;
this.params = null;
this.styleMode = 'paint';
this.eyeSource = 'parametric';
this.mouthSource = 'parametric';
this.dirty = true;
this.lastDraw = -Infinity;
// The face plates are shells of the body surface, pushed a hair outwards by
// the loader, so they win the depth test against the body by themselves.
// Nudging them with a polygon offset as well keeps the two from z-fighting
// along grazing angles. Depth *testing* stays on: that is what lets the nose
// (which pokes further out) and an arm waved in front of the face hide the
// artwork the way they should - it only ever hid the old planes because
// those did not reach far enough down the head.
const orders = new Map([[eyeMesh, 2], [mouthMesh, 1]]);
if (hairMesh) orders.set(hairMesh, 3);
for (const mesh of [eyeMesh, mouthMesh, ...(hairMesh ? [hairMesh] : [])]) {
const material = mesh.material;
material.polygonOffset = true;
material.polygonOffsetFactor = -1;
material.polygonOffsetUnits = -2;
material.depthTest = true;
material.depthWrite = false;
// The GLB may describe the plate material as opaque; the artwork is a
// texture with alpha, so blend instead of replacing what is behind it.
material.transparent = true;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
// Tears are drawn on the eye plate and the mouth on the other one, and
// both sit on the same shell: draw the mouth first so a teardrop can fall
// across it instead of being painted over.
mesh.renderOrder = orders.get(mesh) ?? 0;
}
this.applyTextures();
}
/** Show or hide the mouth without touching the artwork. */
setMouthVisible(visible) {
if (this.mouthMesh.visible !== visible) this.mouthMesh.visible = visible;
}
applyTextures() {
const eyeMap = this.eyeSource === 'parametric'
? this.eyeTexture
: (this.originals.eyes[this.eyeSource] ?? this.eyeTexture);
const mouthMap = this.mouthSource === 'parametric'
? this.mouthTexture
: (this.originals.mouth ?? this.mouthTexture);
if (this.eyeMesh.material.map !== eyeMap) {
this.eyeMesh.material.map = eyeMap;
this.eyeMesh.material.needsUpdate = true;
}
if (this.mouthMesh.material.map !== mouthMap) {
this.mouthMesh.material.map = mouthMap;
this.mouthMesh.material.needsUpdate = true;
}
if (this.hairMesh && this.hairMesh.material.map !== this.hairTexture) {
this.hairMesh.material.map = this.hairTexture;
this.hairMesh.material.needsUpdate = true;
}
}
/**
* Load a hand-drawn image and use it for the eyes or the mouth. Any size is
* accepted; it is scaled to the layout the model expects (1024 x 380).
* Returns the key the new artwork is registered under.
*/
async loadImage(kind, file) {
const layout = kind === 'eyes' ? this.eyeLayout : this.mouthLayout;
const bitmap = await createImageBitmap(file);
// Any size is accepted; it is scaled into the artwork window, which is where
// the plate's rewritten UVs expect it.
const canvas = placeInWindow(bitmap, layout);
if (typeof bitmap.close === 'function') bitmap.close();
const texture = makeTexture(canvas);
if (kind === 'eyes') {
const key = `custom-${this.customNames.size + 1}`;
this.customNames.set(key, file.name);
this.originals.eyes[key] = texture;
this.setSource('eyes', key);
return key;
}
this.originals.mouth = texture;
this.setSource('mouth', 'original');
return 'original';
}
/**
* Set the drawings used for the beards, keyed by beard kind (`scotch`, `kaiser`,
* `apron`, `cat`). Called by the app once the files are loaded; the next redraw
* picks them up.
*/
setBeardImages(map) {
this.beardImages = map ?? {};
this.dirty = true;
}
/**
* Set the drawings used for the brows, keyed by brow kind (`gol`). Called by the
* app once the files are loaded; the next redraw picks them up.
*/
setBrowImages(map) {
this.browImages = map ?? {};
this.dirty = true;
}
/** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */
setSource(kind, source) {
if (kind === 'eyes') {
if (this.eyeSource === source) return;
this.eyeSource = source;
} else {
if (this.mouthSource === source) return;
this.mouthSource = source;
}
this.applyTextures();
this.dirty = true;
}
setParams(params, styleMode = this.styleMode) {
this.params = params;
this.styleMode = styleMode;
this.dirty = true;
}
/**
* Redraw the procedural textures when they are stale. `minInterval` throttles
* redraws while animating; pass 0 before an export so nothing is left pending.
*/
flush(now = performance.now(), minInterval = 0) {
if (!this.dirty) return false;
if (now - this.lastDraw < minInterval) return false;
this.redraw();
this.lastDraw = now;
return true;
}
redraw() {
const p = this.params;
if (!p) {
this.dirty = false;
return;
}
// Hiding the mouth has to hide the plane itself, not just stop drawing on
// it - otherwise the last drawing stays on screen.
this.setMouthVisible(p.mouth.visible !== false);
const mode = this.styleMode === 'line' ? 'line' : 'paint';
if (this.eyeSource === 'parametric') {
const layout = this.eyeLayout;
const ctx = this.eyeCtx;
ctx.clearRect(0, 0, layout.width, layout.height);
ctx.save();
// The artwork window sits at an offset inside the bigger canvas.
ctx.translate(layout.offsetX, layout.offsetY);
drawEyes(ctx, {
mode,
eyes: { left: p.eyes.left, right: p.eyes.right },
style: {
white: p.eyes.white,
iris: p.eyes.iris,
line: p.eyes.line,
irisScale: p.eyes.irisScale,
lookMax: p.eyes.lookMax,
highlight: p.eyes.highlight,
lidWidth: p.eyes.lidWidth,
lowerLid: p.eyes.lowerLid,
lidShape: p.eyes.lidShape,
lidTilt: p.eyes.lidTilt,
lashes: p.eyes.lashes,
brow: p.eyes.brow,
// ほっぺ and 頭の模様 are shared by the whole face, so they travel with
// the other shared eye fields (see `drawEyes` in src/faceArt.js).
cheeks: p.eyes.cheeks,
// When the model has a wrapping hair plate the covering is drawn there
// instead (below), so it is switched off on the front plate to keep it
// from being drawn twice.
headMark: this.hairMesh ? { shape: 'off' } : p.eyes.headMark,
// ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D
// object hung off the nose - see src/main.js - so it is not drawn here.)
beard: p.eyes.beard,
beards: this.beardImages,
brows: this.browImages,
// 眼鏡 / サングラス are shared by both eyes and drawn into this same
// texture, so they travel with the other shared eye fields.
glasses: p.eyes.glasses,
heartScale: p.eyes.heartScale,
heartColor: p.eyes.heartColor,
tearColor: p.eyes.tearColor,
// Where the artwork's real edges are, so a brow lifted too far or a
// tear dropped too low can stop inside the artwork (see `canvasLayout`).
limits: layout.limits,
},
});
ctx.restore();
this.eyeTexture.needsUpdate = true;
}
if (this.mouthSource === 'parametric' && p.mouth.visible) {
const layout = this.mouthLayout;
const ctx = this.mouthCtx;
ctx.clearRect(0, 0, layout.width, layout.height);
ctx.save();
ctx.translate(layout.offsetX, layout.offsetY);
drawMouth(ctx, { mode, ...p.mouth, limits: layout.limits });
ctx.restore();
this.mouthTexture.needsUpdate = true;
}
if (this.hairMesh) {
const hairShape = p.eyes.headMark?.shape ?? 'off';
// Shapes that draw nothing (the retired front-plate ones, or なし). A fully
// transparent texture still tinted the head on iOS, so the plate is hidden
// outright when there is no hair rather than left showing an empty texture.
const hairShown = !(hairShape === 'off' || hairShape === 'none'
|| hairShape === 'split' || hairShape === 'wave' || hairShape === 'side');
this.hairMesh.visible = hairShown;
if (hairShown) {
// `overEyes` decides the draw order: the hair plate sits in front of the
// eyes (bangs over the face) or behind them.
this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3;
// The colour lives on the material (the texture is a white mask), so it
// follows the same colour management as the body on every platform.
this.hairMesh.material.color.set(p.eyes.headMark?.color ?? '#8a4fe0');
const layout = this.hairLayout;
const ctx = this.hairCtx;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, layout.width * HAIR_SCALE, layout.height * HAIR_SCALE);
ctx.scale(HAIR_SCALE, HAIR_SCALE);
// `drawHeadCoverWrap` works in canvas coordinates (its x axis is the UV
// angle already normalised), so it is not shifted by the window offset.
drawHeadCoverWrap(ctx, p.eyes.headMark ?? {}, layout);
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.hairTexture.needsUpdate = true;
}
}
this.dirty = false;
}
}
function makeCanvas({ width, height }, scale = 1) {
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
return canvas;
}
/**
* Rewrite a plate's UVs so its own range maps onto 0..1, and return the canvas
* that keeps the artwork at its original pixel size (`canvasLayout`).
*
* The plate covers the whole front half of the body, so its UVs used to run well
* outside the artwork (v -1.02..1.93 on the eye plate). Everything past the edge
* clamped to the canvas border, which is what stretched a tear or a brow that
* reached it. After this the whole plate samples real canvas, so nothing clamps.
*/
function remapPlate(mesh) {
const uv = mesh?.geometry?.attributes?.uv;
if (!uv) return canvasLayout(null);
let u0 = Infinity;
let u1 = -Infinity;
let v0 = Infinity;
let v1 = -Infinity;
for (let i = 0; i < uv.count; i += 1) {
const u = uv.getX(i);
const v = uv.getY(i);
if (u < u0) u0 = u;
if (u > u1) u1 = u;
if (v < v0) v0 = v;
if (v > v1) v1 = v;
}
const du = Math.max(1e-6, u1 - u0);
const dv = Math.max(1e-6, v1 - v0);
for (let i = 0; i < uv.count; i += 1) {
uv.setXY(i, (uv.getX(i) - u0) / du, (uv.getY(i) - v0) / dv);
}
uv.needsUpdate = true;
return canvasLayout({ u0, u1, v0, v1 });
}
/**
* Draw a hand-drawn 1024 x 380 image where the artwork window now sits, on a
* canvas as big as the plate's own UV range. The plates cover far more than the
* window (see `canvasLayout`) and the UVs were rewritten to match, so the window
* is exactly where that image belongs.
*/
function placeInWindow(source, layout) {
const canvas = document.createElement('canvas');
canvas.width = layout.width;
canvas.height = layout.height;
if (source) {
canvas.getContext('2d').drawImage(
source, layout.offsetX, layout.offsetY, layout.window.width, layout.window.height,
);
}
return canvas;
}
/** `placeInWindow` for an existing texture (keeps `null` as `null`). */
function placeInWindowTexture(texture, layout) {
const image = texture?.image;
if (!image || !image.width || !image.height) return texture ?? null;
return makeTexture(placeInWindow(image, layout));
}
function makeTexture(canvas) {
const texture = new THREE.CanvasTexture(canvas);
// glTF puts v = 0 at the top of the image and the loader uploads the model's
// own textures with flipY = false; matching that keeps the artwork aligned
// with the mesh UVs.
texture.flipY = false;
texture.colorSpace = THREE.SRGBColorSpace;
texture.premultiplyAlpha = false;
texture.needsUpdate = true;
return clampToEdge(texture);
}
/**
* The face plates are shells of the whole body, so their UVs run well past the
* 0..1 of the artwork (a plate corner can sit at v = -1.26). glTF's default
* sampler repeats, which would stamp the drawing back onto the model several
* times - a frown flicked up onto the forehead, a smile's end onto its side.
* Clamping sends everything outside the artwork to the blank edge of the canvas
* instead, where nothing is drawn.
*/
function clampToEdge(texture) {
if (!texture) return texture;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.needsUpdate = true;
return texture;
}
/**
* Make the flat colour that fills the background of a hand-drawn texture
* transparent. The edge is feathered rather than hard, so the anti-aliased
* pixels along the artwork do not leave a pale fringe.
*/
function keyOutBackground(texture, layout) {
const image = texture?.image;
if (!image || !image.width || !image.height) return texture;
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
// The background is uniform, so the top-left pixel is a reliable sample.
const baseR = pixels[0];
const baseG = pixels[1];
const baseB = pixels[2];
const fullyClear = 30; // at or below this distance: transparent
const fullySolid = 96; // at or above this distance: untouched
for (let i = 0; i < pixels.length; i += 4) {
const distance = Math.abs(pixels[i] - baseR)
+ Math.abs(pixels[i + 1] - baseG)
+ Math.abs(pixels[i + 2] - baseB);
if (distance <= fullyClear) {
pixels[i + 3] = 0;
} else if (distance < fullySolid) {
const ratio = (distance - fullyClear) / (fullySolid - fullyClear);
pixels[i + 3] = Math.round(pixels[i + 3] * ratio);
}
}
ctx.putImageData(imageData, 0, 0);
return makeTexture(placeInWindow(canvas, layout));
}
|