From e332019acb312ec64893c26cf3d797d5ce472f26 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:51:34 +0900 Subject: bluebey: ぶるべー スタジオのページを公開 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/styles.js | 350 ++++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 public/bluebey-studio/src/styles.js (limited to 'public/bluebey-studio/src/styles.js') diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js new file mode 100644 index 0000000..d7bd7b9 --- /dev/null +++ b/public/bluebey-studio/src/styles.js @@ -0,0 +1,350 @@ +import * as THREE from 'three'; + +/** + * Render styles for the body meshes, plus the outline pass. + * + * real the PBR materials exactly as authored in the GLB + * flat two-tone toon shading, the classic mascot look + * lineart white paper + lines, the face artwork drawn as strokes + * outline nothing but the lines, kept invisible via colorWrite = false so the + * result composites on top of anything (used by the SVG export) + * + * The lines come from two systems, each used where it is strong (see + * MODEL-GUIDE.md §5): every mesh gets an inverted-hull copy, and the thin + * overlapping leaves are handed to the screen-space pass instead (outline.js), + * which the caller arranges with `setHullHidden`. + */ + +export const STYLE_DEFS = [ + { value: 'real', label: 'リアル' }, + { value: 'flat', label: 'フラット' }, + { value: 'lineart', label: '線画' }, +]; + +// 'outline' is still used internally by the SVG export (lines on a transparent +// background), it is just no longer offered as a viewing style. +const LINE_STYLES = new Set(['lineart', 'outline']); + +export const isLineStyle = (value) => LINE_STYLES.has(value); + +/** Parts that need no outline at all. */ +const OUTLINE_SKIP = new Set(); + +/** + * Parts whose outline is only useful in the line-art styles. + * + * Empty now: the leaves used to be listed here, but the line styles get their + * leaf lines from the `Vein` faces themselves (see OUTLINE_SHADED_ONLY), so the + * hull is the wrong tool for them in either direction. + */ +const OUTLINE_LINE_ONLY = new Set(); + +/** + * Parts that should be outlined in the SHADED styles only. + * + * The inverted hull draws an outline by expanding a copy of the mesh along its + * normals and rendering the back faces. That works on one big round body, but + * the leaf skirt is a dozen thin shells lying on top of each other: each hull's + * far side shows through its neighbours, which is what produced the faint + * overlapping hairlines. In the SVG pass the leaves get their line from their + * own `Vein` faces (filled with the ink colour) instead, so the hull is dropped + * there and kept for the cartoon styles, where it does read as an edge. + */ +const OUTLINE_SHADED_ONLY = new Set(['vein']); + +/** + * Per-part outline thickness, as a fraction of the global width. + * + * The hull is offset by a fixed distance in world units, so the right factor + * depends on how fine a part's mesh is compared with that offset: + * + * - the nose is only ~0.24 units across, and the offset that looks right on the + * body reads as a thick ring on it, hence the small factor. In the line-art + * styles it is the other way round (a hairline looks like dust on paper), so + * the nose gets a fuller line there - see OUTLINE_SCALE_LINE. + * - the leaves need a *larger* factor, which only shows up in the "hull only" + * style: the 13 blades overlap and interpenetrate, so each one's expanded + * shell cuts across its neighbours and the line breaks up. A thicker hull + * merges those scratches back into a band. Making the leaves thicker does NOT + * fix it - measured, see MODEL-GUIDE.md §5-4 - because the overlaps are the + * blocker, not the leaf's own thickness. + */ +const OUTLINE_SCALE = { nose: 0.4, leaf: 1.7, vein: 2.2 }; + +/** Fuller lines for the paper-and-ink styles, where a hairline reads as dust. */ +const OUTLINE_SCALE_LINE = { nose: 0.9, leaf: 1.9, vein: 2.4 }; + +/** + * Parts whose outline should only survive where the part itself sticks out past + * the body. Their hull is drawn first with depth testing off, so everything the + * body covers paints over it: the nose then only gets a line in the views where + * it actually pokes out of the silhouette, instead of a ring around it - which is + * what the original artwork does, the nose reads by its own colour there. + * + * In the line-art styles there is no colour to read it by (the nose is paper on + * paper), so this is switched off and the nose goes to the screen-space pass + * instead - see `screenParts` in main.js. + */ +const OUTLINE_SILHOUETTE_ONLY = new Set(['nose']); + +export class Styles { + constructor({ meshes, paper = '#ffffff', outlineColor = '#2a1e33', outlineWidth = 0.022 }) { + this.meshes = meshes; + this.paper = paper; + this.style = 'real'; + this.outlineEnabled = true; + /** + * Source meshes whose hull outline must stay off, because another method + * (the screen-space pass) is drawing them. Empty = every hull is available. + * Only the leaves ever land here: see MODEL-GUIDE.md §5. + */ + this.hullHidden = new Set(); + + this.originals = new Map(); + for (const mesh of meshes) { + this.originals.set(mesh, mesh.material); + mesh.castShadow = true; + mesh.receiveShadow = false; + } + + this.gradientMap = makeGradientMap(); + this.toonMaterials = new Map(); + this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false }); + this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true }); + + this.outlineWidth = outlineWidth; + this.outlineColor = outlineColor; + /** scale -> { material, uniform }, so the width slider updates all of them. */ + this.hullMaterials = new Map(); + this.outlineMeshes = this.createOutlines(); + this.setStyle('real'); + } + + /** + * The part kind, taken from the material the GLB shipped with. + * + * It must be read from `originals`, not from `mesh.material`: `setStyle` + * replaces the materials, and the replacements (paper, ink, toon) carry no + * name, so after the first swap every part would look like an unknown one and + * the leaf/nose special cases would silently stop applying. + */ + kindOf(mesh) { + return (this.originals.get(mesh)?.name ?? '').toLowerCase(); + } + + hullMaterialFor(scale, overlay = false) { + const key = `${scale}:${overlay ? 'overlay' : 'solid'}`; + let entry = this.hullMaterials.get(key); + if (!entry) { + const uniform = { value: this.outlineWidth * scale }; + const material = makeHullMaterial(this.outlineColor, uniform); + if (overlay) { + // Depth-*tested*, but writing none: the hull is drawn before the body + // (renderOrder -1), so the body still paints over its interior and the + // nose only gets a line where it pokes out of the silhouette. Testing + // (rather than ignoring) depth is what lets the 見えない壁 hide it too - + // with the test off, a nose buried in the wall left a filled blob, + // because nothing was left to paint over the hull's inside. + material.depthTest = true; + material.depthWrite = false; + } + entry = { uniform, material, scale }; + this.hullMaterials.set(key, entry); + } + return entry.material; + } + + createOutlines() { + const hulls = []; + for (const mesh of this.meshes) { + const kind = this.kindOf(mesh); + if (OUTLINE_SKIP.has(kind)) continue; + const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); + const material = this.hullMaterialFor(this.scaleFor(kind), overlay); + const hull = mesh.isSkinnedMesh + ? new THREE.SkinnedMesh(mesh.geometry, material) + : new THREE.Mesh(mesh.geometry, material); + hull.name = `${mesh.name || 'mesh'}:outline`; + hull.position.copy(mesh.position); + hull.quaternion.copy(mesh.quaternion); + hull.scale.copy(mesh.scale); + hull.frustumCulled = false; + hull.castShadow = false; + hull.receiveShadow = false; + if (overlay) hull.renderOrder = -1; + hull.userData.kind = kind; + hull.userData.source = mesh; + hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind); + hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind); + if (hull.isSkinnedMesh) hull.bind(mesh.skeleton, mesh.bindMatrix); + mesh.parent.add(hull); + hulls.push(hull); + } + return hulls; + } + + /** The outline factor for a part in the current style (line art wants more). */ + scaleFor(kind) { + const line = LINE_STYLES.has(this.style); + const table = line ? OUTLINE_SCALE_LINE : OUTLINE_SCALE; + return table[kind] ?? OUTLINE_SCALE[kind] ?? 1; + } + + setStyle(value) { + this.style = value; + const line = LINE_STYLES.has(value); + for (const mesh of this.meshes) { + const original = this.originals.get(mesh); + let material; + if (line) { + // Paper on paper in `lineart`; invisible in `outline`, which is the pass + // the SVG trace reads. Either way every edge - the body, the nose, and + // the leaves - is drawn by the screen-space outline, so no part needs a + // material trick of its own any more. + material = value === 'lineart' ? this.paperMaterial : this.invisibleMaterial; + } else if (value === 'flat') { + material = this.toonFor(original); + } else { + material = original; + } + if (mesh.material !== material) mesh.material = material; + } + + // The nose only keeps a line where it pokes out of the body in the shaded + // styles; in a line drawing it is handed to the screen-space pass instead + // (see `screenParts` in main.js), because a hull cannot ring a bump that sits + // flush on the surface it is drawn on - the expanded shell lands *inside* the + // body and loses the depth test. + for (const hull of this.outlineMeshes) { + const kind = hull.userData.kind; + const silhouetteOnly = OUTLINE_SILHOUETTE_ONLY.has(kind) && !line; + hull.material = this.hullMaterialFor(this.scaleFor(kind), silhouetteOnly); + hull.renderOrder = silhouetteOnly ? -1 : 0; + } + + // Lines are the whole point of the line-art styles. + this.setOutlineVisible(line ? true : this.outlineEnabled); + } + + /** + * Hand a set of parts to the screen-space outline, or take them back. + * + * The hull and the screen-space pass each have a shape they cannot draw: a hull + * cannot outline a thin closed shell (the leaves), and the screen-space pass + * draws a stepped line because it works on the pixel grid. So the leaves go to + * the screen-space pass and everything else keeps its hull, which is drawn from + * the geometry and therefore comes out smooth. + */ + setHullHidden(meshes) { + this.hullHidden = new Set(meshes ?? []); + this.setStyle(this.style); + } + + toonFor(original) { + let material = this.toonMaterials.get(original); + if (!material) { + material = new THREE.MeshToonMaterial({ + color: original.color ? original.color.clone() : new THREE.Color(0xffffff), + map: original.map ?? null, + vertexColors: original.vertexColors === true, + gradientMap: this.gradientMap, + side: original.side, + transparent: original.transparent === true, + alphaTest: original.alphaTest ?? 0, + depthWrite: original.depthWrite !== false, + }); + this.toonMaterials.set(original, material); + } + return material; + } + + setPaper(color) { + this.paper = color; + this.paperMaterial.color.set(color); + } + + setOutlineVisible(visible) { + const lineMode = LINE_STYLES.has(this.style); + for (const hull of this.outlineMeshes) { + const lineOnly = hull.userData.lineOnly === true; + const shadedOnly = hull.userData.shadedOnly === true; + hull.visible = visible + && (!lineOnly || lineMode) + && !(shadedOnly && lineMode) + && !this.hullHidden.has(hull.userData.source); + } + } + + setOutlineEnabled(enabled) { + this.outlineEnabled = enabled; + if (!LINE_STYLES.has(this.style)) this.setOutlineVisible(enabled); + } + + setOutlineWidth(width) { + this.outlineWidth = width; + for (const entry of this.hullMaterials.values()) entry.uniform.value = width * entry.scale; + } + + setOutlineColor(color) { + this.outlineColor = color; + for (const entry of this.hullMaterials.values()) entry.material.color.set(color); + } + + /** + * The toon materials are cached copies of the originals, so a colour theme that + * edits `original.color` has to be copied across or the flat style keeps + * showing the old colour. See src/look.js. + */ + refreshColors() { + for (const [mesh, original] of this.originals) { + const toon = this.toonMaterials.get(original); + if (toon && original.color) toon.color.copy(original.color); + // The outline hull is a colour of its own, so it is left alone. + void mesh; + } + return this; + } + + dispose() { + for (const hull of this.outlineMeshes) { + hull.removeFromParent(); + hull.skeleton = null; + } + this.outlineMeshes = []; + for (const entry of this.hullMaterials.values()) entry.material.dispose(); + this.hullMaterials.clear(); + this.paperMaterial.dispose(); + this.invisibleMaterial.dispose(); + this.gradientMap.dispose(); + for (const material of this.toonMaterials.values()) material.dispose(); + this.toonMaterials.clear(); + } +} + +/** A 3-step ramp gives crisper cartoon bands than the shader's default. */ +function makeGradientMap() { + const steps = new Uint8Array([90, 165, 255]); + const texture = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return texture; +} + +function makeHullMaterial(color, uniform) { + const material = new THREE.MeshBasicMaterial({ + color, + side: THREE.BackSide, + toneMapped: false, + }); + material.onBeforeCompile = (shader) => { + shader.uniforms.uOutline = uniform; + shader.vertexShader = `uniform float uOutline;\n${shader.vertexShader}`.replace( + '#include ', + '#include \n\ttransformed += normal * uOutline;', + ); + }; + material.customProgramCacheKey = () => 'bluebey-outline-hull'; + return material; +} -- cgit v1.3.1