aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/model.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/model.js')
-rw-r--r--public/bluebey-studio/src/model.js283
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;
-}