aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/model.js
blob: 68f125552042baa29f593b5e17ff09c7dfc73f17 (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
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

/**
 * Loads bluebey.glb and works out everything the studio needs to know about it:
 * the rig, which meshes are the face overlays, and the original hand-drawn
 * textures that ship inside the file.
 *
 * The GLB layout (materials carry the reliable names; meshes are named after
 * Blender objects):
 *   body parts   Body, Foots, Leaf, Vein, LeftHand, RightHand, Nose
 *   overlays     eyes-* (eight hand-drawn variants, one texture each) and Mouth
 *   face plates  eyes-plate / mouth-plate, the big shells the studio draws on
 *   hair plate   hair-plate, a shell that wraps right round the head (optional)
 *
 * The two face plates are what the artwork is painted onto. They are the front
 * half of the body, so wherever the drawing lands there is a surface to carry
 * it - the old hand-drawn planes only covered a patch of the head, which clipped
 * the bottom of a deep smile and most of a teardrop. When a model has no plates
 * (the 2022 file), the studio falls back to drawing on the hand-drawn planes.
 */

/** Hand-drawn texture variants inside the GLB, keyed by their material suffix. */
export const EYE_VARIANTS = [
  { key: 'opened', label: '開いた目' },
  { key: 'closed', label: '閉じた目' },
  { key: 'close-tight', label: 'ぎゅっと閉じ' },
  { key: 'left-wink', label: '左ウインク' },
  { key: 'look-up', label: '上を見る' },
  { key: 'look-down', label: '下を見る' },
  { key: 'look-left', label: '左を見る' },
  { key: 'look-right', label: '右を見る' },
];

/**
 * Friendly names for the eleven bones of the rig. Keys are lower-cased with
 * dots removed, because three's GLTFLoader sanitises node names (`armsupport.l`
 * arrives as `armsupportl`).
 */
const BONE_LABELS = {
  master: '全身',
  armsupportl: '左うでの付け根',
  arml: '左うで',
  handl: '左手先',
  armr: '右うで',
  handr: '右手先',
  legsupportl: '左あしの付け根',
  footl: '左足',
  toel: '左つまさき',
  legsupportr: '右あしの付け根',
  footr: '右足',
  toer: '右つまさき',
};

const normalizeBoneName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase();

/**
 * The overlay planes hug the head, so nudge them outwards before anything else
 * happens. They are pushed along their own normals (not away from the head
 * centre): the mouth in particular needs to clear the head's lower surface and
 * the ring of leaves around the base, otherwise a low or frowning mouth gets
 * swallowed by them.
 */
const OVERLAY_INFLATE = { eyes: 0.004, mouth: 0.004, hair: 0.008 };

export async function loadModel(source, { onProgress } = {}) {
  const loader = new GLTFLoader();
  let gltf;
  if (typeof source === 'string') {
    gltf = await loader.loadAsync(source, (event) => {
      if (onProgress && event.lengthComputable) onProgress(event.loaded / event.total);
    });
  } else {
    // A File/ArrayBuffer, e.g. from drag and drop or the file picker.
    gltf = await loader.parseAsync(source, '');
  }

  const root = gltf.scene;

  // The file carries its own camera node; we drive our own.
  const cameras = [];
  root.traverse((object) => { if (object.isCamera) cameras.push(object); });
  for (const camera of cameras) camera.removeFromParent();

  const eyes = new Map();
  const body = [];
  /** The original material name of every body mesh (`blb`, `leaf`, `vein`, ...). */
  const kinds = new Map();
  /** The plane holding the hand-drawn mouth texture (a texture source only). */
  let mouthOriginal = null;
  /** The big shells the artwork is drawn onto, if the file has them. */
  let eyePlate = null;
  let mouthPlate = null;
  /** The shell that wraps round the head, so hair shows from behind (optional). */
  let hairPlate = null;

  root.traverse((object) => {
    if (!object.isMesh) return;
    // Skinned bounds stop matching the pose, so culling would pop meshes away.
    object.frustumCulled = false;
    const material = Array.isArray(object.material) ? object.material[0] : object.material;
    const materialName = (material?.name ?? '').toLowerCase();
    if (materialName === 'eyes-plate') {
      eyePlate = object;
      return;
    }
    if (materialName === 'mouth-plate') {
      mouthPlate = object;
      return;
    }
    if (materialName === 'hair-plate') {
      hairPlate = object;
      return;
    }
    if (materialName.startsWith('eyes-')) {
      eyes.set(materialName.slice('eyes-'.length), object);
      return;
    }
    if (materialName === 'mouth') {
      mouthOriginal = object;
      return;
    }
    body.push(object);
    kinds.set(object, materialName);
  });

  // The waist leaves - the `Leaf` blade and the `Vein` rim that shares its mesh.
  // These are the thin, overlapping shells the inverted-hull outline cannot draw
  // (see MODEL-GUIDE.md §5), so the screen-space pass takes them over on its own.
  const leaves = body.filter((mesh) => kinds.get(mesh) === 'leaf' || kinds.get(mesh) === 'vein');

  // The drawing surface: the big plate when present, otherwise the 2022 plane.
  const eyeMesh = eyePlate ?? eyes.get('opened') ?? eyes.values().next().value;
  const mouth = mouthPlate ?? mouthOriginal;
  // The wrapping hair shell, if the model carries one (`hair-plate`).
  const hairMesh = hairPlate;
  if (!eyeMesh) throw new Error('eye mesh not found in the GLB');
  if (!mouth) throw new Error('mouth mesh not found in the GLB');

  // The nose ball. It is a bump sitting on the body rather than a part with a
  // silhouette of its own, so the outline pass treats it per style - see
  // `outlineExclusion` in main.js.
  const noseMesh = body.find((mesh) => (mesh.material?.name ?? '').toLowerCase() === 'nose') ?? null;

  // Only the drawing surface stays visible; every other overlay is a texture
  // source, so hide it. (`mouthOriginal` may be the drawing surface itself.)
  for (const mesh of eyes.values()) mesh.visible = mesh === eyeMesh;
  if (mouthOriginal && mouthOriginal !== mouth) mouthOriginal.visible = false;
  if (mouthPlate && eyePlate) mouthPlate.visible = true;

  /** @type {{ eyes: Record<string, THREE.Texture>, mouth: THREE.Texture|null }} */
  const originals = { eyes: {}, mouth: mouthOriginal?.material?.map ?? mouth.material.map ?? null };
  for (const [key, mesh] of eyes) {
    if (mesh.material.map) originals.eyes[key] = mesh.material.map;
  }

  // Push the overlay planes a hair off the body surface, so the two do not
  // z-fight. The plates are shells of the body, so the offset is tiny; a large
  // one is what made the mouth look like it floated in profile.
  const bodyMesh = body.find((mesh) => mesh.geometry?.attributes?.position?.count > 2000) ?? body[0];
  const headCentre = new THREE.Box3()
    .setFromBufferAttribute(bodyMesh.geometry.attributes.position)
    .getCenter(new THREE.Vector3());
  inflateOverlay(eyeMesh, headCentre, OVERLAY_INFLATE.eyes);
  inflateOverlay(mouth, headCentre, OVERLAY_INFLATE.mouth);
  if (hairMesh) inflateOverlay(hairMesh, headCentre, OVERLAY_INFLATE.hair);

  // Recentre: put the character on the origin with its feet on the ground so the
  // camera maths stays trivial.
  const box = new THREE.Box3().setFromObject(root);
  const centre = box.getCenter(new THREE.Vector3());
  root.position.x -= centre.x;
  root.position.z -= centre.z;
  root.position.y -= box.min.y;
  root.updateMatrixWorld(true);

  const bounds = new THREE.Box3().setFromObject(root);
  const size = bounds.getSize(new THREE.Vector3());
  const middle = bounds.getCenter(new THREE.Vector3());

  const skinned = [];
  root.traverse((object) => { if (object.isSkinnedMesh && object.skeleton) skinned.push(object); });
  const skeleton = skinned[0]?.skeleton ?? null;

  const bones = [];
  if (skeleton) {
    for (const bone of skeleton.bones) {
      bones.push({
        name: bone.name,
        label: BONE_LABELS[normalizeBoneName(bone.name)] ?? bone.name,
        bone,
        rest: bone.quaternion.clone(),
      });
    }
  }

  return {
    gltf,
    root,
    size,
    middle,
    bounds,
    skeleton,
    bones,
    parts: {
      body,
      kinds,
      leaves,
      overlays: [eyeMesh, mouth, ...(hairMesh ? [hairMesh] : [])],
      eyeMesh,
      mouthMesh: mouth,
      hairMesh,
      noseMesh,
      hasFacePlates: Boolean(eyePlate && mouthPlate),
    },
    originals,
  };
}

function inflateOverlay(mesh, centre, distance) {
  const geometry = mesh.geometry;
  const position = geometry?.attributes?.position;
  if (!position) return;
  const normal = geometry.attributes.normal;
  const radial = new THREE.Vector3();
  const offset = new THREE.Vector3();
  const vertexNormal = new THREE.Vector3();
  for (let i = 0; i < position.count; i++) {
    radial.fromBufferAttribute(position, i).sub(centre);
    if (radial.lengthSq() < 1e-12) continue;
    offset.copy(radial).normalize();
    if (normal) {
      // The planes are double sided, so the stored normal may point either way.
      vertexNormal.fromBufferAttribute(normal, i);
      if (vertexNormal.lengthSq() > 1e-12) {
        if (vertexNormal.dot(offset) < 0) vertexNormal.negate();
        offset.copy(vertexNormal.normalize());
      }
    }
    position.setXYZ(
      i,
      position.getX(i) + offset.x * distance,
      position.getY(i) + offset.y * distance,
      position.getZ(i) + offset.z * distance,
    );
  }
  position.needsUpdate = true;
  geometry.computeBoundingBox();
  geometry.computeBoundingSphere();
}