aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 00:06:06 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 00:06:06 +0900
commitc81104129a9ba6e26aa33a00770f76a6aa97b2a9 (patch)
tree6329d9b3cd779c7373fd20e63f7db0ff6d4f45ac /public
parentcb0cce903f8ea3ad4943cabab83b74a40d87fc00 (diff)
bluebey-studio: 髪の板(hair-plate)で頭の模様を後ろまで回す
モデルに追加された hair-plate(頭をぐるりと覆う円筒UVの板)を読み込み、頭の模様をその板にも描くようにした。これで横や後ろから見ても髪が見える。髪の板が無いモデルは従来どおり前面の板に描く。
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/assets/bluebey.glbbin2427384 -> 3253400 bytes
-rw-r--r--public/bluebey-studio/src/face.js35
-rw-r--r--public/bluebey-studio/src/faceArt.js80
-rw-r--r--public/bluebey-studio/src/main.js2
-rw-r--r--public/bluebey-studio/src/model.js15
5 files changed, 126 insertions, 6 deletions
diff --git a/public/bluebey-studio/assets/bluebey.glb b/public/bluebey-studio/assets/bluebey.glb
index f098194..13131f5 100644
--- a/public/bluebey-studio/assets/bluebey.glb
+++ b/public/bluebey-studio/assets/bluebey.glb
Binary files differ
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),
},