aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/styles.js
blob: 2e4f62d737f42dd094b76ac4d1c5536d506d2275 (plain)
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
444
445
446
447
448
449
450
451
452
453
454
455
456
457
import * as THREE from 'three';

/**
 * Render styles for the body meshes, plus the outline pass.
 *
 *   real     the PBR materials exactly as authored in the GLB
 *   flat     two-tone toon shading, the classic mascot look
 *   lineart  white paper + lines, the face artwork drawn as strokes
 *   outline  nothing but the lines, kept invisible via colorWrite = false so the
 *            result composites on top of anything (used by the SVG export)
 *
 * The lines come from two systems, each used where it is strong (see
 * MODEL-GUIDE.md §5): every mesh gets an inverted-hull copy, and the thin
 * overlapping leaves are handed to the screen-space pass instead (outline.js),
 * which the caller arranges with `setHullHidden`.
 */

export const STYLE_DEFS = [
  { value: 'real', label: 'リアル' },
  { value: 'flat', label: 'フラット' },
  { value: 'lineart', label: '線画' },
];

// 'outline' is still used internally by the SVG export (lines on a transparent
// background), it is just no longer offered as a viewing style.
const LINE_STYLES = new Set(['lineart', 'outline']);

export const isLineStyle = (value) => LINE_STYLES.has(value);

/** Parts that need no outline at all. */
const OUTLINE_SKIP = new Set();

/**
 * Parts whose outline is only useful in the line-art styles.
 *
 * Empty now: the leaves used to be listed here, but the line styles get their
 * leaf lines from the `Vein` faces themselves (see OUTLINE_SHADED_ONLY), so the
 * hull is the wrong tool for them in either direction.
 */
const OUTLINE_LINE_ONLY = new Set();

/**
 * Parts that should be outlined in the SHADED styles only.
 *
 * The inverted hull draws an outline by expanding a copy of the mesh along its
 * normals and rendering the back faces. That works on one big round body, but
 * the leaf skirt is a dozen thin shells lying on top of each other: each hull's
 * far side shows through its neighbours, which is what produced the faint
 * overlapping hairlines. In the SVG pass the leaves get their line from their
 * own `Vein` faces (filled with the ink colour) instead, so the hull is dropped
 * there and kept for the cartoon styles, where it does read as an edge.
 */
const OUTLINE_SHADED_ONLY = new Set(['vein']);

/**
 * Per-part outline thickness, as a fraction of the global width.
 *
 * The hull is offset by a fixed distance in world units, so the right factor
 * depends on how fine a part's mesh is compared with that offset:
 *
 *  - the nose is only ~0.24 units across, and the offset that looks right on the
 *    body reads as a thick ring on it, hence the small factor. In the line-art
 *    styles it is the other way round (a hairline looks like dust on paper), so
 *    the nose gets a fuller line there - see OUTLINE_SCALE_LINE.
 *  - the leaves need a *larger* factor, which only shows up in the "hull only"
 *    style: the 13 blades overlap and interpenetrate, so each one's expanded
 *    shell cuts across its neighbours and the line breaks up. A thicker hull
 *    merges those scratches back into a band. Making the leaves thicker does NOT
 *    fix it - measured, see MODEL-GUIDE.md §5-4 - because the overlaps are the
 *    blocker, not the leaf's own thickness.
 */
const OUTLINE_SCALE = { nose: 0.4, leaf: 1.7, vein: 2.2 };

/** Fuller lines for the paper-and-ink styles, where a hairline reads as dust. */
const OUTLINE_SCALE_LINE = { nose: 0.9, leaf: 1.9, vein: 2.4 };

/**
 * Parts whose outline should only survive where the part itself sticks out past
 * the body. Their hull is drawn first with depth testing off, so everything the
 * body covers paints over it: the nose then only gets a line in the views where
 * it actually pokes out of the silhouette, instead of a ring around it - which is
 * what the original artwork does, the nose reads by its own colour there.
 *
 * In the line-art styles there is no colour to read it by (the nose is paper on
 * paper), so this is switched off and the nose goes to the screen-space pass
 * instead - see `screenParts` in main.js.
 */
const OUTLINE_SILHOUETTE_ONLY = new Set(['nose']);

