From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:09:40 +0900 Subject: bluebey-studio: public/ の外へ移動し非公開化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/outline.js | 368 ----------------------------------- 1 file changed, 368 deletions(-) delete mode 100644 public/bluebey-studio/src/outline.js (limited to 'public/bluebey-studio/src/outline.js') diff --git a/public/bluebey-studio/src/outline.js b/public/bluebey-studio/src/outline.js deleted file mode 100644 index 127d576..0000000 --- a/public/bluebey-studio/src/outline.js +++ /dev/null @@ -1,368 +0,0 @@ -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|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 }; -} -- cgit v1.3.1