aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/styles.js
diff options
context:
space:
mode:
Diffstat (limited to 'bluebey-studio/src/styles.js')
-rw-r--r--bluebey-studio/src/styles.js457
1 files changed, 457 insertions, 0 deletions
diff --git a/bluebey-studio/src/styles.js b/bluebey-studio/src/styles.js
new file mode 100644
index 0000000..2e4f62d
--- /dev/null
+++ b/bluebey-studio/src/styles.js
@@ -0,0 +1,457 @@
+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 <begin_vertex>',
+ '#include <begin_vertex>\n\ttransformed += normal * uOutline;',
+ );
+ };
+ material.customProgramCacheKey = () => 'bluebey-outline-hull';
+ return material;
+}