@driftengine/assets 3.62.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * An encoded material as the chunk a file carries it in.
3
+ *
4
+ * **The join between two packages that cannot see each other.** `@driftengine/assets` knows how to
5
+ * encode a material — a latent, a small network and a decode program — and `@driftengine/drft` knows
6
+ * how to store bytes and nothing about what they mean. This is the one place that knows both, and
7
+ * it is deliberately thin: it moves fields and packs a channel's semantic into a word.
8
+ *
9
+ * **The channels are the one thing the encoding does not already carry.** `EncodedMaterial` holds
10
+ * the latent, the weights and the graph, and the graph is two nodes — sample, then evaluate. What
11
+ * a channel *means* lives in the `ChannelSpec`s the caller handed `encodeMaterial`, so they are
12
+ * handed over again here rather than recovered from a program that never held them.
13
+ *
14
+ * **The tiles are the caller's dedup, not this function's.** Two materials in a bake share a tile
15
+ * whenever their bytes match, which is a decision across the whole bake; a function given one
16
+ * material cannot make it. A caller with no tiling hands over none and the chunk carries its latent
17
+ * as one run, which is what a small material wants anyway.
18
+ */
19
+ import type { DtexMaterial } from '@driftengine/drft';
20
+ import { hashTile, semanticIndex, type ChannelSpec } from '@driftengine/texture';
21
+
22
+ import type { EncodedMaterial } from './latent.ts';
23
+
24
+ /** Where each tile's bytes sit in the latent payload, and what they hash to. */
25
+ export interface DtexTiles {
26
+ readonly offset: Uint32Array;
27
+ readonly length: Uint32Array;
28
+ /** Two words a tile, high first, as `hashTile` splits its 64 bits. */
29
+ readonly hash: Uint32Array;
30
+ /** The payload the offsets index: tile-major, each tile's bytes in one run. */
31
+ readonly payload: Uint8Array;
32
+ }
33
+
34
+ /**
35
+ * A latent cut into tiles, in the grid's order, with identical tiles sharing one run.
36
+ *
37
+ * **Tile-major, because a tile of a row-major image is not contiguous** — its rows are a stride
38
+ * apart, so no offset and length can name one, which is why the table this replaces could not be
39
+ * written. Cut across a row and then down, the order `@driftengine/texture`'s `latentTileGrid`
40
+ * uses, so the entry for a tile is `ty * across + tx` and a reader with a coordinate can find it.
41
+ *
42
+ * **Deduplicated by content**, which is the whole reason the hash is in the file: a flat surface
43
+ * tiles into hundreds of identical squares, and a material that stored each of them separately
44
+ * would pay for the flatness. Two tiles with the same bytes get the same offset and length.
45
+ */
46
+ export function cutLatentTiles(
47
+ latent: Uint8Array,
48
+ width: number,
49
+ height: number,
50
+ components: number,
51
+ tileSize: number,
52
+ ): DtexTiles {
53
+ if (!Number.isInteger(tileSize) || tileSize < 1) {
54
+ throw new RangeError(`cutLatentTiles: an edge of ${String(tileSize)} texels is not a tile`);
55
+ }
56
+ const across = Math.ceil(width / tileSize);
57
+ const down = Math.ceil(height / tileSize);
58
+ const offset = new Uint32Array(across * down);
59
+ const length = new Uint32Array(across * down);
60
+ const hash = new Uint32Array(across * down * 2);
61
+ const runs = new Map<string, [number, number]>();
62
+ const bytes: number[] = [];
63
+
64
+ for (let ty = 0; ty < down; ty += 1) {
65
+ for (let tx = 0; tx < across; tx += 1) {
66
+ const w = Math.min(tileSize, width - tx * tileSize);
67
+ const h = Math.min(tileSize, height - ty * tileSize);
68
+ const tile = new Uint8Array(w * h * components);
69
+ for (let y = 0; y < h; y += 1) {
70
+ const from = ((ty * tileSize + y) * width + tx * tileSize) * components;
71
+ tile.set(latent.subarray(from, from + w * components), y * w * components);
72
+ }
73
+ const digest = hashTile(tile);
74
+ const at = ty * across + tx;
75
+ const held = runs.get(digest);
76
+ if (held === undefined) {
77
+ runs.set(digest, [bytes.length, tile.length]);
78
+ offset[at] = bytes.length;
79
+ length[at] = tile.length;
80
+ for (const value of tile) bytes.push(value);
81
+ } else {
82
+ offset[at] = held[0];
83
+ length[at] = held[1];
84
+ }
85
+ hash[at * 2] = Number.parseInt(digest.slice(0, 8), 16);
86
+ hash[at * 2 + 1] = Number.parseInt(digest.slice(8), 16);
87
+ }
88
+ }
89
+ return { offset, length, hash, payload: Uint8Array.from(bytes) };
90
+ }
91
+
92
+ /**
93
+ * `encoded` as a `DtexMaterial`, with `channels` saying what each of its outputs is.
94
+ *
95
+ * A channel is packed as `(semanticIndex << 4) | component`, which is what the decode interpreter
96
+ * unpacks and what the chunk stores. **A semantic this build does not know is refused** rather than
97
+ * written as something else: the index is the format's, and a wrong one is a channel that decodes
98
+ * as another channel, which reads as a shading fault in a scene nobody has changed.
99
+ */
100
+ export function dtexFromEncoded(
101
+ encoded: EncodedMaterial,
102
+ channels: readonly ChannelSpec[],
103
+ tileSize?: number,
104
+ ): DtexMaterial {
105
+ if (channels.length === 0) {
106
+ throw new RangeError('dtexFromEncoded: a material with no channels decodes to nothing');
107
+ }
108
+ const packed = new Uint32Array(channels.length);
109
+ channels.forEach((spec, at) => {
110
+ const index = semanticIndex(spec.semantic);
111
+ if (index < 0) {
112
+ throw new RangeError(`dtexFromEncoded: ${spec.semantic} is not a semantic this build knows`);
113
+ }
114
+ if (!Number.isInteger(spec.component) || spec.component < 0 || spec.component > 15) {
115
+ throw new RangeError(
116
+ `dtexFromEncoded: component ${spec.component} does not fit the four bits a channel has`,
117
+ );
118
+ }
119
+ packed[at] = (index << 4) | spec.component;
120
+ });
121
+
122
+ /*
123
+ * **No tile size means no table at all**, rather than one entry over the whole image. A table
124
+ * with a made-up hash in it is a table a streaming reader would believe; an empty one says
125
+ * plainly that this material is one image and is not streamed. See `dtex.ts`.
126
+ */
127
+ const table =
128
+ tileSize === undefined
129
+ ? null
130
+ : cutLatentTiles(
131
+ encoded.latent,
132
+ encoded.latentWidth,
133
+ encoded.latentHeight,
134
+ encoded.components,
135
+ tileSize,
136
+ );
137
+
138
+ return {
139
+ latentWidth: encoded.latentWidth,
140
+ latentHeight: encoded.latentHeight,
141
+ latentComponents: encoded.components,
142
+ channels: packed,
143
+ nodes: encoded.graph.nodes.slice(0, encoded.graph.count * 4),
144
+ resultRegister: encoded.graph.result,
145
+ addressMode: encoded.graph.addressMode,
146
+ networkInputs: encoded.shape.inputs,
147
+ networkOutputs: encoded.shape.outputs,
148
+ hidden: Uint32Array.from(encoded.shape.hidden),
149
+ weights: encoded.weights,
150
+ tileOffset: table?.offset ?? new Uint32Array(0),
151
+ tileLength: table?.length ?? new Uint32Array(0),
152
+ tileHash: table?.hash ?? new Uint32Array(0),
153
+ tileSize: tileSize ?? 0,
154
+ latent: table?.payload ?? encoded.latent,
155
+ };
156
+ }
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Hierarchical detail: one proxy for a whole group of cells, and one sprite for a whole object.
3
+ *
4
+ * **A distant city has to become a handful of draws, and there are only two ways to do it.** Merge
5
+ * the geometry down until a group of cells is one coarse mesh, which is what a proxy is; or stop
6
+ * drawing geometry at all and draw the object's own picture, which is what an impostor is. This
7
+ * module bakes both, offline, so nothing here runs per frame and it may allocate freely.
8
+ *
9
+ * **The proxy is not a new mesh merger.** `coarseLevel.ts` already builds one mesh from many, by
10
+ * emitting the boundary of an occupancy grid rather than decimating a surface — and it already
11
+ * argues, at length, why the decimation it replaced grew triangles straight through a car's
12
+ * bodywork. A cell group is the same problem one level up, so what is new here is the *grouping*
13
+ * and the level-to-resolution schedule, not the geometry.
14
+ *
15
+ * **And a proxy is a budget, so it can be refused.** The plan for this said a proxy has fewer
16
+ * triangles than the sum of its members; a grid-based merge cannot promise that, because its output
17
+ * scales with the group's surface area and not with the input's triangle count. Four plain boxes
18
+ * are forty-eight triangles and every grid that resolves them at all produces more. So the budget
19
+ * is checked — `isOutlineWorthWriting`'s half, the same rule the container already uses — and a
20
+ * group that is already cheap gets `null` rather than something bigger than what it replaced.
21
+ *
22
+ * **The impostor's hard part is its edges, not its views.** Rasterising a mesh from sixty-four
23
+ * directions is arithmetic. What makes distant trees glow is that the texels *outside* the
24
+ * silhouette are left at the clear colour, and a bilinear tap at the edge blends the object with
25
+ * black — a dark rim under a lit pass, a bright one under an additive pass. Two things fix it and
26
+ * both are here: colour is dilated outward so a transparent texel carries its nearest opaque
27
+ * neighbour's colour, and every tile is surrounded by a gutter repeating its edge so a tap near a
28
+ * tile boundary cannot reach the next direction's picture.
29
+ */
30
+ import type { MeshData } from '@driftengine/drft';
31
+
32
+ import { buildCoarseLevel, isOutlineWorthWriting } from '../coarseLevel.ts';
33
+
34
+ /** A cell's geometry, keyed by the identifier `core/src/world/cell.ts` gives it. */
35
+ export interface ProxyCell {
36
+ readonly id: number;
37
+ readonly meshes: readonly MeshData[];
38
+ }
39
+
40
+ /**
41
+ * Grid cells along the longest axis of a level-0 proxy.
42
+ *
43
+ * Coarser than `DEFAULT_COARSE_CELLS` because the subject is different: that outline stands in for
44
+ * one asset arriving over a network and is looked at from a few metres, this one stands in for a
45
+ * block of a city seen from the far side of a valley.
46
+ */
47
+ export const PROXY_BASE_CELLS = 32;
48
+
49
+ /** The finest a proxy is allowed to get, matching `buildCoarseLevel`'s own floor. */
50
+ export const PROXY_MIN_CELLS = 4;
51
+
52
+ /** The grid a level uses. Each level halves the last, which is what makes it a hierarchy. */
53
+ export function proxyResolution(level: number): number {
54
+ const at = Math.max(0, Math.floor(level));
55
+ return Math.max(PROXY_MIN_CELLS, PROXY_BASE_CELLS >> at);
56
+ }
57
+
58
+ /**
59
+ * One mesh standing in for a whole cell group, or `null` when it is not worth having.
60
+ *
61
+ * `null` in three cases, and they are all the same case: there is nothing to gain. An empty group,
62
+ * a group whose geometry does not span a single grid cell, and a group already cheaper than any
63
+ * proxy of it would be.
64
+ */
65
+ export function buildProxy(cells: readonly ProxyCell[], level: number): MeshData | null {
66
+ const meshes: MeshData[] = [];
67
+ for (const cell of cells) for (const mesh of cell.meshes) meshes.push(mesh);
68
+ /* No guard for an empty group: `buildCoarseLevel` answers `null` for one, for the same reason,
69
+ and a second check here would be a copy of a rule rather than a rule. */
70
+ const proxy = buildCoarseLevel(meshes, { cells: proxyResolution(level) });
71
+ if (proxy === null) return null;
72
+ if (!isOutlineWorthWriting(proxy, meshes)) return null;
73
+ return proxy;
74
+ }
75
+
76
+ /**
77
+ * The sphere a bake frames its views on: a centre and a radius, and nothing about direction.
78
+ *
79
+ * **Carried on the impostor rather than recomputed by its consumer**, because an atlas on its own
80
+ * cannot be placed. The quad a sprite is drawn on needs the object's centre and half-extent, and a
81
+ * consumer deriving them a second time from geometry it may no longer be holding is a consumer
82
+ * whose sprite sits somewhere the object is not.
83
+ */
84
+ export interface ViewFrame {
85
+ readonly cx: number;
86
+ readonly cy: number;
87
+ readonly cz: number;
88
+ readonly radius: number;
89
+ }
90
+
91
+ /** The frame that fits every mesh given. A radius of zero for a point, rather than a NaN. */
92
+ export function frameOf(meshes: readonly MeshData[]): ViewFrame {
93
+ let minX = Infinity;
94
+ let minY = Infinity;
95
+ let minZ = Infinity;
96
+ let maxX = -Infinity;
97
+ let maxY = -Infinity;
98
+ let maxZ = -Infinity;
99
+ for (const mesh of meshes) {
100
+ for (let at = 0; at + 2 < mesh.positions.length; at += 3) {
101
+ const x = mesh.positions[at] as number;
102
+ const y = mesh.positions[at + 1] as number;
103
+ const z = mesh.positions[at + 2] as number;
104
+ if (x < minX) minX = x;
105
+ if (y < minY) minY = y;
106
+ if (z < minZ) minZ = z;
107
+ if (x > maxX) maxX = x;
108
+ if (y > maxY) maxY = y;
109
+ if (z > maxZ) maxZ = z;
110
+ }
111
+ }
112
+ if (!Number.isFinite(minX)) return { cx: 0, cy: 0, cz: 0, radius: 0 };
113
+ const cx = (minX + maxX) / 2;
114
+ const cy = (minY + maxY) / 2;
115
+ const cz = (minZ + maxZ) / 2;
116
+ let radius = 0;
117
+ for (const mesh of meshes) {
118
+ for (let at = 0; at + 2 < mesh.positions.length; at += 3) {
119
+ const d = Math.hypot(
120
+ (mesh.positions[at] as number) - cx,
121
+ (mesh.positions[at + 1] as number) - cy,
122
+ (mesh.positions[at + 2] as number) - cz,
123
+ );
124
+ if (d > radius) radius = d;
125
+ }
126
+ }
127
+ return { cx, cy, cz, radius };
128
+ }
129
+
130
+ function signOf(value: number): number {
131
+ return value >= 0 ? 1 : -1;
132
+ }
133
+
134
+ /**
135
+ * The direction a tile of the atlas was baked from, for an octahedral coordinate in `[0, 1]`.
136
+ *
137
+ * **The whole sphere and not a hemisphere.** A hemi-octahedral map spends its texels on the upper
138
+ * half and is right for anything standing on ground the viewer also stands on; a tree seen from a
139
+ * bridge, a rock seen from a cliff and anything at all seen by a flying camera need the underside,
140
+ * and a map that does not carry it substitutes the horizon view and reads as a card.
141
+ */
142
+ export function octahedralDirection(u: number, v: number, out: Float32Array): void {
143
+ const ex = u * 2 - 1;
144
+ const ez = v * 2 - 1;
145
+ let y = 1 - Math.abs(ex) - Math.abs(ez);
146
+ let x = ex;
147
+ let z = ez;
148
+ if (y < 0) {
149
+ x = (1 - Math.abs(ez)) * signOf(ex);
150
+ z = (1 - Math.abs(ex)) * signOf(ez);
151
+ }
152
+ const length = Math.hypot(x, y, z);
153
+ if (length === 0) {
154
+ out[0] = 0;
155
+ out[1] = -1;
156
+ out[2] = 0;
157
+ return;
158
+ }
159
+ out[0] = x / length;
160
+ out[1] = y / length;
161
+ out[2] = z / length;
162
+ y = 0;
163
+ }
164
+
165
+ /** The octahedral coordinate a direction came from: the inverse of `octahedralDirection`. */
166
+ export function octahedralCoord(dir: ArrayLike<number>, out: Float32Array): void {
167
+ const x = dir[0] as number;
168
+ const y = dir[1] as number;
169
+ const z = dir[2] as number;
170
+ const l1 = Math.abs(x) + Math.abs(y) + Math.abs(z);
171
+ if (l1 === 0) {
172
+ out[0] = 0.5;
173
+ out[1] = 0.5;
174
+ return;
175
+ }
176
+ const px = x / l1;
177
+ const py = y / l1;
178
+ const pz = z / l1;
179
+ let ex = px;
180
+ let ez = pz;
181
+ if (py < 0) {
182
+ ex = (1 - Math.abs(pz)) * signOf(px);
183
+ ez = (1 - Math.abs(px)) * signOf(pz);
184
+ }
185
+ out[0] = ex * 0.5 + 0.5;
186
+ out[1] = ez * 0.5 + 0.5;
187
+ }
188
+
189
+ /**
190
+ * An orthographic rasterisation of a mesh from one direction, as `resolution²` RGBA texels.
191
+ *
192
+ * `dir` points *from the object toward the viewer*. Alpha is 1 where a triangle covers the texel
193
+ * centre and 0 where none does — coverage rather than an anti-aliased edge, because what this
194
+ * feeds is a silhouette comparison and an atlas whose edge ramp comes from its own filtering.
195
+ *
196
+ * Exported because the claim a proxy has to meet is about its silhouette, and a test that cannot
197
+ * rasterise cannot check one.
198
+ */
199
+ export function rasteriseView(
200
+ mesh: MeshData,
201
+ frame: ViewFrame,
202
+ dir: ArrayLike<number>,
203
+ resolution: number,
204
+ out: Float32Array,
205
+ ): void {
206
+ out.fill(0);
207
+ const radius = frame.radius;
208
+ if (radius <= 0) return;
209
+
210
+ const wx = dir[0] as number;
211
+ const wy = dir[1] as number;
212
+ const wz = dir[2] as number;
213
+ const wl = Math.hypot(wx, wy, wz);
214
+ if (wl === 0) return;
215
+ const w = [wx / wl, wy / wl, wz / wl];
216
+ /* Any up vector not parallel to the view. Chosen by a threshold so the basis never degenerates. */
217
+ const up = Math.abs(w[1] as number) > 0.999 ? [0, 0, 1] : [0, 1, 0];
218
+ const ux = (up[1] as number) * (w[2] as number) - (up[2] as number) * (w[1] as number);
219
+ const uy = (up[2] as number) * (w[0] as number) - (up[0] as number) * (w[2] as number);
220
+ const uz = (up[0] as number) * (w[1] as number) - (up[1] as number) * (w[0] as number);
221
+ const ul = Math.hypot(ux, uy, uz);
222
+ const u = [ux / ul, uy / ul, uz / ul];
223
+ const v = [
224
+ (w[1] as number) * (u[2] as number) - (w[2] as number) * (u[1] as number),
225
+ (w[2] as number) * (u[0] as number) - (w[0] as number) * (u[2] as number),
226
+ (w[0] as number) * (u[1] as number) - (w[1] as number) * (u[0] as number),
227
+ ];
228
+
229
+ const vertices = mesh.positions.length / 3;
230
+ const screen = new Float32Array(vertices * 3);
231
+ for (let i = 0; i < vertices; i += 1) {
232
+ const dx = (mesh.positions[i * 3] as number) - frame.cx;
233
+ const dy = (mesh.positions[i * 3 + 1] as number) - frame.cy;
234
+ const dz = (mesh.positions[i * 3 + 2] as number) - frame.cz;
235
+ const sx = (dx * (u[0] as number) + dy * (u[1] as number) + dz * (u[2] as number)) / radius;
236
+ const sy = (dx * (v[0] as number) + dy * (v[1] as number) + dz * (v[2] as number)) / radius;
237
+ const depth = dx * (w[0] as number) + dy * (w[1] as number) + dz * (w[2] as number);
238
+ screen[i * 3] = (sx * 0.5 + 0.5) * resolution;
239
+ screen[i * 3 + 1] = (0.5 - sy * 0.5) * resolution;
240
+ screen[i * 3 + 2] = depth;
241
+ }
242
+
243
+ const depths = new Float32Array(resolution * resolution).fill(-Infinity);
244
+ const colours = mesh.colors;
245
+ for (let tri = 0; tri * 3 + 2 < mesh.indices.length; tri += 1) {
246
+ const ia = mesh.indices[tri * 3] as number;
247
+ const ib = mesh.indices[tri * 3 + 1] as number;
248
+ const ic = mesh.indices[tri * 3 + 2] as number;
249
+ const ax = screen[ia * 3] as number;
250
+ const ay = screen[ia * 3 + 1] as number;
251
+ const bx = screen[ib * 3] as number;
252
+ const by = screen[ib * 3 + 1] as number;
253
+ const cx = screen[ic * 3] as number;
254
+ const cy = screen[ic * 3 + 1] as number;
255
+ const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
256
+ if (area === 0) continue;
257
+ /* Either winding rasterises: an impostor sees both sides of a shell that is not closed. */
258
+ const flip = area < 0 ? -1 : 1;
259
+
260
+ const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
261
+ const x1 = Math.min(resolution - 1, Math.ceil(Math.max(ax, bx, cx)));
262
+ const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy)));
263
+ const y1 = Math.min(resolution - 1, Math.ceil(Math.max(ay, by, cy)));
264
+ for (let y = y0; y <= y1; y += 1) {
265
+ for (let x = x0; x <= x1; x += 1) {
266
+ const px = x + 0.5;
267
+ const py = y + 0.5;
268
+ const e0 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * flip;
269
+ const e1 = ((ax - cx) * (py - cy) - (ay - cy) * (px - cx)) * flip;
270
+ const e2 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * flip;
271
+ if (e0 < 0 || e1 < 0 || e2 < 0) continue;
272
+ const sum = e0 + e1 + e2;
273
+ if (sum <= 0) continue;
274
+ const wa = e0 / sum;
275
+ const wb = e1 / sum;
276
+ const wc = e2 / sum;
277
+ const depth =
278
+ wa * (screen[ia * 3 + 2] as number) +
279
+ wb * (screen[ib * 3 + 2] as number) +
280
+ wc * (screen[ic * 3 + 2] as number);
281
+ const at = y * resolution + x;
282
+ if (depth <= (depths[at] as number)) continue;
283
+ depths[at] = depth;
284
+ const o = at * 4;
285
+ for (let c = 0; c < 3; c += 1) {
286
+ out[o + c] =
287
+ wa * (colours[ia * 3 + c] as number) +
288
+ wb * (colours[ib * 3 + c] as number) +
289
+ wc * (colours[ic * 3 + c] as number);
290
+ }
291
+ out[o + 3] = 1;
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ /** Texels a tile's picture occupies, before its gutter. */
298
+ export const IMPOSTOR_TILE = 24;
299
+
300
+ /**
301
+ * Texels of edge repeat around every tile.
302
+ *
303
+ * Two, not one: a bilinear tap needs one, and a mip of the atlas needs the second. One gutter is
304
+ * the version that looks right until somebody enables mipping on the atlas, which is exactly when
305
+ * the object is far enough away for an impostor to be in use.
306
+ */
307
+ export const IMPOSTOR_GUTTER = 2;
308
+
309
+ /** How far colour is pushed outward past the silhouette. Covers the gutter and a tap beyond it. */
310
+ const DILATE_PASSES = IMPOSTOR_GUTTER + 2;
311
+
312
+ export interface Impostor {
313
+ /** `size * size` RGBA texels. */
314
+ readonly atlas: Float32Array;
315
+ /** Texels across the whole atlas. */
316
+ readonly size: number;
317
+ /** Tiles across one axis; the atlas holds this squared. */
318
+ readonly directions: number;
319
+ /** The sphere the views were framed on, which is what places the sprite. */
320
+ readonly frame: ViewFrame;
321
+ }
322
+
323
+ /**
324
+ * Bake `directions²` views of a mesh into one atlas.
325
+ *
326
+ * At least two directions a side, because a single tile has no octahedral coordinate to be at and
327
+ * an impostor of one view is a billboard — a different thing with a different failure.
328
+ */
329
+ export function buildImpostor(mesh: MeshData, directions: number): Impostor {
330
+ const n = Math.floor(directions);
331
+ if (!(n >= 2)) throw new Error(`buildImpostor: needs at least 2 directions a side, got ${n}`);
332
+
333
+ const frame = frameOf([mesh]);
334
+ const stride = IMPOSTOR_TILE + IMPOSTOR_GUTTER * 2;
335
+ const size = stride * n;
336
+ const atlas = new Float32Array(size * size * 4);
337
+ const tile = new Float32Array(IMPOSTOR_TILE * IMPOSTOR_TILE * 4);
338
+ const dir = new Float32Array(3);
339
+
340
+ for (let j = 0; j < n; j += 1) {
341
+ for (let i = 0; i < n; i += 1) {
342
+ octahedralDirection(i / (n - 1), j / (n - 1), dir);
343
+ rasteriseView(mesh, frame, dir, IMPOSTOR_TILE, tile);
344
+ dilateColour(tile, IMPOSTOR_TILE);
345
+ /* The gutter is the clamp, written rather than implied, because the sampler is a shader. */
346
+ for (let y = 0; y < stride; y += 1) {
347
+ for (let x = 0; x < stride; x += 1) {
348
+ const sx = Math.min(IMPOSTOR_TILE - 1, Math.max(0, x - IMPOSTOR_GUTTER));
349
+ const sy = Math.min(IMPOSTOR_TILE - 1, Math.max(0, y - IMPOSTOR_GUTTER));
350
+ const from = (sy * IMPOSTOR_TILE + sx) * 4;
351
+ const to = ((j * stride + y) * size + i * stride + x) * 4;
352
+ for (let c = 0; c < 4; c += 1) atlas[to + c] = tile[from + c] as number;
353
+ }
354
+ }
355
+ }
356
+ }
357
+ return { atlas, size, directions: n, frame };
358
+ }
359
+
360
+ /**
361
+ * Push colour outward into the transparent texels, leaving alpha alone.
362
+ *
363
+ * Alpha is the silhouette and must not move; what moves is the colour underneath it, so that the
364
+ * blend a filtered tap produces at the edge is the object's own colour at a lower alpha rather than
365
+ * the object mixed with the clear value. A pass writes into a copy and swaps at the end, so the
366
+ * result does not depend on the order texels are visited and the bake stays deterministic.
367
+ */
368
+ function dilateColour(texels: Float32Array, resolution: number): void {
369
+ const filled = new Uint8Array(resolution * resolution);
370
+ for (let at = 0; at < filled.length; at += 1)
371
+ filled[at] = (texels[at * 4 + 3] as number) > 0 ? 1 : 0;
372
+
373
+ for (let pass = 0; pass < DILATE_PASSES; pass += 1) {
374
+ const added: number[] = [];
375
+ for (let y = 0; y < resolution; y += 1) {
376
+ for (let x = 0; x < resolution; x += 1) {
377
+ const at = y * resolution + x;
378
+ if (filled[at] === 1) continue;
379
+ let r = 0;
380
+ let g = 0;
381
+ let b = 0;
382
+ let count = 0;
383
+ for (let dy = -1; dy <= 1; dy += 1) {
384
+ for (let dx = -1; dx <= 1; dx += 1) {
385
+ const nx = x + dx;
386
+ const ny = y + dy;
387
+ if (nx < 0 || ny < 0 || nx >= resolution || ny >= resolution) continue;
388
+ const n = ny * resolution + nx;
389
+ if (filled[n] !== 1) continue;
390
+ r += texels[n * 4] as number;
391
+ g += texels[n * 4 + 1] as number;
392
+ b += texels[n * 4 + 2] as number;
393
+ count += 1;
394
+ }
395
+ }
396
+ if (count === 0) continue;
397
+ texels[at * 4] = r / count;
398
+ texels[at * 4 + 1] = g / count;
399
+ texels[at * 4 + 2] = b / count;
400
+ added.push(at);
401
+ }
402
+ }
403
+ if (added.length === 0) return;
404
+ for (const at of added) filled[at] = 1;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Sample the impostor as the shader would: bilinear inside a tile, bilinear across the four tiles
410
+ * around the requested direction.
411
+ *
412
+ * **No parallax correction**, which is the whole of the error between baked directions and is
413
+ * stated here rather than discovered: blending four flat pictures of a shape with depth ghosts it.
414
+ * The remedy is more directions, and it costs their square.
415
+ */
416
+ export function sampleImpostor(
417
+ impostor: Impostor,
418
+ dir: ArrayLike<number>,
419
+ u: number,
420
+ v: number,
421
+ out: Float32Array,
422
+ ): void {
423
+ const coord = new Float32Array(2);
424
+ octahedralCoord(dir, coord);
425
+ const n = impostor.directions;
426
+ const fu = (coord[0] as number) * (n - 1);
427
+ const fv = (coord[1] as number) * (n - 1);
428
+ const i0 = Math.min(n - 1, Math.max(0, Math.floor(fu)));
429
+ const j0 = Math.min(n - 1, Math.max(0, Math.floor(fv)));
430
+ const i1 = Math.min(n - 1, i0 + 1);
431
+ const j1 = Math.min(n - 1, j0 + 1);
432
+ const wu = Math.min(1, Math.max(0, fu - i0));
433
+ const wv = Math.min(1, Math.max(0, fv - j0));
434
+
435
+ const corner = new Float32Array(4);
436
+ for (let c = 0; c < 4; c += 1) out[c] = 0;
437
+ const weights = [
438
+ [i0, j0, (1 - wu) * (1 - wv)],
439
+ [i1, j0, wu * (1 - wv)],
440
+ [i0, j1, (1 - wu) * wv],
441
+ [i1, j1, wu * wv],
442
+ ];
443
+ for (const [i, j, weight] of weights) {
444
+ if ((weight as number) <= 0) continue;
445
+ sampleTile(impostor, i as number, j as number, u, v, corner);
446
+ for (let c = 0; c < 4; c += 1)
447
+ out[c] = (out[c] as number) + (corner[c] as number) * (weight as number);
448
+ }
449
+ }
450
+
451
+ /** One tile, bilinear, reading through the gutter exactly as a clamped sampler would. */
452
+ function sampleTile(
453
+ impostor: Impostor,
454
+ i: number,
455
+ j: number,
456
+ u: number,
457
+ v: number,
458
+ out: Float32Array,
459
+ ): void {
460
+ const stride = IMPOSTOR_TILE + IMPOSTOR_GUTTER * 2;
461
+ const originX = i * stride + IMPOSTOR_GUTTER;
462
+ const originY = j * stride + IMPOSTOR_GUTTER;
463
+ const tu = u * IMPOSTOR_TILE - 0.5;
464
+ const tv = v * IMPOSTOR_TILE - 0.5;
465
+ const x0 = Math.floor(tu);
466
+ const y0 = Math.floor(tv);
467
+ const fx = tu - x0;
468
+ const fy = tv - y0;
469
+ /*
470
+ * Clamped to the tile and not into its gutter, which would read the same values: the gutter
471
+ * repeats the edge, so a padded read and a clamped one cannot differ. It exists for the hardware
472
+ * sampler, which clamps to the whole *atlas* and would otherwise walk into the next direction's
473
+ * picture, and the test that walks every gutter texel is what holds it.
474
+ */
475
+ const clamp = (value: number): number => Math.min(IMPOSTOR_TILE - 1, Math.max(0, value));
476
+ const ax = clamp(x0);
477
+ const bx = clamp(x0 + 1);
478
+ const ay = clamp(y0);
479
+ const by = clamp(y0 + 1);
480
+ const read = (x: number, y: number, c: number): number =>
481
+ impostor.atlas[((originY + y) * impostor.size + originX + x) * 4 + c] as number;
482
+ for (let c = 0; c < 4; c += 1) {
483
+ const top = read(ax, ay, c) * (1 - fx) + read(bx, ay, c) * fx;
484
+ const bottom = read(ax, by, c) * (1 - fx) + read(bx, by, c) * fx;
485
+ out[c] = top * (1 - fy) + bottom * fy;
486
+ }
487
+ }