aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/model.js
diff options
context:
space:
mode:
Diffstat (limited to 'bluebey-studio/src/model.js')
-rw-r--r--bluebey-studio/src/model.js283
1 files changed, 283 insertions, 0 deletions
diff --git a/bluebey-studio/src/model.js b/bluebey-studio/src/model.js
new file mode 100644
index 0000000..3925e85
--- /dev/null
+++ b/bluebey-studio/src/model.js
@@ -0,0 +1,283 @@
+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;
+ // Its cylindrical UV has a seam at the back of the head. Triangles that cross
+ // it get one UV near 0 and the next near 1, so the whole texture is stretched
+ // across them - a visible streak down the back. Close the seam per triangle.
+ if (hairMesh) healCylinderSeam(hairMesh);
+ 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();
+}
+
+/**
+ * Make every triangle of a cylindrical UV island sit on one side of the seam.
+ *
+ * A cylinder unwrap cuts the surface open along one line (here the back of the
+ * head). A triangle that spans the cut has its corners at u ~ 1 and u ~ 0, so
+ * interpolating them drags the whole texture across the triangle - the streak
+ * seen down the back. Wrapping the low corners up by one turn keeps each
+ * triangle local; the texture's two edges are identical (the covering is drawn
+ * right across), so the join is invisible.
+ */
+function healCylinderSeam(mesh) {
+ const source = mesh?.geometry;
+ if (!source?.attributes?.uv) return;
+ // Give every triangle its own corners first. The seam's low and high sides
+ // share vertices, so editing a shared one would drag its neighbours too; a
+ // non-indexed copy isolates each triangle and the fix cannot leak.
+ const geometry = source.index ? source.toNonIndexed() : source;
+ const uv = geometry.attributes.uv;
+ for (let i = 0; i < uv.count; i += 3) {
+ const top = Math.max(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2));
+ if (top - Math.min(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2)) <= 0.5) continue;
+ for (let k = 0; k < 3; k += 1) {
+ if (uv.getX(i + k) < top - 0.5) uv.setX(i + k, uv.getX(i + k) + 1);
+ }
+ }
+ uv.needsUpdate = true;
+ mesh.geometry = geometry;
+}