@driftengine/assets 3.63.0 → 4.1.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,370 @@
1
+ /** A signed distance field per object, baked offline, for tracing light through a world. */
2
+ /**
3
+ * Voxels of clearance around the mesh, on every axis.
4
+ *
5
+ * **A field that stops at the mesh cannot be marched into.** A ray approaching from outside needs
6
+ * somewhere to take its first steps, and a gradient at the boundary needs a neighbour on both
7
+ * sides — the central difference at an edge texel has neither. Two is the smallest number that
8
+ * gives both and it is cheap on the thin axis of a wall, which is exactly where padding by a
9
+ * fraction of the extent would cost the most.
10
+ */
11
+ const PAD_VOXELS = 2;
12
+ /**
13
+ * Voxels past a triangle's own box that get an exactly computed distance.
14
+ *
15
+ * Everything further away is filled by propagation, which carries the *closest triangle* outward
16
+ * and recomputes the exact distance to it rather than accumulating a stepped approximation — so
17
+ * the far field is exact wherever the nearest triangle is reachable from a neighbour, which for
18
+ * anything short of a spiral is everywhere.
19
+ */
20
+ const EXACT_BAND = 1;
21
+ /** Larger than any distance a padded grid can hold, and finite so arithmetic on it behaves. */
22
+ const UNREACHED = 1e30;
23
+ /**
24
+ * Bake one mesh into a signed distance field.
25
+ *
26
+ * `resolution` is voxels along the mesh's **longest** axis; the other two get however many the
27
+ * same spacing needs. **Cubic voxels are not tidiness** — a field with a different step per axis
28
+ * has a gradient whose length varies with direction, which is the one property a sphere trace
29
+ * depends on, and the failure is a march that overshoots along the coarse axis.
30
+ *
31
+ * **The sign comes from crossing parity, not from the winding.** A face normal, an
32
+ * angle-weighted pseudonormal and a generalised winding number all flip when a mesh is exported
33
+ * with its triangles the other way round, and importers disagree about that constantly — so a
34
+ * model from the wrong tool would come back solid where it is empty. Counting how many times a
35
+ * ray along `x` crosses the surface does not care which way a triangle faces.
36
+ *
37
+ * **The magnitude comes from exact point-to-triangle distance, not from voxelisation.** Marking
38
+ * the cells a triangle passes through and flooding outward loses any wall thinner than a cell,
39
+ * because no cell centre is inside it — and a wall thinner than a cell is most of them. This
40
+ * measures the distance from every grid point in a band around each triangle to that triangle,
41
+ * so a plate a fifth of a voxel thick is still a plate at a fifth of a voxel.
42
+ */
43
+ export function bakeObjectSdf(mesh, resolution) {
44
+ const positions = mesh.positions;
45
+ const indices = mesh.indices;
46
+ const triangles = Math.floor(indices.length / 3);
47
+ if (triangles < 1) {
48
+ throw new Error('bakeObjectSdf: the mesh has no triangle; a field of nothing has no surface.');
49
+ }
50
+ const voxels = Math.trunc(resolution);
51
+ if (!(voxels >= 2)) {
52
+ throw new Error(`bakeObjectSdf: resolution is ${String(resolution)} and the minimum is 2. One sample on an ` +
53
+ 'axis has no neighbour to take a gradient against.');
54
+ }
55
+ const min = [Infinity, Infinity, Infinity];
56
+ const max = [-Infinity, -Infinity, -Infinity];
57
+ for (let at = 0; at + 2 < positions.length; at += 3) {
58
+ for (let axis = 0; axis < 3; axis++) {
59
+ const value = positions[at + axis];
60
+ if (value < min[axis])
61
+ min[axis] = value;
62
+ if (value > max[axis])
63
+ max[axis] = value;
64
+ }
65
+ }
66
+ /*
67
+ * A degenerate axis still needs a step, and the longest axis is what sets it. A perfectly flat
68
+ * quad has zero extent on one axis and is a completely ordinary thing to bake.
69
+ */
70
+ const span = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
71
+ if (!(span > 0)) {
72
+ throw new Error('bakeObjectSdf: the mesh has no extent on any axis; there is nothing to bake.');
73
+ }
74
+ const step = span / voxels;
75
+ const pad = PAD_VOXELS * step;
76
+ const origin = new Float32Array(3);
77
+ const dims = [0, 0, 0];
78
+ for (let axis = 0; axis < 3; axis++) {
79
+ origin[axis] = min[axis] - pad;
80
+ const reach = max[axis] + pad - origin[axis];
81
+ dims[axis] = Math.ceil(reach / step) + 1;
82
+ }
83
+ const [nx, ny, nz] = dims;
84
+ const bounds = new Float32Array([
85
+ origin[0],
86
+ origin[1],
87
+ origin[2],
88
+ origin[0] + (nx - 1) * step,
89
+ origin[1] + (ny - 1) * step,
90
+ origin[2] + (nz - 1) * step,
91
+ ]);
92
+ const count = nx * ny * nz;
93
+ const field = new Float32Array(count).fill(UNREACHED);
94
+ /** The triangle each grid point's distance was measured to, and -1 where none has reached. */
95
+ const closest = new Int32Array(count).fill(-1);
96
+ /** How many times the surface is crossed between grid point `i - 1` and `i` along `x`. */
97
+ const crossings = new Int32Array(count);
98
+ const ox = origin[0];
99
+ const oy = origin[1];
100
+ const oz = origin[2];
101
+ for (let tri = 0; tri < triangles; tri++) {
102
+ const ia = indices[tri * 3] * 3;
103
+ const ib = indices[tri * 3 + 1] * 3;
104
+ const ic = indices[tri * 3 + 2] * 3;
105
+ const ax = positions[ia];
106
+ const ay = positions[ia + 1];
107
+ const az = positions[ia + 2];
108
+ const bx = positions[ib];
109
+ const by = positions[ib + 1];
110
+ const bz = positions[ib + 2];
111
+ const cx = positions[ic];
112
+ const cy = positions[ic + 1];
113
+ const cz = positions[ic + 2];
114
+ const i0 = clampIndex(Math.floor((Math.min(ax, bx, cx) - ox) / step) - EXACT_BAND, nx);
115
+ const i1 = clampIndex(Math.ceil((Math.max(ax, bx, cx) - ox) / step) + EXACT_BAND, nx);
116
+ const j0 = clampIndex(Math.floor((Math.min(ay, by, cy) - oy) / step) - EXACT_BAND, ny);
117
+ const j1 = clampIndex(Math.ceil((Math.max(ay, by, cy) - oy) / step) + EXACT_BAND, ny);
118
+ const k0 = clampIndex(Math.floor((Math.min(az, bz, cz) - oz) / step) - EXACT_BAND, nz);
119
+ const k1 = clampIndex(Math.ceil((Math.max(az, bz, cz) - oz) / step) + EXACT_BAND, nz);
120
+ for (let k = k0; k <= k1; k++) {
121
+ const gz = oz + k * step;
122
+ for (let j = j0; j <= j1; j++) {
123
+ const gy = oy + j * step;
124
+ for (let i = i0; i <= i1; i++) {
125
+ const gx = ox + i * step;
126
+ const distance = pointTriangleDistance(gx, gy, gz, ax, ay, az, bx, by, bz, cx, cy, cz);
127
+ const index = i + nx * (j + ny * k);
128
+ if (distance < field[index]) {
129
+ field[index] = distance;
130
+ closest[index] = tri;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ /*
136
+ * Where a ray along `x` through each grid column crosses this triangle. Recorded at the first
137
+ * grid point past the crossing, so a scan along `x` accumulating these counts knows, at every
138
+ * point, how many surfaces stand between it and the grid's `-x` face.
139
+ */
140
+ for (let k = clampIndex(Math.ceil((Math.min(az, bz, cz) - oz) / step), nz); k < nz; k++) {
141
+ const gz = oz + k * step;
142
+ if (gz > Math.max(az, bz, cz))
143
+ break;
144
+ for (let j = clampIndex(Math.ceil((Math.min(ay, by, cy) - oy) / step), ny); j < ny; j++) {
145
+ const gy = oy + j * step;
146
+ if (gy > Math.max(ay, by, cy))
147
+ break;
148
+ const bary = barycentric2d(gy, gz, ay, az, by, bz, cy, cz);
149
+ if (bary === null)
150
+ continue;
151
+ const x = bary[0] * ax + bary[1] * bx + bary[2] * cx;
152
+ const i = Math.ceil((x - ox) / step);
153
+ /*
154
+ * **Unreachable while `PAD_VOXELS` is at least one, and kept because it is a constant.**
155
+ * The grid reaches two voxels past the mesh on every axis, so no crossing can land past
156
+ * the `+x` face; with no padding one could, and clamping it would mark the last grid point
157
+ * as having one more surface behind it than it does — flipping the parity of a point that
158
+ * is outside. A crossing before the `-x` face is clamped to zero, where it is true: the
159
+ * grid's first point really does have that surface behind it.
160
+ */
161
+ if (i >= nx)
162
+ continue;
163
+ crossings[Math.max(0, i) + nx * (j + ny * k)]++;
164
+ }
165
+ }
166
+ }
167
+ /*
168
+ * Carry the closest triangle outward and recompute against it, eight directions twice. Two
169
+ * passes rather than one because a point whose nearest triangle lies around a corner is reached
170
+ * by a sweep that has itself only just been filled, and the second pass is what lets that
171
+ * propagate the rest of the way.
172
+ */
173
+ for (let pass = 0; pass < 2; pass++) {
174
+ for (const [dx, dy, dz] of SWEEPS) {
175
+ sweep(field, closest, positions, indices, dims, origin, step, dx, dy, dz);
176
+ }
177
+ }
178
+ /* Anything nothing ever reached is as far away as the grid can express, not a sentinel. */
179
+ const reach = step * (nx + ny + nz);
180
+ for (let index = 0; index < count; index++) {
181
+ if (field[index] >= UNREACHED)
182
+ field[index] = reach;
183
+ }
184
+ for (let k = 0; k < nz; k++) {
185
+ for (let j = 0; j < ny; j++) {
186
+ let total = 0;
187
+ const row = nx * (j + ny * k);
188
+ for (let i = 0; i < nx; i++) {
189
+ total += crossings[row + i];
190
+ if (total % 2 === 1)
191
+ field[row + i] = -field[row + i];
192
+ }
193
+ }
194
+ }
195
+ return { field, dims, bounds };
196
+ }
197
+ /** The eight diagonal orders a propagation runs in, so every direction has one that follows it. */
198
+ const SWEEPS = [
199
+ [1, 1, 1],
200
+ [-1, -1, -1],
201
+ [1, 1, -1],
202
+ [-1, -1, 1],
203
+ [1, -1, 1],
204
+ [-1, 1, -1],
205
+ [1, -1, -1],
206
+ [-1, 1, 1],
207
+ ];
208
+ /** One propagation pass: every point takes its neighbours' triangles if one of them is nearer. */
209
+ function sweep(field, closest, positions, indices, dims, origin, step, dx, dy, dz) {
210
+ const [nx, ny, nz] = dims;
211
+ const i0 = dx > 0 ? 1 : nx - 2;
212
+ const i1 = dx > 0 ? nx : -1;
213
+ const j0 = dy > 0 ? 1 : ny - 2;
214
+ const j1 = dy > 0 ? ny : -1;
215
+ const k0 = dz > 0 ? 1 : nz - 2;
216
+ const k1 = dz > 0 ? nz : -1;
217
+ for (let k = k0; k !== k1; k += dz) {
218
+ const gz = origin[2] + k * step;
219
+ for (let j = j0; j !== j1; j += dy) {
220
+ const gy = origin[1] + j * step;
221
+ for (let i = i0; i !== i1; i += dx) {
222
+ const gx = origin[0] + i * step;
223
+ const index = i + nx * (j + ny * k);
224
+ /* The three already-visited axis neighbours. See `NEIGHBOURS` for why three and not seven. */
225
+ for (const [ni, nj, nk] of NEIGHBOURS) {
226
+ const at = i - ni * dx + nx * (j - nj * dy + ny * (k - nk * dz));
227
+ const triangle = closest[at];
228
+ if (triangle < 0)
229
+ continue;
230
+ const ia = indices[triangle * 3] * 3;
231
+ const ib = indices[triangle * 3 + 1] * 3;
232
+ const ic = indices[triangle * 3 + 2] * 3;
233
+ const distance = pointTriangleDistance(gx, gy, gz, positions[ia], positions[ia + 1], positions[ia + 2], positions[ib], positions[ib + 1], positions[ib + 2], positions[ic], positions[ic + 1], positions[ic + 2]);
234
+ if (distance < field[index]) {
235
+ field[index] = distance;
236
+ closest[index] = triangle;
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
243
+ /**
244
+ * The already-visited neighbours of a point, in the sweep's own frame.
245
+ *
246
+ * **Three, and the reference implementation this follows uses seven.** The other four are the face
247
+ * and body diagonals, and the argument for them is that a field propagating along the axes alone
248
+ * acquires a plus-shaped bias. **That argument does not survive being measured here**, because the
249
+ * diagonals are already carried by the eight *sweep orders*: a unit sphere at resolution 40 comes
250
+ * out with a worst error against the analytic distance of 9.6e-3 either way — the same number to
251
+ * two digits — while the seven-neighbour version takes 1123 ms against 529 ms.
252
+ *
253
+ * What does move is the worst gradient error, 4.0e-2 to 4.6e-2, both of them dominated by the
254
+ * tessellation's own facets and both far under what a march needs. **Reproduce** by adding the
255
+ * four diagonals back and running `sdf.test.ts`, which passes either way and is why this is a
256
+ * measurement rather than a test.
257
+ */
258
+ const NEIGHBOURS = [
259
+ [1, 0, 0],
260
+ [0, 1, 0],
261
+ [0, 0, 1],
262
+ ];
263
+ /** A whole index forced inside a grid, for a triangle's box which may hang over an edge. */
264
+ function clampIndex(value, count) {
265
+ return value < 0 ? 0 : value > count - 1 ? count - 1 : value;
266
+ }
267
+ /**
268
+ * Where a point stands in a triangle projected onto one plane, or null if it stands outside.
269
+ *
270
+ * **The tie-break is what stops a shared edge being counted twice or not at all.** A grid column
271
+ * passing exactly along the edge two triangles share is inside both or neither depending on the
272
+ * sign of a zero, and either answer flips the parity of an entire row. Breaking the tie on the
273
+ * coordinates themselves makes the choice consistent between the two triangles: whichever way it
274
+ * goes, exactly one of them claims the column.
275
+ */
276
+ function barycentric2d(px, py, ax, ay, bx, by, cx, cy) {
277
+ const bxr = bx - px;
278
+ const byr = by - py;
279
+ const cxr = cx - px;
280
+ const cyr = cy - py;
281
+ const axr = ax - px;
282
+ const ayr = ay - py;
283
+ const areaA = orientation(bxr, byr, cxr, cyr);
284
+ if (areaA.sign === 0)
285
+ return null;
286
+ const areaB = orientation(cxr, cyr, axr, ayr);
287
+ if (areaB.sign !== areaA.sign)
288
+ return null;
289
+ const areaC = orientation(axr, ayr, bxr, byr);
290
+ if (areaC.sign !== areaA.sign)
291
+ return null;
292
+ const sum = areaA.area + areaB.area + areaC.area;
293
+ if (sum === 0)
294
+ return null;
295
+ return [areaA.area / sum, areaB.area / sum, areaC.area / sum];
296
+ }
297
+ /** Twice the signed area of a triangle with the origin, with zero broken deterministically. */
298
+ function orientation(x1, y1, x2, y2) {
299
+ const area = y1 * x2 - x1 * y2;
300
+ if (area > 0)
301
+ return { sign: 1, area };
302
+ if (area < 0)
303
+ return { sign: -1, area };
304
+ if (y2 > y1)
305
+ return { sign: 1, area };
306
+ if (y2 < y1)
307
+ return { sign: -1, area };
308
+ if (x1 > x2)
309
+ return { sign: 1, area };
310
+ if (x1 < x2)
311
+ return { sign: -1, area };
312
+ return { sign: 0, area };
313
+ }
314
+ /**
315
+ * The distance from a point to the nearest point of a triangle.
316
+ *
317
+ * The seven regions of Voronoi space around a triangle — three vertices, three edges, the face —
318
+ * tested in the order that lets each test assume the ones before it failed. Written out rather
319
+ * than solved as a projection with a clamp, because the clamped projection is wrong outside the
320
+ * face's own region and the error is largest exactly at the silhouette, which is where a marching
321
+ * ray spends its time.
322
+ */
323
+ function pointTriangleDistance(px, py, pz, ax, ay, az, bx, by, bz, cx, cy, cz) {
324
+ const abx = bx - ax;
325
+ const aby = by - ay;
326
+ const abz = bz - az;
327
+ const acx = cx - ax;
328
+ const acy = cy - ay;
329
+ const acz = cz - az;
330
+ const apx = px - ax;
331
+ const apy = py - ay;
332
+ const apz = pz - az;
333
+ const d1 = abx * apx + aby * apy + abz * apz;
334
+ const d2 = acx * apx + acy * apy + acz * apz;
335
+ if (d1 <= 0 && d2 <= 0)
336
+ return Math.hypot(apx, apy, apz);
337
+ const bpx = px - bx;
338
+ const bpy = py - by;
339
+ const bpz = pz - bz;
340
+ const d3 = abx * bpx + aby * bpy + abz * bpz;
341
+ const d4 = acx * bpx + acy * bpy + acz * bpz;
342
+ if (d3 >= 0 && d4 <= d3)
343
+ return Math.hypot(bpx, bpy, bpz);
344
+ const vc = d1 * d4 - d3 * d2;
345
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) {
346
+ const v = d1 / (d1 - d3);
347
+ return Math.hypot(apx - v * abx, apy - v * aby, apz - v * abz);
348
+ }
349
+ const cpx = px - cx;
350
+ const cpy = py - cy;
351
+ const cpz = pz - cz;
352
+ const d5 = abx * cpx + aby * cpy + abz * cpz;
353
+ const d6 = acx * cpx + acy * cpy + acz * cpz;
354
+ if (d6 >= 0 && d5 <= d6)
355
+ return Math.hypot(cpx, cpy, cpz);
356
+ const vb = d5 * d2 - d1 * d6;
357
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) {
358
+ const w = d2 / (d2 - d6);
359
+ return Math.hypot(apx - w * acx, apy - w * acy, apz - w * acz);
360
+ }
361
+ const va = d3 * d6 - d5 * d4;
362
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
363
+ const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
364
+ return Math.hypot(bpx - w * (cx - bx), bpy - w * (cy - by), bpz - w * (cz - bz));
365
+ }
366
+ const denom = 1 / (va + vb + vc);
367
+ const v = vb * denom;
368
+ const w = vc * denom;
369
+ return Math.hypot(apx - (v * abx + w * acx), apy - (v * aby + w * acy), apz - (v * abz + w * acz));
370
+ }
@@ -27,7 +27,23 @@ export type DrftFit = {
27
27
  /** Scale 1 and no offset: the model arrives in the coordinates the file states. */
28
28
  readonly fit: 'none';
29
29
  };
30
+ /**
31
+ * How a container is reached. `typeof fetch`, narrowed to the one call shape this file makes.
32
+ *
33
+ * `@driftengine/audio` has had the identical seam since it was written, under the same name, and
34
+ * this package went without it — so the loader for the engine's *own* format was the one that
35
+ * could not be pointed at a service worker, a packed archive, a memory map or a fixture.
36
+ */
37
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
30
38
  export interface DrftLoaderOptions {
39
+ /**
40
+ * Where the bytes come from. The global `fetch` unless a consumer says otherwise.
41
+ *
42
+ * Optional and defaulting to the browser's, so no existing caller changes — the shape
43
+ * `AGENTS.md` asks for: take the capability as a parameter, ship a browser implementation as
44
+ * the default.
45
+ */
46
+ readonly fetchImpl?: FetchLike;
31
47
  /**
32
48
  * The most parts one `update` may take, however cheap they turn out to be.
33
49
  *
@@ -272,7 +272,8 @@ export class DrftLoader {
272
272
  * to the *baker* appears to have changed nothing, and the time lost to that is out of all
273
273
  * proportion to the saving.
274
274
  */
275
- const response = await fetch(url, { cache: 'no-store' });
275
+ // platform: browser default — `DrftLoaderOptions.fetchImpl` is the seam
276
+ const response = await (this.options.fetchImpl ?? fetch)(url, { cache: 'no-store' });
276
277
  if (!response.ok) {
277
278
  this.set({ phase: 'absent', message: `no model at ${url}` });
278
279
  return;
@@ -948,6 +949,23 @@ export function isRawCodec(codec) {
948
949
  * The `.slice()` matters: the bytes are a view into the streamed buffer, and a `Blob` built on
949
950
  * the view would keep the whole file alive for as long as the image does.
950
951
  */
952
+ /**
953
+ * How every image a model carries is decoded: **straight alpha, and no colour conversion.**
954
+ *
955
+ * `createImageBitmap` premultiplies unless told not to, and the upload into a straight-alpha
956
+ * texture then divides back out. A texel with no alpha comes back with no colour, so a cutout or an
957
+ * emblem loses the colour its author padded past its edge, and filtering pulls black into the edge;
958
+ * a partly transparent one comes back rounded to a coarser step. Colour management rewrites a normal
959
+ * or ORM map, whose values are not colours at all, and glTF — which these assets are baked from —
960
+ * says an image's own colour metadata is ignored. `render/imageTexels.ts` asks for the same two.
961
+ *
962
+ * What it gives up: an albedo authored in a wide-gamut space with a profile saying so is read as
963
+ * sRGB. The baker is where that conversion belongs, and it has no colour management either.
964
+ */
965
+ const AS_AUTHORED = {
966
+ premultiplyAlpha: 'none',
967
+ colorSpaceConversion: 'none',
968
+ };
951
969
  async function decodeImage(texture, longestSide) {
952
970
  /*
953
971
  * **A raw texture has no decoder, because it is already decoded.**
@@ -969,11 +987,11 @@ async function decodeImage(texture, longestSide) {
969
987
  if (pixels <= 0 || texture.bytes.length < pixels * 4) {
970
988
  throw new DrftError(`a raw texture needs ${pixels * 4} bytes for ${texture.width}x${texture.height} and carries ${texture.bytes.length}`);
971
989
  }
972
- return await createImageBitmap(new ImageData(new Uint8ClampedArray(texture.bytes.slice(0, pixels * 4)), texture.width, texture.height));
990
+ return await createImageBitmap(new ImageData(new Uint8ClampedArray(texture.bytes.slice(0, pixels * 4)), texture.width, texture.height), AS_AUTHORED);
973
991
  }
974
992
  const blob = new Blob([texture.bytes.slice()], { type: imageTypeFor(texture.codec) });
975
993
  if (longestSide === undefined)
976
- return await createImageBitmap(blob);
994
+ return await createImageBitmap(blob, AS_AUTHORED);
977
995
  /*
978
996
  * Resized while it decodes, which is the point: asking for a 256 pixel version of a 2048
979
997
  * square is a fraction of the work of decoding one and throwing most of it away, and the
@@ -986,9 +1004,10 @@ async function decodeImage(texture, longestSide) {
986
1004
  */
987
1005
  const longest = Math.max(texture.width, texture.height);
988
1006
  if (longest <= 0 || longest <= longestSide)
989
- return await createImageBitmap(blob);
1007
+ return await createImageBitmap(blob, AS_AUTHORED);
990
1008
  const scale = longestSide / longest;
991
1009
  return await createImageBitmap(blob, {
1010
+ ...AS_AUTHORED,
992
1011
  resizeWidth: Math.max(1, Math.round(texture.width * scale)),
993
1012
  resizeHeight: Math.max(1, Math.round(texture.height * scale)),
994
1013
  resizeQuality: 'medium',
@@ -0,0 +1,34 @@
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
+ /** The grid a chunk's table describes, or `null` where the material is one image and not streamed. */
26
+ export declare function tileGridFromDtex(material: DtexMaterial): MaterialTileGrid | null;
27
+ /**
28
+ * The bytes of one tile, by where it is rather than by its number.
29
+ *
30
+ * The table is in the grid's order — across a row, then down — so this is an index and a
31
+ * subarray. It is here rather than left to a caller because the order is a format rule, and a
32
+ * caller working it out again is a second place for it to be wrong.
33
+ */
34
+ export declare function dtexTileBytes(material: DtexMaterial, tx: number, ty: number): Uint8Array | null;
@@ -0,0 +1,37 @@
1
+ /** The grid a chunk's table describes, or `null` where the material is one image and not streamed. */
2
+ export function tileGridFromDtex(material) {
3
+ const tiles = material.tileOffset.length;
4
+ if (tiles === 0 || material.tileSize === 0)
5
+ return null;
6
+ const hashes = new Array(tiles);
7
+ for (let at = 0; at < tiles; at += 1) {
8
+ const high = material.tileHash[at * 2] >>> 0;
9
+ const low = material.tileHash[at * 2 + 1] >>> 0;
10
+ hashes[at] = high.toString(16).padStart(8, '0') + low.toString(16).padStart(8, '0');
11
+ }
12
+ return {
13
+ width: material.latentWidth,
14
+ height: material.latentHeight,
15
+ tileSize: material.tileSize,
16
+ addressMode: material.addressMode,
17
+ levels: [hashes],
18
+ };
19
+ }
20
+ /**
21
+ * The bytes of one tile, by where it is rather than by its number.
22
+ *
23
+ * The table is in the grid's order — across a row, then down — so this is an index and a
24
+ * subarray. It is here rather than left to a caller because the order is a format rule, and a
25
+ * caller working it out again is a second place for it to be wrong.
26
+ */
27
+ export function dtexTileBytes(material, tx, ty) {
28
+ if (material.tileSize === 0)
29
+ return null;
30
+ const across = Math.ceil(material.latentWidth / material.tileSize);
31
+ const down = Math.ceil(material.latentHeight / material.tileSize);
32
+ if (tx < 0 || ty < 0 || tx >= across || ty >= down)
33
+ return null;
34
+ const at = ty * across + tx;
35
+ const from = material.tileOffset[at];
36
+ return material.latent.subarray(from, from + material.tileLength[at]);
37
+ }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * world imports none of this and pays for none of it.
8
8
  */
9
9
  export { DrftLoader } from './drftLoader.ts';
10
+ export { dtexTileBytes, tileGridFromDtex } from './dtexTiles.ts';
10
11
  export type { DrftFit, DrftLoaderOptions } from './drftLoader.ts';
11
12
  export { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
12
13
  export type { DrawGrouping, DrawSurfaceOverride } from './drawKey.ts';
@@ -21,6 +22,14 @@ export type { GltfDocument } from './gltf.ts';
21
22
  export { parseObj, parseMtl } from './obj.ts';
22
23
  export { parseStl } from './stl.ts';
23
24
  export { readRadianceHdr } from './radianceHdr.ts';
25
+ /**
26
+ * The material bake, exported for the GPU-driven pipeline's rig: it encodes a material to the
27
+ * decode programs that pipeline samples on the device.
28
+ */
29
+ export { encodeMaterial, latentImageOf } from './bake/latent.ts';
30
+ export { dtexFromEncoded } from './bake/dtexChunk.ts';
31
+ export type { DtexTiles } from './bake/dtexChunk.ts';
32
+ export type { ChannelInput, EncodeMaterialOptions, EncodedMaterial } from './bake/latent.ts';
24
33
  export { readIesProfile } from './iesPhotometry.ts';
25
34
  export type { IesProfile } from './iesPhotometry.ts';
26
35
  export type { RadianceImage } from './radianceHdr.ts';
@@ -56,3 +65,9 @@ export type { Localised } from './localise.ts';
56
65
  export { colliderBeside, levelsBeside } from './readModel.ts';
57
66
  export { ddsToRgba, isDds } from './dds.ts';
58
67
  export type { DdsImage } from './dds.ts';
68
+ export { buildClusters } from './bake/cluster.ts';
69
+ export type { ClusterOptions, ClusterSet } from './bake/cluster.ts';
70
+ export { buildClusterDag } from './bake/clusterLod.ts';
71
+ export type { ClusterDag, ClusterLevel } from './bake/clusterLod.ts';
72
+ export { bakeObjectSdf } from './bake/sdf.ts';
73
+ export type { ObjectSdf } from './bake/sdf.ts';
package/dist/index.js CHANGED
@@ -12,6 +12,13 @@
12
12
  * consumer writes the words, because what to call a stage is a decision about an audience.
13
13
  */
14
14
  export { DrftLoader } from './drftLoader.js';
15
+ /*
16
+ * **A `DTEX` chunk's tile table as the grid residency asks about.** The other constructor of a
17
+ * `MaterialTileGrid` — `latentTileGrid` cuts one from a latent a baker holds, and this reads one a
18
+ * consumer downloaded. The hashes are the file's, over the bytes a fetch returns, which is what
19
+ * makes a tile that arrived for one material already resident for another.
20
+ */
21
+ export { dtexTileBytes, tileGridFromDtex } from './dtexTiles.js';
15
22
  /*
16
23
  * The loader's own draw-call grouping, exported because a consumer counting draws needs it and was
17
24
  * keeping a copy that could not be kept in step. See `drawKey.ts`.
@@ -34,6 +41,13 @@ export { parseStl } from './stl.js';
34
41
  * keeps the zero-texture-files rule met on its own terms rather than waived.
35
42
  */
36
43
  export { readRadianceHdr } from './radianceHdr.js';
44
+ /**
45
+ * The material bake, exported for the GPU-driven pipeline's rig: it encodes a material to the
46
+ * decode programs that pipeline samples on the device.
47
+ */
48
+ export { encodeMaterial, latentImageOf } from './bake/latent.js';
49
+ /* An encoded material as the chunk a file carries it in: the one place that knows both packages. */
50
+ export { dtexFromEncoded } from './bake/dtexChunk.js';
37
51
  /* IESNA LM-63, on the same opt-in terms: a consumer that loads no profile ships no parser. */
38
52
  export { readIesProfile } from './iesPhotometry.js';
39
53
  /*
@@ -130,3 +144,24 @@ export { colliderBeside, levelsBeside } from './readModel.js';
130
144
  * is larger than the source and nothing ships a block decoder into a frame.
131
145
  */
132
146
  export { ddsToRgba, isDds } from './dds.js';
147
+ /*
148
+ * **The offline half of the GPU-driven pipeline, which nothing outside this package could reach.**
149
+ *
150
+ * `bake/cluster.ts` and `bake/clusterLod.ts` produce exactly what `@driftengine/core`'s
151
+ * `buildGpuDrivenScene` consumes, and until this line neither was exported — so a consumer could
152
+ * hold the pipeline and had no way to make its input. Every other gate was green about it: the
153
+ * modules are tested, the package is sized, the licence is in place. Nothing asks whether a thing
154
+ * with no route out of the package is reachable, and the first scene that tried to use one found
155
+ * it by failing the demo boundary test.
156
+ *
157
+ * Baker work rather than frame work, and the split is the same one `ddsToRgba` above makes: a
158
+ * million triangles cluster in 648 ms, which is a build step and not a mount.
159
+ */
160
+ export { buildClusters } from './bake/cluster.js';
161
+ export { buildClusterDag } from './bake/clusterLod.js';
162
+ /*
163
+ * The offline half of global illumination, exported for the same reason and against the same
164
+ * mistake: `bake/sdf.ts` produces what `globalField.ts` composes and what the `SDFV` chunk
165
+ * carries, and a field a consumer cannot bake is a pipeline with no input.
166
+ */
167
+ export { bakeObjectSdf } from './bake/sdf.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/assets",
3
- "version": "3.63.0",
3
+ "version": "4.1.0",
4
4
  "description": "Model readers and the streaming loader for the .drft container",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,10 +27,11 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
- "@driftengine/drft": "3.63.0"
30
+ "@driftengine/drft": "4.1.0",
31
+ "@driftengine/texture": "4.1.0"
31
32
  },
32
33
  "peerDependencies": {
33
- "@driftengine/core": "3.63.0"
34
+ "@driftengine/core": "4.1.0"
34
35
  },
35
36
  "author": "Drift Technologies",
36
37
  "repository": {