aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/outline.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/outline.js')
-rw-r--r--public/bluebey-studio/src/outline.js368
1 files changed, 0 insertions, 368 deletions
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<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 };
-}