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 }; }