diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/outline.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/outline.js')
| -rw-r--r-- | public/bluebey-studio/src/outline.js | 368 |
1 files changed, 368 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/outline.js b/public/bluebey-studio/src/outline.js new file mode 100644 index 0000000..127d576 --- /dev/null +++ b/public/bluebey-studio/src/outline.js @@ -0,0 +1,368 @@ +import * as THREE from 'three'; + +/** + * A screen-space outline: the label version of "line art". + * + * The studio's other outline is an *inverted hull* - a copy of a mesh, expanded + * along its normals, drawn back-faces-only. That is cheap and its line is + * computed from the geometry, so it comes out smooth, but it cannot outline a + * thin closed solid: a leaf blade is 0.026 units thick and the hull expands by + * 0.022 in every direction, so the expanded front and back cross inside the leaf + * and the line breaks up. No amount of mesh fixing removes that; it is the + * technique. + * + * So the thin overlapping parts - the waist leaves, and the nose in the + * line-art styles - are handed to this pass instead, and everything else keeps + * its hull (see MODEL-GUIDE.md §5). This module renders the scene once into a + * buffer holding a *label* per pixel, then draws a full-screen pass that inks + * pixels where the labels disagree. + * + * ## Why labels, not coverage + * + * The parts want different lines: + * + * - a LEAF that runs into the body must NOT get a line along the intersection. + * Such a line reads as the leaf sinking into the body, and the original + * artwork does not draw one either - the leaf simply passes behind the body. + * - the NOSE is the opposite. It is a bump sitting on the body, so the ring + * where it meets the body *is* its outline. In a line drawing there is no + * colour to read the nose by, so that ring is the only thing that shows it. + * + * A single "coverage" cannot express that difference, so each part writes a + * label: + * + * 0 (paper) nothing is there + * BEHIND something that is merely behind: it still writes depth, so it + * hides what is behind *it*, but a leaf in front of it is still + * outlined - a foot behind the skirt does not swallow the leaf's + * edge. This is the default for meshes nobody claimed. + * SOLID the body: the leaves *emerge* from it, so a leaf must not be + * outlined where it meets it (that reads as the leaf sinking into + * the body, and the original artwork draws no line there either). + * LEAF a leaf + * NOSE the nose + * + * The label is read with `NearestFilter`, because it is an identity, not a + * colour: filtering it would blend two labels into a third value that means + * neither. + * + * ## What this pass deliberately does not do + * + * It does not look for folds, and so it needs no normals at all - only the + * labels. Surface shape was tried (a second buffer of filtered normals, with the + * fold test gated to the leaves) and taken out again: it did bring back the + * lines where one leaf lies over the next, but it cost a whole extra scene pass + * and it read as a grainy speckle across the skirt, because a leaf is thin + * enough (0.026) that its own rim is a 145-degree crease and the mesh arrives + * with those edges split into separate vertices. The recipe is written up in + * MODEL-GUIDE.md §5-2c if it is ever wanted back - for instance to make raised + * leaf veins show. + * + * Callers must keep the outline hulls out of this pass: `exclude()` takes them + * (see `outlineExclusion` in main.js). A hull is an expanded copy of a mesh, and + * this pass swaps a front-side material onto everything it sees, so a hull would + * paint an enlarged copy of the character's own label over all of it. + * + * A mesh that should *hide* parts of the character without being outlined itself + * (the invisible wall) is handed to `occlude()` instead: it is drawn for its + * depth alone, with a paper label, so the leaves and the nose buried behind it + * get no label - and therefore no line. + */ + +// Taps around the pixel, averaged into a coverage. 16 gives a smooth ramp. +const TAPS = 16; + +/** Written into the alpha channel; see the note above. */ +export const BEHIND_LABEL = 0.1; +export const SOLID_LABEL = 0.4; +export const LEAF_LABEL = 0.7; +export const NOSE_LABEL = 1; + +const VERTEX = /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + // The quad is already in clip space, so no camera maths is involved. + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +const FRAGMENT = /* glsl */` + uniform sampler2D uLabels; + uniform vec2 uTexel; + uniform vec3 uColor; + uniform float uRadius; + varying vec2 vUv; + + const float TAU = 6.28318530718; + + // The labels, read back out of the alpha channel (see the top of this file). + // They are exact values, not a gradient, so the tests are simple comparisons. + // + // "paper" means "does not block a line": the untouched background (0) and the + // parts that are merely behind (0.1). Only the body (0.4) blocks one. + float paper(float a) { return 1.0 - step(0.2, a); } + float drawn(float a) { return step(0.6, a); } + float nose(float a) { return step(0.85, a); } + + void main() { + vec4 here = texture2D(uLabels, vUv); + float ink = 0.0; + + for (int i = 0; i < ${TAPS}; i++) { + float angle = (float(i) / float(${TAPS})) * TAU; + vec2 offset = vec2(cos(angle), sin(angle)) * uTexel * uRadius; + vec4 there = texture2D(uLabels, vUv + offset); + + float edge = 0.0; + // 1. a drawn part against the paper. A leaf against the *body* fires + // nothing here, which is the point: the leaf simply passes behind it. + edge = max(edge, max(drawn(here.a) * paper(there.a), + drawn(there.a) * paper(here.a))); + // 2. the nose against anything that is not the nose, so its whole ring - + // including the part against the body - is inked. + edge = max(edge, abs(nose(here.a) - nose(there.a))); + + ink += edge; + } + + // Averaging the taps turns the flag into a coverage, and that is what takes + // the steps out of the line: a pixel half over an edge gets half the ink, so + // the line gets soft edges instead of landing on the pixel grid. It also + // reads lighter than a hard band of the same width, which is what makes it + // sit next to the hull's line without looking heavier. + ink /= float(${TAPS}); + // The ramp starts at about three taps out of sixteen rather than at one, so an + // isolated tap is not enough to ink a pixel; a real edge has half the taps + // crossing it, so asking for a few costs nothing there. + float line = smoothstep(0.19, 0.45, ink); + if (line < 0.02) discard; + gl_FragColor = vec4(uColor, line); + } +`; + +export function createScreenOutline({ renderer, scene, camera, width = 1280, height = 800 }) { + const target = new THREE.WebGLRenderTarget(width, height, { + // The label is an identity, not a shade: filtering would blend two labels into + // a third value that means neither of them. The softness of the line comes + // from spreading the taps in the shader, not from blurring this buffer. + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + depthBuffer: true, + // The alpha channel carries the label, so it must not be filled in. + format: THREE.RGBAFormat, + }); + target.texture.generateMipmaps = false; + + /** + * The label each part writes into the alpha channel. + * + * `transparent: true` keeps three.js from defining `OPAQUE`, which would force + * the alpha to 1, and `blending: NoBlending` makes the fragment *replace* the + * pixel instead of blending into it, so the labels stay exact. `depthWrite` + * stays on, which is what hides the parts that are behind something else. + */ + const labelMaterials = new Map(); + function labelMaterialFor(label) { + let material = labelMaterials.get(label); + if (material) return material; + material = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: label, + blending: THREE.NoBlending, + depthWrite: true, + }); + material.customProgramCacheKey = () => `bluebey-outline-label-${label}`; + labelMaterials.set(label, material); + return material; + } + + const uniforms = { + uLabels: { value: target.texture }, + uTexel: { value: new THREE.Vector2(1 / width, 1 / height) }, + uColor: { value: new THREE.Color('#2a1e33') }, + uRadius: { value: 1.4 }, + }; + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthTest: false, + depthWrite: false, + toneMapped: false, + }); + + // A single quad in clip space, with the camera taken out of the equation. + const quadScene = new THREE.Scene(); + const quadCamera = new THREE.Camera(); + quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)); + + /** Objects that must not appear in the outline (ground, shadows, gizmos). */ + const hidden = []; + /** Meshes whose material (and render order) is borrowed for the label pass. */ + const swapped = []; + const exclusion = new Set(); + + /** + * Meshes that only *occlude* in the label pass (the 見えない壁). + * + * They are drawn with a paper label and both sides, so their depth hides + * whatever is behind them: a leaf or the nose buried in the wall then gets no + * label at all, and so no line. Their own silhouette inks nothing either, + * because paper against paper is not an edge - which is what keeps the wall + * itself invisible instead of drawing a rectangle. + */ + const occluders = new Set(); + let occluderMaterial = null; + + function occluderMaterialFor() { + if (!occluderMaterial) { + occluderMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0, // paper: it never reads as a drawn part + blending: THREE.NoBlending, + depthWrite: true, + side: THREE.DoubleSide, // the wall can be seen from either side + }); + } + return occluderMaterial; + } + + /** Objects to draw in the label pass for their depth alone (see `occluders`). */ + function occlude(objects) { + occluders.clear(); + for (const object of objects) if (object) occluders.add(object); + } + + /** + * When set, these meshes write the given label and everything else writes + * BEHIND. Without it the whole scene shares one label. + * + * @type {Map<THREE.Object3D, number>|null} + */ + let focus = null; + + function setSize(nextWidth, nextHeight) { + const w = Math.max(1, Math.round(nextWidth)); + const h = Math.max(1, Math.round(nextHeight)); + if (target.width === w && target.height === h) return; + target.setSize(w, h); + uniforms.uTexel.value.set(1 / w, 1 / h); + } + + /** Layers/objects to leave out of the label pass (ground, shadow, gizmo, hulls). */ + function exclude(objects) { + exclusion.clear(); + for (const object of objects) if (object) exclusion.add(object); + } + + /** + * Hand the pass a split: these meshes get this label, and everything else is + * drawn as BEHIND - present, so it still hides what is behind it, but not + * blocking a leaf's outline. Callers should therefore name every part that a + * leaf must not be outlined against (the body) as well as the outlined ones + * (the leaves, the nose). + * + * `null` puts the whole scene back on one label. + * + * @param {Array<[THREE.Object3D, number]>|null} parts + */ + function only(parts) { + focus = parts ? new Map(parts) : null; + } + + /** + * Draw one frame. `baseRender` renders the scene the normal way; it is called + * between the label pass and the ink so the ink lands on top of it. + */ + function render(baseRender, options = {}) { + const enabled = options.enabled !== false; + if (!enabled) { + baseRender(); + return; + } + + uniforms.uColor.value.set(options.color ?? '#2a1e33'); + uniforms.uRadius.value = Math.max(0.6, options.radius ?? 1.4); + + // --- 1. the labels pass -------------------------------------------- + const previousOverride = scene.overrideMaterial; + const previousClear = renderer.getClearColor(new THREE.Color()); + const previousAlpha = renderer.getClearAlpha(); + hidden.length = 0; + swapped.length = 0; + const hide = (object) => { + if (object.visible) { + hidden.push(object); + object.visible = false; + } + }; + for (const object of exclusion) hide(object); + + if (focus) { + // A per-mesh material, so the meshes outside `focus` can occlude without + // contributing a line. (`overrideMaterial` would put one label on all of + // them, and a leaf ending on a foot would then be read the same way as a + // leaf ending on the body.) + // + // The occluders are also pushed to the front of the draw order. Every body + // mesh sits at the same origin, so three.js would otherwise fall back to + // insertion order and could draw a far-side leaf *before* the body that + // hides it - and writing a label cannot erase what is already in the + // buffer, only stop it being drawn. Drawn first, the depth test does it. + scene.traverse((object) => { + if (!object.isMesh || !object.visible) return; + swapped.push([object, object.material, object.renderOrder]); + if (occluders.has(object)) { + // Depth only, drawn first: the wall hides the labels behind it. + object.material = occluderMaterialFor(); + object.renderOrder = -1; + return; + } + const label = focus.get(object) ?? BEHIND_LABEL; + object.material = labelMaterialFor(label); + if (label < 0.6) object.renderOrder = -1; + }); + scene.overrideMaterial = null; + } else { + // No split given: nothing is outlined, so no label may read as "drawn". + scene.overrideMaterial = labelMaterialFor(SOLID_LABEL); + } + + renderer.setRenderTarget(target); + renderer.setClearColor(0x000000, 0); + renderer.clear(true, true, false); + renderer.render(scene, options.camera ?? camera); + + renderer.setRenderTarget(null); + scene.overrideMaterial = previousOverride; + for (const object of hidden) object.visible = true; + for (const [object, swappedMaterial, renderOrder] of swapped) { + object.material = swappedMaterial; + object.renderOrder = renderOrder; + } + renderer.setClearColor(previousClear, previousAlpha); + + // --- 2. the scene itself ------------------------------------------- + baseRender(); + + // --- 3. the ink, straight over the top ----------------------------- + renderer.autoClear = false; + renderer.render(quadScene, quadCamera); + renderer.autoClear = true; + } + + function dispose() { + target.dispose(); + for (const material of labelMaterials.values()) material.dispose(); + labelMaterials.clear(); + material.dispose(); + occluderMaterial?.dispose(); + for (const child of quadScene.children) child.geometry.dispose(); + } + + return { render, setSize, exclude, occlude, only, uniforms, target, material, dispose }; +} |
