@driftengine/drft 3.61.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +9 -0
- package/dist/animationData.d.ts +53 -0
- package/dist/animationData.js +13 -0
- package/dist/coarseFirst.d.ts +27 -0
- package/dist/coarseFirst.js +118 -0
- package/dist/drftColliders.d.ts +53 -0
- package/dist/drftColliders.js +137 -0
- package/dist/drftFormat.d.ts +454 -0
- package/dist/drftFormat.js +350 -0
- package/dist/drftRead.d.ts +121 -0
- package/dist/drftRead.js +487 -0
- package/dist/drftSkin.d.ts +40 -0
- package/dist/drftSkin.js +270 -0
- package/dist/drftStream.d.ts +170 -0
- package/dist/drftStream.js +315 -0
- package/dist/drftSubs.d.ts +18 -0
- package/dist/drftSubs.js +70 -0
- package/dist/drftWrite.d.ts +103 -0
- package/dist/drftWrite.js +478 -0
- package/dist/fixtures/v1-0.d.ts +9 -0
- package/dist/fixtures/v1-0.js +9 -0
- package/dist/fixtures/v1-1.d.ts +16 -0
- package/dist/fixtures/v1-1.js +16 -0
- package/dist/fixtures/v1-11.d.ts +14 -0
- package/dist/fixtures/v1-11.js +14 -0
- package/dist/fixtures/v1-2.d.ts +19 -0
- package/dist/fixtures/v1-2.js +19 -0
- package/dist/fixtures/v1-3.d.ts +18 -0
- package/dist/fixtures/v1-3.js +18 -0
- package/dist/fixtures/v1-4.d.ts +13 -0
- package/dist/fixtures/v1-4.js +13 -0
- package/dist/fixtures/v1-5.d.ts +14 -0
- package/dist/fixtures/v1-5.js +14 -0
- package/dist/fixtures/v1-6.d.ts +9 -0
- package/dist/fixtures/v1-6.js +9 -0
- package/dist/fixtures/v1-7.d.ts +9 -0
- package/dist/fixtures/v1-7.js +9 -0
- package/dist/fixtures/v1-9.d.ts +9 -0
- package/dist/fixtures/v1-9.js +9 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +29 -0
- package/dist/meshData.d.ts +210 -0
- package/dist/meshData.js +105 -0
- package/package.json +57 -0
- package/src/animationData.ts +58 -0
- package/src/coarseFirst.ts +113 -0
- package/src/drftColliders.ts +153 -0
- package/src/drftFormat.ts +537 -0
- package/src/drftRead.ts +652 -0
- package/src/drftSkin.ts +326 -0
- package/src/drftStream.ts +436 -0
- package/src/drftSubs.ts +86 -0
- package/src/drftWrite.ts +613 -0
- package/src/fixtures/v1-0.ts +10 -0
- package/src/fixtures/v1-1.ts +17 -0
- package/src/fixtures/v1-11.ts +15 -0
- package/src/fixtures/v1-2.ts +20 -0
- package/src/fixtures/v1-3.ts +19 -0
- package/src/fixtures/v1-4.ts +14 -0
- package/src/fixtures/v1-5.ts +15 -0
- package/src/fixtures/v1-6.ts +10 -0
- package/src/fixtures/v1-7.ts +10 -0
- package/src/fixtures/v1-9.ts +10 -0
- package/src/index.ts +56 -0
- package/src/meshData.ts +301 -0
package/src/drftRead.ts
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a `.drft`.
|
|
3
|
+
*
|
|
4
|
+
* Two rules govern everything here, and they pull in opposite directions on purpose:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Never misread.** Every offset and length is checked against the buffer before it
|
|
7
|
+
* is used. A file that is wrong is refused with the byte that failed, never
|
|
8
|
+
* approximated and never half-returned. A partial asset shows up later as a rendering
|
|
9
|
+
* artefact with no route back to its cause, which is the failure mode this whole
|
|
10
|
+
* format was designed after.
|
|
11
|
+
* 2. **Never copy.** Vertex data is viewed in place — `new Float32Array(buffer, offset,
|
|
12
|
+
* count)` — so loading a mesh costs the bounds checks and nothing else.
|
|
13
|
+
*
|
|
14
|
+
* Compatibility lives here too. See docs/FORMAT.md §4.4: old files open forever, unknown
|
|
15
|
+
* optional chunks are skipped in silence, unknown *required* chunks are a refusal.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AnimationClip, DrftSkin } from './animationData.ts';
|
|
19
|
+
import type { DrftNode } from './drftSkin.ts';
|
|
20
|
+
import type { DrftMorph } from './drftSkin.ts';
|
|
21
|
+
import { readClip, readMorph, readNodes, readSkin } from './drftSkin.ts';
|
|
22
|
+
import type { MeshData } from './meshData.ts';
|
|
23
|
+
import { validateMeshData } from './meshData.ts';
|
|
24
|
+
import {
|
|
25
|
+
ATTR_EMISSIVE_COLOR,
|
|
26
|
+
ATTR_GRAIN,
|
|
27
|
+
ATTR_JOINTS,
|
|
28
|
+
ATTR_RELIEF,
|
|
29
|
+
ATTR_TANGENT,
|
|
30
|
+
ATTR_ROUGHNESS,
|
|
31
|
+
ATTR_SPECULAR,
|
|
32
|
+
ATTR_UVS,
|
|
33
|
+
ATTR_WEIGHTS,
|
|
34
|
+
CHUNK_ENTRY_BYTES,
|
|
35
|
+
CHUNK_HEAD,
|
|
36
|
+
CHUNK_LODM,
|
|
37
|
+
CHUNK_LODF,
|
|
38
|
+
CHUNK_SPLT,
|
|
39
|
+
SPLAT_BLOCK_PREFIX,
|
|
40
|
+
CHUNK_MESH,
|
|
41
|
+
CHUNK_REQUIRED,
|
|
42
|
+
DRFT_MAGIC,
|
|
43
|
+
DRFT_VERSION_MAJOR,
|
|
44
|
+
DrftError,
|
|
45
|
+
HEADER_BYTES,
|
|
46
|
+
CHUNK_MATL,
|
|
47
|
+
CHUNK_TEXS,
|
|
48
|
+
KNOWN_CHUNKS,
|
|
49
|
+
MATERIAL_ENTRY_BYTES,
|
|
50
|
+
align,
|
|
51
|
+
fourCCName,
|
|
52
|
+
CHUNK_ANIM,
|
|
53
|
+
CHUNK_NODE,
|
|
54
|
+
CHUNK_SKIN,
|
|
55
|
+
CHUNK_MORP,
|
|
56
|
+
CHUNK_SUBS,
|
|
57
|
+
CHUNK_COLL,
|
|
58
|
+
} from './drftFormat.ts';
|
|
59
|
+
import type { DrftChunk, DrftHead, DrftMaterial, DrftSplatBlock } from './drftFormat.ts';
|
|
60
|
+
import { type DrftSubs, readSubs } from './drftSubs.ts';
|
|
61
|
+
import { readColliders } from './drftColliders.ts';
|
|
62
|
+
|
|
63
|
+
/** An embedded image, still compressed. Decoding is the consumer's, through `createImageBitmap`. */
|
|
64
|
+
export interface DrftTexture {
|
|
65
|
+
/** What the source called this image. Empty for a file written before names existed. */
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly codec: number;
|
|
68
|
+
readonly width: number;
|
|
69
|
+
readonly height: number;
|
|
70
|
+
/**
|
|
71
|
+
* A view over the fetched buffer, not a copy, exactly like the vertex arrays.
|
|
72
|
+
*
|
|
73
|
+
* It therefore keeps the whole file alive for as long as it is held. That is the intended
|
|
74
|
+
* trade for a load that allocates nothing, and it is why a consumer decoding these should
|
|
75
|
+
* drop its reference to the asset afterwards rather than keep it beside the GPU textures.
|
|
76
|
+
*/
|
|
77
|
+
readonly bytes: Uint8Array;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A decoded asset. See docs/FORMAT.md for what each chunk carries. */
|
|
81
|
+
export interface DrftAsset {
|
|
82
|
+
readonly head: DrftHead;
|
|
83
|
+
readonly meshes: readonly MeshData[];
|
|
84
|
+
/**
|
|
85
|
+
* Coarse whole-asset levels of detail, coarsest first, or empty for a file carrying none.
|
|
86
|
+
*
|
|
87
|
+
* Separate from `meshes` because they are not parts of the model: each one *is* the model,
|
|
88
|
+
* at a resolution that arrives sooner. A consumer reading a whole file at once has no use
|
|
89
|
+
* for them and ignores them; the streaming loader draws the best one it has until the real
|
|
90
|
+
* geometry is complete. See `DrftLoader` and docs/FORMAT.md §4.6.
|
|
91
|
+
*/
|
|
92
|
+
readonly lods: readonly MeshData[];
|
|
93
|
+
/**
|
|
94
|
+
* Discrete levels of detail, finest first, each a complete `.drft` still in its bytes.
|
|
95
|
+
*
|
|
96
|
+
* **Handed over unparsed on purpose.** A level is a whole model, and a consumer that picks one
|
|
97
|
+
* by distance wants exactly one of them — parsing all four to hand back three that will not be
|
|
98
|
+
* drawn is work nobody asked for. Pass the one you want to `readDrft` again.
|
|
99
|
+
*/
|
|
100
|
+
readonly levels: readonly Uint8Array[];
|
|
101
|
+
/**
|
|
102
|
+
* The whole capture, assembled from every `SPLT` block in ordinal order, or null for a file
|
|
103
|
+
* carrying none.
|
|
104
|
+
*
|
|
105
|
+
* **Assembled rather than handed over as blocks**, because a caller reading a whole file at
|
|
106
|
+
* once has no use for the split: it exists so a *stream* can put a sparse capture on screen
|
|
107
|
+
* early, and `DrftStream.onSplats` is where that matters. The records still arrive in the
|
|
108
|
+
* file's coarse-first order, so a consumer that draws a prefix of them draws a sparse whole.
|
|
109
|
+
*/
|
|
110
|
+
readonly splats: DrftSplatBlock | null;
|
|
111
|
+
/**
|
|
112
|
+
* The asset's own hierarchy, or empty for a file carrying no `NODE`.
|
|
113
|
+
*
|
|
114
|
+
* Empty means what it always meant — one mesh at the origin — so a caller written before 1.6
|
|
115
|
+
* behaves identically against a file with a hierarchy it ignores.
|
|
116
|
+
*/
|
|
117
|
+
readonly nodes: readonly DrftNode[];
|
|
118
|
+
/** Skins, in the order their chunks appear. Empty for a file that deforms nothing. */
|
|
119
|
+
readonly skins: readonly DrftSkin[];
|
|
120
|
+
/** Clips, in the order their chunks appear. Empty for a file that animates nothing. */
|
|
121
|
+
readonly clips: readonly AnimationClip[];
|
|
122
|
+
/** One per mesh by ordinal, or empty when the file names no materials. */
|
|
123
|
+
readonly materials: readonly DrftMaterial[];
|
|
124
|
+
readonly textures: readonly DrftTexture[];
|
|
125
|
+
/**
|
|
126
|
+
* Which substance each material is made of, or empty where the file labels none.
|
|
127
|
+
*
|
|
128
|
+
* The `SUBS` chunk, added at 1.8. Hand these to `installChemistry().match` — which matches
|
|
129
|
+
* **exactly** and reports what it could not, rather than guessing.
|
|
130
|
+
*/
|
|
131
|
+
readonly substances: readonly { readonly material: number; readonly substance: string }[];
|
|
132
|
+
/**
|
|
133
|
+
* The convex hulls this asset collides as, or empty for a file carrying no `COLL`.
|
|
134
|
+
*
|
|
135
|
+
* Views over the fetched buffer, xyz-packed, in the asset's own space. Turning them into shapes is
|
|
136
|
+
* one line and belongs to whoever has a physics package:
|
|
137
|
+
*
|
|
138
|
+
* ```ts
|
|
139
|
+
* const shapes = asset.colliders.map((points) => hullShape(points));
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
readonly colliders: readonly Float32Array[];
|
|
143
|
+
/** Optional chunks this reader did not understand, in file order. Diagnostics only. */
|
|
144
|
+
readonly skipped: readonly string[];
|
|
145
|
+
readonly versionMajor: number;
|
|
146
|
+
readonly versionMinor: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const decoder = new TextDecoder();
|
|
150
|
+
|
|
151
|
+
function readString(
|
|
152
|
+
view: DataView,
|
|
153
|
+
buffer: ArrayBuffer,
|
|
154
|
+
at: number,
|
|
155
|
+
limit: number,
|
|
156
|
+
): [string, number] {
|
|
157
|
+
if (at + 4 > limit) throw new DrftError(`string length runs past its chunk at ${at}`);
|
|
158
|
+
const length = view.getUint32(at, true);
|
|
159
|
+
if (at + 4 + length > limit)
|
|
160
|
+
throw new DrftError(`string of ${length} bytes runs past its chunk at ${at}`);
|
|
161
|
+
const text = decoder.decode(new Uint8Array(buffer, at + 4, length));
|
|
162
|
+
return [text, align(at + 4 + length)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readHead(buffer: ArrayBuffer, chunk: DrftChunk): DrftHead {
|
|
166
|
+
const view = new DataView(buffer);
|
|
167
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
168
|
+
if (chunk.byteLength < 28) throw new DrftError('HEAD is too short to hold its numbers');
|
|
169
|
+
const numbers = new Float32Array(buffer, chunk.offset, 7);
|
|
170
|
+
const [name, afterName] = readString(view, buffer, chunk.offset + 28, limit);
|
|
171
|
+
const [generator] = readString(view, buffer, afterName, limit);
|
|
172
|
+
return {
|
|
173
|
+
name,
|
|
174
|
+
generator,
|
|
175
|
+
unitScale: numbers[0] as number,
|
|
176
|
+
bounds: [
|
|
177
|
+
numbers[1] as number,
|
|
178
|
+
numbers[2] as number,
|
|
179
|
+
numbers[3] as number,
|
|
180
|
+
numbers[4] as number,
|
|
181
|
+
numbers[5] as number,
|
|
182
|
+
numbers[6] as number,
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function readMesh(buffer: ArrayBuffer, chunk: DrftChunk): MeshData {
|
|
188
|
+
const view = new DataView(buffer);
|
|
189
|
+
if (chunk.byteLength < 16) throw new DrftError('MESH is too short to hold its header');
|
|
190
|
+
const vertices = view.getUint32(chunk.offset, true);
|
|
191
|
+
const indexCount = view.getUint32(chunk.offset + 4, true);
|
|
192
|
+
const attributes = view.getUint32(chunk.offset + 8, true);
|
|
193
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
194
|
+
|
|
195
|
+
let at = chunk.offset + 16;
|
|
196
|
+
/** A float view over the next `count` floats, checked before it is built. */
|
|
197
|
+
const floats = (count: number, what: string): Float32Array => {
|
|
198
|
+
const bytes = count * 4;
|
|
199
|
+
if (at + bytes > limit) {
|
|
200
|
+
throw new DrftError(`${what} needs ${bytes} bytes but only ${limit - at} remain at ${at}`);
|
|
201
|
+
}
|
|
202
|
+
const array = new Float32Array(buffer, at, count);
|
|
203
|
+
at += bytes;
|
|
204
|
+
return array;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const positions = floats(vertices * 3, 'positions');
|
|
208
|
+
const normals = floats(vertices * 3, 'normals');
|
|
209
|
+
const colors = floats(vertices * 3, 'colors');
|
|
210
|
+
const emissive = floats(vertices, 'emissive');
|
|
211
|
+
const specular = (attributes & ATTR_SPECULAR) !== 0 ? floats(vertices, 'specular') : undefined;
|
|
212
|
+
const uvs = (attributes & ATTR_UVS) !== 0 ? floats(vertices * 2, 'uvs') : undefined;
|
|
213
|
+
const emissiveColor =
|
|
214
|
+
(attributes & ATTR_EMISSIVE_COLOR) !== 0 ? floats(vertices * 3, 'emissiveColor') : undefined;
|
|
215
|
+
const roughness = (attributes & ATTR_ROUGHNESS) !== 0 ? floats(vertices, 'roughness') : undefined;
|
|
216
|
+
const grain = (attributes & ATTR_GRAIN) !== 0 ? floats(vertices, 'grain') : undefined;
|
|
217
|
+
const relief = (attributes & ATTR_RELIEF) !== 0 ? floats(vertices, 'relief') : undefined;
|
|
218
|
+
/* Four floats a vertex, which is what makes it the widest optional array in the format. */
|
|
219
|
+
const tangents = (attributes & ATTR_TANGENT) !== 0 ? floats(vertices * 4, 'tangents') : undefined;
|
|
220
|
+
/* A skin's per-vertex half, four floats each and read in that order because that is the order
|
|
221
|
+
the bits are in. `validateMeshData` below refuses one without the other, so a file whose
|
|
222
|
+
writer set only one bit is rejected rather than half-skinned. See ATTR_JOINTS. */
|
|
223
|
+
const joints = (attributes & ATTR_JOINTS) !== 0 ? floats(vertices * 4, 'joints') : undefined;
|
|
224
|
+
const weights = (attributes & ATTR_WEIGHTS) !== 0 ? floats(vertices * 4, 'weights') : undefined;
|
|
225
|
+
|
|
226
|
+
const indexBytes = indexCount * 4;
|
|
227
|
+
if (at + indexBytes > limit) {
|
|
228
|
+
throw new DrftError(`indices need ${indexBytes} bytes but only ${limit - at} remain at ${at}`);
|
|
229
|
+
}
|
|
230
|
+
const indices = new Uint32Array(buffer, at, indexCount);
|
|
231
|
+
|
|
232
|
+
const mesh: MeshData = {
|
|
233
|
+
positions,
|
|
234
|
+
normals,
|
|
235
|
+
colors,
|
|
236
|
+
emissive,
|
|
237
|
+
indices,
|
|
238
|
+
...(specular === undefined ? {} : { specular }),
|
|
239
|
+
...(uvs === undefined ? {} : { uvs }),
|
|
240
|
+
...(emissiveColor === undefined ? {} : { emissiveColor }),
|
|
241
|
+
...(roughness === undefined ? {} : { roughness }),
|
|
242
|
+
...(grain === undefined ? {} : { grain }),
|
|
243
|
+
...(relief === undefined ? {} : { relief }),
|
|
244
|
+
...(tangents === undefined ? {} : { tangents }),
|
|
245
|
+
...(joints === undefined ? {} : { joints }),
|
|
246
|
+
...(weights === undefined ? {} : { weights }),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/*
|
|
250
|
+
* Checked again on the way in, even though the writer checked on the way out. The file
|
|
251
|
+
* may not have come from this writer, and the cost of trusting it is a mesh that draws
|
|
252
|
+
* on one GPU and vanishes on another.
|
|
253
|
+
*/
|
|
254
|
+
validateMeshData(mesh);
|
|
255
|
+
return mesh;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Decode a `.drft` from a buffer. Throws `DrftError` on anything it cannot read. */
|
|
259
|
+
export function readDrft(buffer: ArrayBuffer): DrftAsset {
|
|
260
|
+
if (buffer.byteLength < HEADER_BYTES) {
|
|
261
|
+
throw new DrftError(`a file is at least ${HEADER_BYTES} bytes, got ${buffer.byteLength}`);
|
|
262
|
+
}
|
|
263
|
+
const view = new DataView(buffer);
|
|
264
|
+
if (view.getUint32(0, true) !== DRFT_MAGIC) {
|
|
265
|
+
throw new DrftError('not a drft file — the magic does not match');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const versionMajor = view.getUint16(4, true);
|
|
269
|
+
const versionMinor = view.getUint16(6, true);
|
|
270
|
+
const minReaderMajor = view.getUint16(8, true);
|
|
271
|
+
|
|
272
|
+
/*
|
|
273
|
+
* The one direction compatibility may fail, and it fails by saying so. A file needing a
|
|
274
|
+
* newer generation is refused; a file from an older one always opens, which is the
|
|
275
|
+
* promise the format is built around.
|
|
276
|
+
*/
|
|
277
|
+
if (minReaderMajor > DRFT_VERSION_MAJOR) {
|
|
278
|
+
throw new DrftError(
|
|
279
|
+
`this file needs a reader of version ${minReaderMajor} or newer and this one is ` +
|
|
280
|
+
`${DRFT_VERSION_MAJOR}. Its own version is ${versionMajor}.${versionMinor}.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const chunkCount = view.getUint32(12, true);
|
|
285
|
+
const totalBytes = view.getUint32(16, true);
|
|
286
|
+
if (totalBytes > buffer.byteLength) {
|
|
287
|
+
throw new DrftError(
|
|
288
|
+
`the header claims ${totalBytes} bytes and the buffer holds ${buffer.byteLength}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const tableEnd = HEADER_BYTES + chunkCount * CHUNK_ENTRY_BYTES;
|
|
292
|
+
if (tableEnd > buffer.byteLength) {
|
|
293
|
+
throw new DrftError(`a table of ${chunkCount} chunks runs past the end of the file`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const meshes: MeshData[] = [];
|
|
297
|
+
/** Held with their level ordinals, because the table's order is a bake-time choice. */
|
|
298
|
+
const levels: { level: number; mesh: MeshData }[] = [];
|
|
299
|
+
const discreteLevels: Uint8Array[] = [];
|
|
300
|
+
const textures: DrftTexture[] = [];
|
|
301
|
+
let materials: DrftMaterial[] = [];
|
|
302
|
+
const splatBlocks: { block: DrftSplatBlock; at: number }[] = [];
|
|
303
|
+
let nodes: DrftNode[] = [];
|
|
304
|
+
const skins: DrftSkin[] = [];
|
|
305
|
+
const clips: AnimationClip[] = [];
|
|
306
|
+
const morphs: DrftMorph[] = [];
|
|
307
|
+
let subs: DrftSubs | null = null;
|
|
308
|
+
let colliders: readonly Float32Array[] = [];
|
|
309
|
+
const skipped: string[] = [];
|
|
310
|
+
let head: DrftHead | null = null;
|
|
311
|
+
|
|
312
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
313
|
+
const entry = HEADER_BYTES + i * CHUNK_ENTRY_BYTES;
|
|
314
|
+
const chunk: DrftChunk = {
|
|
315
|
+
code: view.getUint32(entry, true),
|
|
316
|
+
offset: view.getUint32(entry + 4, true),
|
|
317
|
+
byteLength: view.getUint32(entry + 8, true),
|
|
318
|
+
flags: view.getUint16(entry + 12, true),
|
|
319
|
+
index: view.getUint16(entry + 14, true),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (chunk.offset + chunk.byteLength > buffer.byteLength) {
|
|
323
|
+
throw new DrftError(
|
|
324
|
+
`chunk ${i} (${fourCCName(chunk.code)}) spans ${chunk.offset}..` +
|
|
325
|
+
`${chunk.offset + chunk.byteLength} past the end of a ${buffer.byteLength} byte file`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (chunk.offset % 4 !== 0) {
|
|
329
|
+
throw new DrftError(`chunk ${i} (${fourCCName(chunk.code)}) is not 4-byte aligned`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!KNOWN_CHUNKS.has(chunk.code)) {
|
|
333
|
+
/*
|
|
334
|
+
* Rule 2 and rule 3 of §4.4, and the whole of forward compatibility. A writer that
|
|
335
|
+
* marks a chunk required is saying the asset is wrong without it, so a reader that
|
|
336
|
+
* does not know it must refuse rather than quietly produce something else.
|
|
337
|
+
*/
|
|
338
|
+
if ((chunk.flags & CHUNK_REQUIRED) !== 0) {
|
|
339
|
+
throw new DrftError(
|
|
340
|
+
`this file requires chunk "${fourCCName(chunk.code)}", which this reader does not ` +
|
|
341
|
+
`understand. It was written by version ${versionMajor}.${versionMinor}.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
skipped.push(fourCCName(chunk.code));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (chunk.code === CHUNK_HEAD) head = readHead(buffer, chunk);
|
|
349
|
+
else if (chunk.code === CHUNK_MESH) meshes.push(readMesh(buffer, chunk));
|
|
350
|
+
/* A level of detail is a mesh payload under another code, so it takes the same reader. */
|
|
351
|
+
else if (chunk.code === CHUNK_LODM)
|
|
352
|
+
levels.push({ level: chunk.index, mesh: readMesh(buffer, chunk) });
|
|
353
|
+
else if (chunk.code === CHUNK_LODF) {
|
|
354
|
+
/* A whole nested file, copied out and left unparsed. See `DrftAsset.levels`. */
|
|
355
|
+
discreteLevels.push(new Uint8Array(buffer, chunk.offset, chunk.byteLength).slice());
|
|
356
|
+
} else if (chunk.code === CHUNK_TEXS) textures.push(readTexture(buffer, chunk));
|
|
357
|
+
else if (chunk.code === CHUNK_MATL) materials = readMaterials(buffer, chunk);
|
|
358
|
+
else if (chunk.code === CHUNK_SPLT)
|
|
359
|
+
splatBlocks.push({ block: readSplatBlock(buffer, chunk), at: chunk.index });
|
|
360
|
+
else if (chunk.code === CHUNK_NODE) nodes = readNodes(buffer, chunk.offset, chunk.byteLength);
|
|
361
|
+
else if (chunk.code === CHUNK_SKIN)
|
|
362
|
+
skins.push(readSkin(buffer, chunk.offset, chunk.byteLength));
|
|
363
|
+
else if (chunk.code === CHUNK_ANIM)
|
|
364
|
+
clips.push(readClip(buffer, chunk.offset, chunk.byteLength));
|
|
365
|
+
else if (chunk.code === CHUNK_MORP)
|
|
366
|
+
morphs.push(readMorph(buffer, chunk.offset, chunk.byteLength));
|
|
367
|
+
else if (chunk.code === CHUNK_SUBS) subs = readSubs(buffer, chunk.offset, chunk.byteLength);
|
|
368
|
+
else if (chunk.code === CHUNK_COLL)
|
|
369
|
+
colliders = readColliders(buffer, chunk.offset, chunk.byteLength);
|
|
370
|
+
/* Every other known chunk is defined but not yet carried; see docs/FORMAT.md phase table. */
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (head === null) throw new DrftError('no HEAD chunk — every asset must describe itself');
|
|
374
|
+
/*
|
|
375
|
+
* **A capture counts as content, which is why this is not simply a `MESH` check any more.** A
|
|
376
|
+
* file holding one Gaussian splat capture and no geometry is an ordinary asset, and refusing it
|
|
377
|
+
* for the absence of a chunk kind it never needed would be the format telling a consumer what
|
|
378
|
+
* its scene may be made of.
|
|
379
|
+
*/
|
|
380
|
+
if (meshes.length === 0 && splatBlocks.length === 0) {
|
|
381
|
+
throw new DrftError('no MESH and no SPLT chunk — an asset with neither geometry nor a capture');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/*
|
|
385
|
+
* Checked here rather than trusted, because the pairing is by ordinal and a mismatch is
|
|
386
|
+
* silent: every material would describe the wrong mesh, and the asset would draw as a
|
|
387
|
+
* scene whose surfaces had been shuffled. That reads as a baker bug anywhere but here.
|
|
388
|
+
*/
|
|
389
|
+
if (materials.length > 0 && materials.length !== meshes.length) {
|
|
390
|
+
throw new DrftError(
|
|
391
|
+
`MATL carries ${materials.length} materials for ${meshes.length} meshes; they pair by ordinal`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
/*
|
|
395
|
+
* **All four map indices, not just the albedo.** The check existed for one of them because one
|
|
396
|
+
* was all there was; a material naming texture 5 in a file carrying two is a corrupt file
|
|
397
|
+
* whichever field names it, and the reason to catch it here rather than at the draw is the same
|
|
398
|
+
* reason `drftTextures.ts` refuses an unknown name: an index that resolves to the wrong thing
|
|
399
|
+
* fails silently at some later frame, and one that resolves to nothing fails at none.
|
|
400
|
+
*/
|
|
401
|
+
for (const material of materials) {
|
|
402
|
+
for (const [field, index] of [
|
|
403
|
+
['albedo', material.albedo],
|
|
404
|
+
['normal', material.normalMap],
|
|
405
|
+
['ORM', material.ormMap],
|
|
406
|
+
['emissive', material.emissiveMap],
|
|
407
|
+
] as const) {
|
|
408
|
+
if (index >= textures.length) {
|
|
409
|
+
throw new DrftError(
|
|
410
|
+
`a material names ${field} texture ${index} and the file carries ${textures.length}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/*
|
|
417
|
+
* Deltas onto the meshes they name, once every chunk is in hand.
|
|
418
|
+
*
|
|
419
|
+
* Attached here rather than as they arrive, because a `MORP` chunk can precede the `MESH` it
|
|
420
|
+
* belongs to — a caller writing them in another order, or a range-fetching reader, both make
|
|
421
|
+
* that possible, and the mesh ordinal in the payload is what makes the pairing independent of
|
|
422
|
+
* arrival order.
|
|
423
|
+
*/
|
|
424
|
+
for (const morph of morphs) {
|
|
425
|
+
const mesh = meshes[morph.mesh];
|
|
426
|
+
if (mesh === undefined) {
|
|
427
|
+
throw new DrftError(`MORP names mesh ${morph.mesh}, and this file has ${meshes.length}`);
|
|
428
|
+
}
|
|
429
|
+
meshes[morph.mesh] = {
|
|
430
|
+
...mesh,
|
|
431
|
+
morphTargets: morph.deltas,
|
|
432
|
+
morphTargetCount: morph.targetCount,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* By the ordinal the file gave them rather than by where they sat, coarsest first. */
|
|
437
|
+
levels.sort((a, b) => a.level - b.level);
|
|
438
|
+
const lods = levels.map((entry) => entry.mesh);
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
head,
|
|
442
|
+
meshes,
|
|
443
|
+
lods,
|
|
444
|
+
levels: discreteLevels,
|
|
445
|
+
nodes,
|
|
446
|
+
skins,
|
|
447
|
+
clips,
|
|
448
|
+
materials,
|
|
449
|
+
textures,
|
|
450
|
+
substances: subs?.entries ?? [],
|
|
451
|
+
colliders,
|
|
452
|
+
splats: joinSplatBlocks(splatBlocks),
|
|
453
|
+
skipped,
|
|
454
|
+
versionMajor,
|
|
455
|
+
versionMinor,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* One `SPLT` block, viewed in place.
|
|
461
|
+
*
|
|
462
|
+
* **Zero-copy, like every other payload here.** The records are a `Uint32Array` over the file's
|
|
463
|
+
* own bytes, which is what the format's four-byte alignment exists for and what lets a capture of
|
|
464
|
+
* tens of megabytes reach the GPU without being duplicated on the way.
|
|
465
|
+
*/
|
|
466
|
+
export function readSplatBlock(buffer: ArrayBuffer, chunk: DrftChunk): DrftSplatBlock {
|
|
467
|
+
if (chunk.byteLength < SPLAT_BLOCK_PREFIX) {
|
|
468
|
+
throw new DrftError('SPLT is too short to hold its header');
|
|
469
|
+
}
|
|
470
|
+
const view = new DataView(buffer, chunk.offset, chunk.byteLength);
|
|
471
|
+
const count = view.getUint32(0, true);
|
|
472
|
+
const totalCount = view.getUint32(4, true);
|
|
473
|
+
const wordsPerSplat = view.getUint32(8, true);
|
|
474
|
+
const sphericalHarmonics = view.getUint32(12, true);
|
|
475
|
+
if (wordsPerSplat === 0) {
|
|
476
|
+
throw new DrftError('a SPLT block says its records are zero words, which stores nothing');
|
|
477
|
+
}
|
|
478
|
+
const words = count * wordsPerSplat;
|
|
479
|
+
if (SPLAT_BLOCK_PREFIX + words * 4 > chunk.byteLength) {
|
|
480
|
+
throw new DrftError(
|
|
481
|
+
`a SPLT block of ${count} splats at ${wordsPerSplat} words runs past its own chunk`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (count > totalCount) {
|
|
485
|
+
throw new DrftError(
|
|
486
|
+
`a SPLT block holds ${count} splats of a capture that says it has ${totalCount}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const bounds = new Float32Array(buffer, chunk.offset + 16, 6);
|
|
490
|
+
return {
|
|
491
|
+
count,
|
|
492
|
+
totalCount,
|
|
493
|
+
wordsPerSplat,
|
|
494
|
+
sphericalHarmonics,
|
|
495
|
+
boundsMin: bounds.subarray(0, 3),
|
|
496
|
+
boundsMax: bounds.subarray(3, 6),
|
|
497
|
+
records: new Uint32Array(buffer, chunk.offset + SPLAT_BLOCK_PREFIX, words),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Join every block of a capture into one, in the ordinal order the file gave them.
|
|
503
|
+
*
|
|
504
|
+
* The records are copied here and nowhere else in this reader, because joining is the one thing
|
|
505
|
+
* that cannot be a view: the blocks are separate chunks at separate offsets. A consumer that
|
|
506
|
+
* wants to avoid the copy takes the blocks as they arrive, through `DrftStream.onSplats`.
|
|
507
|
+
*/
|
|
508
|
+
function joinSplatBlocks(blocks: { block: DrftSplatBlock; at: number }[]): DrftSplatBlock | null {
|
|
509
|
+
const first = blocks[0]?.block;
|
|
510
|
+
if (first === undefined) return null;
|
|
511
|
+
blocks.sort((a, b) => a.at - b.at);
|
|
512
|
+
|
|
513
|
+
let words = 0;
|
|
514
|
+
for (const entry of blocks) {
|
|
515
|
+
if (entry.block.wordsPerSplat !== first.wordsPerSplat) {
|
|
516
|
+
throw new DrftError(
|
|
517
|
+
`one SPLT block records ${entry.block.wordsPerSplat} words a splat and another records ` +
|
|
518
|
+
`${first.wordsPerSplat}; a capture has one record layout`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
words += entry.block.records.length;
|
|
522
|
+
}
|
|
523
|
+
const records = new Uint32Array(words);
|
|
524
|
+
let at = 0;
|
|
525
|
+
let count = 0;
|
|
526
|
+
for (const entry of blocks) {
|
|
527
|
+
records.set(entry.block.records, at);
|
|
528
|
+
at += entry.block.records.length;
|
|
529
|
+
count += entry.block.count;
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
count,
|
|
533
|
+
totalCount: first.totalCount,
|
|
534
|
+
wordsPerSplat: first.wordsPerSplat,
|
|
535
|
+
sphericalHarmonics: first.sphericalHarmonics,
|
|
536
|
+
boundsMin: first.boundsMin,
|
|
537
|
+
boundsMax: first.boundsMax,
|
|
538
|
+
records,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function readTexture(buffer: ArrayBuffer, chunk: DrftChunk): DrftTexture {
|
|
543
|
+
if (chunk.byteLength < 16) throw new DrftError('TEXS is too short to hold its header');
|
|
544
|
+
const view = new DataView(buffer, chunk.offset, chunk.byteLength);
|
|
545
|
+
const byteLength = view.getUint32(12, true);
|
|
546
|
+
if (16 + byteLength > chunk.byteLength) {
|
|
547
|
+
throw new DrftError(`a TEXS payload of ${byteLength} bytes runs past its own chunk`);
|
|
548
|
+
}
|
|
549
|
+
/* Absent in a file written before the name existed, which reads as empty rather than as
|
|
550
|
+
a malformed chunk. That is rule 4 of the compatibility contract in practice. */
|
|
551
|
+
const nameAt = chunk.offset + align(16 + byteLength);
|
|
552
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
553
|
+
const name =
|
|
554
|
+
nameAt + 4 <= limit ? readString(new DataView(buffer), buffer, nameAt, limit)[0] : '';
|
|
555
|
+
return {
|
|
556
|
+
name,
|
|
557
|
+
codec: view.getUint32(0, true),
|
|
558
|
+
width: view.getUint32(4, true),
|
|
559
|
+
height: view.getUint32(8, true),
|
|
560
|
+
bytes: new Uint8Array(buffer, chunk.offset + 16, byteLength),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function readMaterials(buffer: ArrayBuffer, chunk: DrftChunk): DrftMaterial[] {
|
|
565
|
+
if (chunk.byteLength < 8) throw new DrftError('MATL is too short to hold its header');
|
|
566
|
+
const view = new DataView(buffer, chunk.offset, chunk.byteLength);
|
|
567
|
+
const count = view.getUint32(0, true);
|
|
568
|
+
/*
|
|
569
|
+
* The writer's stride, which may exceed this reader's if the file is newer. Stepping by it
|
|
570
|
+
* rather than by our own is the whole of forward compatibility for this chunk: a later
|
|
571
|
+
* field is skipped instead of shifting every entry after the first.
|
|
572
|
+
*/
|
|
573
|
+
const stride = view.getUint32(4, true);
|
|
574
|
+
/*
|
|
575
|
+
* A stride *shorter* than this version's is an older file, not a broken one, and it must
|
|
576
|
+
* open. This threw at first, which had the rule exactly half right: stepping by the file's
|
|
577
|
+
* stride handles a newer writer, and defaulting the fields that predate it handles an
|
|
578
|
+
* older one. Refusing the second direction breaks the promise in §4.4 rule 1 that a file
|
|
579
|
+
* written today opens in every future reader, which is the whole point of the field.
|
|
580
|
+
*/
|
|
581
|
+
if (stride < 4)
|
|
582
|
+
throw new DrftError(`MATL declares a ${stride} byte entry, which cannot hold a material`);
|
|
583
|
+
/** Whether a field at this offset is present in the stride the file actually used. */
|
|
584
|
+
const has = (offset: number, bytes: number): boolean => offset + bytes <= stride;
|
|
585
|
+
if (8 + count * stride > chunk.byteLength) {
|
|
586
|
+
throw new DrftError(`MATL claims ${count} materials and its chunk cannot hold them`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const out: DrftMaterial[] = [];
|
|
590
|
+
let namesAt = chunk.offset + 8 + count * stride;
|
|
591
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
592
|
+
for (let i = 0; i < count; i++) {
|
|
593
|
+
const at = 8 + i * stride;
|
|
594
|
+
/*
|
|
595
|
+
* A name that cannot be read costs the name and nothing else.
|
|
596
|
+
*
|
|
597
|
+
* Every other field in this chunk is load-bearing and a wrong one draws a wrong picture,
|
|
598
|
+
* so those are checked and refused. A material's name is how a *consumer* addresses the
|
|
599
|
+
* surface, and losing it degrades a lookup rather than the render — so a truncated or
|
|
600
|
+
* misaligned name block leaves the remaining names empty instead of taking a whole asset
|
|
601
|
+
* down. Geometry that draws correctly must not be lost to metadata that does not.
|
|
602
|
+
*/
|
|
603
|
+
let name = '';
|
|
604
|
+
if (namesAt + 4 <= limit) {
|
|
605
|
+
try {
|
|
606
|
+
const [text, next] = readString(new DataView(buffer), buffer, namesAt, limit);
|
|
607
|
+
name = text;
|
|
608
|
+
namesAt = next;
|
|
609
|
+
} catch {
|
|
610
|
+
namesAt = limit;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
out.push({
|
|
614
|
+
name,
|
|
615
|
+
color: has(8, 4)
|
|
616
|
+
? [view.getFloat32(at, true), view.getFloat32(at + 4, true), view.getFloat32(at + 8, true)]
|
|
617
|
+
: [0.8, 0.8, 0.8],
|
|
618
|
+
specular: has(12, 4) ? view.getFloat32(at + 12, true) : 0,
|
|
619
|
+
roughness: has(16, 4) ? view.getFloat32(at + 16, true) : 0.4277,
|
|
620
|
+
emissive: has(20, 4) ? view.getFloat32(at + 20, true) : 0,
|
|
621
|
+
emissiveColor: has(32, 4)
|
|
622
|
+
? [
|
|
623
|
+
view.getFloat32(at + 24, true),
|
|
624
|
+
view.getFloat32(at + 28, true),
|
|
625
|
+
view.getFloat32(at + 32, true),
|
|
626
|
+
]
|
|
627
|
+
: [-1, -1, -1],
|
|
628
|
+
/* Each default is what the engine assumes for a material that never stated it: opaque,
|
|
629
|
+
untextured, and not mirroring anything. */
|
|
630
|
+
opacity: has(36, 4) ? view.getFloat32(at + 36, true) : 1,
|
|
631
|
+
albedo: has(40, 4) ? view.getInt32(at + 40, true) : -1,
|
|
632
|
+
reflectivity: has(44, 4) ? view.getFloat32(at + 44, true) : 0,
|
|
633
|
+
/* Each defaults to "no map", which is exactly what a file written before these meant. */
|
|
634
|
+
normalMap: has(48, 4) ? view.getInt32(at + 48, true) : -1,
|
|
635
|
+
ormMap: has(52, 4) ? view.getInt32(at + 52, true) : -1,
|
|
636
|
+
emissiveMap: has(56, 4) ? view.getInt32(at + 56, true) : -1,
|
|
637
|
+
/*
|
|
638
|
+
* 1 and 1 and **0**. The two scales default to the identity, because a file that never
|
|
639
|
+
* stated them meant its map as authored. The strength defaults to zero for the opposite
|
|
640
|
+
* reason: nothing in an older file establishes that its ORM map carries occlusion in R at
|
|
641
|
+
* all, and glTF leaves that channel undefined unless an occlusionTexture names the same
|
|
642
|
+
* image. Defaulting it to 1 would read whatever the exporter left as a shadow.
|
|
643
|
+
*/
|
|
644
|
+
roughnessScale: has(60, 4) ? view.getFloat32(at + 60, true) : 1,
|
|
645
|
+
metallicScale: has(64, 4) ? view.getFloat32(at + 64, true) : 1,
|
|
646
|
+
occlusionStrength: has(68, 4) ? view.getFloat32(at + 68, true) : 0,
|
|
647
|
+
/* Discard nothing, which is exactly what a file written before this field meant. */
|
|
648
|
+
cutout: has(72, 4) ? view.getFloat32(at + 72, true) : 0,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return out;
|
|
652
|
+
}
|