@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.
package/NOTICE CHANGED
@@ -7,3 +7,23 @@ This product includes software developed at Drift Technologies
7
7
  Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
8
8
  requires this NOTICE to be reproduced in any derivative work you distribute,
9
9
  in the source, the documentation, or a display generated by the work.
10
+
11
+ Models run by DriftCapture
12
+ --------------------------
13
+ DriftCapture runs the models below. Their weights are not in this repository
14
+ and are not distributed with it: tools/capture-weights/ fetches each at the
15
+ revision its manifest pins, verifies it, and converts it, and a game that
16
+ ships one ships it under that model's own licence. Each model's definition in
17
+ this engine is written from its project's published code.
18
+
19
+ - Depth Anything 3 Small and Depth Anything 3 Base. Apache License 2.0.
20
+ Copyright (c) 2025 ByteDance Ltd. and/or its affiliates.
21
+ - Depth Anything V2 Small. Apache License 2.0. The University of Hong Kong
22
+ and TikTok; converted to the Transformers layout by The HuggingFace Team.
23
+ - MobileSAM. Apache License 2.0. Its image encoder is TinyViT, Copyright (c)
24
+ 2022 Microsoft; its prompt encoder and mask decoder are Segment Anything's,
25
+ Copyright (c) Meta Platforms, Inc. and affiliates.
26
+ - SAM 2.1 tiny. Apache License 2.0. Copyright (c) Meta Platforms, Inc. and
27
+ affiliates.
28
+ - OWLv2 base patch16. Apache License 2.0. Copyright 2023 Google AI and The
29
+ HuggingFace Team.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Splitting a mesh into clusters: the unit a GPU-driven frame culls, chooses detail for and draws.
3
+ *
4
+ * **A cluster is the unit of three separate decisions**, which is why its size is a parameter
5
+ * rather than a constant. Smaller clusters cull more precisely and pay more per-cluster overhead
6
+ * on every one of them; 128 triangles is the default because it is one workgroup of 128
7
+ * invocations doing one triangle each.
8
+ *
9
+ * **Growth is by shared vertices, and ties break on the lowest index.** That tie-break is not a
10
+ * detail: a baker whose output depends on iteration order produces a different container from the
11
+ * same source, which breaks every content hash downstream. The determinism test in this module's
12
+ * suite is what holds it.
13
+ *
14
+ * Each cluster carries a bounding sphere and a normal cone, because the runtime culls against both
15
+ * and computing them here costs nothing that the bake was not already paying.
16
+ */
17
+ import type { MeshData } from '@driftengine/drft';
18
+ export interface ClusterSet {
19
+ /** First triangle of each cluster, as an index into `indices` divided by three. */
20
+ triangleOffsets: Uint32Array;
21
+ /** How many triangles each cluster holds. */
22
+ triangleCounts: Uint32Array;
23
+ /** The mesh's indices, reordered so each cluster's triangles are contiguous. */
24
+ indices: Uint32Array;
25
+ /** Three floats per cluster. */
26
+ boundsCentre: Float32Array;
27
+ /** One float per cluster. */
28
+ boundsRadius: Float32Array;
29
+ /** Three floats per cluster: the average face normal, normalised. */
30
+ coneAxis: Float32Array;
31
+ /** One float per cluster: the minimum dot product of any face normal against the axis. */
32
+ coneCutoff: Float32Array;
33
+ count: number;
34
+ }
35
+ export interface ClusterOptions {
36
+ /**
37
+ * What makes two triangles neighbours while a cluster grows. **`'index'` by default**, which is
38
+ * what every baked container was hashed with and must go on producing.
39
+ *
40
+ * **`'position'` counts two corners as shared when they stand at the same point**, whatever their
41
+ * indices. A mesh that splits its vertices at every seam — a voxel mesher, whose faces each carry
42
+ * their own uv and occlusion, or a box built face by face — shares no index between two faces,
43
+ * so growth by index stops at every face and each quad becomes a cluster of two triangles: a
44
+ * median voxel chunk was 1,012 clusters for 2,024 triangles. The indices written out are still
45
+ * the mesh's own, because welding decides who is a neighbour and nothing else.
46
+ */
47
+ readonly share?: 'index' | 'position';
48
+ }
49
+ export declare function buildClusters(mesh: MeshData, target: number, options?: ClusterOptions): ClusterSet;
@@ -0,0 +1,277 @@
1
+ /** Vertices shared between two triangles, each given as three vertex indices. */
2
+ function shared(a, ai, b, bi) {
3
+ let count = 0;
4
+ for (let i = 0; i < 3; i += 1) {
5
+ const v = a[ai * 3 + i];
6
+ for (let j = 0; j < 3; j += 1)
7
+ if (v === b[bi * 3 + j])
8
+ count += 1;
9
+ }
10
+ return count;
11
+ }
12
+ function faceNormal(positions, indices, tri, out) {
13
+ const a = indices[tri * 3] * 3;
14
+ const b = indices[tri * 3 + 1] * 3;
15
+ const c = indices[tri * 3 + 2] * 3;
16
+ const ux = positions[b] - positions[a];
17
+ const uy = positions[b + 1] - positions[a + 1];
18
+ const uz = positions[b + 2] - positions[a + 2];
19
+ const vx = positions[c] - positions[a];
20
+ const vy = positions[c + 1] - positions[a + 1];
21
+ const vz = positions[c + 2] - positions[a + 2];
22
+ let nx = uy * vz - uz * vy;
23
+ let ny = uz * vx - ux * vz;
24
+ let nz = ux * vy - uy * vx;
25
+ const length = Math.hypot(nx, ny, nz);
26
+ if (length > 0) {
27
+ nx /= length;
28
+ ny /= length;
29
+ nz /= length;
30
+ }
31
+ out[0] = nx;
32
+ out[1] = ny;
33
+ out[2] = nz;
34
+ }
35
+ /**
36
+ * Every triangle touching each vertex, as a compressed row: offsets, then the triangles.
37
+ *
38
+ * **This is what makes the growth linear rather than cubic**, and the difference is not an
39
+ * optimisation. Growth picks the unassigned triangle sharing the most vertices with the cluster so
40
+ * far, and a triangle sharing none can never be picked — the loop stops when the best score is
41
+ * zero. So the only triangles worth considering are the ones reachable through a vertex, and this
42
+ * is the map that says which those are. Scanning the whole mesh to discover that answer is what
43
+ * the first version here did: 128 triangles took 16 ms, 2,048 took 1.9 s, 4,608 took 9.7 s, and a
44
+ * million-triangle asset extrapolated to days.
45
+ *
46
+ * Two passes and no arrays of arrays, because a million-triangle mesh is a million small
47
+ * allocations otherwise.
48
+ */
49
+ function vertexTriangles(indices, vertices) {
50
+ const triangles = Math.floor(indices.length / 3);
51
+ const starts = new Uint32Array(vertices + 1);
52
+ for (let i = 0; i < triangles * 3; i += 1)
53
+ starts[indices[i] + 1] += 1;
54
+ for (let v = 0; v < vertices; v += 1) {
55
+ starts[v + 1] = starts[v + 1] + starts[v];
56
+ }
57
+ const cursor = Uint32Array.from(starts.subarray(0, vertices));
58
+ const list = new Uint32Array(triangles * 3);
59
+ for (let t = 0; t < triangles; t += 1) {
60
+ for (let corner = 0; corner < 3; corner += 1) {
61
+ const v = indices[t * 3 + corner];
62
+ list[cursor[v]] = t;
63
+ cursor[v] = cursor[v] + 1;
64
+ }
65
+ }
66
+ return { starts, list };
67
+ }
68
+ /**
69
+ * Each index replaced by the first vertex standing where it stands.
70
+ *
71
+ * **The first, in vertex order**, so the answer depends on the mesh and not on a hash table's
72
+ * iteration — which is the reproducibility the tie-break above is written for. Keyed on the
73
+ * numbers rather than their bits, so a corner at `-0` meets one at `0`.
74
+ */
75
+ function canonicalIndices(indices, positions, vertices) {
76
+ const first = new Map();
77
+ const canonical = new Uint32Array(vertices);
78
+ for (let v = 0; v < vertices; v += 1) {
79
+ const key = `${positions[v * 3]},${positions[v * 3 + 1]},${positions[v * 3 + 2]}`;
80
+ const known = first.get(key);
81
+ if (known === undefined) {
82
+ first.set(key, v);
83
+ canonical[v] = v;
84
+ }
85
+ else {
86
+ canonical[v] = known;
87
+ }
88
+ }
89
+ return Uint32Array.from(indices, (index) => canonical[index]);
90
+ }
91
+ export function buildClusters(mesh, target, options = {}) {
92
+ const source = mesh.indices;
93
+ const triangles = Math.floor(source.length / 3);
94
+ const positions = mesh.positions;
95
+ const vertices = Math.floor(positions.length / 3);
96
+ const assigned = new Uint8Array(triangles);
97
+ const indices = new Uint32Array(source.length);
98
+ const offsets = [];
99
+ const counts = [];
100
+ /*
101
+ * **Who neighbours whom**, which is the source itself unless the caller asked for positions.
102
+ * Growth and scoring read this; what is written out reads `source`.
103
+ */
104
+ const linked = options.share === 'position' ? canonicalIndices(source, positions, vertices) : source;
105
+ const adjacency = vertexTriangles(linked, vertices);
106
+ const member = [];
107
+ /*
108
+ * The frontier: unassigned triangles that share at least one vertex with a member, and the best
109
+ * score each has against any member so far. `score` is indexed by triangle and is only read for
110
+ * triangles in `frontier`, so it never has to be cleared between clusters.
111
+ */
112
+ const frontier = [];
113
+ const score = new Uint8Array(triangles);
114
+ const queued = new Uint8Array(triangles);
115
+ let written = 0;
116
+ let placed = 0;
117
+ /* The lowest unassigned triangle seeds the next cluster, so the walk is reproducible. Monotone,
118
+ because every triangle below it is assigned — scanning from zero each time is a second
119
+ quadratic term hiding behind the first. */
120
+ let nextSeed = 0;
121
+ while (placed < triangles) {
122
+ while (nextSeed < triangles && assigned[nextSeed] === 1)
123
+ nextSeed += 1;
124
+ if (nextSeed >= triangles)
125
+ break;
126
+ const seed = nextSeed;
127
+ member.length = 0;
128
+ frontier.length = 0;
129
+ member.push(seed);
130
+ assigned[seed] = 1;
131
+ placed += 1;
132
+ offer(seed);
133
+ while (member.length < target) {
134
+ /*
135
+ * The best score, ties broken by the lowest triangle index — the same answer the whole-mesh
136
+ * scan gave, because it ran in index order and compared strictly greater. The frontier is
137
+ * not in index order, so the tie-break is written out rather than inherited from the loop.
138
+ */
139
+ let best = -1;
140
+ let bestScore = 0;
141
+ let live = 0;
142
+ for (let i = 0; i < frontier.length; i += 1) {
143
+ const t = frontier[i];
144
+ if (assigned[t] === 1) {
145
+ queued[t] = 0;
146
+ continue;
147
+ }
148
+ /* Compacted in place as it is scanned, so a consumed frontier entry is not walked again. */
149
+ frontier[live] = t;
150
+ live += 1;
151
+ const s = score[t];
152
+ if (s > bestScore || (s === bestScore && best >= 0 && t < best)) {
153
+ bestScore = s;
154
+ best = t;
155
+ }
156
+ }
157
+ frontier.length = live;
158
+ if (best === -1 || bestScore <= 0)
159
+ break;
160
+ member.push(best);
161
+ assigned[best] = 1;
162
+ placed += 1;
163
+ offer(best);
164
+ }
165
+ /*
166
+ * **`queued` is per cluster and the frontier is where it is recorded.** Leaving a triangle
167
+ * marked from a cluster that never took it makes it invisible to every cluster afterwards —
168
+ * `offer` would update its score and never put it on the new frontier — and what that looks
169
+ * like is growth stopping early everywhere: 25,088 triangles came out as 11,860 clusters
170
+ * rather than 196.
171
+ *
172
+ * **The frontier alone is enough and a second loop over the members is not.** Every member
173
+ * except the seed was offered before it was chosen, so it is in this array; the seed was
174
+ * already assigned when its own `offer` ran and was never marked. A loop over the members
175
+ * stood here and a perturbation survived it.
176
+ */
177
+ for (const t of frontier)
178
+ queued[t] = 0;
179
+ offsets.push(written / 3);
180
+ counts.push(member.length);
181
+ for (const t of member) {
182
+ indices[written] = source[t * 3];
183
+ indices[written + 1] = source[t * 3 + 1];
184
+ indices[written + 2] = source[t * 3 + 2];
185
+ written += 3;
186
+ }
187
+ }
188
+ /** Raise every neighbour of a new member to the score it now has against the cluster. */
189
+ function offer(added) {
190
+ for (let corner = 0; corner < 3; corner += 1) {
191
+ const v = linked[added * 3 + corner];
192
+ const from = adjacency.starts[v];
193
+ const to = adjacency.starts[v + 1];
194
+ for (let i = from; i < to; i += 1) {
195
+ const t = adjacency.list[i];
196
+ /* Already in a cluster. The frontier scan refuses it again, so deleting this changes
197
+ no answer — it is a cost guard, and the cost is 105 ms against 89 ms over a mesh of
198
+ 100,352 triangles, which is an eighteenth of the bake. A perturbation survives it. */
199
+ if (assigned[t] === 1)
200
+ continue;
201
+ const shares = shared(linked, added, linked, t);
202
+ if (queued[t] === 0) {
203
+ queued[t] = 1;
204
+ score[t] = shares;
205
+ frontier.push(t);
206
+ }
207
+ else if (shares > score[t]) {
208
+ score[t] = shares;
209
+ }
210
+ }
211
+ }
212
+ }
213
+ const count = offsets.length;
214
+ const set = {
215
+ triangleOffsets: Uint32Array.from(offsets),
216
+ triangleCounts: Uint32Array.from(counts),
217
+ indices,
218
+ boundsCentre: new Float32Array(count * 3),
219
+ boundsRadius: new Float32Array(count),
220
+ coneAxis: new Float32Array(count * 3),
221
+ coneCutoff: new Float32Array(count),
222
+ count,
223
+ };
224
+ const normal = new Float32Array(3);
225
+ for (let c = 0; c < count; c += 1) {
226
+ const at = set.triangleOffsets[c];
227
+ const n = set.triangleCounts[c];
228
+ let cx = 0;
229
+ let cy = 0;
230
+ let cz = 0;
231
+ let vertices = 0;
232
+ for (let i = at * 3; i < (at + n) * 3; i += 1) {
233
+ const v = indices[i] * 3;
234
+ cx += positions[v];
235
+ cy += positions[v + 1];
236
+ cz += positions[v + 2];
237
+ vertices += 1;
238
+ }
239
+ cx /= vertices;
240
+ cy /= vertices;
241
+ cz /= vertices;
242
+ let radius = 0;
243
+ for (let i = at * 3; i < (at + n) * 3; i += 1) {
244
+ const v = indices[i] * 3;
245
+ radius = Math.max(radius, Math.hypot(positions[v] - cx, positions[v + 1] - cy, positions[v + 2] - cz));
246
+ }
247
+ let ax = 0;
248
+ let ay = 0;
249
+ let az = 0;
250
+ for (let t = at; t < at + n; t += 1) {
251
+ faceNormal(positions, indices, t, normal);
252
+ ax += normal[0];
253
+ ay += normal[1];
254
+ az += normal[2];
255
+ }
256
+ const axisLength = Math.hypot(ax, ay, az);
257
+ if (axisLength > 0) {
258
+ ax /= axisLength;
259
+ ay /= axisLength;
260
+ az /= axisLength;
261
+ }
262
+ let cutoff = 1;
263
+ for (let t = at; t < at + n; t += 1) {
264
+ faceNormal(positions, indices, t, normal);
265
+ cutoff = Math.min(cutoff, normal[0] * ax + normal[1] * ay + normal[2] * az);
266
+ }
267
+ set.boundsCentre[c * 3] = cx;
268
+ set.boundsCentre[c * 3 + 1] = cy;
269
+ set.boundsCentre[c * 3 + 2] = cz;
270
+ set.boundsRadius[c] = radius;
271
+ set.coneAxis[c * 3] = ax;
272
+ set.coneAxis[c * 3 + 1] = ay;
273
+ set.coneAxis[c * 3 + 2] = az;
274
+ set.coneCutoff[c] = cutoff;
275
+ }
276
+ return set;
277
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The level-of-detail graph over a mesh's clusters, and the property the whole pipeline rests on.
3
+ *
4
+ * **A cluster's own error must never exceed its parent's.** Given that, a cut chosen independently
5
+ * per cluster — draw this one if its projected error is under the threshold, otherwise ask its
6
+ * parent — is guaranteed crack-free, because two adjacent clusters can never choose levels whose
7
+ * shared edge was simplified differently. Without it the pipeline draws holes, and the holes appear
8
+ * at viewing distances nobody tested. The property holds here **by construction**: each level's
9
+ * error is forced up to at least its children's maximum.
10
+ *
11
+ * **Simplification is vertex clustering on a grid, not edge collapse, and that is a deliberate
12
+ * first implementation.** Grid snapping is deterministic without a tie-break argument, needs no
13
+ * dependency, and — the part that matters — has an error bound that is *known in advance* rather
14
+ * than measured after the fact: no vertex moves further than half a cell diagonal. A quadric edge
15
+ * collapse would give a better-looking result at the same triangle count and would have to
16
+ * measure its error to keep the invariant above. The graph shape, the error discipline and every
17
+ * consumer of both are identical either way, so the better simplifier is a drop-in replacement
18
+ * for `simplifyGroup` and nothing else.
19
+ */
20
+ import type { MeshData } from '@driftengine/drft';
21
+ import type { ClusterSet } from './cluster.ts';
22
+ export interface ClusterLevel {
23
+ /** The clusters at this level. Level 0 is the source mesh's. */
24
+ set: ClusterSet;
25
+ /** Positions the level's indices address. Level 0 shares the source mesh's. */
26
+ positions: Float32Array;
27
+ /** Geometric error each cluster at this level carries. Zero at level 0. */
28
+ ownError: Float32Array;
29
+ /** The error of the group that replaces this cluster one level up, or Infinity at the root. */
30
+ parentError: Float32Array;
31
+ /** Which group of this level was simplified together. Clusters sharing one share a parent. */
32
+ groupOf: Uint32Array;
33
+ }
34
+ export interface ClusterDag {
35
+ levels: ClusterLevel[];
36
+ }
37
+ export declare function buildClusterDag(set: ClusterSet, mesh: MeshData, options?: {
38
+ target?: number;
39
+ maxLevels?: number;
40
+ }): ClusterDag;
@@ -0,0 +1,162 @@
1
+ import { buildClusters } from './cluster.js';
2
+ /**
3
+ * The mean length of a triangle edge, which is the mesh's own sense of how far apart its detail is.
4
+ *
5
+ * Sampled over every triangle rather than every unique edge: each interior edge is counted twice
6
+ * and each boundary edge once, which biases the mean by a fraction of a percent on any mesh large
7
+ * enough to matter and saves building an edge set.
8
+ */
9
+ function meanEdgeLength(positions, indices) {
10
+ let total = 0;
11
+ let count = 0;
12
+ for (let t = 0; t < indices.length / 3; t += 1) {
13
+ for (let e = 0; e < 3; e += 1) {
14
+ const a = indices[t * 3 + e] * 3;
15
+ const b = indices[t * 3 + ((e + 1) % 3)] * 3;
16
+ total += Math.hypot(positions[b] - positions[a], positions[b + 1] - positions[a + 1], positions[b + 2] - positions[a + 2]);
17
+ count += 1;
18
+ }
19
+ }
20
+ return count === 0 ? 0 : total / count;
21
+ }
22
+ /** How far a vertex can move when snapped to a grid of this size: half the cell diagonal. */
23
+ function snapError(cell) {
24
+ return (cell * Math.sqrt(3)) / 2;
25
+ }
26
+ /**
27
+ * Collapse a group's triangles by snapping vertices to a grid and dropping what degenerates.
28
+ *
29
+ * Returns the surviving geometry and the error bound, which is a property of the grid rather than
30
+ * a measurement of the result — see the header for why that matters.
31
+ */
32
+ function simplifyGroup(positions, indices, cell) {
33
+ const keyOf = new Map();
34
+ const remap = new Map();
35
+ const out = [];
36
+ for (let i = 0; i < indices.length; i += 1) {
37
+ const v = indices[i];
38
+ if (remap.has(v))
39
+ continue;
40
+ const at = v * 3;
41
+ const gx = Math.round(positions[at] / cell);
42
+ const gy = Math.round(positions[at + 1] / cell);
43
+ const gz = Math.round(positions[at + 2] / cell);
44
+ const key = `${gx},${gy},${gz}`;
45
+ let slot = keyOf.get(key);
46
+ if (slot === undefined) {
47
+ slot = out.length / 3;
48
+ keyOf.set(key, slot);
49
+ out.push(gx * cell, gy * cell, gz * cell);
50
+ }
51
+ remap.set(v, slot);
52
+ }
53
+ const kept = [];
54
+ for (let t = 0; t < indices.length / 3; t += 1) {
55
+ const a = remap.get(indices[t * 3]);
56
+ const b = remap.get(indices[t * 3 + 1]);
57
+ const c = remap.get(indices[t * 3 + 2]);
58
+ /* A triangle whose corners snapped together has no area and is dropped, not kept flat. */
59
+ if (a === b || b === c || a === c)
60
+ continue;
61
+ kept.push(a, b, c);
62
+ }
63
+ return {
64
+ positions: Float32Array.from(out),
65
+ indices: Uint32Array.from(kept),
66
+ error: snapError(cell),
67
+ };
68
+ }
69
+ /**
70
+ * Group clusters for simplification, by proximity, `size` at a time.
71
+ *
72
+ * Ordered by cluster index rather than by a spatial sort, then filled greedily from the nearest
73
+ * unassigned centroid — deterministic, and close enough to spatial that groups are contiguous on
74
+ * ordinary geometry. Clusters in one group are simplified together, so edges interior to the group
75
+ * may collapse and edges on its boundary may not.
76
+ */
77
+ function groupClusters(set, size) {
78
+ const groupOf = new Uint32Array(set.count).fill(0xffffffff);
79
+ let group = 0;
80
+ for (let seed = 0; seed < set.count; seed += 1) {
81
+ if (groupOf[seed] !== 0xffffffff)
82
+ continue;
83
+ groupOf[seed] = group;
84
+ let taken = 1;
85
+ while (taken < size) {
86
+ let best = -1;
87
+ let bestDistance = Infinity;
88
+ for (let c = 0; c < set.count; c += 1) {
89
+ if (groupOf[c] !== 0xffffffff)
90
+ continue;
91
+ const d = Math.hypot(set.boundsCentre[c * 3] - set.boundsCentre[seed * 3], set.boundsCentre[c * 3 + 1] - set.boundsCentre[seed * 3 + 1], set.boundsCentre[c * 3 + 2] - set.boundsCentre[seed * 3 + 2]);
92
+ if (d < bestDistance) {
93
+ bestDistance = d;
94
+ best = c;
95
+ }
96
+ }
97
+ if (best === -1)
98
+ break;
99
+ groupOf[best] = group;
100
+ taken += 1;
101
+ }
102
+ group += 1;
103
+ }
104
+ return groupOf;
105
+ }
106
+ const GROUP_SIZE = 4;
107
+ export function buildClusterDag(set, mesh, options = {}) {
108
+ const target = options.target ?? 128;
109
+ const maxLevels = options.maxLevels ?? 8;
110
+ const levels = [];
111
+ let currentSet = set;
112
+ let currentPositions = mesh.positions;
113
+ let currentError = new Float32Array(set.count);
114
+ /*
115
+ * The starting cell comes from the mesh's own edge length, not from its extent.
116
+ *
117
+ * Derived from the extent it is arbitrary, and arbitrary here means wrong in a way that looks
118
+ * like the algorithm failing: a cell finer than the vertex spacing merges nothing, the triangle
119
+ * count does not fall, and the build stops at one level believing the mesh cannot be
120
+ * simplified. Starting a little above the mean edge collapses neighbouring vertices on the
121
+ * first pass, which is what the doubling then builds on.
122
+ */
123
+ let cell = Math.max(1e-3, meanEdgeLength(mesh.positions, set.indices) * 1.5);
124
+ for (let level = 0; level < maxLevels; level += 1) {
125
+ const groupOf = groupClusters(currentSet, GROUP_SIZE);
126
+ const parentError = new Float32Array(currentSet.count);
127
+ const last = currentSet.count <= 1;
128
+ if (last) {
129
+ parentError.fill(Infinity);
130
+ levels.push({
131
+ set: currentSet,
132
+ positions: currentPositions,
133
+ ownError: currentError,
134
+ parentError,
135
+ groupOf,
136
+ });
137
+ break;
138
+ }
139
+ /* Every cluster of a group takes the group's error, so two in one group cannot disagree. */
140
+ const error = Math.max(snapError(cell), ...Array.from(currentError));
141
+ for (let c = 0; c < currentSet.count; c += 1)
142
+ parentError[c] = error;
143
+ levels.push({
144
+ set: currentSet,
145
+ positions: currentPositions,
146
+ ownError: currentError,
147
+ parentError,
148
+ groupOf,
149
+ });
150
+ const simplified = simplifyGroup(currentPositions, currentSet.indices, cell);
151
+ if (simplified.indices.length === 0 || simplified.indices.length >= currentSet.indices.length) {
152
+ break;
153
+ }
154
+ const nextMesh = { ...mesh, positions: simplified.positions, indices: simplified.indices };
155
+ const nextSet = buildClusters(nextMesh, target);
156
+ currentSet = nextSet;
157
+ currentPositions = simplified.positions;
158
+ currentError = new Float32Array(nextSet.count).fill(error);
159
+ cell *= 2;
160
+ }
161
+ return { levels };
162
+ }
@@ -0,0 +1,52 @@
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 { type ChannelSpec } from '@driftengine/texture';
21
+ import type { EncodedMaterial } from './latent.ts';
22
+ /** Where each tile's bytes sit in the latent payload, and what they hash to. */
23
+ export interface DtexTiles {
24
+ readonly offset: Uint32Array;
25
+ readonly length: Uint32Array;
26
+ /** Two words a tile, high first, as `hashTile` splits its 64 bits. */
27
+ readonly hash: Uint32Array;
28
+ /** The payload the offsets index: tile-major, each tile's bytes in one run. */
29
+ readonly payload: Uint8Array;
30
+ }
31
+ /**
32
+ * A latent cut into tiles, in the grid's order, with identical tiles sharing one run.
33
+ *
34
+ * **Tile-major, because a tile of a row-major image is not contiguous** — its rows are a stride
35
+ * apart, so no offset and length can name one, which is why the table this replaces could not be
36
+ * written. Cut across a row and then down, the order `@driftengine/texture`'s `latentTileGrid`
37
+ * uses, so the entry for a tile is `ty * across + tx` and a reader with a coordinate can find it.
38
+ *
39
+ * **Deduplicated by content**, which is the whole reason the hash is in the file: a flat surface
40
+ * tiles into hundreds of identical squares, and a material that stored each of them separately
41
+ * would pay for the flatness. Two tiles with the same bytes get the same offset and length.
42
+ */
43
+ export declare function cutLatentTiles(latent: Uint8Array, width: number, height: number, components: number, tileSize: number): DtexTiles;
44
+ /**
45
+ * `encoded` as a `DtexMaterial`, with `channels` saying what each of its outputs is.
46
+ *
47
+ * A channel is packed as `(semanticIndex << 4) | component`, which is what the decode interpreter
48
+ * unpacks and what the chunk stores. **A semantic this build does not know is refused** rather than
49
+ * written as something else: the index is the format's, and a wrong one is a channel that decodes
50
+ * as another channel, which reads as a shading fault in a scene nobody has changed.
51
+ */
52
+ export declare function dtexFromEncoded(encoded: EncodedMaterial, channels: readonly ChannelSpec[], tileSize?: number): DtexMaterial;