@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/dist/drftRead.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
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
|
+
import { readClip, readMorph, readNodes, readSkin } from './drftSkin.js';
|
|
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_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_SUBS, CHUNK_COLL, } from './drftFormat.js';
|
|
20
|
+
import { readSubs } from './drftSubs.js';
|
|
21
|
+
import { readColliders } from './drftColliders.js';
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
function readString(view, buffer, at, limit) {
|
|
24
|
+
if (at + 4 > limit)
|
|
25
|
+
throw new DrftError(`string length runs past its chunk at ${at}`);
|
|
26
|
+
const length = view.getUint32(at, true);
|
|
27
|
+
if (at + 4 + length > limit)
|
|
28
|
+
throw new DrftError(`string of ${length} bytes runs past its chunk at ${at}`);
|
|
29
|
+
const text = decoder.decode(new Uint8Array(buffer, at + 4, length));
|
|
30
|
+
return [text, align(at + 4 + length)];
|
|
31
|
+
}
|
|
32
|
+
export function readHead(buffer, chunk) {
|
|
33
|
+
const view = new DataView(buffer);
|
|
34
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
35
|
+
if (chunk.byteLength < 28)
|
|
36
|
+
throw new DrftError('HEAD is too short to hold its numbers');
|
|
37
|
+
const numbers = new Float32Array(buffer, chunk.offset, 7);
|
|
38
|
+
const [name, afterName] = readString(view, buffer, chunk.offset + 28, limit);
|
|
39
|
+
const [generator] = readString(view, buffer, afterName, limit);
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
generator,
|
|
43
|
+
unitScale: numbers[0],
|
|
44
|
+
bounds: [
|
|
45
|
+
numbers[1],
|
|
46
|
+
numbers[2],
|
|
47
|
+
numbers[3],
|
|
48
|
+
numbers[4],
|
|
49
|
+
numbers[5],
|
|
50
|
+
numbers[6],
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function readMesh(buffer, chunk) {
|
|
55
|
+
const view = new DataView(buffer);
|
|
56
|
+
if (chunk.byteLength < 16)
|
|
57
|
+
throw new DrftError('MESH is too short to hold its header');
|
|
58
|
+
const vertices = view.getUint32(chunk.offset, true);
|
|
59
|
+
const indexCount = view.getUint32(chunk.offset + 4, true);
|
|
60
|
+
const attributes = view.getUint32(chunk.offset + 8, true);
|
|
61
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
62
|
+
let at = chunk.offset + 16;
|
|
63
|
+
/** A float view over the next `count` floats, checked before it is built. */
|
|
64
|
+
const floats = (count, what) => {
|
|
65
|
+
const bytes = count * 4;
|
|
66
|
+
if (at + bytes > limit) {
|
|
67
|
+
throw new DrftError(`${what} needs ${bytes} bytes but only ${limit - at} remain at ${at}`);
|
|
68
|
+
}
|
|
69
|
+
const array = new Float32Array(buffer, at, count);
|
|
70
|
+
at += bytes;
|
|
71
|
+
return array;
|
|
72
|
+
};
|
|
73
|
+
const positions = floats(vertices * 3, 'positions');
|
|
74
|
+
const normals = floats(vertices * 3, 'normals');
|
|
75
|
+
const colors = floats(vertices * 3, 'colors');
|
|
76
|
+
const emissive = floats(vertices, 'emissive');
|
|
77
|
+
const specular = (attributes & ATTR_SPECULAR) !== 0 ? floats(vertices, 'specular') : undefined;
|
|
78
|
+
const uvs = (attributes & ATTR_UVS) !== 0 ? floats(vertices * 2, 'uvs') : undefined;
|
|
79
|
+
const emissiveColor = (attributes & ATTR_EMISSIVE_COLOR) !== 0 ? floats(vertices * 3, 'emissiveColor') : undefined;
|
|
80
|
+
const roughness = (attributes & ATTR_ROUGHNESS) !== 0 ? floats(vertices, 'roughness') : undefined;
|
|
81
|
+
const grain = (attributes & ATTR_GRAIN) !== 0 ? floats(vertices, 'grain') : undefined;
|
|
82
|
+
const relief = (attributes & ATTR_RELIEF) !== 0 ? floats(vertices, 'relief') : undefined;
|
|
83
|
+
/* Four floats a vertex, which is what makes it the widest optional array in the format. */
|
|
84
|
+
const tangents = (attributes & ATTR_TANGENT) !== 0 ? floats(vertices * 4, 'tangents') : undefined;
|
|
85
|
+
/* A skin's per-vertex half, four floats each and read in that order because that is the order
|
|
86
|
+
the bits are in. `validateMeshData` below refuses one without the other, so a file whose
|
|
87
|
+
writer set only one bit is rejected rather than half-skinned. See ATTR_JOINTS. */
|
|
88
|
+
const joints = (attributes & ATTR_JOINTS) !== 0 ? floats(vertices * 4, 'joints') : undefined;
|
|
89
|
+
const weights = (attributes & ATTR_WEIGHTS) !== 0 ? floats(vertices * 4, 'weights') : undefined;
|
|
90
|
+
const indexBytes = indexCount * 4;
|
|
91
|
+
if (at + indexBytes > limit) {
|
|
92
|
+
throw new DrftError(`indices need ${indexBytes} bytes but only ${limit - at} remain at ${at}`);
|
|
93
|
+
}
|
|
94
|
+
const indices = new Uint32Array(buffer, at, indexCount);
|
|
95
|
+
const mesh = {
|
|
96
|
+
positions,
|
|
97
|
+
normals,
|
|
98
|
+
colors,
|
|
99
|
+
emissive,
|
|
100
|
+
indices,
|
|
101
|
+
...(specular === undefined ? {} : { specular }),
|
|
102
|
+
...(uvs === undefined ? {} : { uvs }),
|
|
103
|
+
...(emissiveColor === undefined ? {} : { emissiveColor }),
|
|
104
|
+
...(roughness === undefined ? {} : { roughness }),
|
|
105
|
+
...(grain === undefined ? {} : { grain }),
|
|
106
|
+
...(relief === undefined ? {} : { relief }),
|
|
107
|
+
...(tangents === undefined ? {} : { tangents }),
|
|
108
|
+
...(joints === undefined ? {} : { joints }),
|
|
109
|
+
...(weights === undefined ? {} : { weights }),
|
|
110
|
+
};
|
|
111
|
+
/*
|
|
112
|
+
* Checked again on the way in, even though the writer checked on the way out. The file
|
|
113
|
+
* may not have come from this writer, and the cost of trusting it is a mesh that draws
|
|
114
|
+
* on one GPU and vanishes on another.
|
|
115
|
+
*/
|
|
116
|
+
validateMeshData(mesh);
|
|
117
|
+
return mesh;
|
|
118
|
+
}
|
|
119
|
+
/** Decode a `.drft` from a buffer. Throws `DrftError` on anything it cannot read. */
|
|
120
|
+
export function readDrft(buffer) {
|
|
121
|
+
if (buffer.byteLength < HEADER_BYTES) {
|
|
122
|
+
throw new DrftError(`a file is at least ${HEADER_BYTES} bytes, got ${buffer.byteLength}`);
|
|
123
|
+
}
|
|
124
|
+
const view = new DataView(buffer);
|
|
125
|
+
if (view.getUint32(0, true) !== DRFT_MAGIC) {
|
|
126
|
+
throw new DrftError('not a drft file — the magic does not match');
|
|
127
|
+
}
|
|
128
|
+
const versionMajor = view.getUint16(4, true);
|
|
129
|
+
const versionMinor = view.getUint16(6, true);
|
|
130
|
+
const minReaderMajor = view.getUint16(8, true);
|
|
131
|
+
/*
|
|
132
|
+
* The one direction compatibility may fail, and it fails by saying so. A file needing a
|
|
133
|
+
* newer generation is refused; a file from an older one always opens, which is the
|
|
134
|
+
* promise the format is built around.
|
|
135
|
+
*/
|
|
136
|
+
if (minReaderMajor > DRFT_VERSION_MAJOR) {
|
|
137
|
+
throw new DrftError(`this file needs a reader of version ${minReaderMajor} or newer and this one is ` +
|
|
138
|
+
`${DRFT_VERSION_MAJOR}. Its own version is ${versionMajor}.${versionMinor}.`);
|
|
139
|
+
}
|
|
140
|
+
const chunkCount = view.getUint32(12, true);
|
|
141
|
+
const totalBytes = view.getUint32(16, true);
|
|
142
|
+
if (totalBytes > buffer.byteLength) {
|
|
143
|
+
throw new DrftError(`the header claims ${totalBytes} bytes and the buffer holds ${buffer.byteLength}`);
|
|
144
|
+
}
|
|
145
|
+
const tableEnd = HEADER_BYTES + chunkCount * CHUNK_ENTRY_BYTES;
|
|
146
|
+
if (tableEnd > buffer.byteLength) {
|
|
147
|
+
throw new DrftError(`a table of ${chunkCount} chunks runs past the end of the file`);
|
|
148
|
+
}
|
|
149
|
+
const meshes = [];
|
|
150
|
+
/** Held with their level ordinals, because the table's order is a bake-time choice. */
|
|
151
|
+
const levels = [];
|
|
152
|
+
const discreteLevels = [];
|
|
153
|
+
const textures = [];
|
|
154
|
+
let materials = [];
|
|
155
|
+
const splatBlocks = [];
|
|
156
|
+
let nodes = [];
|
|
157
|
+
const skins = [];
|
|
158
|
+
const clips = [];
|
|
159
|
+
const morphs = [];
|
|
160
|
+
let subs = null;
|
|
161
|
+
let colliders = [];
|
|
162
|
+
const skipped = [];
|
|
163
|
+
let head = null;
|
|
164
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
165
|
+
const entry = HEADER_BYTES + i * CHUNK_ENTRY_BYTES;
|
|
166
|
+
const chunk = {
|
|
167
|
+
code: view.getUint32(entry, true),
|
|
168
|
+
offset: view.getUint32(entry + 4, true),
|
|
169
|
+
byteLength: view.getUint32(entry + 8, true),
|
|
170
|
+
flags: view.getUint16(entry + 12, true),
|
|
171
|
+
index: view.getUint16(entry + 14, true),
|
|
172
|
+
};
|
|
173
|
+
if (chunk.offset + chunk.byteLength > buffer.byteLength) {
|
|
174
|
+
throw new DrftError(`chunk ${i} (${fourCCName(chunk.code)}) spans ${chunk.offset}..` +
|
|
175
|
+
`${chunk.offset + chunk.byteLength} past the end of a ${buffer.byteLength} byte file`);
|
|
176
|
+
}
|
|
177
|
+
if (chunk.offset % 4 !== 0) {
|
|
178
|
+
throw new DrftError(`chunk ${i} (${fourCCName(chunk.code)}) is not 4-byte aligned`);
|
|
179
|
+
}
|
|
180
|
+
if (!KNOWN_CHUNKS.has(chunk.code)) {
|
|
181
|
+
/*
|
|
182
|
+
* Rule 2 and rule 3 of §4.4, and the whole of forward compatibility. A writer that
|
|
183
|
+
* marks a chunk required is saying the asset is wrong without it, so a reader that
|
|
184
|
+
* does not know it must refuse rather than quietly produce something else.
|
|
185
|
+
*/
|
|
186
|
+
if ((chunk.flags & CHUNK_REQUIRED) !== 0) {
|
|
187
|
+
throw new DrftError(`this file requires chunk "${fourCCName(chunk.code)}", which this reader does not ` +
|
|
188
|
+
`understand. It was written by version ${versionMajor}.${versionMinor}.`);
|
|
189
|
+
}
|
|
190
|
+
skipped.push(fourCCName(chunk.code));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (chunk.code === CHUNK_HEAD)
|
|
194
|
+
head = readHead(buffer, chunk);
|
|
195
|
+
else if (chunk.code === CHUNK_MESH)
|
|
196
|
+
meshes.push(readMesh(buffer, chunk));
|
|
197
|
+
/* A level of detail is a mesh payload under another code, so it takes the same reader. */
|
|
198
|
+
else if (chunk.code === CHUNK_LODM)
|
|
199
|
+
levels.push({ level: chunk.index, mesh: readMesh(buffer, chunk) });
|
|
200
|
+
else if (chunk.code === CHUNK_LODF) {
|
|
201
|
+
/* A whole nested file, copied out and left unparsed. See `DrftAsset.levels`. */
|
|
202
|
+
discreteLevels.push(new Uint8Array(buffer, chunk.offset, chunk.byteLength).slice());
|
|
203
|
+
}
|
|
204
|
+
else if (chunk.code === CHUNK_TEXS)
|
|
205
|
+
textures.push(readTexture(buffer, chunk));
|
|
206
|
+
else if (chunk.code === CHUNK_MATL)
|
|
207
|
+
materials = readMaterials(buffer, chunk);
|
|
208
|
+
else if (chunk.code === CHUNK_SPLT)
|
|
209
|
+
splatBlocks.push({ block: readSplatBlock(buffer, chunk), at: chunk.index });
|
|
210
|
+
else if (chunk.code === CHUNK_NODE)
|
|
211
|
+
nodes = readNodes(buffer, chunk.offset, chunk.byteLength);
|
|
212
|
+
else if (chunk.code === CHUNK_SKIN)
|
|
213
|
+
skins.push(readSkin(buffer, chunk.offset, chunk.byteLength));
|
|
214
|
+
else if (chunk.code === CHUNK_ANIM)
|
|
215
|
+
clips.push(readClip(buffer, chunk.offset, chunk.byteLength));
|
|
216
|
+
else if (chunk.code === CHUNK_MORP)
|
|
217
|
+
morphs.push(readMorph(buffer, chunk.offset, chunk.byteLength));
|
|
218
|
+
else if (chunk.code === CHUNK_SUBS)
|
|
219
|
+
subs = readSubs(buffer, chunk.offset, chunk.byteLength);
|
|
220
|
+
else if (chunk.code === CHUNK_COLL)
|
|
221
|
+
colliders = readColliders(buffer, chunk.offset, chunk.byteLength);
|
|
222
|
+
/* Every other known chunk is defined but not yet carried; see docs/FORMAT.md phase table. */
|
|
223
|
+
}
|
|
224
|
+
if (head === null)
|
|
225
|
+
throw new DrftError('no HEAD chunk — every asset must describe itself');
|
|
226
|
+
/*
|
|
227
|
+
* **A capture counts as content, which is why this is not simply a `MESH` check any more.** A
|
|
228
|
+
* file holding one Gaussian splat capture and no geometry is an ordinary asset, and refusing it
|
|
229
|
+
* for the absence of a chunk kind it never needed would be the format telling a consumer what
|
|
230
|
+
* its scene may be made of.
|
|
231
|
+
*/
|
|
232
|
+
if (meshes.length === 0 && splatBlocks.length === 0) {
|
|
233
|
+
throw new DrftError('no MESH and no SPLT chunk — an asset with neither geometry nor a capture');
|
|
234
|
+
}
|
|
235
|
+
/*
|
|
236
|
+
* Checked here rather than trusted, because the pairing is by ordinal and a mismatch is
|
|
237
|
+
* silent: every material would describe the wrong mesh, and the asset would draw as a
|
|
238
|
+
* scene whose surfaces had been shuffled. That reads as a baker bug anywhere but here.
|
|
239
|
+
*/
|
|
240
|
+
if (materials.length > 0 && materials.length !== meshes.length) {
|
|
241
|
+
throw new DrftError(`MATL carries ${materials.length} materials for ${meshes.length} meshes; they pair by ordinal`);
|
|
242
|
+
}
|
|
243
|
+
/*
|
|
244
|
+
* **All four map indices, not just the albedo.** The check existed for one of them because one
|
|
245
|
+
* was all there was; a material naming texture 5 in a file carrying two is a corrupt file
|
|
246
|
+
* whichever field names it, and the reason to catch it here rather than at the draw is the same
|
|
247
|
+
* reason `drftTextures.ts` refuses an unknown name: an index that resolves to the wrong thing
|
|
248
|
+
* fails silently at some later frame, and one that resolves to nothing fails at none.
|
|
249
|
+
*/
|
|
250
|
+
for (const material of materials) {
|
|
251
|
+
for (const [field, index] of [
|
|
252
|
+
['albedo', material.albedo],
|
|
253
|
+
['normal', material.normalMap],
|
|
254
|
+
['ORM', material.ormMap],
|
|
255
|
+
['emissive', material.emissiveMap],
|
|
256
|
+
]) {
|
|
257
|
+
if (index >= textures.length) {
|
|
258
|
+
throw new DrftError(`a material names ${field} texture ${index} and the file carries ${textures.length}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/*
|
|
263
|
+
* Deltas onto the meshes they name, once every chunk is in hand.
|
|
264
|
+
*
|
|
265
|
+
* Attached here rather than as they arrive, because a `MORP` chunk can precede the `MESH` it
|
|
266
|
+
* belongs to — a caller writing them in another order, or a range-fetching reader, both make
|
|
267
|
+
* that possible, and the mesh ordinal in the payload is what makes the pairing independent of
|
|
268
|
+
* arrival order.
|
|
269
|
+
*/
|
|
270
|
+
for (const morph of morphs) {
|
|
271
|
+
const mesh = meshes[morph.mesh];
|
|
272
|
+
if (mesh === undefined) {
|
|
273
|
+
throw new DrftError(`MORP names mesh ${morph.mesh}, and this file has ${meshes.length}`);
|
|
274
|
+
}
|
|
275
|
+
meshes[morph.mesh] = {
|
|
276
|
+
...mesh,
|
|
277
|
+
morphTargets: morph.deltas,
|
|
278
|
+
morphTargetCount: morph.targetCount,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/* By the ordinal the file gave them rather than by where they sat, coarsest first. */
|
|
282
|
+
levels.sort((a, b) => a.level - b.level);
|
|
283
|
+
const lods = levels.map((entry) => entry.mesh);
|
|
284
|
+
return {
|
|
285
|
+
head,
|
|
286
|
+
meshes,
|
|
287
|
+
lods,
|
|
288
|
+
levels: discreteLevels,
|
|
289
|
+
nodes,
|
|
290
|
+
skins,
|
|
291
|
+
clips,
|
|
292
|
+
materials,
|
|
293
|
+
textures,
|
|
294
|
+
substances: subs?.entries ?? [],
|
|
295
|
+
colliders,
|
|
296
|
+
splats: joinSplatBlocks(splatBlocks),
|
|
297
|
+
skipped,
|
|
298
|
+
versionMajor,
|
|
299
|
+
versionMinor,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* One `SPLT` block, viewed in place.
|
|
304
|
+
*
|
|
305
|
+
* **Zero-copy, like every other payload here.** The records are a `Uint32Array` over the file's
|
|
306
|
+
* own bytes, which is what the format's four-byte alignment exists for and what lets a capture of
|
|
307
|
+
* tens of megabytes reach the GPU without being duplicated on the way.
|
|
308
|
+
*/
|
|
309
|
+
export function readSplatBlock(buffer, chunk) {
|
|
310
|
+
if (chunk.byteLength < SPLAT_BLOCK_PREFIX) {
|
|
311
|
+
throw new DrftError('SPLT is too short to hold its header');
|
|
312
|
+
}
|
|
313
|
+
const view = new DataView(buffer, chunk.offset, chunk.byteLength);
|
|
314
|
+
const count = view.getUint32(0, true);
|
|
315
|
+
const totalCount = view.getUint32(4, true);
|
|
316
|
+
const wordsPerSplat = view.getUint32(8, true);
|
|
317
|
+
const sphericalHarmonics = view.getUint32(12, true);
|
|
318
|
+
if (wordsPerSplat === 0) {
|
|
319
|
+
throw new DrftError('a SPLT block says its records are zero words, which stores nothing');
|
|
320
|
+
}
|
|
321
|
+
const words = count * wordsPerSplat;
|
|
322
|
+
if (SPLAT_BLOCK_PREFIX + words * 4 > chunk.byteLength) {
|
|
323
|
+
throw new DrftError(`a SPLT block of ${count} splats at ${wordsPerSplat} words runs past its own chunk`);
|
|
324
|
+
}
|
|
325
|
+
if (count > totalCount) {
|
|
326
|
+
throw new DrftError(`a SPLT block holds ${count} splats of a capture that says it has ${totalCount}`);
|
|
327
|
+
}
|
|
328
|
+
const bounds = new Float32Array(buffer, chunk.offset + 16, 6);
|
|
329
|
+
return {
|
|
330
|
+
count,
|
|
331
|
+
totalCount,
|
|
332
|
+
wordsPerSplat,
|
|
333
|
+
sphericalHarmonics,
|
|
334
|
+
boundsMin: bounds.subarray(0, 3),
|
|
335
|
+
boundsMax: bounds.subarray(3, 6),
|
|
336
|
+
records: new Uint32Array(buffer, chunk.offset + SPLAT_BLOCK_PREFIX, words),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Join every block of a capture into one, in the ordinal order the file gave them.
|
|
341
|
+
*
|
|
342
|
+
* The records are copied here and nowhere else in this reader, because joining is the one thing
|
|
343
|
+
* that cannot be a view: the blocks are separate chunks at separate offsets. A consumer that
|
|
344
|
+
* wants to avoid the copy takes the blocks as they arrive, through `DrftStream.onSplats`.
|
|
345
|
+
*/
|
|
346
|
+
function joinSplatBlocks(blocks) {
|
|
347
|
+
const first = blocks[0]?.block;
|
|
348
|
+
if (first === undefined)
|
|
349
|
+
return null;
|
|
350
|
+
blocks.sort((a, b) => a.at - b.at);
|
|
351
|
+
let words = 0;
|
|
352
|
+
for (const entry of blocks) {
|
|
353
|
+
if (entry.block.wordsPerSplat !== first.wordsPerSplat) {
|
|
354
|
+
throw new DrftError(`one SPLT block records ${entry.block.wordsPerSplat} words a splat and another records ` +
|
|
355
|
+
`${first.wordsPerSplat}; a capture has one record layout`);
|
|
356
|
+
}
|
|
357
|
+
words += entry.block.records.length;
|
|
358
|
+
}
|
|
359
|
+
const records = new Uint32Array(words);
|
|
360
|
+
let at = 0;
|
|
361
|
+
let count = 0;
|
|
362
|
+
for (const entry of blocks) {
|
|
363
|
+
records.set(entry.block.records, at);
|
|
364
|
+
at += entry.block.records.length;
|
|
365
|
+
count += entry.block.count;
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
count,
|
|
369
|
+
totalCount: first.totalCount,
|
|
370
|
+
wordsPerSplat: first.wordsPerSplat,
|
|
371
|
+
sphericalHarmonics: first.sphericalHarmonics,
|
|
372
|
+
boundsMin: first.boundsMin,
|
|
373
|
+
boundsMax: first.boundsMax,
|
|
374
|
+
records,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
export function readTexture(buffer, chunk) {
|
|
378
|
+
if (chunk.byteLength < 16)
|
|
379
|
+
throw new DrftError('TEXS is too short to hold its header');
|
|
380
|
+
const view = new DataView(buffer, chunk.offset, chunk.byteLength);
|
|
381
|
+
const byteLength = view.getUint32(12, true);
|
|
382
|
+
if (16 + byteLength > chunk.byteLength) {
|
|
383
|
+
throw new DrftError(`a TEXS payload of ${byteLength} bytes runs past its own chunk`);
|
|
384
|
+
}
|
|
385
|
+
/* Absent in a file written before the name existed, which reads as empty rather than as
|
|
386
|
+
a malformed chunk. That is rule 4 of the compatibility contract in practice. */
|
|
387
|
+
const nameAt = chunk.offset + align(16 + byteLength);
|
|
388
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
389
|
+
const name = nameAt + 4 <= limit ? readString(new DataView(buffer), buffer, nameAt, limit)[0] : '';
|
|
390
|
+
return {
|
|
391
|
+
name,
|
|
392
|
+
codec: view.getUint32(0, true),
|
|
393
|
+
width: view.getUint32(4, true),
|
|
394
|
+
height: view.getUint32(8, true),
|
|
395
|
+
bytes: new Uint8Array(buffer, chunk.offset + 16, byteLength),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
export function readMaterials(buffer, chunk) {
|
|
399
|
+
if (chunk.byteLength < 8)
|
|
400
|
+
throw new DrftError('MATL is too short to hold its header');
|
|
401
|
+
const view = new DataView(buffer, chunk.offset, chunk.byteLength);
|
|
402
|
+
const count = view.getUint32(0, true);
|
|
403
|
+
/*
|
|
404
|
+
* The writer's stride, which may exceed this reader's if the file is newer. Stepping by it
|
|
405
|
+
* rather than by our own is the whole of forward compatibility for this chunk: a later
|
|
406
|
+
* field is skipped instead of shifting every entry after the first.
|
|
407
|
+
*/
|
|
408
|
+
const stride = view.getUint32(4, true);
|
|
409
|
+
/*
|
|
410
|
+
* A stride *shorter* than this version's is an older file, not a broken one, and it must
|
|
411
|
+
* open. This threw at first, which had the rule exactly half right: stepping by the file's
|
|
412
|
+
* stride handles a newer writer, and defaulting the fields that predate it handles an
|
|
413
|
+
* older one. Refusing the second direction breaks the promise in §4.4 rule 1 that a file
|
|
414
|
+
* written today opens in every future reader, which is the whole point of the field.
|
|
415
|
+
*/
|
|
416
|
+
if (stride < 4)
|
|
417
|
+
throw new DrftError(`MATL declares a ${stride} byte entry, which cannot hold a material`);
|
|
418
|
+
/** Whether a field at this offset is present in the stride the file actually used. */
|
|
419
|
+
const has = (offset, bytes) => offset + bytes <= stride;
|
|
420
|
+
if (8 + count * stride > chunk.byteLength) {
|
|
421
|
+
throw new DrftError(`MATL claims ${count} materials and its chunk cannot hold them`);
|
|
422
|
+
}
|
|
423
|
+
const out = [];
|
|
424
|
+
let namesAt = chunk.offset + 8 + count * stride;
|
|
425
|
+
const limit = chunk.offset + chunk.byteLength;
|
|
426
|
+
for (let i = 0; i < count; i++) {
|
|
427
|
+
const at = 8 + i * stride;
|
|
428
|
+
/*
|
|
429
|
+
* A name that cannot be read costs the name and nothing else.
|
|
430
|
+
*
|
|
431
|
+
* Every other field in this chunk is load-bearing and a wrong one draws a wrong picture,
|
|
432
|
+
* so those are checked and refused. A material's name is how a *consumer* addresses the
|
|
433
|
+
* surface, and losing it degrades a lookup rather than the render — so a truncated or
|
|
434
|
+
* misaligned name block leaves the remaining names empty instead of taking a whole asset
|
|
435
|
+
* down. Geometry that draws correctly must not be lost to metadata that does not.
|
|
436
|
+
*/
|
|
437
|
+
let name = '';
|
|
438
|
+
if (namesAt + 4 <= limit) {
|
|
439
|
+
try {
|
|
440
|
+
const [text, next] = readString(new DataView(buffer), buffer, namesAt, limit);
|
|
441
|
+
name = text;
|
|
442
|
+
namesAt = next;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
namesAt = limit;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
out.push({
|
|
449
|
+
name,
|
|
450
|
+
color: has(8, 4)
|
|
451
|
+
? [view.getFloat32(at, true), view.getFloat32(at + 4, true), view.getFloat32(at + 8, true)]
|
|
452
|
+
: [0.8, 0.8, 0.8],
|
|
453
|
+
specular: has(12, 4) ? view.getFloat32(at + 12, true) : 0,
|
|
454
|
+
roughness: has(16, 4) ? view.getFloat32(at + 16, true) : 0.4277,
|
|
455
|
+
emissive: has(20, 4) ? view.getFloat32(at + 20, true) : 0,
|
|
456
|
+
emissiveColor: has(32, 4)
|
|
457
|
+
? [
|
|
458
|
+
view.getFloat32(at + 24, true),
|
|
459
|
+
view.getFloat32(at + 28, true),
|
|
460
|
+
view.getFloat32(at + 32, true),
|
|
461
|
+
]
|
|
462
|
+
: [-1, -1, -1],
|
|
463
|
+
/* Each default is what the engine assumes for a material that never stated it: opaque,
|
|
464
|
+
untextured, and not mirroring anything. */
|
|
465
|
+
opacity: has(36, 4) ? view.getFloat32(at + 36, true) : 1,
|
|
466
|
+
albedo: has(40, 4) ? view.getInt32(at + 40, true) : -1,
|
|
467
|
+
reflectivity: has(44, 4) ? view.getFloat32(at + 44, true) : 0,
|
|
468
|
+
/* Each defaults to "no map", which is exactly what a file written before these meant. */
|
|
469
|
+
normalMap: has(48, 4) ? view.getInt32(at + 48, true) : -1,
|
|
470
|
+
ormMap: has(52, 4) ? view.getInt32(at + 52, true) : -1,
|
|
471
|
+
emissiveMap: has(56, 4) ? view.getInt32(at + 56, true) : -1,
|
|
472
|
+
/*
|
|
473
|
+
* 1 and 1 and **0**. The two scales default to the identity, because a file that never
|
|
474
|
+
* stated them meant its map as authored. The strength defaults to zero for the opposite
|
|
475
|
+
* reason: nothing in an older file establishes that its ORM map carries occlusion in R at
|
|
476
|
+
* all, and glTF leaves that channel undefined unless an occlusionTexture names the same
|
|
477
|
+
* image. Defaulting it to 1 would read whatever the exporter left as a shadow.
|
|
478
|
+
*/
|
|
479
|
+
roughnessScale: has(60, 4) ? view.getFloat32(at + 60, true) : 1,
|
|
480
|
+
metallicScale: has(64, 4) ? view.getFloat32(at + 64, true) : 1,
|
|
481
|
+
occlusionStrength: has(68, 4) ? view.getFloat32(at + 68, true) : 0,
|
|
482
|
+
/* Discard nothing, which is exactly what a file written before this field meant. */
|
|
483
|
+
cutout: has(72, 4) ? view.getFloat32(at + 72, true) : 0,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AnimationClip, DrftSkin } from './animationData.ts';
|
|
2
|
+
/**
|
|
3
|
+
* `NODE`, `SKIN` and `ANIM`: reading and writing the three chunks a rig needs.
|
|
4
|
+
*
|
|
5
|
+
* **All three are optional, which is what makes this additive.** Rule 2 of docs/FORMAT.md §4.4
|
|
6
|
+
* says an optional chunk a reader does not understand is skipped in silence, so a 1.6 file opens
|
|
7
|
+
* in a 1.5 reader as the static geometry it also holds — the right degradation, since a reader
|
|
8
|
+
* that has never heard of a skin cannot deform anything.
|
|
9
|
+
*
|
|
10
|
+
* **`NODE` was specified in §4.3 and implemented nowhere.** `CHUNK_NODE` has been declared and in
|
|
11
|
+
* `KNOWN_CHUNKS` since v1 with no writer and no reader; rigid TRS animation is the first thing to
|
|
12
|
+
* need a hierarchy, so this is a chunk the format has claimed for its whole life finally being
|
|
13
|
+
* built. Its payload is what §4.3 already promised — parent index, TRS, mesh index, name — rather
|
|
14
|
+
* than whatever would be convenient now.
|
|
15
|
+
*/
|
|
16
|
+
/** A node in the asset's own hierarchy. `mesh` is -1 for a node that draws nothing. */
|
|
17
|
+
export interface DrftNode {
|
|
18
|
+
readonly parent: number;
|
|
19
|
+
readonly translation: readonly [number, number, number];
|
|
20
|
+
readonly rotation: readonly [number, number, number, number];
|
|
21
|
+
readonly scale: readonly [number, number, number];
|
|
22
|
+
readonly mesh: number;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function buildNodes(nodes: readonly DrftNode[]): Uint8Array;
|
|
26
|
+
export declare function readNodes(buffer: ArrayBuffer, offset: number, byteLength: number): DrftNode[];
|
|
27
|
+
export declare function buildSkin(skin: DrftSkin): Uint8Array;
|
|
28
|
+
export declare function readSkin(buffer: ArrayBuffer, offset: number, byteLength: number): DrftSkin;
|
|
29
|
+
export declare function buildClip(clip: AnimationClip): Uint8Array;
|
|
30
|
+
export declare function readClip(buffer: ArrayBuffer, offset: number, byteLength: number): AnimationClip;
|
|
31
|
+
/** One mesh's morph deltas, and which mesh they belong to. */
|
|
32
|
+
export interface DrftMorph {
|
|
33
|
+
/** The ordinal of the mesh these deform. */
|
|
34
|
+
readonly mesh: number;
|
|
35
|
+
readonly targetCount: number;
|
|
36
|
+
/** Three floats a vertex per target, interleaved by vertex. See `MeshData.morphTargets`. */
|
|
37
|
+
readonly deltas: Float32Array;
|
|
38
|
+
}
|
|
39
|
+
export declare function buildMorph(morph: DrftMorph): Uint8Array;
|
|
40
|
+
export declare function readMorph(buffer: ArrayBuffer, offset: number, byteLength: number): DrftMorph;
|