diff options
Diffstat (limited to 'public/bluebey-studio')
| -rw-r--r-- | public/bluebey-studio/assets/bluebey.glb | bin | 2427384 -> 3253400 bytes | |||
| -rw-r--r-- | public/bluebey-studio/src/face.js | 35 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 80 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 2 | ||||
| -rw-r--r-- | public/bluebey-studio/src/model.js | 15 |
5 files changed, 126 insertions, 6 deletions
diff --git a/public/bluebey-studio/assets/bluebey.glb b/public/bluebey-studio/assets/bluebey.glb Binary files differindex f098194..13131f5 100644 --- a/public/bluebey-studio/assets/bluebey.glb +++ b/public/bluebey-studio/assets/bluebey.glb diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 0813c54..de8a31c 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { drawEyes, drawMouth, canvasLayout } from './faceArt.js'; +import { drawEyes, drawMouth, drawHeadCoverWrap, canvasLayout } from './faceArt.js'; /** * Owns the two procedural textures (eyes and mouth) and keeps them in sync with @@ -16,14 +16,19 @@ import { drawEyes, drawMouth, canvasLayout } from './faceArt.js'; * that ships inside the GLB. */ export class Face { - constructor({ eyeMesh, mouthMesh, originals }) { + constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) { this.eyeMesh = eyeMesh; this.mouthMesh = mouthMesh; + // 頭の模様 can be carried by a plate that wraps right round the head (the + // `hair-plate`), so hair is visible from behind as well. Older models have no + // such plate and draw the covering on the front one instead. + this.hairMesh = hairMesh; // Rewrite each plate's UVs so its own range fills 0..1, and work out the // canvas that keeps the artwork exactly where the 2022 textures put it. this.eyeLayout = remapPlate(eyeMesh); this.mouthLayout = remapPlate(mouthMesh); + this.hairLayout = hairMesh ? remapPlate(hairMesh) : null; // The hand-drawn eye textures bake the face colour into their background, // which is invisible in the lit "real" style but shows up as a flat purple @@ -43,11 +48,14 @@ export class Face { this.beardImages = {}; this.eyeCanvas = makeCanvas(this.eyeLayout); this.mouthCanvas = makeCanvas(this.mouthLayout); + this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout) : null; this.eyeCtx = this.eyeCanvas.getContext('2d'); this.mouthCtx = this.mouthCanvas.getContext('2d'); + this.hairCtx = this.hairCanvas ? this.hairCanvas.getContext('2d') : null; this.eyeTexture = makeTexture(this.eyeCanvas); this.mouthTexture = makeTexture(this.mouthCanvas); + this.hairTexture = this.hairCanvas ? makeTexture(this.hairCanvas) : null; this.params = null; this.styleMode = 'paint'; @@ -64,7 +72,8 @@ export class Face { // artwork the way they should - it only ever hid the old planes because // those did not reach far enough down the head. const orders = new Map([[eyeMesh, 2], [mouthMesh, 1]]); - for (const mesh of [eyeMesh, mouthMesh]) { + if (hairMesh) orders.set(hairMesh, 3); + for (const mesh of [eyeMesh, mouthMesh, ...(hairMesh ? [hairMesh] : [])]) { const material = mesh.material; material.polygonOffset = true; material.polygonOffsetFactor = -1; @@ -106,6 +115,10 @@ export class Face { this.mouthMesh.material.map = mouthMap; this.mouthMesh.material.needsUpdate = true; } + if (this.hairMesh && this.hairMesh.material.map !== this.hairTexture) { + this.hairMesh.material.map = this.hairTexture; + this.hairMesh.material.needsUpdate = true; + } } /** @@ -210,7 +223,10 @@ export class Face { // ほっぺ and 頭の模様 are shared by the whole face, so they travel with // the other shared eye fields (see `drawEyes` in src/faceArt.js). cheeks: p.eyes.cheeks, - headMark: p.eyes.headMark, + // When the model has a wrapping hair plate the covering is drawn there + // instead (below), so it is switched off on the front plate to keep it + // from being drawn twice. + headMark: this.hairMesh ? { shape: 'off' } : p.eyes.headMark, // ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D // object hung off the nose - see src/main.js - so it is not drawn here.) beard: p.eyes.beard, @@ -242,6 +258,17 @@ export class Face { this.mouthTexture.needsUpdate = true; } + if (this.hairMesh) { + const layout = this.hairLayout; + const ctx = this.hairCtx; + ctx.clearRect(0, 0, layout.width, layout.height); + ctx.save(); + ctx.translate(layout.offsetX, layout.offsetY); + drawHeadCoverWrap(ctx, p.eyes.headMark ?? {}, layout); + ctx.restore(); + this.hairTexture.needsUpdate = true; + } + this.dirty = false; } } diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index e999045..1083e52 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -178,6 +178,26 @@ export const HEAD_MARK_LAYOUT = { }; /** + * 頭の模様 on the *hair plate*: the head's cover when the model carries a plate + * that wraps all the way round (material `hair-plate`, cylindrical UV). + * + * The front plate only reaches the head's silhouette, so hair drawn on it stops + * at the front half. The hair plate's UV is `u` = angle round the head (front at + * 0.5, back at the seam 0/1) and `v` = height (0 at the model's feet, 1 at the + * top of the head), so the covering is everything *above* a boundary line: a cap + * that is visible from behind as well. + */ +export const HAIR_WRAP_LAYOUT = { + // The wrap's v runs top-of-head (0) to the model's feet (1) after the GLB's + // V-flip, so `base` is how far down from the top the hairline sits at size 1. + // `reach` is how much a unit of `size` moves it; `offsetScale` converts the + // `offsetY` slider (artwork px) to the same units, so one slider drives both. + base: 0.30, + reach: 0.30, + offsetScale: 0.30 / 190, +}; + +/** * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate * at the nose, the same spot the snot bubble uses. `shape` picks a well-known * kind; `size` scales it about the nose, and the colours are adjustable. @@ -861,6 +881,66 @@ function drawHeadCover(ctx, spec, limits) { } /** + * 頭の模様 drawn onto the hair plate, which wraps right round the head. + * + * The plate's canvas is the cylindrical UV unrolled: `x` is the angle (front at + * the middle, the seam at the back at both ends) and `y` is the height. The GLB + * flips V on export, so the top of the head lands at the top of the canvas and + * the covering is everything above the boundary line - the same direction as + * `drawHeadCover`, which fills down from the top edge of the front plate. + * + * The same `shape` names are reused, with their front-to-back modulation tied to + * how far the column leans towards the front (`t`). + */ +export function drawHeadCoverWrap(ctx, spec, layout) { + const shape = spec?.shape ?? 'off'; + // `split` is a retired front-plate shape; it has no wrap equivalent. + if (shape === 'off' || shape === 'none' || shape === 'split') return; + + const size = clamp(spec.size ?? 1, 0.02, 3); + const W = layout.width; + const H = layout.height; + // After the GLB's V-flip the wrap's v is 0 at the top of the head and 1 at the + // model's feet, so a *larger* boundary means more hair (it reaches further + // down) - the same direction as `size` and `offsetY` on the front plate. + const base = HAIR_WRAP_LAYOUT.base + + (size - 1) * HAIR_WRAP_LAYOUT.reach + + clamp(spec.offsetY ?? 0, -600, 600) * HAIR_WRAP_LAYOUT.offsetScale; + const color = spec.color ?? '#8a4fe0'; + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, W, H); + ctx.clip(); + + // `t` is -1..1 across the front (+-90 deg around u = 0.5); everything behind is + // pinned to +-1, so the hairline runs flat round the back of the head. + const half = W * 0.25; + const cx = W / 2 + (clamp(spec.offsetX ?? 0, -600, 600) / 512) * half; + const vBound = (x) => { + const t = clamp((x - cx) / half, -1, 1); + if (shape === 'fringe') return base + 0.14 * (1 - Math.abs(t)) - 0.05 * Math.abs(Math.sin(t * Math.PI)); + if (shape === 'fringe-up') return base - 0.14 * (1 - Math.abs(t)) + 0.05 * Math.abs(Math.sin(t * Math.PI)); + if (shape === 'wave') return base + 0.10 * Math.sin(t * Math.PI * 0.9 + 0.5); + if (shape === 'side') return base + 0.20 * t; + return base - 0.07 * (1 - t * t); // `cap` + }; + + const steps = 96; + ctx.beginPath(); + ctx.moveTo(0, 0); + for (let i = 0; i <= steps; i += 1) { + const x = (W * i) / steps; + ctx.lineTo(x, clamp(vBound(x), 0, 1) * H); + } + ctx.lineTo(W, 0); + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); + ctx.restore(); +} + +/** * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes, * so the eyes, brows and glasses win any overlap and the cheeks are not doubled. */ diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index cfd6773..548be76 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -385,6 +385,7 @@ async function init() { const face = new Face({ eyeMesh: model.parts.eyeMesh, mouthMesh: model.parts.mouthMesh, + hairMesh: model.parts.hairMesh, originals: model.originals, }); const animator = new Animator({ state }); @@ -1155,6 +1156,7 @@ async function init() { rig.helper, model.parts.eyeMesh, model.parts.mouthMesh, + ...(model.parts.hairMesh ? [model.parts.hairMesh] : []), ...snotMeshes, ...styles.outlineMeshes, ]; diff --git a/public/bluebey-studio/src/model.js b/public/bluebey-studio/src/model.js index 083c745..68f1255 100644 --- a/public/bluebey-studio/src/model.js +++ b/public/bluebey-studio/src/model.js @@ -11,6 +11,7 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; * 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 @@ -60,7 +61,7 @@ const normalizeBoneName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCa * 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 }; +const OVERLAY_INFLATE = { eyes: 0.004, mouth: 0.004, hair: 0.008 }; export async function loadModel(source, { onProgress } = {}) { const loader = new GLTFLoader(); @@ -90,6 +91,8 @@ export async function loadModel(source, { onProgress } = {}) { /** 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; @@ -105,6 +108,10 @@ export async function loadModel(source, { onProgress } = {}) { mouthPlate = object; return; } + if (materialName === 'hair-plate') { + hairPlate = object; + return; + } if (materialName.startsWith('eyes-')) { eyes.set(materialName.slice('eyes-'.length), object); return; @@ -125,6 +132,8 @@ export async function loadModel(source, { onProgress } = {}) { // 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; if (!eyeMesh) throw new Error('eye mesh not found in the GLB'); if (!mouth) throw new Error('mouth mesh not found in the GLB'); @@ -154,6 +163,7 @@ export async function loadModel(source, { onProgress } = {}) { .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. @@ -196,9 +206,10 @@ export async function loadModel(source, { onProgress } = {}) { body, kinds, leaves, - overlays: [eyeMesh, mouth], + overlays: [eyeMesh, mouth, ...(hairMesh ? [hairMesh] : [])], eyeMesh, mouthMesh: mouth, + hairMesh, noseMesh, hasFacePlates: Boolean(eyePlate && mouthPlate), }, |
