From e9b8f3194c8604f5d0826df1b1e6a17cf27f68f7 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:54:43 +0900 Subject: bluebey-studio: 髪の生え際をなめらかにし、カーブとわけめを作り直す MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 髪のテクスチャを2倍解像度・曲線の分割を細かくして生え際のギザつきを解消。カーブを深い丸カバーに。わけめは正面側だけで非対称にし、継ぎ目で戻すことで背中の段差を解消。分け目はギャップで表現。 --- public/bluebey-studio/src/face.js | 19 ++++++++++++++----- public/bluebey-studio/src/faceArt.js | 35 +++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) (limited to 'public') diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index e3063f2..f2440d3 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -1,6 +1,12 @@ import * as THREE from 'three'; import { drawEyes, drawMouth, drawHeadCoverWrap, canvasLayout } from './faceArt.js'; +// The hair plate's texture is drawn at this multiple of the plate's natural UV +// size. The hairline crosses the head over only a fraction of that height, so at +// 1x its edge comes out visibly jagged; doubling it (with the finer curve +// subdivision in `drawHeadCoverWrap`) keeps the edge smooth. +const HAIR_SCALE = 2; + /** * Owns the two procedural textures (eyes and mouth) and keeps them in sync with * the face parameters. @@ -48,7 +54,7 @@ export class Face { this.beardImages = {}; this.eyeCanvas = makeCanvas(this.eyeLayout); this.mouthCanvas = makeCanvas(this.mouthLayout); - this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout) : null; + this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null; this.eyeCtx = this.eyeCanvas.getContext('2d'); this.mouthCtx = this.mouthCanvas.getContext('2d'); this.hairCtx = this.hairCanvas ? this.hairCanvas.getContext('2d') : null; @@ -264,10 +270,13 @@ export class Face { this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3; const layout = this.hairLayout; const ctx = this.hairCtx; - ctx.clearRect(0, 0, layout.width, layout.height); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, layout.width * HAIR_SCALE, layout.height * HAIR_SCALE); + ctx.scale(HAIR_SCALE, HAIR_SCALE); // `drawHeadCoverWrap` works in canvas coordinates (its x axis is the UV // angle already normalised), so it is not shifted by the window offset. drawHeadCoverWrap(ctx, p.eyes.headMark ?? {}, layout); + ctx.setTransform(1, 0, 0, 1, 0, 0); this.hairTexture.needsUpdate = true; } @@ -275,10 +284,10 @@ export class Face { } } -function makeCanvas({ width, height }) { +function makeCanvas({ width, height }, scale = 1) { const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; + canvas.width = Math.round(width * scale); + canvas.height = Math.round(height * scale); return canvas; } diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 7b5f7de..9d5e20d 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -919,15 +919,21 @@ export function drawHeadCoverWrap(ctx, spec, layout) { if (shape === 'fringe') return base + 0.15 * Math.max(0, 1 - Math.abs(t) * 1.6); // はちわれ: the same V upside down - a widow's peak. if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5); - // カーブ: a plain shallow curve, lower (more hair) round the sides. - if (shape === 'curve') return base + 0.10 * t * t; - // わけめ: a 7:3 parting - a step at 30% from the left, so the larger side - // sits lower (more hair). - if (shape === 'side') return base + 0.08 - 0.16 / (1 + Math.exp(-(t + 0.4) * 10)); + // カーブ: a deep round cover - low at the sides, high in the middle, like the + // blue of a certain robot cat's head. + if (shape === 'curve') return base + 0.42 * t * t; + // わけめ: a 7:3 part. The hairline is asymmetric across the *front* only and + // comes back to the base level at the sides, so it is continuous at the back + // seam (an angle-varying hairline there would ride the seam and step). The + // parting line is the gap cut out below. + if (shape === 'side') { + const w = Math.max(0, 1 - t * t); // 0 at the sides, 1 at the front + return base + 0.18 * w * (0.5 + 0.5 * t); + } return base - 0.07 * (1 - t * t); // `cap` }; - const steps = 96; + const steps = Math.max(96, Math.ceil(W / 2)); ctx.beginPath(); ctx.moveTo(0, 0); for (let i = 0; i <= steps; i += 1) { @@ -938,6 +944,23 @@ export function drawHeadCoverWrap(ctx, spec, layout) { ctx.closePath(); ctx.fillStyle = color; ctx.fill(); + + if (shape === 'side') { + // The parting: a thin wedge cut out at 30% (a 7:3 split), running from the + // top of the head down to the hairline. + const partX = span * 0.4; + const gw = Math.max(8, span * 0.016) * (0.6 + size); + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.moveTo(partX - gw * 0.5, 0); + ctx.lineTo(partX + gw * 0.5, 0); + ctx.lineTo(partX + gw * 1.2, clamp(vBound(partX), 0, 1) * H + 2); + ctx.lineTo(partX - gw * 1.2, clamp(vBound(partX), 0, 1) * H + 2); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } ctx.restore(); } -- cgit v1.3.1