aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/outline.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
commite332019acb312ec64893c26cf3d797d5ce472f26 (patch)
treed97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/outline.js
parent99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff)
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/outline.js')
-rw-r--r--public/bluebey-studio/src/outline.js368
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 };
+}