diff options
Diffstat (limited to 'public/bluebey-studio/src/model.js')
| -rw-r--r-- | public/bluebey-studio/src/model.js | 239 |
1 files changed, 239 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/model.js b/public/bluebey-studio/src/model.js new file mode 100644 index 0000000..083c745 --- /dev/null +++ b/public/bluebey-studio/src/model.js @@ -0,0 +1,239 @@ +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<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); + + // 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(); +} |