export class Styles {
  constructor({ meshes, paper = '#ffffff', outlineColor = '#2a1e33', outlineWidth = 0.022 }) {
    this.meshes = meshes;
    this.paper = paper;
    this.style = 'real';
    this.outlineEnabled = true;
    /**
     * Source meshes whose hull outline must stay off, because another method
     * (the screen-space pass) is drawing them. Empty = every hull is available.
     * Only the leaves ever land here: see MODEL-GUIDE.md §5.
     */
    this.hullHidden = new Set();

    this.originals = new Map();
    for (const mesh of meshes) {
      this.originals.set(mesh, mesh.material);
      mesh.castShadow = true;
      mesh.receiveShadow = false;
    }

    this.gradientMap = makeGradientMap();
    this.toonMaterials = new Map();
    this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false });
    // A hat is not flat: in a line drawing a white hat on a white head merges
    // into one shape, so hats get a light tone instead of paper (see addMesh).
    // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it
    // steps from the paper towards the ink is cached by strength in
    // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a
    // pencil's lead) can ask for a darker fill and its seam then reads.
    this.toneMaterials = new Map();
    /** Runtime meshes that want a tone fill rather than paper in 線画. */
    this.toneMeshes = new Map();
    this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true });

    this.outlineWidth = outlineWidth;
    this.outlineColor = outlineColor;
    /** scale -> { material, uniform }, so the width slider updates all of them. */
    this.hullMaterials = new Map();
    this.outlineMeshes = this.createOutlines();
    this.setStyle('real');
  }

  /**
   * The part kind, taken from the material the GLB shipped with.
   *
   * It must be read from `originals`, not from `mesh.material`: `setStyle`
   * replaces the materials, and the replacements (paper, ink, toon) carry no
   * name, so after the first swap every part would look like an unknown one and
   * the leaf/nose special cases would silently stop applying.
   */
  kindOf(mesh) {
    return (this.originals.get(mesh)?.name ?? '').toLowerCase();
  }

  hullMaterialFor(scale, overlay = false) {
    const key = `${scale}:${overlay ? 'overlay' : 'solid'}`;
    let entry = this.hullMaterials.get(key);
    if (!entry) {
      const uniform = { value: this.outlineWidth * scale };
      const material = makeHullMaterial(this.outlineColor, uniform);
      if (overlay) {
        // Depth-*tested*, but writing none: the hull is drawn before the body
        // (renderOrder -1), so the body still paints over its interior and the
        // nose only gets a line where it pokes out of the silhouette. Testing
        // (rather than ignoring) depth is what lets the 見えない壁 hide it too -
        // with the test off, a nose buried in the wall left a filled blob,
        // because nothing was left to paint over the hull's inside.
        material.depthTest = true;
        material.depthWrite = false;
      }
      entry = { uniform, material, scale };
      this.hullMaterials.set(key, entry);
    }
    return entry.material;
  }

  /**
   * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far
   * it steps from the paper towards the ink. `userData.tone` on the mesh (set by
   * the builder) carries the amount; the hat default comes from `options.tone`.
   */
  toneMaterialFor(strength = DEFAULT_TONE) {
    const key = String(strength);
    let material = this.toneMaterials.get(key);
    if (!material) {
      material = new THREE.MeshBasicMaterial({
        color: toneOf(this.paper, strength),
        toneMapped: false,
      });
      material.userData.tone = strength;
      this.toneMaterials.set(key, material);
    }
    return material;
  }

  createOutlines() {
    const hulls = [];
    for (const mesh of this.meshes) {
      const kind = this.kindOf(mesh);
      if (OUTLINE_SKIP.has(kind)) continue;
      const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
      const material = this.hullMaterialFor(this.scaleFor(kind), overlay);
      const hull = mesh.isSkinnedMesh
        ? new THREE.SkinnedMesh(mesh.geometry, material)
        : new THREE.Mesh(mesh.geometry, material);
      hull.name = `${mesh.name || 'mesh'}:outline`;
      hull.position.copy(mesh.position);
      hull.quaternion.copy(mesh.quaternion);
      hull.scale.copy(mesh.scale);
      hull.frustumCulled = false;
      hull.castShadow = false;
      hull.receiveShadow = false;
      if (overlay) hull.renderOrder = -1;
      hull.userData.kind = kind;
      hull.userData.source = mesh;
      hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind);
      hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
      if (hull.isSkinnedMesh) hull.bind(mesh.skeleton, mesh.bindMatrix);
      mesh.parent.add(hull);
      hulls.push(hull);
    }
    return hulls;
  }

  /**
   * Register a mesh that is built at runtime (a hat), so it follows the render
   * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it.
   *
   * `options.tone` gives the mesh the light tone fill in 線画 instead of paper,
   * for parts whose shape would otherwise merge with the body (the hats). A
   * number instead of `true` picks how far to step towards the ink, and the
   * builder can override it per mesh with `userData.tone` - which is how a part
   * draws its seam against a same-coloured neighbour.
   *
   * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh
   * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット.
   */
  addMesh(mesh, options = {}) {
    if (!mesh || this.originals.has(mesh)) return;
    this.originals.set(mesh, mesh.material);
    this.meshes.push(mesh);
    const tone = mesh.userData.tone ?? options.tone;
    if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone));
    const kind = this.kindOf(mesh);
    if (!OUTLINE_SKIP.has(kind)) {
      const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
      const hull = new THREE.Mesh(mesh.geometry, this.hullMaterialFor(this.scaleFor(kind), overlay));
      hull.name = `${mesh.name || 'mesh'}:outline`;
      hull.position.copy(mesh.position);
      hull.quaternion.copy(mesh.quaternion);
      hull.scale.copy(mesh.scale);
      hull.frustumCulled = false;
      hull.castShadow = false;
      hull.receiveShadow = false;
      if (overlay) hull.renderOrder = -1;
      hull.userData.kind = kind;
      hull.userData.source = mesh;
      hull.userData.lineOnly = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind);
      hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
      mesh.parent?.add(hull);
      this.outlineMeshes.push(hull);
      mesh.userData.hull = hull;
    }
    this.setStyle(this.style);
  }

  /** Take a runtime mesh (and its outline hull) back out. */
  removeMesh(mesh) {
    if (!mesh || !this.originals.has(mesh)) return;
    const original = this.originals.get(mesh);
    const hull = mesh.userData.hull;
    if (hull) {
      hull.removeFromParent();
      const i = this.outlineMeshes.indexOf(hull);
      if (i >= 0) this.outlineMeshes.splice(i, 1);
      delete mesh.userData.hull;
    }
    // Put the mesh's own material back before dropping it: while a line style is
    // on it is wearing the *shared* paper/tone material, and the caller is about
    // to dispose it - which would blank every part using it.
    if (original) mesh.material = original;
    this.originals.delete(mesh);
    this.toneMeshes.delete(mesh);
    const j = this.meshes.indexOf(mesh);
    if (j >= 0) this.meshes.splice(j, 1);
  }

  /** The outline factor for a part in the current style (line art wants more). */
  scaleFor(kind) {
    const line = LINE_STYLES.has(this.style);
    const table = line ? OUTLINE_SCALE_LINE : OUTLINE_SCALE;
    return table[kind] ?? OUTLINE_SCALE[kind] ?? 1;
  }

  setStyle(value) {
    this.style = value;
    const line = LINE_STYLES.has(value);
    for (const mesh of this.meshes) {
      const original = this.originals.get(mesh);
      let material;
      if (line) {
        // Paper on paper in `lineart`; a light tone for hats, which would
        // otherwise merge into the head; invisible in `outline`, which is the pass
        // the SVG trace reads. Either way every edge - the body, the nose, and
        // the leaves - is drawn by the screen-space outline, so no part needs a
        // material trick of its own any more.
        material = value === 'lineart'
          ? (this.toneMeshes.get(mesh) ?? this.paperMaterial)
          : this.invisibleMaterial;
      } else if (value === 'flat') {
        material = this.toonFor(original);
      } else {
        material = original;
      }
      if (mesh.material !== material) mesh.material = material;
    }

    // The nose only keeps a line where it pokes out of the body in the shaded
    // styles; in a line drawing it is handed to the screen-space pass instead
    // (see `screenParts` in main.js), because a hull cannot ring a bump that sits
    // flush on the surface it is drawn on - the expanded shell lands *inside* the
    // body and loses the depth test.
    for (const hull of this.outlineMeshes) {
      const kind = hull.userData.kind;
      const silhouetteOnly = OUTLINE_SILHOUETTE_ONLY.has(kind) && !line;
      hull.material = this.hullMaterialFor(this.scaleFor(kind), silhouetteOnly);
      hull.renderOrder = silhouetteOnly ? -1 : 0;
    }

    // Lines are the whole point of the line-art styles.
    this.setOutlineVisible(line ? true : this.outlineEnabled);
  }

  /**
   * Hand a set of parts to the screen-space outline, or take them back.
   *
   * The hull and the screen-space pass each have a shape they cannot draw: a hull
   * cannot outline a thin closed shell (the leaves), and the screen-space pass
   * draws a stepped line because it works on the pixel grid. So the leaves go to
   * the screen-space pass and everything else keeps its hull, which is drawn from
   * the geometry and therefore comes out smooth.
   */
  setHullHidden(meshes) {
    this.hullHidden = new Set(meshes ?? []);
    this.setStyle(this.style);
  }

  toonFor(original) {
    let material = this.toonMaterials.get(original);
    if (!material) {
      material = new THREE.MeshToonMaterial({
        color: original.color ? original.color.clone() : new THREE.Color(0xffffff),
        map: original.map ?? null,
        vertexColors: original.vertexColors === true,
        gradientMap: this.gradientMap,
        side: original.side,
        transparent: original.transparent === true,
        alphaTest: original.alphaTest ?? 0,
        depthWrite: original.depthWrite !== false,
      });
      this.toonMaterials.set(original, material);
    }
    return material;
  }

  setPaper(color) {
    this.paper = color;
    this.paperMaterial.color.set(color);
    for (const material of this.toneMaterials.values()) {
      material.color.copy(toneOf(color, material.userData.tone));
    }
  }

  setOutlineVisible(visible) {
    const lineMode = LINE_STYLES.has(this.style);
    for (const hull of this.outlineMeshes) {
      const lineOnly = hull.userData.lineOnly === true;
      const shadedOnly = hull.userData.shadedOnly === true;
      hull.visible = visible
        && (!lineOnly || lineMode)
        && !(shadedOnly && lineMode)
        && !this.hullHidden.has(hull.userData.source);
    }
  }

  setOutlineEnabled(enabled) {
    this.outlineEnabled = enabled;
    if (!LINE_STYLES.has(this.style)) this.setOutlineVisible(enabled);
  }

  setOutlineWidth(width) {
    this.outlineWidth = width;
    for (const entry of this.hullMaterials.values()) entry.uniform.value = width * entry.scale;
  }

  setOutlineColor(color) {
    this.outlineColor = color;
    for (const entry of this.hullMaterials.values()) entry.material.color.set(color);
  }

  /**
   * The toon materials are cached copies of the originals, so a colour theme that
   * edits `original.color` has to be copied across or the flat style keeps
   * showing the old colour. See src/look.js.
   */
  refreshColors() {
    for (const [mesh, original] of this.originals) {
      const toon = this.toonMaterials.get(original);
      if (toon && original.color) toon.color.copy(original.color);
      // The outline hull is a colour of its own, so it is left alone.
      void mesh;
    }
    return this;
  }

  dispose() {
    for (const hull of this.outlineMeshes) {
      hull.removeFromParent();
      hull.skeleton = null;
    }
    this.outlineMeshes = [];
    for (const entry of this.hullMaterials.values()) entry.material.dispose();
    this.hullMaterials.clear();
    this.paperMaterial.dispose();
    for (const material of this.toneMaterials.values()) material.dispose();
    this.toneMaterials.clear();
    this.invisibleMaterial.dispose();
    this.gradientMap.dispose();
    for (const material of this.toonMaterials.values()) material.dispose();
    this.toonMaterials.clear();
  }
}

/** How far the hat default steps from the paper towards the ink in 線画. */
const DEFAULT_TONE = 0.14;

/** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */
function toneOf(paper, strength = DEFAULT_TONE) {
  return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength);
}

/** A 3-step ramp gives crisper cartoon bands than the shader's default. */
function makeGradientMap() {
  const steps = new Uint8Array([90, 165, 255]);
  const texture = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat);
  texture.minFilter = THREE.NearestFilter;
  texture.magFilter = THREE.NearestFilter;
  texture.generateMipmaps = false;
  texture.needsUpdate = true;
  return texture;
}

function makeHullMaterial(color, uniform) {
  const material = new THREE.MeshBasicMaterial({
    color,
    side: THREE.BackSide,
    toneMapped: false,
  });
  material.onBeforeCompile = (shader) => {
    shader.uniforms.uOutline = uniform;
    shader.vertexShader = `uniform float uOutline;\n${shader.vertexShader}`.replace(
      '#include <begin_vertex>',
      '#include <begin_vertex>\n\ttransformed += normal * uOutline;',
    );
  };
  material.customProgramCacheKey = () => 'bluebey-outline-hull';
  return material;
}