1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
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 };
}
|