@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,468 @@
1
+ /** A signed distance field per object, baked offline, for tracing light through a world. */
2
+
3
+ import type { MeshData } from '@driftengine/drft';
4
+
5
+ /**
6
+ * What an object's distance field is, and why it is baked rather than computed.
7
+ *
8
+ * **Indirect light needs to ask what is in a direction, and a triangle list cannot answer that
9
+ * cheaply.** A ray against a mesh is a traversal; a ray against a distance field is a march that
10
+ * steps by the field's own value and converges in a handful of steps, because the value *is* a
11
+ * safe step. That is the whole of sphere tracing, and the one property it rests on is that the
12
+ * field is a distance — gradient of unit length — rather than merely something with the right
13
+ * sign. `sdf.test.ts` measures that property rather than assuming it.
14
+ *
15
+ * **Per object rather than per world**, because the world moves: `globalField.ts` composes these
16
+ * into a field around the camera each frame, and an instance that rotates or streams in has to
17
+ * bring its field with it. A world-scale bake would have to be redone whenever anything moved.
18
+ */
19
+ export interface ObjectSdf {
20
+ /** Distance in metres, negative inside. `x` fastest, then `y`, then `z`. */
21
+ readonly field: Float32Array;
22
+ /** Samples on each axis. The spacing is the same on all three; see `bakeObjectSdf`. */
23
+ readonly dims: readonly [number, number, number];
24
+ /** The field's own extent, `[minX, minY, minZ, maxX, maxY, maxZ]`. Padded past the mesh. */
25
+ readonly bounds: Float32Array;
26
+ }
27
+
28
+ /**
29
+ * Voxels of clearance around the mesh, on every axis.
30
+ *
31
+ * **A field that stops at the mesh cannot be marched into.** A ray approaching from outside needs
32
+ * somewhere to take its first steps, and a gradient at the boundary needs a neighbour on both
33
+ * sides — the central difference at an edge texel has neither. Two is the smallest number that
34
+ * gives both and it is cheap on the thin axis of a wall, which is exactly where padding by a
35
+ * fraction of the extent would cost the most.
36
+ */
37
+ const PAD_VOXELS = 2;
38
+
39
+ /**
40
+ * Voxels past a triangle's own box that get an exactly computed distance.
41
+ *
42
+ * Everything further away is filled by propagation, which carries the *closest triangle* outward
43
+ * and recomputes the exact distance to it rather than accumulating a stepped approximation — so
44
+ * the far field is exact wherever the nearest triangle is reachable from a neighbour, which for
45
+ * anything short of a spiral is everywhere.
46
+ */
47
+ const EXACT_BAND = 1;
48
+
49
+ /** Larger than any distance a padded grid can hold, and finite so arithmetic on it behaves. */
50
+ const UNREACHED = 1e30;
51
+
52
+ /**
53
+ * Bake one mesh into a signed distance field.
54
+ *
55
+ * `resolution` is voxels along the mesh's **longest** axis; the other two get however many the
56
+ * same spacing needs. **Cubic voxels are not tidiness** — a field with a different step per axis
57
+ * has a gradient whose length varies with direction, which is the one property a sphere trace
58
+ * depends on, and the failure is a march that overshoots along the coarse axis.
59
+ *
60
+ * **The sign comes from crossing parity, not from the winding.** A face normal, an
61
+ * angle-weighted pseudonormal and a generalised winding number all flip when a mesh is exported
62
+ * with its triangles the other way round, and importers disagree about that constantly — so a
63
+ * model from the wrong tool would come back solid where it is empty. Counting how many times a
64
+ * ray along `x` crosses the surface does not care which way a triangle faces.
65
+ *
66
+ * **The magnitude comes from exact point-to-triangle distance, not from voxelisation.** Marking
67
+ * the cells a triangle passes through and flooding outward loses any wall thinner than a cell,
68
+ * because no cell centre is inside it — and a wall thinner than a cell is most of them. This
69
+ * measures the distance from every grid point in a band around each triangle to that triangle,
70
+ * so a plate a fifth of a voxel thick is still a plate at a fifth of a voxel.
71
+ */
72
+ export function bakeObjectSdf(mesh: MeshData, resolution: number): ObjectSdf {
73
+ const positions = mesh.positions;
74
+ const indices = mesh.indices;
75
+ const triangles = Math.floor(indices.length / 3);
76
+ if (triangles < 1) {
77
+ throw new Error('bakeObjectSdf: the mesh has no triangle; a field of nothing has no surface.');
78
+ }
79
+ const voxels = Math.trunc(resolution);
80
+ if (!(voxels >= 2)) {
81
+ throw new Error(
82
+ `bakeObjectSdf: resolution is ${String(resolution)} and the minimum is 2. One sample on an ` +
83
+ 'axis has no neighbour to take a gradient against.',
84
+ );
85
+ }
86
+
87
+ const min = [Infinity, Infinity, Infinity];
88
+ const max = [-Infinity, -Infinity, -Infinity];
89
+ for (let at = 0; at + 2 < positions.length; at += 3) {
90
+ for (let axis = 0; axis < 3; axis++) {
91
+ const value = positions[at + axis] as number;
92
+ if (value < (min[axis] as number)) min[axis] = value;
93
+ if (value > (max[axis] as number)) max[axis] = value;
94
+ }
95
+ }
96
+
97
+ /*
98
+ * A degenerate axis still needs a step, and the longest axis is what sets it. A perfectly flat
99
+ * quad has zero extent on one axis and is a completely ordinary thing to bake.
100
+ */
101
+ const span = Math.max(
102
+ (max[0] as number) - (min[0] as number),
103
+ (max[1] as number) - (min[1] as number),
104
+ (max[2] as number) - (min[2] as number),
105
+ );
106
+ if (!(span > 0)) {
107
+ throw new Error('bakeObjectSdf: the mesh has no extent on any axis; there is nothing to bake.');
108
+ }
109
+ const step = span / voxels;
110
+ const pad = PAD_VOXELS * step;
111
+
112
+ const origin = new Float32Array(3);
113
+ const dims: [number, number, number] = [0, 0, 0];
114
+ for (let axis = 0; axis < 3; axis++) {
115
+ origin[axis] = (min[axis] as number) - pad;
116
+ const reach = (max[axis] as number) + pad - (origin[axis] as number);
117
+ dims[axis] = Math.ceil(reach / step) + 1;
118
+ }
119
+ const [nx, ny, nz] = dims;
120
+ const bounds = new Float32Array([
121
+ origin[0] as number,
122
+ origin[1] as number,
123
+ origin[2] as number,
124
+ (origin[0] as number) + (nx - 1) * step,
125
+ (origin[1] as number) + (ny - 1) * step,
126
+ (origin[2] as number) + (nz - 1) * step,
127
+ ]);
128
+
129
+ const count = nx * ny * nz;
130
+ const field = new Float32Array(count).fill(UNREACHED);
131
+ /** The triangle each grid point's distance was measured to, and -1 where none has reached. */
132
+ const closest = new Int32Array(count).fill(-1);
133
+ /** How many times the surface is crossed between grid point `i - 1` and `i` along `x`. */
134
+ const crossings = new Int32Array(count);
135
+
136
+ const ox = origin[0] as number;
137
+ const oy = origin[1] as number;
138
+ const oz = origin[2] as number;
139
+
140
+ for (let tri = 0; tri < triangles; tri++) {
141
+ const ia = (indices[tri * 3] as number) * 3;
142
+ const ib = (indices[tri * 3 + 1] as number) * 3;
143
+ const ic = (indices[tri * 3 + 2] as number) * 3;
144
+ const ax = positions[ia] as number;
145
+ const ay = positions[ia + 1] as number;
146
+ const az = positions[ia + 2] as number;
147
+ const bx = positions[ib] as number;
148
+ const by = positions[ib + 1] as number;
149
+ const bz = positions[ib + 2] as number;
150
+ const cx = positions[ic] as number;
151
+ const cy = positions[ic + 1] as number;
152
+ const cz = positions[ic + 2] as number;
153
+
154
+ const i0 = clampIndex(Math.floor((Math.min(ax, bx, cx) - ox) / step) - EXACT_BAND, nx);
155
+ const i1 = clampIndex(Math.ceil((Math.max(ax, bx, cx) - ox) / step) + EXACT_BAND, nx);
156
+ const j0 = clampIndex(Math.floor((Math.min(ay, by, cy) - oy) / step) - EXACT_BAND, ny);
157
+ const j1 = clampIndex(Math.ceil((Math.max(ay, by, cy) - oy) / step) + EXACT_BAND, ny);
158
+ const k0 = clampIndex(Math.floor((Math.min(az, bz, cz) - oz) / step) - EXACT_BAND, nz);
159
+ const k1 = clampIndex(Math.ceil((Math.max(az, bz, cz) - oz) / step) + EXACT_BAND, nz);
160
+
161
+ for (let k = k0; k <= k1; k++) {
162
+ const gz = oz + k * step;
163
+ for (let j = j0; j <= j1; j++) {
164
+ const gy = oy + j * step;
165
+ for (let i = i0; i <= i1; i++) {
166
+ const gx = ox + i * step;
167
+ const distance = pointTriangleDistance(gx, gy, gz, ax, ay, az, bx, by, bz, cx, cy, cz);
168
+ const index = i + nx * (j + ny * k);
169
+ if (distance < (field[index] as number)) {
170
+ field[index] = distance;
171
+ closest[index] = tri;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ /*
178
+ * Where a ray along `x` through each grid column crosses this triangle. Recorded at the first
179
+ * grid point past the crossing, so a scan along `x` accumulating these counts knows, at every
180
+ * point, how many surfaces stand between it and the grid's `-x` face.
181
+ */
182
+ for (let k = clampIndex(Math.ceil((Math.min(az, bz, cz) - oz) / step), nz); k < nz; k++) {
183
+ const gz = oz + k * step;
184
+ if (gz > Math.max(az, bz, cz)) break;
185
+ for (let j = clampIndex(Math.ceil((Math.min(ay, by, cy) - oy) / step), ny); j < ny; j++) {
186
+ const gy = oy + j * step;
187
+ if (gy > Math.max(ay, by, cy)) break;
188
+ const bary = barycentric2d(gy, gz, ay, az, by, bz, cy, cz);
189
+ if (bary === null) continue;
190
+ const x = bary[0] * ax + bary[1] * bx + bary[2] * cx;
191
+ const i = Math.ceil((x - ox) / step);
192
+ /*
193
+ * **Unreachable while `PAD_VOXELS` is at least one, and kept because it is a constant.**
194
+ * The grid reaches two voxels past the mesh on every axis, so no crossing can land past
195
+ * the `+x` face; with no padding one could, and clamping it would mark the last grid point
196
+ * as having one more surface behind it than it does — flipping the parity of a point that
197
+ * is outside. A crossing before the `-x` face is clamped to zero, where it is true: the
198
+ * grid's first point really does have that surface behind it.
199
+ */
200
+ if (i >= nx) continue;
201
+ crossings[Math.max(0, i) + nx * (j + ny * k)]++;
202
+ }
203
+ }
204
+ }
205
+
206
+ /*
207
+ * Carry the closest triangle outward and recompute against it, eight directions twice. Two
208
+ * passes rather than one because a point whose nearest triangle lies around a corner is reached
209
+ * by a sweep that has itself only just been filled, and the second pass is what lets that
210
+ * propagate the rest of the way.
211
+ */
212
+ for (let pass = 0; pass < 2; pass++) {
213
+ for (const [dx, dy, dz] of SWEEPS) {
214
+ sweep(field, closest, positions, indices, dims, origin, step, dx, dy, dz);
215
+ }
216
+ }
217
+
218
+ /* Anything nothing ever reached is as far away as the grid can express, not a sentinel. */
219
+ const reach = step * (nx + ny + nz);
220
+ for (let index = 0; index < count; index++) {
221
+ if ((field[index] as number) >= UNREACHED) field[index] = reach;
222
+ }
223
+
224
+ for (let k = 0; k < nz; k++) {
225
+ for (let j = 0; j < ny; j++) {
226
+ let total = 0;
227
+ const row = nx * (j + ny * k);
228
+ for (let i = 0; i < nx; i++) {
229
+ total += crossings[row + i] as number;
230
+ if (total % 2 === 1) field[row + i] = -(field[row + i] as number);
231
+ }
232
+ }
233
+ }
234
+
235
+ return { field, dims, bounds };
236
+ }
237
+
238
+ /** The eight diagonal orders a propagation runs in, so every direction has one that follows it. */
239
+ const SWEEPS: readonly (readonly [number, number, number])[] = [
240
+ [1, 1, 1],
241
+ [-1, -1, -1],
242
+ [1, 1, -1],
243
+ [-1, -1, 1],
244
+ [1, -1, 1],
245
+ [-1, 1, -1],
246
+ [1, -1, -1],
247
+ [-1, 1, 1],
248
+ ];
249
+
250
+ /** One propagation pass: every point takes its neighbours' triangles if one of them is nearer. */
251
+ function sweep(
252
+ field: Float32Array,
253
+ closest: Int32Array,
254
+ positions: Float32Array,
255
+ indices: Uint32Array,
256
+ dims: readonly [number, number, number],
257
+ origin: Float32Array,
258
+ step: number,
259
+ dx: number,
260
+ dy: number,
261
+ dz: number,
262
+ ): void {
263
+ const [nx, ny, nz] = dims;
264
+ const i0 = dx > 0 ? 1 : nx - 2;
265
+ const i1 = dx > 0 ? nx : -1;
266
+ const j0 = dy > 0 ? 1 : ny - 2;
267
+ const j1 = dy > 0 ? ny : -1;
268
+ const k0 = dz > 0 ? 1 : nz - 2;
269
+ const k1 = dz > 0 ? nz : -1;
270
+
271
+ for (let k = k0; k !== k1; k += dz) {
272
+ const gz = (origin[2] as number) + k * step;
273
+ for (let j = j0; j !== j1; j += dy) {
274
+ const gy = (origin[1] as number) + j * step;
275
+ for (let i = i0; i !== i1; i += dx) {
276
+ const gx = (origin[0] as number) + i * step;
277
+ const index = i + nx * (j + ny * k);
278
+ /* The three already-visited axis neighbours. See `NEIGHBOURS` for why three and not seven. */
279
+ for (const [ni, nj, nk] of NEIGHBOURS) {
280
+ const at = i - ni * dx + nx * (j - nj * dy + ny * (k - nk * dz));
281
+ const triangle = closest[at] as number;
282
+ if (triangle < 0) continue;
283
+ const ia = (indices[triangle * 3] as number) * 3;
284
+ const ib = (indices[triangle * 3 + 1] as number) * 3;
285
+ const ic = (indices[triangle * 3 + 2] as number) * 3;
286
+ const distance = pointTriangleDistance(
287
+ gx,
288
+ gy,
289
+ gz,
290
+ positions[ia] as number,
291
+ positions[ia + 1] as number,
292
+ positions[ia + 2] as number,
293
+ positions[ib] as number,
294
+ positions[ib + 1] as number,
295
+ positions[ib + 2] as number,
296
+ positions[ic] as number,
297
+ positions[ic + 1] as number,
298
+ positions[ic + 2] as number,
299
+ );
300
+ if (distance < (field[index] as number)) {
301
+ field[index] = distance;
302
+ closest[index] = triangle;
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The already-visited neighbours of a point, in the sweep's own frame.
312
+ *
313
+ * **Three, and the reference implementation this follows uses seven.** The other four are the face
314
+ * and body diagonals, and the argument for them is that a field propagating along the axes alone
315
+ * acquires a plus-shaped bias. **That argument does not survive being measured here**, because the
316
+ * diagonals are already carried by the eight *sweep orders*: a unit sphere at resolution 40 comes
317
+ * out with a worst error against the analytic distance of 9.6e-3 either way — the same number to
318
+ * two digits — while the seven-neighbour version takes 1123 ms against 529 ms.
319
+ *
320
+ * What does move is the worst gradient error, 4.0e-2 to 4.6e-2, both of them dominated by the
321
+ * tessellation's own facets and both far under what a march needs. **Reproduce** by adding the
322
+ * four diagonals back and running `sdf.test.ts`, which passes either way and is why this is a
323
+ * measurement rather than a test.
324
+ */
325
+ const NEIGHBOURS: readonly (readonly [number, number, number])[] = [
326
+ [1, 0, 0],
327
+ [0, 1, 0],
328
+ [0, 0, 1],
329
+ ];
330
+
331
+ /** A whole index forced inside a grid, for a triangle's box which may hang over an edge. */
332
+ function clampIndex(value: number, count: number): number {
333
+ return value < 0 ? 0 : value > count - 1 ? count - 1 : value;
334
+ }
335
+
336
+ /**
337
+ * Where a point stands in a triangle projected onto one plane, or null if it stands outside.
338
+ *
339
+ * **The tie-break is what stops a shared edge being counted twice or not at all.** A grid column
340
+ * passing exactly along the edge two triangles share is inside both or neither depending on the
341
+ * sign of a zero, and either answer flips the parity of an entire row. Breaking the tie on the
342
+ * coordinates themselves makes the choice consistent between the two triangles: whichever way it
343
+ * goes, exactly one of them claims the column.
344
+ */
345
+ function barycentric2d(
346
+ px: number,
347
+ py: number,
348
+ ax: number,
349
+ ay: number,
350
+ bx: number,
351
+ by: number,
352
+ cx: number,
353
+ cy: number,
354
+ ): [number, number, number] | null {
355
+ const bxr = bx - px;
356
+ const byr = by - py;
357
+ const cxr = cx - px;
358
+ const cyr = cy - py;
359
+ const axr = ax - px;
360
+ const ayr = ay - py;
361
+
362
+ const areaA = orientation(bxr, byr, cxr, cyr);
363
+ if (areaA.sign === 0) return null;
364
+ const areaB = orientation(cxr, cyr, axr, ayr);
365
+ if (areaB.sign !== areaA.sign) return null;
366
+ const areaC = orientation(axr, ayr, bxr, byr);
367
+ if (areaC.sign !== areaA.sign) return null;
368
+
369
+ const sum = areaA.area + areaB.area + areaC.area;
370
+ if (sum === 0) return null;
371
+ return [areaA.area / sum, areaB.area / sum, areaC.area / sum];
372
+ }
373
+
374
+ /** Twice the signed area of a triangle with the origin, with zero broken deterministically. */
375
+ function orientation(
376
+ x1: number,
377
+ y1: number,
378
+ x2: number,
379
+ y2: number,
380
+ ): { sign: number; area: number } {
381
+ const area = y1 * x2 - x1 * y2;
382
+ if (area > 0) return { sign: 1, area };
383
+ if (area < 0) return { sign: -1, area };
384
+ if (y2 > y1) return { sign: 1, area };
385
+ if (y2 < y1) return { sign: -1, area };
386
+ if (x1 > x2) return { sign: 1, area };
387
+ if (x1 < x2) return { sign: -1, area };
388
+ return { sign: 0, area };
389
+ }
390
+
391
+ /**
392
+ * The distance from a point to the nearest point of a triangle.
393
+ *
394
+ * The seven regions of Voronoi space around a triangle — three vertices, three edges, the face —
395
+ * tested in the order that lets each test assume the ones before it failed. Written out rather
396
+ * than solved as a projection with a clamp, because the clamped projection is wrong outside the
397
+ * face's own region and the error is largest exactly at the silhouette, which is where a marching
398
+ * ray spends its time.
399
+ */
400
+ function pointTriangleDistance(
401
+ px: number,
402
+ py: number,
403
+ pz: number,
404
+ ax: number,
405
+ ay: number,
406
+ az: number,
407
+ bx: number,
408
+ by: number,
409
+ bz: number,
410
+ cx: number,
411
+ cy: number,
412
+ cz: number,
413
+ ): number {
414
+ const abx = bx - ax;
415
+ const aby = by - ay;
416
+ const abz = bz - az;
417
+ const acx = cx - ax;
418
+ const acy = cy - ay;
419
+ const acz = cz - az;
420
+ const apx = px - ax;
421
+ const apy = py - ay;
422
+ const apz = pz - az;
423
+
424
+ const d1 = abx * apx + aby * apy + abz * apz;
425
+ const d2 = acx * apx + acy * apy + acz * apz;
426
+ if (d1 <= 0 && d2 <= 0) return Math.hypot(apx, apy, apz);
427
+
428
+ const bpx = px - bx;
429
+ const bpy = py - by;
430
+ const bpz = pz - bz;
431
+ const d3 = abx * bpx + aby * bpy + abz * bpz;
432
+ const d4 = acx * bpx + acy * bpy + acz * bpz;
433
+ if (d3 >= 0 && d4 <= d3) return Math.hypot(bpx, bpy, bpz);
434
+
435
+ const vc = d1 * d4 - d3 * d2;
436
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) {
437
+ const v = d1 / (d1 - d3);
438
+ return Math.hypot(apx - v * abx, apy - v * aby, apz - v * abz);
439
+ }
440
+
441
+ const cpx = px - cx;
442
+ const cpy = py - cy;
443
+ const cpz = pz - cz;
444
+ const d5 = abx * cpx + aby * cpy + abz * cpz;
445
+ const d6 = acx * cpx + acy * cpy + acz * cpz;
446
+ if (d6 >= 0 && d5 <= d6) return Math.hypot(cpx, cpy, cpz);
447
+
448
+ const vb = d5 * d2 - d1 * d6;
449
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) {
450
+ const w = d2 / (d2 - d6);
451
+ return Math.hypot(apx - w * acx, apy - w * acy, apz - w * acz);
452
+ }
453
+
454
+ const va = d3 * d6 - d5 * d4;
455
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
456
+ const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
457
+ return Math.hypot(bpx - w * (cx - bx), bpy - w * (cy - by), bpz - w * (cz - bz));
458
+ }
459
+
460
+ const denom = 1 / (va + vb + vc);
461
+ const v = vb * denom;
462
+ const w = vc * denom;
463
+ return Math.hypot(
464
+ apx - (v * abx + w * acx),
465
+ apy - (v * aby + w * acy),
466
+ apz - (v * abz + w * acz),
467
+ );
468
+ }
package/src/drftLoader.ts CHANGED
@@ -44,7 +44,24 @@ export type DrftFit =
44
44
  readonly fit: 'none';
45
45
  };
46
46
 
47
+ /**
48
+ * How a container is reached. `typeof fetch`, narrowed to the one call shape this file makes.
49
+ *
50
+ * `@driftengine/audio` has had the identical seam since it was written, under the same name, and
51
+ * this package went without it — so the loader for the engine's *own* format was the one that
52
+ * could not be pointed at a service worker, a packed archive, a memory map or a fixture.
53
+ */
54
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
55
+
47
56
  export interface DrftLoaderOptions {
57
+ /**
58
+ * Where the bytes come from. The global `fetch` unless a consumer says otherwise.
59
+ *
60
+ * Optional and defaulting to the browser's, so no existing caller changes — the shape
61
+ * `AGENTS.md` asks for: take the capability as a parameter, ship a browser implementation as
62
+ * the default.
63
+ */
64
+ readonly fetchImpl?: FetchLike;
48
65
  /**
49
66
  * The most parts one `update` may take, however cheap they turn out to be.
50
67
  *
@@ -451,7 +468,8 @@ export class DrftLoader {
451
468
  * to the *baker* appears to have changed nothing, and the time lost to that is out of all
452
469
  * proportion to the saving.
453
470
  */
454
- const response = await fetch(url, { cache: 'no-store' });
471
+ // platform: browser default — `DrftLoaderOptions.fetchImpl` is the seam
472
+ const response = await (this.options.fetchImpl ?? fetch)(url, { cache: 'no-store' });
455
473
  if (!response.ok) {
456
474
  this.set({ phase: 'absent', message: `no model at ${url}` });
457
475
  return;
@@ -1143,6 +1161,24 @@ export function isRawCodec(codec: number): boolean {
1143
1161
  * The `.slice()` matters: the bytes are a view into the streamed buffer, and a `Blob` built on
1144
1162
  * the view would keep the whole file alive for as long as the image does.
1145
1163
  */
1164
+ /**
1165
+ * How every image a model carries is decoded: **straight alpha, and no colour conversion.**
1166
+ *
1167
+ * `createImageBitmap` premultiplies unless told not to, and the upload into a straight-alpha
1168
+ * texture then divides back out. A texel with no alpha comes back with no colour, so a cutout or an
1169
+ * emblem loses the colour its author padded past its edge, and filtering pulls black into the edge;
1170
+ * a partly transparent one comes back rounded to a coarser step. Colour management rewrites a normal
1171
+ * or ORM map, whose values are not colours at all, and glTF — which these assets are baked from —
1172
+ * says an image's own colour metadata is ignored. `render/imageTexels.ts` asks for the same two.
1173
+ *
1174
+ * What it gives up: an albedo authored in a wide-gamut space with a profile saying so is read as
1175
+ * sRGB. The baker is where that conversion belongs, and it has no colour management either.
1176
+ */
1177
+ const AS_AUTHORED = {
1178
+ premultiplyAlpha: 'none',
1179
+ colorSpaceConversion: 'none',
1180
+ } as const satisfies ImageBitmapOptions;
1181
+
1146
1182
  async function decodeImage(texture: DrftTexture, longestSide?: number): Promise<ImageBitmap> {
1147
1183
  /*
1148
1184
  * **A raw texture has no decoder, because it is already decoded.**
@@ -1172,10 +1208,11 @@ async function decodeImage(texture: DrftTexture, longestSide?: number): Promise<
1172
1208
  texture.width,
1173
1209
  texture.height,
1174
1210
  ),
1211
+ AS_AUTHORED,
1175
1212
  );
1176
1213
  }
1177
1214
  const blob = new Blob([texture.bytes.slice()], { type: imageTypeFor(texture.codec) });
1178
- if (longestSide === undefined) return await createImageBitmap(blob);
1215
+ if (longestSide === undefined) return await createImageBitmap(blob, AS_AUTHORED);
1179
1216
  /*
1180
1217
  * Resized while it decodes, which is the point: asking for a 256 pixel version of a 2048
1181
1218
  * square is a fraction of the work of decoding one and throwing most of it away, and the
@@ -1187,9 +1224,10 @@ async function decodeImage(texture: DrftTexture, longestSide?: number): Promise<
1187
1224
  * is the honest answer.
1188
1225
  */
1189
1226
  const longest = Math.max(texture.width, texture.height);
1190
- if (longest <= 0 || longest <= longestSide) return await createImageBitmap(blob);
1227
+ if (longest <= 0 || longest <= longestSide) return await createImageBitmap(blob, AS_AUTHORED);
1191
1228
  const scale = longestSide / longest;
1192
1229
  return await createImageBitmap(blob, {
1230
+ ...AS_AUTHORED,
1193
1231
  resizeWidth: Math.max(1, Math.round(texture.width * scale)),
1194
1232
  resizeHeight: Math.max(1, Math.round(texture.height * scale)),
1195
1233
  resizeQuality: 'medium',
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A `DTEX` chunk's tile table as the grid residency asks about.
3
+ *
4
+ * **Two constructors of one grid, and this is the one a streaming consumer uses.**
5
+ * `latentTileGrid` cuts a grid from a latent it has in memory, which is what a baker has;
6
+ * a consumer loading a file has the table instead, and the table is the point — it says where each
7
+ * tile's bytes are and what the tile *is*, so `tilesForView` can name the tiles a camera needs and
8
+ * a loader can fetch exactly those.
9
+ *
10
+ * **The hash is the file's**, over the tile's stored bytes, which is the number the file, the
11
+ * fetch and the cache all have to agree on. `latentTileGrid`'s hashes are over *decoded* texels and
12
+ * are a different number for the same tile — a baker's key rather than a file's. A consumer that
13
+ * mixed them would fetch every tile it already had.
14
+ *
15
+ * **One level, because a `DTEX` carries one.** The chunk holds a single latent grid, so this hands
16
+ * back a single level; a mip chain in the container is a further format step and not an omission
17
+ * here.
18
+ *
19
+ * This lives in `@driftengine/assets` because it is the one package that sees both the container
20
+ * and the texture runtime — `drft` has no dependencies by rule, and `texture` does not know what a
21
+ * chunk is.
22
+ */
23
+ import type { DtexMaterial } from '@driftengine/drft';
24
+ import type { MaterialTileGrid } from '@driftengine/texture';
25
+
26
+ /** The grid a chunk's table describes, or `null` where the material is one image and not streamed. */
27
+ export function tileGridFromDtex(material: DtexMaterial): MaterialTileGrid | null {
28
+ const tiles = material.tileOffset.length;
29
+ if (tiles === 0 || material.tileSize === 0) return null;
30
+
31
+ const hashes = new Array<string>(tiles);
32
+ for (let at = 0; at < tiles; at += 1) {
33
+ const high = (material.tileHash[at * 2] as number) >>> 0;
34
+ const low = (material.tileHash[at * 2 + 1] as number) >>> 0;
35
+ hashes[at] = high.toString(16).padStart(8, '0') + low.toString(16).padStart(8, '0');
36
+ }
37
+ return {
38
+ width: material.latentWidth,
39
+ height: material.latentHeight,
40
+ tileSize: material.tileSize,
41
+ addressMode: material.addressMode,
42
+ levels: [hashes],
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The bytes of one tile, by where it is rather than by its number.
48
+ *
49
+ * The table is in the grid's order — across a row, then down — so this is an index and a
50
+ * subarray. It is here rather than left to a caller because the order is a format rule, and a
51
+ * caller working it out again is a second place for it to be wrong.
52
+ */
53
+ export function dtexTileBytes(material: DtexMaterial, tx: number, ty: number): Uint8Array | null {
54
+ if (material.tileSize === 0) return null;
55
+ const across = Math.ceil(material.latentWidth / material.tileSize);
56
+ const down = Math.ceil(material.latentHeight / material.tileSize);
57
+ if (tx < 0 || ty < 0 || tx >= across || ty >= down) return null;
58
+ const at = ty * across + tx;
59
+ const from = material.tileOffset[at] as number;
60
+ return material.latent.subarray(from, from + (material.tileLength[at] as number));
61
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,13 @@
13
13
  * consumer writes the words, because what to call a stage is a decision about an audience.
14
14
  */
15
15
  export { DrftLoader } from './drftLoader.ts';
16
+ /*
17
+ * **A `DTEX` chunk's tile table as the grid residency asks about.** The other constructor of a
18
+ * `MaterialTileGrid` — `latentTileGrid` cuts one from a latent a baker holds, and this reads one a
19
+ * consumer downloaded. The hashes are the file's, over the bytes a fetch returns, which is what
20
+ * makes a tile that arrived for one material already resident for another.
21
+ */
22
+ export { dtexTileBytes, tileGridFromDtex } from './dtexTiles.ts';
16
23
  export type { DrftFit, DrftLoaderOptions } from './drftLoader.ts';
17
24
  /*
18
25
  * The loader's own draw-call grouping, exported because a consumer counting draws needs it and was
@@ -41,6 +48,15 @@ export { parseStl } from './stl.ts';
41
48
  * keeps the zero-texture-files rule met on its own terms rather than waived.
42
49
  */
43
50
  export { readRadianceHdr } from './radianceHdr.ts';
51
+ /**
52
+ * The material bake, exported for the GPU-driven pipeline's rig: it encodes a material to the
53
+ * decode programs that pipeline samples on the device.
54
+ */
55
+ export { encodeMaterial, latentImageOf } from './bake/latent.ts';
56
+ /* An encoded material as the chunk a file carries it in: the one place that knows both packages. */
57
+ export { dtexFromEncoded } from './bake/dtexChunk.ts';
58
+ export type { DtexTiles } from './bake/dtexChunk.ts';
59
+ export type { ChannelInput, EncodeMaterialOptions, EncodedMaterial } from './bake/latent.ts';
44
60
  /* IESNA LM-63, on the same opt-in terms: a consumer that loads no profile ships no parser. */
45
61
  export { readIesProfile } from './iesPhotometry.ts';
46
62
  export type { IesProfile } from './iesPhotometry.ts';
@@ -160,3 +176,27 @@ export { colliderBeside, levelsBeside } from './readModel.ts';
160
176
  */
161
177
  export { ddsToRgba, isDds } from './dds.ts';
162
178
  export type { DdsImage } from './dds.ts';
179
+ /*
180
+ * **The offline half of the GPU-driven pipeline, which nothing outside this package could reach.**
181
+ *
182
+ * `bake/cluster.ts` and `bake/clusterLod.ts` produce exactly what `@driftengine/core`'s
183
+ * `buildGpuDrivenScene` consumes, and until this line neither was exported — so a consumer could
184
+ * hold the pipeline and had no way to make its input. Every other gate was green about it: the
185
+ * modules are tested, the package is sized, the licence is in place. Nothing asks whether a thing
186
+ * with no route out of the package is reachable, and the first scene that tried to use one found
187
+ * it by failing the demo boundary test.
188
+ *
189
+ * Baker work rather than frame work, and the split is the same one `ddsToRgba` above makes: a
190
+ * million triangles cluster in 648 ms, which is a build step and not a mount.
191
+ */
192
+ export { buildClusters } from './bake/cluster.ts';
193
+ export type { ClusterOptions, ClusterSet } from './bake/cluster.ts';
194
+ export { buildClusterDag } from './bake/clusterLod.ts';
195
+ export type { ClusterDag, ClusterLevel } from './bake/clusterLod.ts';
196
+ /*
197
+ * The offline half of global illumination, exported for the same reason and against the same
198
+ * mistake: `bake/sdf.ts` produces what `globalField.ts` composes and what the `SDFV` chunk
199
+ * carries, and a field a consumer cannot bake is a pipeline with no input.
200
+ */
201
+ export { bakeObjectSdf } from './bake/sdf.ts';
202
+ export type { ObjectSdf } from './bake/sdf.ts';