@driftengine/drft 4.2.1 → 4.4.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/README.md +1 -1
- package/dist/drftFormat.d.ts +31 -1
- package/dist/drftFormat.js +17 -2
- package/dist/drftInstances.d.ts +9 -0
- package/dist/drftInstances.js +68 -0
- package/dist/drftLights.d.ts +21 -0
- package/dist/drftLights.js +108 -0
- package/dist/drftQuantised.d.ts +3 -0
- package/dist/drftQuantised.js +362 -0
- package/dist/drftRead.d.ts +9 -0
- package/dist/drftRead.js +22 -1
- package/dist/drftStream.d.ts +43 -1
- package/dist/drftStream.js +41 -6
- package/dist/drftWrite.d.ts +15 -0
- package/dist/drftWrite.js +110 -20
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -2
- package/dist/meshData.js +9 -2
- package/dist/sdfv.d.ts +15 -0
- package/dist/sdfv.js +15 -0
- package/package.json +4 -4
- package/src/drftFormat.ts +36 -2
- package/src/drftInstances.ts +81 -0
- package/src/drftLights.ts +131 -0
- package/src/drftQuantised.ts +401 -0
- package/src/drftRead.ts +32 -0
- package/src/drftStream.ts +88 -4
- package/src/drftWrite.ts +125 -13
- package/src/index.ts +14 -3
- package/src/meshData.ts +9 -2
- package/src/sdfv.ts +16 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MSHQ`: a mesh stored in the bytes its data needs, decoded on read into the same `MeshData` a
|
|
3
|
+
* `MESH` gives. Added in 1.18.
|
|
4
|
+
*
|
|
5
|
+
* **Why a second mesh chunk rather than a flag in `MESH`.** `MESH`'s reserved word must be ignored
|
|
6
|
+
* by readers (§4.4 rule 6), so a flag there would let an older reader read 16-bit numbers as floats
|
|
7
|
+
* and draw noise without a word. A new FourCC, marked required, makes that reader refuse by name.
|
|
8
|
+
*
|
|
9
|
+
* **One encoding per attribute, chosen from what the data allows:**
|
|
10
|
+
* - constant, when every vertex carries one value: a bought scene's colour and emissive, 16 bytes a
|
|
11
|
+
* vertex of nothing;
|
|
12
|
+
* - 16 bits over the attribute's own range, for positions, UVs, scalars and weights: half a step of
|
|
13
|
+
* error, 0.27 mm across 36 m;
|
|
14
|
+
* - octahedral in two 16-bit numbers, for unit normals, and for unit tangents with the handedness
|
|
15
|
+
* in a sign byte: within 5e-5 rad, 0.003°, measured over the sphere;
|
|
16
|
+
* - raw floats, where exactness is the contract (joints) or where a "direction" is not unit length
|
|
17
|
+
* and an octahedral code would bend it into one: data already malformed is carried as it came.
|
|
18
|
+
*
|
|
19
|
+
* Indices are 16-bit when the mesh has at most 65,536 vertices.
|
|
20
|
+
*
|
|
21
|
+
* What it gives up is exactness: a quantised mesh is within its bound of the source, not equal to it,
|
|
22
|
+
* and it is no longer a zero-copy view of the file. What would make that wrong is a consumer that
|
|
23
|
+
* compares vertices bit for bit, or one that cannot afford the decode's copy; `writeDrft`'s
|
|
24
|
+
* `quantise: false` writes `MESH` as before.
|
|
25
|
+
*/
|
|
26
|
+
import { ATTR_EMISSIVE_COLOR, ATTR_GRAIN, ATTR_JOINTS, ATTR_RELIEF, ATTR_ROUGHNESS, ATTR_SPECULAR, ATTR_TANGENT, ATTR_UVS, ATTR_WEIGHTS, DrftError, align, } from './drftFormat.js';
|
|
27
|
+
const RAW = 0;
|
|
28
|
+
const CONSTANT = 1;
|
|
29
|
+
const RANGE16 = 2;
|
|
30
|
+
const OCTAHEDRAL = 3;
|
|
31
|
+
const OCTAHEDRAL_SIGNED = 4;
|
|
32
|
+
/** Header flag: the indices are 16-bit. */
|
|
33
|
+
const INDICES_16 = 1;
|
|
34
|
+
/** The frozen order `MESH` uses, with each optional array's bit: `FORMAT.md` §4.3. */
|
|
35
|
+
const ORDER = [
|
|
36
|
+
{ name: 'positions', width: 3, bit: 0, kind: 'value' },
|
|
37
|
+
{ name: 'normals', width: 3, bit: 0, kind: 'direction' },
|
|
38
|
+
{ name: 'colors', width: 3, bit: 0, kind: 'value' },
|
|
39
|
+
{ name: 'emissive', width: 1, bit: 0, kind: 'value' },
|
|
40
|
+
{ name: 'specular', width: 1, bit: ATTR_SPECULAR, kind: 'value' },
|
|
41
|
+
{ name: 'uvs', width: 2, bit: ATTR_UVS, kind: 'value' },
|
|
42
|
+
{ name: 'emissiveColor', width: 3, bit: ATTR_EMISSIVE_COLOR, kind: 'value' },
|
|
43
|
+
{ name: 'roughness', width: 1, bit: ATTR_ROUGHNESS, kind: 'value' },
|
|
44
|
+
{ name: 'grain', width: 1, bit: ATTR_GRAIN, kind: 'value' },
|
|
45
|
+
{ name: 'relief', width: 1, bit: ATTR_RELIEF, kind: 'value' },
|
|
46
|
+
{ name: 'tangents', width: 4, bit: ATTR_TANGENT, kind: 'direction' },
|
|
47
|
+
{ name: 'joints', width: 4, bit: ATTR_JOINTS, kind: 'exact' },
|
|
48
|
+
{ name: 'weights', width: 4, bit: ATTR_WEIGHTS, kind: 'value' },
|
|
49
|
+
];
|
|
50
|
+
/** A growing little-endian byte list, for the writer: offline, so it may allocate. */
|
|
51
|
+
class Bytes {
|
|
52
|
+
data = new Uint8Array(1024);
|
|
53
|
+
view = new DataView(this.data.buffer);
|
|
54
|
+
length = 0;
|
|
55
|
+
room(n) {
|
|
56
|
+
if (this.length + n <= this.data.length)
|
|
57
|
+
return;
|
|
58
|
+
let size = this.data.length * 2;
|
|
59
|
+
while (size < this.length + n)
|
|
60
|
+
size *= 2;
|
|
61
|
+
const next = new Uint8Array(size);
|
|
62
|
+
next.set(this.data.subarray(0, this.length));
|
|
63
|
+
this.data = next;
|
|
64
|
+
this.view = new DataView(next.buffer);
|
|
65
|
+
}
|
|
66
|
+
u8(v) {
|
|
67
|
+
this.room(1);
|
|
68
|
+
this.view.setUint8(this.length, v);
|
|
69
|
+
this.length += 1;
|
|
70
|
+
}
|
|
71
|
+
u16(v) {
|
|
72
|
+
this.room(2);
|
|
73
|
+
this.view.setUint16(this.length, v, true);
|
|
74
|
+
this.length += 2;
|
|
75
|
+
}
|
|
76
|
+
i16(v) {
|
|
77
|
+
this.room(2);
|
|
78
|
+
this.view.setInt16(this.length, v, true);
|
|
79
|
+
this.length += 2;
|
|
80
|
+
}
|
|
81
|
+
u32(v) {
|
|
82
|
+
this.room(4);
|
|
83
|
+
this.view.setUint32(this.length, v, true);
|
|
84
|
+
this.length += 4;
|
|
85
|
+
}
|
|
86
|
+
f32(v) {
|
|
87
|
+
this.room(4);
|
|
88
|
+
this.view.setFloat32(this.length, v, true);
|
|
89
|
+
this.length += 4;
|
|
90
|
+
}
|
|
91
|
+
pad() {
|
|
92
|
+
while (this.length % 4 !== 0)
|
|
93
|
+
this.u8(0);
|
|
94
|
+
}
|
|
95
|
+
finish() {
|
|
96
|
+
this.pad();
|
|
97
|
+
return this.data.slice(0, align(this.length));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function encodeQuantisedMesh(mesh) {
|
|
101
|
+
const vertices = mesh.positions.length / 3;
|
|
102
|
+
let attributes = 0;
|
|
103
|
+
for (const entry of ORDER)
|
|
104
|
+
if (entry.bit !== 0 && mesh[entry.name] !== undefined)
|
|
105
|
+
attributes |= entry.bit;
|
|
106
|
+
if (((attributes & ATTR_JOINTS) !== 0) !== ((attributes & ATTR_WEIGHTS) !== 0)) {
|
|
107
|
+
throw new DrftError('a mesh carries joints without weights, or weights without joints');
|
|
108
|
+
}
|
|
109
|
+
const narrow = vertices <= 65_536;
|
|
110
|
+
const out = new Bytes();
|
|
111
|
+
out.u32(vertices);
|
|
112
|
+
out.u32(mesh.indices.length);
|
|
113
|
+
out.u32(attributes);
|
|
114
|
+
out.u32(narrow ? INDICES_16 : 0);
|
|
115
|
+
for (const entry of ORDER) {
|
|
116
|
+
const values = mesh[entry.name];
|
|
117
|
+
if (values === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
writeAttribute(out, values, vertices, entry.width, entry.kind);
|
|
120
|
+
}
|
|
121
|
+
for (let i = 0; i < mesh.indices.length; i++) {
|
|
122
|
+
const index = mesh.indices[i];
|
|
123
|
+
if (narrow)
|
|
124
|
+
out.u16(index);
|
|
125
|
+
else
|
|
126
|
+
out.u32(index);
|
|
127
|
+
}
|
|
128
|
+
return out.finish();
|
|
129
|
+
}
|
|
130
|
+
function writeAttribute(out, values, vertices, width, kind) {
|
|
131
|
+
const encoding = choose(values, vertices, width, kind);
|
|
132
|
+
out.u8(encoding);
|
|
133
|
+
out.u8(width);
|
|
134
|
+
out.u16(0);
|
|
135
|
+
if (encoding === CONSTANT) {
|
|
136
|
+
for (let c = 0; c < width; c++)
|
|
137
|
+
out.f32(vertices === 0 ? 0 : values[c]);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (encoding === RAW) {
|
|
141
|
+
for (let i = 0; i < vertices * width; i++)
|
|
142
|
+
out.f32(values[i]);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (encoding === RANGE16) {
|
|
146
|
+
const lo = new Float64Array(width).fill(Infinity);
|
|
147
|
+
const hi = new Float64Array(width).fill(-Infinity);
|
|
148
|
+
for (let v = 0; v < vertices; v++) {
|
|
149
|
+
for (let c = 0; c < width; c++) {
|
|
150
|
+
const x = values[v * width + c];
|
|
151
|
+
if (x < lo[c])
|
|
152
|
+
lo[c] = x;
|
|
153
|
+
if (x > hi[c])
|
|
154
|
+
hi[c] = x;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (let c = 0; c < width; c++)
|
|
158
|
+
out.f32(lo[c]);
|
|
159
|
+
for (let c = 0; c < width; c++)
|
|
160
|
+
out.f32(hi[c]);
|
|
161
|
+
/* Quantised against the float32 the reader will see, so the reader's arithmetic is the same. */
|
|
162
|
+
const flo = Float32Array.from(lo);
|
|
163
|
+
const fhi = Float32Array.from(hi);
|
|
164
|
+
for (let v = 0; v < vertices; v++) {
|
|
165
|
+
for (let c = 0; c < width; c++) {
|
|
166
|
+
const span = fhi[c] - flo[c];
|
|
167
|
+
const x = values[v * width + c];
|
|
168
|
+
out.u16(span > 0 ? Math.round(((x - flo[c]) / span) * 65535) : 0);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
out.pad();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
/* Octahedral: two snorm16 a direction, and for a tangent a sign byte after them all. */
|
|
175
|
+
for (let v = 0; v < vertices; v++) {
|
|
176
|
+
const [u, w] = octEncode(values[v * width], values[v * width + 1], values[v * width + 2]);
|
|
177
|
+
out.i16(u);
|
|
178
|
+
out.i16(w);
|
|
179
|
+
}
|
|
180
|
+
if (encoding === OCTAHEDRAL_SIGNED) {
|
|
181
|
+
for (let v = 0; v < vertices; v++)
|
|
182
|
+
out.u8(values[v * 4 + 3] < 0 ? 1 : 0);
|
|
183
|
+
out.pad();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function choose(values, vertices, width, kind) {
|
|
187
|
+
if (kind === 'exact')
|
|
188
|
+
return RAW;
|
|
189
|
+
let constant = true;
|
|
190
|
+
for (let v = 1; v < vertices && constant; v++) {
|
|
191
|
+
for (let c = 0; c < width; c++) {
|
|
192
|
+
if (values[v * width + c] !== values[c]) {
|
|
193
|
+
constant = false;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (constant)
|
|
199
|
+
return CONSTANT;
|
|
200
|
+
if (kind === 'direction') {
|
|
201
|
+
/* Only unit directions, and for a tangent only a handedness of exactly ±1, take a code. */
|
|
202
|
+
for (let v = 0; v < vertices; v++) {
|
|
203
|
+
const x = values[v * width];
|
|
204
|
+
const y = values[v * width + 1];
|
|
205
|
+
const z = values[v * width + 2];
|
|
206
|
+
if (Math.abs(Math.hypot(x, y, z) - 1) > 1e-3)
|
|
207
|
+
return RAW;
|
|
208
|
+
if (width === 4 && Math.abs(values[v * 4 + 3]) !== 1)
|
|
209
|
+
return RAW;
|
|
210
|
+
}
|
|
211
|
+
return width === 4 ? OCTAHEDRAL_SIGNED : OCTAHEDRAL;
|
|
212
|
+
}
|
|
213
|
+
return RANGE16;
|
|
214
|
+
}
|
|
215
|
+
const SNORM = 32767;
|
|
216
|
+
/**
|
|
217
|
+
* The octahedral code of a unit direction: the nearest of the four 16-bit neighbours of the exact
|
|
218
|
+
* projection, measured by decoding each: the "precise" variant, whose worst over 200,000 directions
|
|
219
|
+
* is 4.3e-5 rad. Rounding each coordinate alone does noticeably worse near the folds.
|
|
220
|
+
*/
|
|
221
|
+
function octEncode(x, y, z) {
|
|
222
|
+
const l1 = Math.abs(x) + Math.abs(y) + Math.abs(z);
|
|
223
|
+
let u = x / l1;
|
|
224
|
+
let w = y / l1;
|
|
225
|
+
if (z < 0) {
|
|
226
|
+
const pu = u;
|
|
227
|
+
u = (1 - Math.abs(w)) * (pu >= 0 ? 1 : -1);
|
|
228
|
+
w = (1 - Math.abs(pu)) * (w >= 0 ? 1 : -1);
|
|
229
|
+
}
|
|
230
|
+
const fu = Math.floor(u * SNORM);
|
|
231
|
+
const fw = Math.floor(w * SNORM);
|
|
232
|
+
let best = [fu, fw];
|
|
233
|
+
let bestDot = -2;
|
|
234
|
+
for (let du = 0; du <= 1; du++) {
|
|
235
|
+
for (let dw = 0; dw <= 1; dw++) {
|
|
236
|
+
const qu = Math.max(-SNORM, Math.min(SNORM, fu + du));
|
|
237
|
+
const qw = Math.max(-SNORM, Math.min(SNORM, fw + dw));
|
|
238
|
+
octDecode(qu, qw, OCT_SCRATCH, 0);
|
|
239
|
+
const dot = OCT_SCRATCH[0] * x +
|
|
240
|
+
OCT_SCRATCH[1] * y +
|
|
241
|
+
OCT_SCRATCH[2] * z;
|
|
242
|
+
if (dot > bestDot) {
|
|
243
|
+
bestDot = dot;
|
|
244
|
+
best = [qu, qw];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return best;
|
|
249
|
+
}
|
|
250
|
+
/*
|
|
251
|
+
* Float64, because the choice is between codes a ten-thousandth of a radian apart and a float32 dot
|
|
252
|
+
* product near 1 cannot see differences below about 3e-4: its own rounding is that coarse.
|
|
253
|
+
*/
|
|
254
|
+
const OCT_SCRATCH = new Float64Array(3);
|
|
255
|
+
function octDecode(qu, qw, out, at) {
|
|
256
|
+
let x = Math.max(-1, qu / SNORM);
|
|
257
|
+
let y = Math.max(-1, qw / SNORM);
|
|
258
|
+
const z = 1 - Math.abs(x) - Math.abs(y);
|
|
259
|
+
if (z < 0) {
|
|
260
|
+
const px = x;
|
|
261
|
+
x = (1 - Math.abs(y)) * (px >= 0 ? 1 : -1);
|
|
262
|
+
y = (1 - Math.abs(px)) * (y >= 0 ? 1 : -1);
|
|
263
|
+
}
|
|
264
|
+
/*
|
|
265
|
+
* A square root rather than `Math.hypot`, which guards an overflow that three numbers bounded by
|
|
266
|
+
* one cannot reach and costs nearly three times as much for it: 0.7 s of a five-pack load was this
|
|
267
|
+
* function. The float32 a reader keeps agreed with `hypot`'s on every one of 263 million codes
|
|
268
|
+
* sampled across the whole lattice.
|
|
269
|
+
*/
|
|
270
|
+
const len = Math.sqrt(x * x + y * y + z * z);
|
|
271
|
+
out[at] = x / len;
|
|
272
|
+
out[at + 1] = y / len;
|
|
273
|
+
out[at + 2] = z / len;
|
|
274
|
+
}
|
|
275
|
+
export function decodeQuantisedMesh(buffer, offset, byteLength) {
|
|
276
|
+
const view = new DataView(buffer, offset, byteLength);
|
|
277
|
+
if (byteLength < 16)
|
|
278
|
+
throw new DrftError('MSHQ is shorter than its header');
|
|
279
|
+
const vertices = view.getUint32(0, true);
|
|
280
|
+
const indexCount = view.getUint32(4, true);
|
|
281
|
+
const attributes = view.getUint32(8, true);
|
|
282
|
+
const flags = view.getUint32(12, true);
|
|
283
|
+
let at = 16;
|
|
284
|
+
const need = (n, what) => {
|
|
285
|
+
if (at + n > byteLength)
|
|
286
|
+
throw new DrftError(`MSHQ ends inside ${what}`);
|
|
287
|
+
};
|
|
288
|
+
const out = {};
|
|
289
|
+
for (const entry of ORDER) {
|
|
290
|
+
if (entry.bit !== 0 && (attributes & entry.bit) === 0)
|
|
291
|
+
continue;
|
|
292
|
+
need(4, entry.name);
|
|
293
|
+
const encoding = view.getUint8(at);
|
|
294
|
+
const width = view.getUint8(at + 1);
|
|
295
|
+
at += 4;
|
|
296
|
+
if (width !== entry.width) {
|
|
297
|
+
throw new DrftError(`MSHQ ${entry.name} is ${width} wide, and the format says ${entry.width}`);
|
|
298
|
+
}
|
|
299
|
+
const values = new Float32Array(vertices * width);
|
|
300
|
+
if (encoding === CONSTANT) {
|
|
301
|
+
need(width * 4, entry.name);
|
|
302
|
+
for (let c = 0; c < width; c++) {
|
|
303
|
+
const x = view.getFloat32(at + c * 4, true);
|
|
304
|
+
for (let v = 0; v < vertices; v++)
|
|
305
|
+
values[v * width + c] = x;
|
|
306
|
+
}
|
|
307
|
+
at += width * 4;
|
|
308
|
+
}
|
|
309
|
+
else if (encoding === RAW) {
|
|
310
|
+
need(vertices * width * 4, entry.name);
|
|
311
|
+
for (let i = 0; i < vertices * width; i++)
|
|
312
|
+
values[i] = view.getFloat32(at + i * 4, true);
|
|
313
|
+
at += vertices * width * 4;
|
|
314
|
+
}
|
|
315
|
+
else if (encoding === RANGE16) {
|
|
316
|
+
need(width * 8 + vertices * width * 2, entry.name);
|
|
317
|
+
const lo = new Float32Array(width);
|
|
318
|
+
const hi = new Float32Array(width);
|
|
319
|
+
for (let c = 0; c < width; c++)
|
|
320
|
+
lo[c] = view.getFloat32(at + c * 4, true);
|
|
321
|
+
for (let c = 0; c < width; c++)
|
|
322
|
+
hi[c] = view.getFloat32(at + width * 4 + c * 4, true);
|
|
323
|
+
at += width * 8;
|
|
324
|
+
for (let v = 0; v < vertices; v++) {
|
|
325
|
+
for (let c = 0; c < width; c++) {
|
|
326
|
+
const q = view.getUint16(at + (v * width + c) * 2, true);
|
|
327
|
+
values[v * width + c] =
|
|
328
|
+
lo[c] + (q / 65535) * (hi[c] - lo[c]);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
at = align(at + vertices * width * 2);
|
|
332
|
+
}
|
|
333
|
+
else if (encoding === OCTAHEDRAL || encoding === OCTAHEDRAL_SIGNED) {
|
|
334
|
+
need(vertices * 4, entry.name);
|
|
335
|
+
for (let v = 0; v < vertices; v++) {
|
|
336
|
+
octDecode(view.getInt16(at + v * 4, true), view.getInt16(at + v * 4 + 2, true), values, v * width);
|
|
337
|
+
}
|
|
338
|
+
at += vertices * 4;
|
|
339
|
+
if (encoding === OCTAHEDRAL_SIGNED) {
|
|
340
|
+
need(vertices, `${entry.name}' handedness`);
|
|
341
|
+
for (let v = 0; v < vertices; v++)
|
|
342
|
+
values[v * 4 + 3] = view.getUint8(at + v) === 1 ? -1 : 1;
|
|
343
|
+
at = align(at + vertices);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
throw new DrftError(`MSHQ ${entry.name} has encoding ${encoding}, which this reader does not know`);
|
|
348
|
+
}
|
|
349
|
+
out[entry.name] = values;
|
|
350
|
+
}
|
|
351
|
+
const narrow = (flags & INDICES_16) !== 0;
|
|
352
|
+
need(indexCount * (narrow ? 2 : 4), 'its indices');
|
|
353
|
+
const indices = new Uint32Array(indexCount);
|
|
354
|
+
for (let i = 0; i < indexCount; i++) {
|
|
355
|
+
const index = narrow ? view.getUint16(at + i * 2, true) : view.getUint32(at + i * 4, true);
|
|
356
|
+
if (index >= vertices)
|
|
357
|
+
throw new DrftError(`MSHQ index ${i} names vertex ${index} of ${vertices}`);
|
|
358
|
+
indices[i] = index;
|
|
359
|
+
}
|
|
360
|
+
out['indices'] = indices;
|
|
361
|
+
return out;
|
|
362
|
+
}
|
package/dist/drftRead.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { type EntsScene } from './ents.ts';
|
|
|
24
24
|
import { type NavPolyMesh } from './navm.ts';
|
|
25
25
|
import { type DrftNetwork } from './nnet.ts';
|
|
26
26
|
import { type DrftGraph } from './ngrf.ts';
|
|
27
|
+
import type { DrftInstanceGroup } from './drftInstances.ts';
|
|
28
|
+
import type { DrftLight } from './drftLights.ts';
|
|
27
29
|
/** An embedded image, still compressed. Decoding is the consumer's, through `createImageBitmap`. */
|
|
28
30
|
export interface DrftTexture {
|
|
29
31
|
/** What the source called this image. Empty for a file written before names existed. */
|
|
@@ -96,6 +98,13 @@ export interface DrftAsset {
|
|
|
96
98
|
* behaves identically against a file with a hierarchy it ignores.
|
|
97
99
|
*/
|
|
98
100
|
readonly nodes: readonly DrftNode[];
|
|
101
|
+
/**
|
|
102
|
+
* Meshes drawn many times, each group naming the mesh that holds one copy and a matrix a copy.
|
|
103
|
+
* Empty for a file that instances nothing, which is every file before 1.18.
|
|
104
|
+
*/
|
|
105
|
+
readonly instances: readonly DrftInstanceGroup[];
|
|
106
|
+
/** The lights the scene was authored with, or empty. `LITE`, defined at 1.18. */
|
|
107
|
+
readonly lights: readonly DrftLight[];
|
|
99
108
|
/** Skins, in the order their chunks appear. Empty for a file that deforms nothing. */
|
|
100
109
|
readonly skins: readonly DrftSkin[];
|
|
101
110
|
/** Clips, in the order their chunks appear. Empty for a file that animates nothing. */
|
package/dist/drftRead.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { readClip, readMorph, readNodes, readSkin } from './drftSkin.js';
|
|
18
18
|
import { validateMeshData } from './meshData.js';
|
|
19
|
-
import { ATTR_EMISSIVE_COLOR, ATTR_GRAIN, ATTR_JOINTS, ATTR_RELIEF, ATTR_TANGENT, ATTR_ROUGHNESS, ATTR_SPECULAR, ATTR_UVS, ATTR_WEIGHTS, CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_LODM, CHUNK_LODF, CHUNK_DTEX, CHUNK_ENTS, CHUNK_NAVM, CHUNK_SPLT, SPLAT_BLOCK_PREFIX, CHUNK_MESH, CHUNK_REQUIRED, DRFT_MAGIC, DRFT_VERSION_MAJOR, DrftError, HEADER_BYTES, CHUNK_MATL, CHUNK_TEXS, KNOWN_CHUNKS, MATERIAL_ENTRY_BYTES, align, fourCCName, CHUNK_ANIM, CHUNK_NODE, CHUNK_SKIN, CHUNK_MORP, CHUNK_NNET, CHUNK_NGRF, CHUNK_SDFV, CHUNK_SUBS, CHUNK_COLL, } from './drftFormat.js';
|
|
19
|
+
import { ATTR_EMISSIVE_COLOR, ATTR_GRAIN, ATTR_JOINTS, ATTR_RELIEF, ATTR_TANGENT, ATTR_ROUGHNESS, ATTR_SPECULAR, ATTR_UVS, ATTR_WEIGHTS, CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_LODM, CHUNK_LODF, CHUNK_DTEX, CHUNK_ENTS, CHUNK_NAVM, CHUNK_SPLT, SPLAT_BLOCK_PREFIX, CHUNK_MESH, CHUNK_REQUIRED, DRFT_MAGIC, DRFT_VERSION_MAJOR, DrftError, HEADER_BYTES, CHUNK_MATL, CHUNK_TEXS, KNOWN_CHUNKS, MATERIAL_ENTRY_BYTES, align, fourCCName, CHUNK_ANIM, CHUNK_NODE, CHUNK_INST, CHUNK_LITE, CHUNK_MSHQ, CHUNK_SKIN, CHUNK_MORP, CHUNK_NNET, CHUNK_NGRF, CHUNK_SDFV, CHUNK_SUBS, CHUNK_COLL, } from './drftFormat.js';
|
|
20
20
|
import { readSubs } from './drftSubs.js';
|
|
21
21
|
import { readSdfv } from './sdfv.js';
|
|
22
22
|
import { readDtex } from './dtex.js';
|
|
@@ -25,6 +25,9 @@ import { readNavm } from './navm.js';
|
|
|
25
25
|
import { readNnet } from './nnet.js';
|
|
26
26
|
import { readNgrf } from './ngrf.js';
|
|
27
27
|
import { readColliders } from './drftColliders.js';
|
|
28
|
+
import { readInstances } from './drftInstances.js';
|
|
29
|
+
import { readLights } from './drftLights.js';
|
|
30
|
+
import { decodeQuantisedMesh } from './drftQuantised.js';
|
|
28
31
|
const decoder = new TextDecoder();
|
|
29
32
|
function readString(view, buffer, at, limit) {
|
|
30
33
|
if (at + 4 > limit)
|
|
@@ -171,6 +174,9 @@ export function readDrft(buffer) {
|
|
|
171
174
|
let nnet = null;
|
|
172
175
|
let graphs = [];
|
|
173
176
|
let colliders = [];
|
|
177
|
+
/* Read once every mesh is counted, because a group is checked against the mesh count. */
|
|
178
|
+
let instanceChunk = null;
|
|
179
|
+
let lights = [];
|
|
174
180
|
const skipped = [];
|
|
175
181
|
let head = null;
|
|
176
182
|
for (let i = 0; i < chunkCount; i++) {
|
|
@@ -206,6 +212,9 @@ export function readDrft(buffer) {
|
|
|
206
212
|
head = readHead(buffer, chunk);
|
|
207
213
|
else if (chunk.code === CHUNK_MESH)
|
|
208
214
|
meshes.push(readMesh(buffer, chunk));
|
|
215
|
+
/* A mesh like any other, in the same ordinal sequence: see `drftQuantised.ts`. */
|
|
216
|
+
else if (chunk.code === CHUNK_MSHQ)
|
|
217
|
+
meshes.push(decodeQuantisedMesh(buffer, chunk.offset, chunk.byteLength));
|
|
209
218
|
/* A level of detail is a mesh payload under another code, so it takes the same reader. */
|
|
210
219
|
else if (chunk.code === CHUNK_LODM)
|
|
211
220
|
levels.push({ level: chunk.index, mesh: readMesh(buffer, chunk) });
|
|
@@ -227,6 +236,10 @@ export function readDrft(buffer) {
|
|
|
227
236
|
entities = readEnts(buffer, chunk.offset, chunk.byteLength);
|
|
228
237
|
else if (chunk.code === CHUNK_NODE)
|
|
229
238
|
nodes = readNodes(buffer, chunk.offset, chunk.byteLength);
|
|
239
|
+
else if (chunk.code === CHUNK_INST)
|
|
240
|
+
instanceChunk = chunk;
|
|
241
|
+
else if (chunk.code === CHUNK_LITE)
|
|
242
|
+
lights = readLights(buffer, chunk.offset, chunk.byteLength);
|
|
230
243
|
else if (chunk.code === CHUNK_SKIN)
|
|
231
244
|
skins.push(readSkin(buffer, chunk.offset, chunk.byteLength));
|
|
232
245
|
else if (chunk.code === CHUNK_ANIM)
|
|
@@ -315,6 +328,10 @@ export function readDrft(buffer) {
|
|
|
315
328
|
lods,
|
|
316
329
|
levels: discreteLevels,
|
|
317
330
|
nodes,
|
|
331
|
+
instances: instanceChunk === null
|
|
332
|
+
? []
|
|
333
|
+
: readInstances(buffer, instanceChunk.offset, instanceChunk.byteLength, meshes.length),
|
|
334
|
+
lights,
|
|
318
335
|
skins,
|
|
319
336
|
clips,
|
|
320
337
|
materials,
|
|
@@ -515,6 +532,10 @@ export function readMaterials(buffer, chunk) {
|
|
|
515
532
|
occlusionStrength: has(68, 4) ? view.getFloat32(at + 68, true) : 0,
|
|
516
533
|
/* Discard nothing, which is exactly what a file written before this field meant. */
|
|
517
534
|
cutout: has(72, 4) ? view.getFloat32(at + 72, true) : 0,
|
|
535
|
+
/* Opaque unless its opacity says otherwise, which is what a file before 1.18 meant. */
|
|
536
|
+
blend: has(76, 4) ? (view.getUint32(at + 76, true) & 1) !== 0 : false,
|
|
537
|
+
/* One-sided unless it says otherwise, which is how every file before the bit was drawn. */
|
|
538
|
+
doubleSided: has(76, 4) ? (view.getUint32(at + 76, true) & 2) !== 0 : false,
|
|
518
539
|
});
|
|
519
540
|
}
|
|
520
541
|
return out;
|
package/dist/drftStream.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type { DrftTexture } from './drftRead.ts';
|
|
|
5
5
|
import type { AnimationClip, DrftSkin } from './animationData.ts';
|
|
6
6
|
import type { DrftNode } from './drftSkin.ts';
|
|
7
7
|
import type { DrftMorph } from './drftSkin.ts';
|
|
8
|
+
import type { DrftInstanceGroup } from './drftInstances.ts';
|
|
9
|
+
import type { DrftSdfvEntry } from './sdfv.ts';
|
|
10
|
+
import type { DrftLight } from './drftLights.ts';
|
|
8
11
|
/**
|
|
9
12
|
* What the file says is coming, known from the first few kilobytes.
|
|
10
13
|
*
|
|
@@ -76,6 +79,18 @@ export interface DrftStreamHandlers {
|
|
|
76
79
|
* not useful — a child whose parent has not arrived cannot be placed.
|
|
77
80
|
*/
|
|
78
81
|
readonly onNodes?: (nodes: readonly DrftNode[]) => void;
|
|
82
|
+
/**
|
|
83
|
+
* The asset's instance groups, once, when `INST` lands — ahead of the meshes, where the writer
|
|
84
|
+
* puts it, so a consumer knows a mesh is drawn many times before that mesh arrives.
|
|
85
|
+
*/
|
|
86
|
+
readonly onInstances?: (groups: readonly DrftInstanceGroup[]) => void;
|
|
87
|
+
/** The lights the scene was authored with, once, when `LITE` lands. See `drftLights.ts`. */
|
|
88
|
+
readonly onLights?: (lights: readonly DrftLight[]) => void;
|
|
89
|
+
/**
|
|
90
|
+
* The distance fields indirect light is traced against, once, when `SDFV` lands. Each names the
|
|
91
|
+
* mesh it belongs to, or `SDFV_WHOLE_FILE` for one over all of the file's static geometry.
|
|
92
|
+
*/
|
|
93
|
+
readonly onFields?: (fields: readonly DrftSdfvEntry[]) => void;
|
|
79
94
|
/** One mesh's morph deltas, naming the mesh ordinal they belong to. */
|
|
80
95
|
readonly onMorph?: (morph: DrftMorph) => void;
|
|
81
96
|
/** One skin, as its `SKIN` chunk lands. Several arrive for an asset with several. */
|
|
@@ -160,6 +175,33 @@ export declare class DrftStream {
|
|
|
160
175
|
/** Report every chunk whose last byte has now landed, in file order. */
|
|
161
176
|
private settleChunks;
|
|
162
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* How long a stream may work before it gives way for a task, in milliseconds. The loader's own
|
|
180
|
+
* frame budget is six, and this is the same share of a 60 Hz frame for the same reason.
|
|
181
|
+
*/
|
|
182
|
+
export declare const DEFAULT_STREAM_SLICE_MS = 6;
|
|
183
|
+
/**
|
|
184
|
+
* When a stream stops to let something else run, and how.
|
|
185
|
+
*
|
|
186
|
+
* **Why a stream has to stop at all.** Once a body's bytes are buffered, every `read` resolves as a
|
|
187
|
+
* microtask, so a loop that pushes what it reads decodes chunk after chunk inside one task and no
|
|
188
|
+
* frame can draw until the file is done. From a local server a five-file courtyard ran in
|
|
189
|
+
* stretches of 0.6 to 1.1 s and drew 117 frames in five seconds. From a slow network the reads
|
|
190
|
+
* wait, the loop yields there by itself, and this costs nothing.
|
|
191
|
+
*
|
|
192
|
+
* Both functions are capabilities, for the reason `FetchLike` is: a host with its own clock or its
|
|
193
|
+
* own scheduler supplies them. What it gives up is throughput when nothing else wants the thread —
|
|
194
|
+
* a task per slice, which a browser may clamp to four milliseconds once timers nest. What would
|
|
195
|
+
* make it wrong is a single chunk longer than the slice, which a slice cannot divide.
|
|
196
|
+
*/
|
|
197
|
+
export interface DrftStreamPacing {
|
|
198
|
+
/** Work this long, then give way. `DEFAULT_STREAM_SLICE_MS` when absent. */
|
|
199
|
+
readonly sliceMs?: number;
|
|
200
|
+
/** A monotonic clock in milliseconds. `performance.now` when absent. */
|
|
201
|
+
readonly now?: () => number;
|
|
202
|
+
/** Resolve after other tasks have had a turn. A zero-delay timer when absent. */
|
|
203
|
+
readonly giveWay?: () => Promise<void>;
|
|
204
|
+
}
|
|
163
205
|
/**
|
|
164
206
|
* Read a `.drft` from a fetch response as it arrives.
|
|
165
207
|
*
|
|
@@ -167,4 +209,4 @@ export declare class DrftStream {
|
|
|
167
209
|
* and the model improves as the bytes land. A response with no body — an error page, a
|
|
168
210
|
* cache miss served as a redirect — throws rather than resolving to an empty asset.
|
|
169
211
|
*/
|
|
170
|
-
export declare function streamDrft(response: Response, handlers?: DrftStreamHandlers): Promise<void>;
|
|
212
|
+
export declare function streamDrft(response: Response, handlers?: DrftStreamHandlers, pacing?: DrftStreamPacing): Promise<void>;
|
package/dist/drftStream.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/** Reading a `.drft` while it is still arriving, chunk by chunk. */
|
|
2
|
-
import { CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_LODM, CHUNK_SPLT, CHUNK_MATL, CHUNK_MESH, CHUNK_REQUIRED, CHUNK_TEXS, DRFT_MAGIC, DRFT_VERSION_MAJOR, DrftError, HEADER_BYTES, KNOWN_CHUNKS, fourCCName, CHUNK_ANIM, CHUNK_NODE, CHUNK_SKIN, CHUNK_MORP, } from './drftFormat.js';
|
|
2
|
+
import { CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_LODM, CHUNK_SPLT, CHUNK_MATL, CHUNK_MESH, CHUNK_REQUIRED, CHUNK_TEXS, DRFT_MAGIC, DRFT_VERSION_MAJOR, DrftError, HEADER_BYTES, KNOWN_CHUNKS, fourCCName, CHUNK_ANIM, CHUNK_NODE, CHUNK_INST, CHUNK_LITE, CHUNK_SDFV, CHUNK_MSHQ, CHUNK_SKIN, CHUNK_MORP, } from './drftFormat.js';
|
|
3
3
|
import { readHead, readMaterials, readMesh, readSplatBlock, readTexture } from './drftRead.js';
|
|
4
4
|
import { readClip, readMorph, readNodes, readSkin } from './drftSkin.js';
|
|
5
|
+
import { readInstances } from './drftInstances.js';
|
|
6
|
+
import { readLights } from './drftLights.js';
|
|
7
|
+
import { readSdfv } from './sdfv.js';
|
|
8
|
+
import { decodeQuantisedMesh } from './drftQuantised.js';
|
|
5
9
|
/**
|
|
6
10
|
* An incremental `.drft` reader: feed it bytes, it reports each chunk as that chunk completes.
|
|
7
11
|
*
|
|
@@ -196,8 +200,9 @@ export class DrftStream {
|
|
|
196
200
|
* counted in `meshCount`, which is the number of parts — an outline is not a part, and
|
|
197
201
|
* a readout saying "1 of 188 parts" would be counting the model twice.
|
|
198
202
|
*/
|
|
199
|
-
if (chunk.code === CHUNK_MESH || chunk.code === CHUNK_LODM)
|
|
203
|
+
if (chunk.code === CHUNK_MESH || chunk.code === CHUNK_MSHQ || chunk.code === CHUNK_LODM) {
|
|
200
204
|
meshBytes += chunk.byteLength;
|
|
205
|
+
}
|
|
201
206
|
if (chunk.code === CHUNK_TEXS)
|
|
202
207
|
textureBytes += chunk.byteLength;
|
|
203
208
|
/* Its own weight rather than geometry's: a capture is not made of parts, so folding it into
|
|
@@ -216,7 +221,7 @@ export class DrftStream {
|
|
|
216
221
|
this.manifestSent = true;
|
|
217
222
|
this.handlers.onManifest?.({
|
|
218
223
|
totalBytes: this.buffer.byteLength,
|
|
219
|
-
meshCount: this.chunks.filter((c) => c.code === CHUNK_MESH).length,
|
|
224
|
+
meshCount: this.chunks.filter((c) => c.code === CHUNK_MESH || c.code === CHUNK_MSHQ).length,
|
|
220
225
|
textureCount: this.chunks.filter((c) => c.code === CHUNK_TEXS).length,
|
|
221
226
|
lodCount: this.chunks.filter((c) => c.code === CHUNK_LODM).length,
|
|
222
227
|
splatBlockCount: this.chunks.filter((c) => c.code === CHUNK_SPLT).length,
|
|
@@ -242,8 +247,10 @@ export class DrftStream {
|
|
|
242
247
|
this.head = readHead(this.buffer, chunk);
|
|
243
248
|
this.handlers.onHead?.(this.head);
|
|
244
249
|
}
|
|
245
|
-
else if (chunk.code === CHUNK_MESH) {
|
|
246
|
-
const mesh =
|
|
250
|
+
else if (chunk.code === CHUNK_MESH || chunk.code === CHUNK_MSHQ) {
|
|
251
|
+
const mesh = chunk.code === CHUNK_MESH
|
|
252
|
+
? readMesh(this.buffer, chunk)
|
|
253
|
+
: decodeQuantisedMesh(this.buffer, chunk.offset, chunk.byteLength);
|
|
247
254
|
this.meshCount++;
|
|
248
255
|
this.handlers.onMesh?.(mesh, this.meshOrdinal++);
|
|
249
256
|
}
|
|
@@ -265,6 +272,16 @@ export class DrftStream {
|
|
|
265
272
|
else if (chunk.code === CHUNK_NODE) {
|
|
266
273
|
this.handlers.onNodes?.(readNodes(this.buffer, chunk.offset, chunk.byteLength));
|
|
267
274
|
}
|
|
275
|
+
else if (chunk.code === CHUNK_INST) {
|
|
276
|
+
const meshes = this.chunks.filter((c) => c.code === CHUNK_MESH || c.code === CHUNK_MSHQ).length;
|
|
277
|
+
this.handlers.onInstances?.(readInstances(this.buffer, chunk.offset, chunk.byteLength, meshes));
|
|
278
|
+
}
|
|
279
|
+
else if (chunk.code === CHUNK_LITE) {
|
|
280
|
+
this.handlers.onLights?.(readLights(this.buffer, chunk.offset, chunk.byteLength));
|
|
281
|
+
}
|
|
282
|
+
else if (chunk.code === CHUNK_SDFV) {
|
|
283
|
+
this.handlers.onFields?.(readSdfv(this.buffer, chunk.offset, chunk.byteLength).entries);
|
|
284
|
+
}
|
|
268
285
|
else if (chunk.code === CHUNK_SKIN) {
|
|
269
286
|
this.handlers.onSkin?.(readSkin(this.buffer, chunk.offset, chunk.byteLength), chunk.index);
|
|
270
287
|
}
|
|
@@ -288,6 +305,16 @@ export class DrftStream {
|
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* How long a stream may work before it gives way for a task, in milliseconds. The loader's own
|
|
310
|
+
* frame budget is six, and this is the same share of a 60 Hz frame for the same reason.
|
|
311
|
+
*/
|
|
312
|
+
export const DEFAULT_STREAM_SLICE_MS = 6;
|
|
313
|
+
const monotonicNow = () => performance.now();
|
|
314
|
+
/* A timer rather than a microtask: a microtask would rejoin the task it is trying to leave. */
|
|
315
|
+
const nextTask = () => new Promise((resolve) => {
|
|
316
|
+
setTimeout(resolve, 0);
|
|
317
|
+
});
|
|
291
318
|
/**
|
|
292
319
|
* Read a `.drft` from a fetch response as it arrives.
|
|
293
320
|
*
|
|
@@ -295,21 +322,29 @@ export class DrftStream {
|
|
|
295
322
|
* and the model improves as the bytes land. A response with no body — an error page, a
|
|
296
323
|
* cache miss served as a redirect — throws rather than resolving to an empty asset.
|
|
297
324
|
*/
|
|
298
|
-
export async function streamDrft(response, handlers = {}) {
|
|
325
|
+
export async function streamDrft(response, handlers = {}, pacing = {}) {
|
|
299
326
|
if (!response.ok) {
|
|
300
327
|
throw new DrftError(`fetching the asset returned ${response.status} ${response.statusText}`);
|
|
301
328
|
}
|
|
302
329
|
const body = response.body;
|
|
303
330
|
if (body === null)
|
|
304
331
|
throw new DrftError('the response carried no body to stream');
|
|
332
|
+
const sliceMs = pacing.sliceMs ?? DEFAULT_STREAM_SLICE_MS;
|
|
333
|
+
const now = pacing.now ?? monotonicNow;
|
|
334
|
+
const giveWay = pacing.giveWay ?? nextTask;
|
|
305
335
|
const stream = new DrftStream(handlers);
|
|
306
336
|
const reader = body.getReader();
|
|
337
|
+
let sliceStarted = now();
|
|
307
338
|
for (;;) {
|
|
308
339
|
const next = await reader.read();
|
|
309
340
|
if (next.done)
|
|
310
341
|
break;
|
|
311
342
|
if (next.value !== undefined)
|
|
312
343
|
stream.push(next.value);
|
|
344
|
+
if (now() - sliceStarted >= sliceMs) {
|
|
345
|
+
await giveWay();
|
|
346
|
+
sliceStarted = now();
|
|
347
|
+
}
|
|
313
348
|
}
|
|
314
349
|
stream.end();
|
|
315
350
|
}
|
package/dist/drftWrite.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { type DtexEntry } from './dtex.ts';
|
|
|
19
19
|
import { type EntsScene } from './ents.ts';
|
|
20
20
|
import { type NavPolyMesh } from './navm.ts';
|
|
21
21
|
import { type DrftGraph } from './ngrf.ts';
|
|
22
|
+
import type { DrftInstanceGroup } from './drftInstances.ts';
|
|
23
|
+
import type { DrftLight } from './drftLights.ts';
|
|
22
24
|
/** An image to embed, already compressed, with what its own header said about it. */
|
|
23
25
|
export interface DrftTextureSource {
|
|
24
26
|
/**
|
|
@@ -106,6 +108,19 @@ export interface DrftSource {
|
|
|
106
108
|
* the meshes exactly as it always did, which is why this is additive.
|
|
107
109
|
*/
|
|
108
110
|
readonly nodes?: readonly DrftNode[];
|
|
111
|
+
/**
|
|
112
|
+
* Meshes drawn many times: one `INST` chunk, written ahead of the geometry and required. The mesh
|
|
113
|
+
* a group names holds one copy; see `drftInstances.ts`.
|
|
114
|
+
*/
|
|
115
|
+
readonly instances?: readonly DrftInstanceGroup[];
|
|
116
|
+
/** The lights the scene was authored with: one `LITE` chunk, optional. See `drftLights.ts`. */
|
|
117
|
+
readonly lights?: readonly DrftLight[];
|
|
118
|
+
/**
|
|
119
|
+
* Write each mesh as `MSHQ` rather than `MESH`: constants, 16-bit ranges and octahedral
|
|
120
|
+
* directions, about a third of the bytes, within the bounds `drftQuantised.ts` states. Off unless
|
|
121
|
+
* asked, so a caller writing a file gets the bytes it always got; the baker asks by default.
|
|
122
|
+
*/
|
|
123
|
+
readonly quantise?: boolean;
|
|
109
124
|
/** Skins, one `SKIN` chunk each, or absent for an asset that deforms nothing. */
|
|
110
125
|
readonly skins?: readonly DrftSkin[];
|
|
111
126
|
/** Clips, one `ANIM` chunk each, or absent for an asset that animates nothing. */
|