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 * * 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 }; 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; 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.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; 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, 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); // 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], eyeMesh, mouthMesh: mouth, 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(); }