@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.
- package/NOTICE +20 -0
- package/dist/bake/cluster.d.ts +49 -0
- package/dist/bake/cluster.js +277 -0
- package/dist/bake/clusterLod.d.ts +40 -0
- package/dist/bake/clusterLod.js +162 -0
- package/dist/bake/dtexChunk.d.ts +52 -0
- package/dist/bake/dtexChunk.js +103 -0
- package/dist/bake/hlod.d.ts +129 -0
- package/dist/bake/hlod.js +403 -0
- package/dist/bake/latent.d.ts +84 -0
- package/dist/bake/latent.js +339 -0
- package/dist/bake/sdf.d.ts +45 -0
- package/dist/bake/sdf.js +370 -0
- package/dist/drftLoader.d.ts +16 -0
- package/dist/drftLoader.js +23 -4
- package/dist/dtexTiles.d.ts +34 -0
- package/dist/dtexTiles.js +37 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +35 -0
- package/package.json +4 -3
- package/src/bake/cluster.ts +361 -0
- package/src/bake/clusterLod.ts +228 -0
- package/src/bake/dtexChunk.ts +156 -0
- package/src/bake/hlod.ts +487 -0
- package/src/bake/latent.ts +427 -0
- package/src/bake/sdf.ts +468 -0
- package/src/drftLoader.ts +41 -3
- package/src/dtexTiles.ts +61 -0
- package/src/index.ts +40 -0
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;
|