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