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 }); // A hat is not flat: in a line drawing a white hat on a white head merges // into one shape, so hats get a light tone instead of paper (see addMesh). // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it // steps from the paper towards the ink is cached by strength in // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a // pencil's lead) can ask for a darker fill and its seam then reads. this.toneMaterials = new Map(); /** Runtime meshes that want a tone fill rather than paper in 線画. */ this.toneMeshes = new Map(); 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; } /** * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far * it steps from the paper towards the ink. `userData.tone` on the mesh (set by * the builder) carries the amount; the hat default comes from `options.tone`. */ toneMaterialFor(strength = DEFAULT_TONE) { const key = String(strength); let material = this.toneMaterials.get(key); if (!material) { material = new THREE.MeshBasicMaterial({ color: toneOf(this.paper, strength), toneMapped: false, }); material.userData.tone = strength; this.toneMaterials.set(key, material); } return 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; } /** * Register a mesh that is built at runtime (a hat), so it follows the render * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it. * * `options.tone` gives the mesh the light tone fill in 線画 instead of paper, * for parts whose shape would otherwise merge with the body (the hats). A * number instead of `true` picks how far to step towards the ink, and the * builder can override it per mesh with `userData.tone` - which is how a part * draws its seam against a same-coloured neighbour. * * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット. */ addMesh(mesh, options = {}) { if (!mesh || this.originals.has(mesh)) return; this.originals.set(mesh, mesh.material); this.meshes.push(mesh); const tone = mesh.userData.tone ?? options.tone; if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone)); const kind = this.kindOf(mesh); if (!OUTLINE_SKIP.has(kind)) { const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); const hull = new THREE.Mesh(mesh.geometry, this.hullMaterialFor(this.scaleFor(kind), overlay)); 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 = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind); hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind); mesh.parent?.add(hull); this.outlineMeshes.push(hull); mesh.userData.hull = hull; } this.setStyle(this.style); } /** Take a runtime mesh (and its outline hull) back out. */ removeMesh(mesh) { if (!mesh || !this.originals.has(mesh)) return; const original = this.originals.get(mesh); const hull = mesh.userData.hull; if (hull) { hull.removeFromParent(); const i = this.outlineMeshes.indexOf(hull); if (i >= 0) this.outlineMeshes.splice(i, 1); delete mesh.userData.hull; } // Put the mesh's own material back before dropping it: while a line style is // on it is wearing the *shared* paper/tone material, and the caller is about // to dispose it - which would blank every part using it. if (original) mesh.material = original; this.originals.delete(mesh); this.toneMeshes.delete(mesh); const j = this.meshes.indexOf(mesh); if (j >= 0) this.meshes.splice(j, 1); } /** 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`; a light tone for hats, which would // otherwise merge into the head; 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.toneMeshes.get(mesh) ?? 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); for (const material of this.toneMaterials.values()) { material.color.copy(toneOf(color, material.userData.tone)); } } 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(); for (const material of this.toneMaterials.values()) material.dispose(); this.toneMaterials.clear(); this.invisibleMaterial.dispose(); this.gradientMap.dispose(); for (const material of this.toonMaterials.values()) material.dispose(); this.toonMaterials.clear(); } } /** How far the hat default steps from the paper towards the ink in 線画. */ const DEFAULT_TONE = 0.14; /** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */ function toneOf(paper, strength = DEFAULT_TONE) { return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength); } /** 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; }