@es3d/es3d 1.0.1 → 1.0.2

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.
@@ -0,0 +1,1461 @@
1
+ // Packed glTF binary written by the file converter (`es3d glb`).
2
+ //
3
+ // The binary is the same world as the document: every instance, material,
4
+ // light, camera and animation. Meshes are instanced, quantized
5
+ // (KHR_mesh_quantization), meshopt-compressed, and textures are WebP.
6
+ // Tessellation levels are MSFT_lod. Explicit <lod> switches and the scroll
7
+ // flight are the extras the castle glTF stage reads.
8
+ //
9
+ // Node only. The in-browser download stays on exportGLB, so the site bundle
10
+ // does not load meshoptimizer or sharp.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { compile } from '../pipeline.js';
15
+ import { sampleAnimation } from '../scene/animation.js';
16
+ import { localMatrix } from '../transform/transform.js';
17
+ import { colorToLinear } from '../format/colors.js';
18
+ import { proceduralColor, proceduralNormal } from '../render/textures.js';
19
+ import * as mat4 from '../math/mat4.js';
20
+ import { MeshoptEncoder } from 'meshoptimizer';
21
+ import sharp from 'sharp';
22
+
23
+ function fail(msg) {
24
+ throw new Error(msg);
25
+ }
26
+
27
+ const PI = Math.PI;
28
+ const r7 = (v) => (Math.abs(v) < 1e-7 ? 0 : Number(v.toPrecision(7)));
29
+
30
+ function ancestorNames(doc, uid) {
31
+ const names = [];
32
+ let el = doc && doc.byUid.get(uid);
33
+ while (el) {
34
+ const n = el.values && (el.values.name || el.values.id);
35
+ if (n) names.push(n);
36
+ el = el.parent;
37
+ }
38
+ return names;
39
+ }
40
+
41
+ function hash01(i) {
42
+ let x = Math.imul(i + 1, 0x9e3779b1);
43
+ x = Math.imul(x ^ (x >>> 16), 0x7feb352d);
44
+ x = Math.imul(x ^ (x >>> 15), 0x846ca68b);
45
+ x ^= x >>> 16;
46
+ return (x >>> 0) / 4294967296;
47
+ }
48
+
49
+ function norm3(v) {
50
+ const l = Math.hypot(v[0], v[1], v[2]) || 1;
51
+ return [v[0] / l, v[1] / l, v[2] / l];
52
+ }
53
+ function cross(a, b) {
54
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
55
+ }
56
+ function dot4(a, b) {
57
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
58
+ }
59
+
60
+ /** Quaternion whose -Z axis points along `dir` (glTF lights and cameras). */
61
+ function quatFacing(dir, up = [0, 1, 0]) {
62
+ const f = norm3(dir);
63
+ const z = [-f[0], -f[1], -f[2]];
64
+ let u = up;
65
+ if (Math.abs(z[0] * u[0] + z[1] * u[1] + z[2] * u[2]) > 0.99) u = [1, 0, 0];
66
+ const x = norm3(cross(u, z));
67
+ const y = cross(z, x);
68
+ return mat4.quatFromRotationMatrix([x[0], x[1], x[2], y[0], y[1], y[2], z[0], z[1], z[2]]);
69
+ }
70
+
71
+ function transformPoint(m, p) {
72
+ return [
73
+ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
74
+ m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
75
+ m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
76
+ ];
77
+ }
78
+
79
+ function mul(a, b) {
80
+ const o = mat4.createF64();
81
+ mat4.multiply(o, a, b);
82
+ return o;
83
+ }
84
+ function inv(a) {
85
+ const o = mat4.createF64();
86
+ mat4.invert(o, a);
87
+ return o;
88
+ }
89
+ function dequantMatrix(center, half) {
90
+ const m = mat4.createF64();
91
+ m[0] = half[0];
92
+ m[5] = half[1];
93
+ m[10] = half[2];
94
+ m[12] = center[0];
95
+ m[13] = center[1];
96
+ m[14] = center[2];
97
+ return m;
98
+ }
99
+
100
+ function transpose3(a) {
101
+ return [a[0], a[3], a[6], a[1], a[4], a[7], a[2], a[5], a[8]];
102
+ }
103
+ function mul3(a, b) {
104
+ const o = new Array(9);
105
+ for (let col = 0; col < 3; col++) {
106
+ for (let row = 0; row < 3; row++) {
107
+ let s = 0;
108
+ for (let k = 0; k < 3; k++) s += a[k * 3 + row] * b[col * 3 + k];
109
+ o[col * 3 + row] = s;
110
+ }
111
+ }
112
+ return o;
113
+ }
114
+ function det3(a) {
115
+ return a[0] * (a[4] * a[8] - a[7] * a[5]) - a[3] * (a[1] * a[8] - a[7] * a[2]) + a[6] * (a[1] * a[5] - a[4] * a[2]);
116
+ }
117
+
118
+ // Eigenvectors of a symmetric 3x3. Columns of `vectors` match `values`.
119
+ function jacobi3(m) {
120
+ const a = [
121
+ [m[0], m[3], m[6]],
122
+ [m[1], m[4], m[7]],
123
+ [m[2], m[5], m[8]],
124
+ ];
125
+ const v = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
126
+ for (let iter = 0; iter < 24; iter++) {
127
+ let p = 0, q = 1, max = Math.abs(a[0][1]);
128
+ if (Math.abs(a[0][2]) > max) { max = Math.abs(a[0][2]); p = 0; q = 2; }
129
+ if (Math.abs(a[1][2]) > max) { max = Math.abs(a[1][2]); p = 1; q = 2; }
130
+ if (max < 1e-18) break;
131
+ const app = a[p][p], aqq = a[q][q], apq = a[p][q];
132
+ const tau = (aqq - app) / (2 * apq);
133
+ const tt = Math.sign(tau) / (Math.abs(tau) + Math.sqrt(1 + tau * tau));
134
+ const c = 1 / Math.sqrt(1 + tt * tt);
135
+ const s = tt * c;
136
+ a[p][p] = app - tt * apq;
137
+ a[q][q] = aqq + tt * apq;
138
+ a[p][q] = a[q][p] = 0;
139
+ for (let k = 0; k < 3; k++) {
140
+ if (k === p || k === q) continue;
141
+ const aik = a[k][p], aiq = a[k][q];
142
+ a[k][p] = a[p][k] = c * aik - s * aiq;
143
+ a[k][q] = a[q][k] = s * aik + c * aiq;
144
+ }
145
+ for (let k = 0; k < 3; k++) {
146
+ const vip = v[k][p], viq = v[k][q];
147
+ v[k][p] = c * vip - s * viq;
148
+ v[k][q] = s * vip + c * viq;
149
+ }
150
+ }
151
+ return {
152
+ values: [a[0][0], a[1][1], a[2][2]],
153
+ vectors: [v[0][0], v[1][0], v[2][0], v[0][1], v[1][1], v[2][1], v[0][2], v[1][2], v[2][2]],
154
+ };
155
+ }
156
+
157
+ // A = U * diag(sigma) * V^T, both rotations proper after the caller flips a column.
158
+ function svd3(A) {
159
+ const eig = jacobi3(mul3(transpose3(A), A));
160
+ const order = [0, 1, 2].sort((i, j) => Math.abs(eig.values[j]) - Math.abs(eig.values[i]));
161
+ const V = new Array(9);
162
+ const sigma = [0, 0, 0];
163
+ for (let col = 0; col < 3; col++) {
164
+ const src = order[col];
165
+ sigma[col] = Math.sqrt(Math.max(0, eig.values[src]));
166
+ V[col * 3] = eig.vectors[src * 3];
167
+ V[col * 3 + 1] = eig.vectors[src * 3 + 1];
168
+ V[col * 3 + 2] = eig.vectors[src * 3 + 2];
169
+ }
170
+ const AV = mul3(A, V);
171
+ const U = new Array(9).fill(0);
172
+ for (let col = 0; col < 3; col++) {
173
+ const s = sigma[col];
174
+ if (s > 1e-12) {
175
+ U[col * 3] = AV[col * 3] / s;
176
+ U[col * 3 + 1] = AV[col * 3 + 1] / s;
177
+ U[col * 3 + 2] = AV[col * 3 + 2] / s;
178
+ }
179
+ }
180
+ const column = (M, i) => [M[i * 3], M[i * 3 + 1], M[i * 3 + 2]];
181
+ const missing = [0, 1, 2].filter((i) => sigma[i] <= 1e-12);
182
+ if (missing.length === 1) {
183
+ const i = missing[0];
184
+ const a = column(U, (i + 1) % 3);
185
+ const b = column(U, (i + 2) % 3);
186
+ const c = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
187
+ const l = Math.hypot(c[0], c[1], c[2]) || 1;
188
+ U[i * 3] = c[0] / l;
189
+ U[i * 3 + 1] = c[1] / l;
190
+ U[i * 3 + 2] = c[2] / l;
191
+ }
192
+ return { U, sigma, V };
193
+ }
194
+
195
+ // M = T * R1 * S * R2. glTF instancing is one TRS, which cannot hold this,
196
+ // but two ordinary nodes can, and every viewer composes them exactly.
197
+ function factorAffine(m) {
198
+ const A = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
199
+ const { U, sigma, V } = svd3(A);
200
+ const R1 = U.slice();
201
+ const R2 = transpose3(V);
202
+ const scale = sigma.slice();
203
+ if (det3(R1) < 0) {
204
+ R1[6] *= -1; R1[7] *= -1; R1[8] *= -1;
205
+ scale[2] *= -1;
206
+ }
207
+ if (det3(R2) < 0) {
208
+ R2[2] *= -1; R2[5] *= -1; R2[8] *= -1;
209
+ scale[2] *= -1;
210
+ }
211
+ return {
212
+ translation: [m[12], m[13], m[14]],
213
+ rotation: mat4.quatFromRotationMatrix(R1),
214
+ scale,
215
+ rotation2: mat4.quatFromRotationMatrix(R2),
216
+ };
217
+ }
218
+
219
+ function trsParts(translation, rotation, scale) {
220
+ const t = {};
221
+ if (translation.some((v) => Math.abs(v) > 1e-8)) t.translation = translation.map(r7);
222
+ if (rotation.some((v, i) => Math.abs(v - (i === 3 ? 1 : 0)) > 1e-8)) t.rotation = rotation.map(r7);
223
+ if (scale.some((v) => Math.abs(v - 1) > 1e-8)) t.scale = scale.map(r7);
224
+ return t;
225
+ }
226
+
227
+ function quantizeMesh(mesh) {
228
+ const p = mesh.positions;
229
+ const n = mesh.normals;
230
+ const count = mesh.vertexCount;
231
+ let min = [Infinity, Infinity, Infinity];
232
+ let max = [-Infinity, -Infinity, -Infinity];
233
+ for (let i = 0; i < p.length; i += 3) {
234
+ for (let k = 0; k < 3; k++) {
235
+ if (p[i + k] < min[k]) min[k] = p[i + k];
236
+ if (p[i + k] > max[k]) max[k] = p[i + k];
237
+ }
238
+ }
239
+ const center = [0, 1, 2].map((k) => (min[k] + max[k]) / 2);
240
+ // One scalar undoes the quantization (the page's dequant scale is a scalar).
241
+ const h = Math.max(1e-6, ...[0, 1, 2].map((k) => (max[k] - min[k]) / 2));
242
+ const half = [h, h, h];
243
+ const positions = new Int16Array(count * 4);
244
+ const normals = new Int8Array(count * 4);
245
+ const qmin = [32767, 32767, 32767];
246
+ const qmax = [-32767, -32767, -32767];
247
+ for (let i = 0; i < count; i++) {
248
+ const nx = n[i * 3] * half[0];
249
+ const ny = n[i * 3 + 1] * half[1];
250
+ const nz = n[i * 3 + 2] * half[2];
251
+ const nl = Math.hypot(nx, ny, nz) || 1;
252
+ for (let k = 0; k < 3; k++) {
253
+ const q = Math.max(-32767, Math.min(32767, Math.round(((p[i * 3 + k] - center[k]) / half[k]) * 32767)));
254
+ positions[i * 4 + k] = q;
255
+ if (q < qmin[k]) qmin[k] = q;
256
+ if (q > qmax[k]) qmax[k] = q;
257
+ }
258
+ normals[i * 4] = Math.max(-127, Math.min(127, Math.round((nx / nl) * 127)));
259
+ normals[i * 4 + 1] = Math.max(-127, Math.min(127, Math.round((ny / nl) * 127)));
260
+ normals[i * 4 + 2] = Math.max(-127, Math.min(127, Math.round((nz / nl) * 127)));
261
+ }
262
+ return { positions, normals, center, half, min: qmin, max: qmax };
263
+ }
264
+
265
+ function kids(node) {
266
+ if (!node) return [];
267
+ if (node.kind === 'use') return [...(node.target ? [node.target] : []), ...(node.children || [])];
268
+ return node.children || [];
269
+ }
270
+
271
+ function countRows(node) {
272
+ if (!node || node.visible === false) return 0;
273
+ switch (node.kind) {
274
+ case 'shape':
275
+ return node.geometry ? 1 : 0;
276
+ case 'light':
277
+ case 'camera':
278
+ return 0;
279
+ case 'repeat': {
280
+ let n = 0;
281
+ for (const c of node.children || []) n += countRows(c);
282
+ return n * (node.count || 0);
283
+ }
284
+ case 'scatter': {
285
+ let n = 0;
286
+ for (const c of kids(node)) n += countRows(c);
287
+ return n * ((node.params && node.params.count) || 0);
288
+ }
289
+ default: {
290
+ let n = 0;
291
+ for (const c of kids(node)) n += countRows(c);
292
+ return n;
293
+ }
294
+ }
295
+ }
296
+
297
+ function collectAnims(node, out, seen) {
298
+ if (!node || seen.has(node)) return out;
299
+ seen.add(node);
300
+ for (const a of node.anims || []) out.push({ node, a });
301
+ for (const c of kids(node)) collectAnims(c, out, seen);
302
+ return out;
303
+ }
304
+
305
+ function uniformScaleAnim(a) {
306
+ if (a.attr !== 'scale') return false;
307
+ const vals = a.keys || [a.from, a.to].filter(Boolean);
308
+ return vals.every((v) => v && (v.length === 1 || (Math.abs(v[0] - v[1]) < 1e-5 && Math.abs(v[0] - (v[2] ?? v[0])) < 1e-5)));
309
+ }
310
+
311
+ function innerAnims(node) {
312
+ const out = [];
313
+ const seen = new Set([node]);
314
+ for (const c of kids(node)) collectAnims(c, out, seen);
315
+ return out;
316
+ }
317
+
318
+ // An infinite repeat samples t = N*dur as the start of the next cycle.
319
+ // Step just inside the cycle so a looping track ends on its last value.
320
+ function sampleAt(a, t, progress = 0) {
321
+ if (a.timeline !== 'scroll' && a.dur > 0) {
322
+ const elapsed = t - (a.begin || 0);
323
+ const cycles = elapsed / a.dur;
324
+ if (elapsed > 0 && Math.abs(cycles - Math.round(cycles)) < 1e-5) t -= a.dur * 1e-5;
325
+ }
326
+ return sampleAnimation(a, t, progress);
327
+ }
328
+
329
+ function localOf(node, time, progress) {
330
+ const ov = {};
331
+ for (const a of node.anims || []) {
332
+ const v = sampleAt(a, time, progress);
333
+ if (v) ov[a.attr] = v;
334
+ }
335
+ return localMatrix(node.el.values, mat4.createF64(), ov);
336
+ }
337
+
338
+ function nodeLabel(node) {
339
+ const v = node.el && node.el.values;
340
+ return (v && (v.name || v.id)) || node.kind;
341
+ }
342
+
343
+ function periodOf(a) {
344
+ if (a.timeline === 'scroll') return 1;
345
+ return a.alternate ? a.dur * 2 : a.dur;
346
+ }
347
+
348
+ function scaleAt(anim, time) {
349
+ const v = sampleAnimation(anim, time, 0);
350
+ if (!v) return null;
351
+ return v.length === 1 ? v[0] : v[0];
352
+ }
353
+
354
+ /** Key times and values of one animated channel, in the clip's clock. */
355
+ function sampleChannel(anim) {
356
+ const scroll = anim.timeline === 'scroll';
357
+ const period = periodOf(anim);
358
+ const linear = !anim.smooth && !anim.path && Math.abs(anim.ease(0.3) - 0.3) < 1e-6;
359
+ const times = [];
360
+ const values = [];
361
+ const push = (t, progress, localT) => {
362
+ const v = scroll ? sampleAnimation(anim, 0, progress) : sampleAt(anim, anim.begin + localT, 0);
363
+ if (!v) return;
364
+ times.push(t);
365
+ values.push(v);
366
+ };
367
+ // A full turn is the same quaternion at both ends, and an infinite repeat
368
+ // samples t = dur as the start of the next cycle. Both need real in-between keys.
369
+ const spin = anim.attr === 'rotation' && anim.from && anim.to && anim.from.some((v, k) => Math.abs(v - anim.to[k]) > 180);
370
+ if (!scroll && linear && !anim.keys && anim.from && anim.to && !spin) {
371
+ push(0, 0, 0);
372
+ if (anim.alternate) {
373
+ push(anim.dur, 0, anim.dur);
374
+ push(period, 0, period);
375
+ } else {
376
+ // t = dur is cycle 0 again when repeat is infinite; take the value just before the wrap
377
+ const end = sampleAnimation(anim, anim.begin + anim.dur * (1 - 1e-8), 0);
378
+ if (end) {
379
+ times.push(anim.dur);
380
+ values.push(end);
381
+ }
382
+ }
383
+ return { times, values, period };
384
+ }
385
+ if (!scroll && linear && anim.keys && !anim.smooth) {
386
+ const kt = anim.keyTimes;
387
+ for (let i = 0; i < anim.keys.length; i++) push(kt[i] * anim.dur, 0, kt[i] * anim.dur);
388
+ if (anim.alternate) {
389
+ for (let i = anim.keys.length - 2; i >= 0; i--) push(anim.dur + (1 - kt[i]) * anim.dur, 0, anim.dur + (1 - kt[i]) * anim.dur);
390
+ }
391
+ return { times, values, period };
392
+ }
393
+ const n = scroll ? 600 : 48;
394
+ for (let i = 0; i <= n; i++) {
395
+ const t = (period * i) / n;
396
+ push(scroll ? t : t, t, t);
397
+ }
398
+ return { times, values, period };
399
+ }
400
+
401
+ class Bin {
402
+ constructor() {
403
+ this.parts = [];
404
+ this.length = 0;
405
+ this.views = [];
406
+ this.accessors = [];
407
+ }
408
+ raw(bytes) {
409
+ const pad = (4 - (this.length % 4)) % 4;
410
+ if (pad) {
411
+ this.parts.push(new Uint8Array(pad));
412
+ this.length += pad;
413
+ }
414
+ const offset = this.length;
415
+ this.parts.push(bytes);
416
+ this.length += bytes.byteLength;
417
+ this.views.push({ buffer: 0, byteOffset: offset, byteLength: bytes.byteLength });
418
+ return this.views.length - 1;
419
+ }
420
+ meshopt(typed, count, stride, mode) {
421
+ const raw = new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength);
422
+ const encoded = MeshoptEncoder.encodeGltfBuffer(raw, count, stride, mode);
423
+ const pad = (4 - (this.length % 4)) % 4;
424
+ if (pad) {
425
+ this.parts.push(new Uint8Array(pad));
426
+ this.length += pad;
427
+ }
428
+ const offset = this.length;
429
+ this.parts.push(encoded);
430
+ this.length += encoded.byteLength;
431
+ const ext = { buffer: 0, byteOffset: offset, byteLength: encoded.byteLength, byteStride: stride, count, mode };
432
+ // Three.js reads the decoded buffer with bufferView.byteStride. The
433
+ // extension stride alone is not enough, so a padded attribute (an int16
434
+ // vec3 is 8 bytes, not 6) was being read across the padding and every
435
+ // mesh came out scrambled. Index views stay tightly packed.
436
+ const view = {
437
+ buffer: 1,
438
+ byteOffset: 0,
439
+ byteLength: raw.byteLength,
440
+ extensions: { EXT_meshopt_compression: ext },
441
+ };
442
+ if (mode === 'ATTRIBUTES') view.byteStride = stride;
443
+ this.views.push(view);
444
+ return this.views.length - 1;
445
+ }
446
+ accessor(view, componentType, type, count, extra = {}) {
447
+ const a = { bufferView: view, componentType, count, type, ...extra };
448
+ const bv = this.views[view];
449
+ const width = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }[type] * { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }[componentType];
450
+ if (bv && bv.byteStride === width) delete bv.byteStride;
451
+ this.accessors.push(a);
452
+ return this.accessors.length - 1;
453
+ }
454
+ bytes() {
455
+ const out = new Uint8Array(Math.ceil(this.length / 4) * 4);
456
+ let o = 0;
457
+ for (const p of this.parts) {
458
+ out.set(p, o);
459
+ o += p.byteLength;
460
+ }
461
+ return out;
462
+ }
463
+ }
464
+
465
+ function minMax3(arr, stride) {
466
+ const min = [Infinity, Infinity, Infinity];
467
+ const max = [-Infinity, -Infinity, -Infinity];
468
+ for (let i = 0; i < arr.length; i += stride) {
469
+ for (let k = 0; k < 3; k++) {
470
+ const v = arr[i + k];
471
+ if (v < min[k]) min[k] = v;
472
+ if (v > max[k]) max[k] = v;
473
+ }
474
+ }
475
+ return { min, max };
476
+ }
477
+
478
+ async function bakeTextures(materials, dir) {
479
+ const images = [];
480
+ const byKey = new Map();
481
+ const add = async (spec, linear) => {
482
+ if (!spec) return null;
483
+ const key = (linear ? 'n:' : 'c:') + spec.key;
484
+ if (byKey.has(key)) return byKey.get(key);
485
+ let webp;
486
+ let name;
487
+ if (spec.kind === 'url') {
488
+ const file = path.resolve(dir, spec.url);
489
+ webp = await sharp(file).webp({ quality: 90, effort: 4, alphaQuality: 100 }).toBuffer();
490
+ name = path.basename(spec.url, path.extname(spec.url));
491
+ } else if (spec.fn) {
492
+ const px = linear ? proceduralNormal(spec) : proceduralColor(spec);
493
+ webp = await sharp(Buffer.from(px.data.buffer, px.data.byteOffset, px.data.byteLength), {
494
+ raw: { width: px.width, height: px.height, channels: 4 },
495
+ }).webp({ quality: linear ? 95 : 82, effort: 4, alphaQuality: 100 }).toBuffer();
496
+ name = `${spec.fn}${linear ? ' normal' : ''}`;
497
+ } else return null;
498
+ const index = images.length;
499
+ images.push({ name, webp });
500
+ byKey.set(key, index);
501
+ return index;
502
+ };
503
+ const texOf = [];
504
+ for (const m of materials) {
505
+ texOf.push({
506
+ map: await add(m.params.map, false),
507
+ normalMap: await add(m.params.normalMap, true),
508
+ });
509
+ }
510
+ return { images, texOf };
511
+ }
512
+
513
+ export async function exportPackedGLB(text, { baseDir = '', sourceName = 'scene.es3d' } = {}) {
514
+ await MeshoptEncoder.ready;
515
+ const t0 = performance.now();
516
+ const result = compile(text, { time: 0, progress: 0 });
517
+ if (!result.ok) {
518
+ for (const d of result.diagnostics.filter((x) => x.severity === 'error').slice(0, 12)) console.error(d.format(result.doc.lineIndex));
519
+ fail('scene did not compile');
520
+ }
521
+ const scene = result.scene;
522
+ const doc = result.doc;
523
+ const graph = result.graph;
524
+ const meshOf = (entry, lod) => result.cache.mesh(entry, lod);
525
+ console.error(`compiled ${scene.count.toLocaleString('en-US')} instances in ${(performance.now() - t0).toFixed(0)} ms`);
526
+
527
+ const beside = path.resolve(baseDir || '.');
528
+ const texDir = fs.existsSync(path.join(beside, 'textures'))
529
+ ? beside
530
+ : path.resolve(beside, '../../../public/demo/castle/es3d');
531
+ const { images, texOf } = await bakeTextures(scene.materials, texDir);
532
+ console.error(`textures ${images.length}`);
533
+
534
+ const bin = new Bin();
535
+ const json = {
536
+ asset: { version: '2.0', generator: 'ES3D' },
537
+ extensionsUsed: ['EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'EXT_texture_webp', 'KHR_lights_punctual', 'KHR_mesh_quantization'],
538
+ extensionsRequired: ['EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'EXT_texture_webp', 'KHR_mesh_quantization'],
539
+ extensions: { KHR_lights_punctual: { lights: [] } },
540
+ scene: 0,
541
+ scenes: [{ nodes: [], extras: {} }],
542
+ nodes: [],
543
+ meshes: [],
544
+ materials: [],
545
+ textures: [],
546
+ images: [],
547
+ samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }],
548
+ cameras: [],
549
+ animations: [],
550
+ accessors: bin.accessors,
551
+ bufferViews: bin.views,
552
+ buffers: [{ byteLength: 0 }, { byteLength: 0, extensions: { EXT_meshopt_compression: { fallback: true } } }],
553
+ };
554
+ const used = new Set(json.extensionsUsed);
555
+
556
+ const imageIndex = images.map((im) => {
557
+ const view = bin.raw(im.webp);
558
+ const i = json.images.length;
559
+ json.images.push({ name: im.name, mimeType: 'image/webp', bufferView: view });
560
+ json.textures.push({ sampler: 0, extensions: { EXT_texture_webp: { source: i } } });
561
+ return i;
562
+ });
563
+
564
+ const texInfo = (index, uv) => {
565
+ if (index == null) return null;
566
+ const info = { index: imageIndex[index] };
567
+ if (uv && (Math.abs(uv[0] - 1) > 1e-6 || Math.abs(uv[1] - 1) > 1e-6)) {
568
+ used.add('KHR_texture_transform');
569
+ info.extensions = { KHR_texture_transform: { scale: [r7(uv[0]), r7(uv[1])] } };
570
+ }
571
+ return info;
572
+ };
573
+
574
+ scene.materials.forEach((m, i) => {
575
+ const p = m.params;
576
+ const base = colorToLinear(p.color);
577
+ const mat = {
578
+ name: m.name || `material${i}`,
579
+ pbrMetallicRoughness: {
580
+ baseColorFactor: [r7(base[0]), r7(base[1]), r7(base[2]), r7(m.opacity)],
581
+ metallicFactor: r7(m.metallic),
582
+ roughnessFactor: r7(m.roughness),
583
+ },
584
+ };
585
+ const map = texInfo(texOf[i].map, p.uvScale);
586
+ if (map) mat.pbrMetallicRoughness.baseColorTexture = map;
587
+ const nmap = texInfo(texOf[i].normalMap, p.uvScale);
588
+ if (nmap) {
589
+ mat.normalTexture = nmap;
590
+ if (p.normalScale && Math.abs(p.normalScale - 1) > 1e-6) mat.normalTexture.scale = r7(p.normalScale);
591
+ }
592
+ if (m.unlit) {
593
+ used.add('KHR_materials_unlit');
594
+ mat.extensions = { ...(mat.extensions || {}), KHR_materials_unlit: {} };
595
+ }
596
+ const e = colorToLinear(p.emissive);
597
+ if (e[0] + e[1] + e[2] > 1e-5 && p.emissiveIntensity > 0) {
598
+ mat.emissiveFactor = [r7(Math.min(1, e[0])), r7(Math.min(1, e[1])), r7(Math.min(1, e[2]))];
599
+ if (p.emissiveIntensity !== 1) {
600
+ used.add('KHR_materials_emissive_strength');
601
+ mat.extensions = { ...(mat.extensions || {}), KHR_materials_emissive_strength: { emissiveStrength: r7(p.emissiveIntensity) } };
602
+ }
603
+ }
604
+ if (m.alphaMode === 'mask') {
605
+ mat.alphaMode = 'MASK';
606
+ mat.alphaCutoff = m.alphaCutoff ?? 0.5;
607
+ } else if (m.alphaMode === 'blend' || m.opacity < 0.999) mat.alphaMode = 'BLEND';
608
+ if (m.doubleSided) mat.doubleSided = true;
609
+ json.materials.push(mat);
610
+ });
611
+
612
+ // geometry: lod 0 (and further tessellation levels) quantized once per entry
613
+ const needsUv = new Set();
614
+ for (let i = 0; i < scene.count; i++) {
615
+ const m = scene.materials[scene.mat[i]];
616
+ if (m.map || m.normalMap) needsUv.add(scene.geom[i]);
617
+ }
618
+ const geomMesh = new Map(); // `${gi}|${lod}|${mi}|morph` -> { mesh, dequant }
619
+ const prepared = new Map(); // `${gi}|${lod}|q` -> prepared mesh
620
+ function prepare(gi, lod, quantize) {
621
+ const key = `${gi}|${lod}|${quantize ? 1 : 0}`;
622
+ if (prepared.has(key)) return prepared.get(key);
623
+ const entry = scene.geometries[gi];
624
+ const level = Math.min(lod, entry.lods - 1);
625
+ const mesh = meshOf(entry, level);
626
+ const empty = !mesh.vertexCount;
627
+ const lines = mesh.kind === 'lines';
628
+ let dequant = null;
629
+ let attributes;
630
+ let indices;
631
+ if (!empty && !lines && quantize) {
632
+ const q = quantizeMesh(mesh);
633
+ dequant = { translation: q.center.map(r7), scale: r7(q.half[0]), half: q.half, center: q.center };
634
+ dequant.center = q.center;
635
+ const pv = bin.meshopt(q.positions, mesh.vertexCount, 8, 'ATTRIBUTES');
636
+ const nv = bin.meshopt(q.normals, mesh.vertexCount, 4, 'ATTRIBUTES');
637
+ attributes = {
638
+ POSITION: bin.accessor(pv, 5122, 'VEC3', mesh.vertexCount, { normalized: true, byteOffset: 0, ...minmaxAccessor(q.min, q.max) }),
639
+ NORMAL: bin.accessor(nv, 5120, 'VEC3', mesh.vertexCount, { normalized: true }),
640
+ };
641
+ if (needsUv.has(gi)) {
642
+ const uvv = bin.meshopt(mesh.uvs, mesh.vertexCount, 8, 'ATTRIBUTES');
643
+ attributes.TEXCOORD_0 = bin.accessor(uvv, 5126, 'VEC2', mesh.vertexCount);
644
+ }
645
+ const small = mesh.vertexCount < 65536;
646
+ const idx = small ? Uint16Array.from(mesh.indices) : mesh.indices;
647
+ const iv = bin.meshopt(idx, idx.length, small ? 2 : 4, 'TRIANGLES');
648
+ indices = bin.accessor(iv, small ? 5123 : 5125, 'SCALAR', idx.length);
649
+ if (entry.doubleSided) {
650
+ /* material flag applied below when a primitive needs it */
651
+ }
652
+ } else if (!empty && !lines) {
653
+ const pv = bin.meshopt(mesh.positions, mesh.vertexCount, 12, 'ATTRIBUTES');
654
+ const nv = bin.meshopt(mesh.normals, mesh.vertexCount, 12, 'ATTRIBUTES');
655
+ attributes = {
656
+ POSITION: bin.accessor(pv, 5126, 'VEC3', mesh.vertexCount, minMax3(mesh.positions, 3)),
657
+ NORMAL: bin.accessor(nv, 5126, 'VEC3', mesh.vertexCount),
658
+ };
659
+ if (needsUv.has(gi)) {
660
+ const uvv = bin.meshopt(mesh.uvs, mesh.vertexCount, 8, 'ATTRIBUTES');
661
+ attributes.TEXCOORD_0 = bin.accessor(uvv, 5126, 'VEC2', mesh.vertexCount);
662
+ }
663
+ const small = mesh.vertexCount < 65536;
664
+ const idx = small ? Uint16Array.from(mesh.indices) : mesh.indices;
665
+ const iv = bin.meshopt(idx, idx.length, small ? 2 : 4, 'TRIANGLES');
666
+ indices = bin.accessor(iv, small ? 5123 : 5125, 'SCALAR', idx.length);
667
+ } else if (!empty) {
668
+ const pv = bin.meshopt(mesh.positions, mesh.vertexCount, 12, 'ATTRIBUTES');
669
+ attributes = { POSITION: bin.accessor(pv, 5126, 'VEC3', mesh.vertexCount, minMax3(mesh.positions, 3)) };
670
+ const small = mesh.vertexCount < 65536;
671
+ const idx = small ? Uint16Array.from(mesh.indices) : mesh.indices;
672
+ const iv = bin.meshopt(idx, idx.length, small ? 2 : 4, 'INDICES');
673
+ indices = bin.accessor(iv, small ? 5123 : 5125, 'SCALAR', idx.length);
674
+ }
675
+ const rec = { mesh, attributes, indices, empty, lines, dequant, entry, lod: level };
676
+ prepared.set(key, rec);
677
+ return rec;
678
+ }
679
+
680
+ function minmaxAccessor(min, max) {
681
+ return { min, max };
682
+ }
683
+
684
+ function gltfMesh(gi, lod, mi, morph) {
685
+ const key = `${gi}|${lod}|${mi}|${morph ? 1 : 0}`;
686
+ if (geomMesh.has(key)) return geomMesh.get(key);
687
+ const g = prepare(gi, lod, !morph);
688
+ if (g.empty) {
689
+ geomMesh.set(key, null);
690
+ return null;
691
+ }
692
+ if (g.entry.doubleSided) json.materials[mi].doubleSided = true;
693
+ const prim = { attributes: g.attributes, indices: g.indices, material: mi, mode: g.lines ? 1 : 4 };
694
+ const mesh = { name: scene.materials[mi].name || `geom${gi}`, primitives: [prim] };
695
+ if (morph) {
696
+ const base = g.mesh.positions;
697
+ const target = new Float32Array(base.length);
698
+ for (let i = 0; i < base.length; i++) target[i] = base[i]; // delta = base, so weight w yields (1+w)*base
699
+ const tv = bin.meshopt(target, g.mesh.vertexCount, 12, 'ATTRIBUTES');
700
+ prim.targets = [{ POSITION: bin.accessor(tv, 5126, 'VEC3', g.mesh.vertexCount) }];
701
+ mesh.weights = [morph.weight];
702
+ mesh.extras = { targetNames: ['pulse'] };
703
+ }
704
+ const index = json.meshes.length;
705
+ json.meshes.push(mesh);
706
+ const rec = { index, dequant: g.dequant, lines: g.lines, entry: g.entry };
707
+ geomMesh.set(key, rec);
708
+ return rec;
709
+ }
710
+
711
+ const nameList = [];
712
+ const nameKey = new Map();
713
+ function nameId(uid) {
714
+ const chain = ancestorNames(doc, uid);
715
+ const key = chain.join('\0');
716
+ let id = nameKey.get(key);
717
+ if (id === undefined) {
718
+ id = nameList.length;
719
+ nameList.push(chain);
720
+ nameKey.set(key, id);
721
+ }
722
+ return id;
723
+ }
724
+
725
+ const scrollChannels = [];
726
+ const timeClips = new Map();
727
+
728
+ function worldAt(i) {
729
+ const out = mat4.createF64();
730
+ const o = i * 16;
731
+ for (let k = 0; k < 16; k++) out[k] = scene.matrices[o + k];
732
+ return out;
733
+ }
734
+
735
+ function addNode(def, root) {
736
+ const i = json.nodes.length;
737
+ json.nodes.push(def);
738
+ if (root) json.scenes[0].nodes.push(i);
739
+ return i;
740
+ }
741
+
742
+ function trsOf(matrix) {
743
+ const d = mat4.decompose(matrix);
744
+ if (!d) return { matrix: Array.from(matrix, r7) };
745
+ const t = {};
746
+ if (d.translation.some((v) => Math.abs(v) > 1e-8)) t.translation = d.translation.map(r7);
747
+ if (d.rotation.some((v, i) => Math.abs(v - (i === 3 ? 1 : 0)) > 1e-8)) t.rotation = d.rotation.map(r7);
748
+ if (d.scale.some((v) => Math.abs(v - 1) > 1e-8)) t.scale = d.scale.map(r7);
749
+ return t;
750
+ }
751
+
752
+ function vecAccessor(values, count, type, comp, strideFloats) {
753
+ const arr = type === 'SCALAR' ? new Float32Array(values) : Float32Array.from(values.flat ? values.flat() : values);
754
+ const stride = strideFloats * 4;
755
+ const view = bin.meshopt(arr, count, stride, 'ATTRIBUTES');
756
+ return bin.accessor(view, comp, type, count);
757
+ }
758
+
759
+ function pushTrack(clip, nodeIndex, path, times, flat, type) {
760
+ const tv = bin.meshopt(Float32Array.from(times), times.length, 4, 'ATTRIBUTES');
761
+ const input = bin.accessor(tv, 5126, 'SCALAR', times.length, { min: [times[0]], max: [times[times.length - 1]] });
762
+ const comps = type === 'VEC4' ? 4 : type === 'SCALAR' ? 1 : 3;
763
+ const ov = bin.meshopt(Float32Array.from(flat), times.length, comps * 4, 'ATTRIBUTES');
764
+ const output = bin.accessor(ov, 5126, type, times.length);
765
+ const sampler = clip.samplers.length;
766
+ clip.samplers.push({ input, output, interpolation: 'LINEAR' });
767
+ clip.channels.push({ sampler, target: { node: nodeIndex, path } });
768
+ }
769
+
770
+ function clipFor(timeline, begin, period, name) {
771
+ if (timeline === 'scroll') {
772
+ let c = json.animations.find((a) => a.name === 'Scroll');
773
+ if (!c) {
774
+ c = { name: 'Scroll', samplers: [], channels: [], extras: { timeline: 'scroll' } };
775
+ json.animations.push(c);
776
+ }
777
+ return c;
778
+ }
779
+ const key = `${begin}|${period}|${name}`;
780
+ if (!timeClips.has(key)) {
781
+ const c = { name, samplers: [], channels: [], extras: { timeline: 'time', begin, period } };
782
+ json.animations.push(c);
783
+ timeClips.set(key, c);
784
+ }
785
+ return timeClips.get(key);
786
+ }
787
+
788
+ function animateWorld(nodeIndex, node, parentFn) {
789
+ // one TRS track from the node's world matrix over its animation clock
790
+ const anims = node.anims;
791
+ const scroll = anims.some((a) => a.timeline === 'scroll');
792
+ const clock = anims.filter((a) => a.timeline !== 'scroll');
793
+ const sampleWorld = (time, progress) => {
794
+ const parent = parentFn(time, progress);
795
+ return mul(parent, localOf(node, time, progress));
796
+ };
797
+ const emit = (timeline, series, label) => {
798
+ if (!series.times.length) return;
799
+ const mats = series.values;
800
+ const T = [];
801
+ const R = [];
802
+ const S = [];
803
+ let prevQ = null;
804
+ for (const m of mats) {
805
+ const d = mat4.decompose(m) || { translation: [m[12], m[13], m[14]], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
806
+ if (prevQ && dot4(prevQ, d.rotation) < 0) d.rotation = d.rotation.map((v) => -v);
807
+ prevQ = d.rotation;
808
+ T.push(d.translation);
809
+ R.push(d.rotation);
810
+ S.push(d.scale);
811
+ }
812
+ const clip = clipFor(timeline, timeline === 'scroll' ? 0 : anims[0].begin || 0, series.period, label);
813
+ const moving = (arr, n) => arr.some((v) => v.some((x, i) => Math.abs(x - arr[0][i]) > 1e-5));
814
+ if (moving(T, 3)) pushTrack(clip, nodeIndex, 'translation', series.times, T.flat(), 'VEC3');
815
+ if (moving(R, 4)) pushTrack(clip, nodeIndex, 'rotation', series.times, R.flat(), 'VEC4');
816
+ if (moving(S, 3)) pushTrack(clip, nodeIndex, 'scale', series.times, S.flat(), 'VEC3');
817
+ };
818
+ if (scroll) {
819
+ const a = anims.find((x) => x.timeline === 'scroll');
820
+ const s = sampleChannel(a);
821
+ s.values = s.times.map((t) => sampleWorld(0, t));
822
+ emit('scroll', s, nodeLabel(node));
823
+ }
824
+ if (clock.length) {
825
+ const a = clock[0];
826
+ const s = sampleChannel(a);
827
+ s.values = s.times.map((t) => sampleWorld(a.begin + t, 0));
828
+ emit('time', s, nodeLabel(node) + (a.attr ? ` ${a.attr}` : ''));
829
+ }
830
+ }
831
+
832
+ // ---- instances -------------------------------------------------------
833
+ const covered = new Uint8Array(scene.count);
834
+ for (const seg of scene.dynamic) for (let i = seg.start; i < seg.end; i++) covered[i] = 1;
835
+
836
+ let identityAttrs = null;
837
+ function identityInstancing() {
838
+ if (!identityAttrs) {
839
+ identityAttrs = {
840
+ TRANSLATION: vecAccessor(new Float32Array(3), 1, 'VEC3', 5126, 3),
841
+ ROTATION: vecAccessor(new Float32Array([0, 0, 0, 1]), 1, 'VEC4', 5126, 4),
842
+ SCALE: vecAccessor(new Float32Array([1, 1, 1]), 1, 'VEC3', 5126, 3),
843
+ };
844
+ }
845
+ return identityAttrs;
846
+ }
847
+
848
+ function geomUnit(entry) {
849
+ const u = entry.desc && entry.desc.unit;
850
+ if (!u) return 1;
851
+ const c = Math.cbrt(Math.abs(mat4.determinant3(u)) || 1);
852
+ return c > 1e-8 ? c : 1;
853
+ }
854
+
855
+ function emitRange(row0, rowN, parentIndex, parentWorld, label) {
856
+ if (rowN <= 0) return;
857
+ const invP = parentWorld ? inv(parentWorld) : null;
858
+ const groups = new Map();
859
+ for (let i = row0; i < row0 + rowN; i++) {
860
+ const gi = scene.geom[i];
861
+ const mi = scene.mat[i];
862
+ const lodChild = scene.lodNode[i] >= 0 ? scene.lodChild[i] : -1;
863
+ const collide = (scene.flags[i] & 2) === 0;
864
+ const key = `${gi}|${mi}|${lodChild}|${collide ? 1 : 0}`;
865
+ // lod switch identity: distances live on the switch; group by child + geom
866
+ let g = groups.get(key);
867
+ if (!g) groups.set(key, (g = []));
868
+ g.push(i);
869
+ }
870
+ for (const [key, rows] of groups) {
871
+ const i0 = rows[0];
872
+ const gi = scene.geom[i0];
873
+ const mi = scene.mat[i0];
874
+ const entry = scene.geometries[gi];
875
+ const gmesh = gltfMesh(gi, 0, mi, null);
876
+ if (!gmesh) continue;
877
+ const ids = rows.map((i) => nameId(scene.uid[i]));
878
+ const sameName = ids.every((id) => id === ids[0]);
879
+ const collide = (scene.flags[i0] & 2) === 0;
880
+ const dq = gmesh.dequant ? dequantMatrix(gmesh.dequant.center, gmesh.dequant.half) : null;
881
+ // Instancing stores one TRS. A non-uniform scale with a rotation on
882
+ // either side is two rotations and a scale, so those copies become
883
+ // two nodes that share this mesh instead of a flattened instance.
884
+ const placed = [];
885
+ const shear = [];
886
+ for (let k = 0; k < rows.length; k++) {
887
+ const i = rows[k];
888
+ const world = worldAt(i);
889
+ let local = invP ? mul(invP, world) : world;
890
+ if (dq && !gmesh.lines) local = mul(local, dq);
891
+ const d = mat4.decompose(local);
892
+ if (d) placed.push({ i, d, id: ids[k] });
893
+ else shear.push({ local, id: ids[k] });
894
+ }
895
+ const T = new Float32Array(placed.length * 3);
896
+ const R = new Float32Array(placed.length * 4);
897
+ const S = new Float32Array(placed.length * 3);
898
+ const detail = new Float32Array(placed.length);
899
+ const idAttr = new Float32Array(placed.length);
900
+ let lodIds = null;
901
+ for (let k = 0; k < placed.length; k++) {
902
+ const { i, d } = placed[k];
903
+ T.set(d.translation, k * 3);
904
+ R.set(d.rotation, k * 4);
905
+ S.set(d.scale, k * 3);
906
+ detail[k] = hash01(k + i);
907
+ idAttr[k] = placed[k].id;
908
+ }
909
+ const matName = json.materials[mi].name || 'mesh';
910
+ const lodNode = scene.lodNode[i0];
911
+ let tess = null;
912
+ if (entry.lods > 1 && scene.lodChild[i0] === 0) {
913
+ const levels = [];
914
+ const deviation = entry.lodDeviation || [0];
915
+ const map = [];
916
+ for (let l = 0; l < entry.lods; l++) {
917
+ const lm = gltfMesh(gi, l, mi, null);
918
+ if (!lm) continue;
919
+ if (l === 0) continue;
920
+ const half = lm.dequant ? lm.dequant.half : [1, 1, 1];
921
+ const center = lm.dequant ? lm.dequant.center : [0, 0, 0];
922
+ const lower = addNode({
923
+ name: `${matName} level ${l}`,
924
+ mesh: lm.index,
925
+ ...trsOf(dequantMatrix(center, half)),
926
+ }, false);
927
+ levels.push(lower);
928
+ map[l] = levels.length; // 1..
929
+ }
930
+ map[0] = 0;
931
+ for (let l = 1; l < entry.lods; l++) if (map[l] == null) map[l] = map[l - 1] || 0;
932
+ if (levels.length) {
933
+ const dqScale = gmesh.dequant ? gmesh.dequant.scale : 1;
934
+ tess = {
935
+ map,
936
+ deviation: deviation.map((d) => r7(d)),
937
+ unit: r7(dqScale),
938
+ dequant: {
939
+ translation: gmesh.dequant ? gmesh.dequant.center.map(r7) : [0, 0, 0],
940
+ scale: r7(dqScale),
941
+ },
942
+ };
943
+ lodIds = levels;
944
+ }
945
+ }
946
+ const link = (parent, child) => {
947
+ const p = json.nodes[parent];
948
+ p.children = p.children || [];
949
+ p.children.push(child);
950
+ };
951
+ if (placed.length) {
952
+ const attrs = {
953
+ TRANSLATION: vecAccessor(T, placed.length, 'VEC3', 5126, 3),
954
+ ROTATION: vecAccessor(R, placed.length, 'VEC4', 5126, 4),
955
+ SCALE: vecAccessor(S, placed.length, 'VEC3', 5126, 3),
956
+ };
957
+ const extras = {};
958
+ if (sameName) extras.id = ids[0];
959
+ else attrs._ID = vecAccessor(idAttr, placed.length, 'SCALAR', 5126, 1);
960
+ if (!collide) extras.collide = false;
961
+ if (scene.materials[mi].transparent) extras.sort = true;
962
+ if (lodNode >= 0) {
963
+ const sw = scene.lodSwitches[lodNode];
964
+ const unit = geomUnit(entry);
965
+ const world = worldAt(i0);
966
+ const scale = Math.cbrt(Math.abs(mat4.determinant3(world)) || 1) / unit || 1;
967
+ const qScale = gmesh.dequant ? gmesh.dequant.scale : 1;
968
+ extras.lod = {
969
+ distances: sw.distances.map((d) => r7(d / scale)),
970
+ child: scene.lodChild[i0],
971
+ unit: r7(unit * qScale),
972
+ };
973
+ attrs._DETAIL = vecAccessor(detail, placed.length, 'SCALAR', 5126, 1);
974
+ }
975
+ if (tess) extras.tess = tess;
976
+ const count = placed.length;
977
+ const node = {
978
+ name: label || (count > 1 ? `${matName} ×${count}` : matName),
979
+ mesh: gmesh.index,
980
+ extras,
981
+ extensions: { EXT_mesh_gpu_instancing: { attributes: attrs } },
982
+ };
983
+ if (lodIds) {
984
+ node.extensions.MSFT_lod = { ids: lodIds };
985
+ used.add('MSFT_lod');
986
+ }
987
+ const idx = addNode(node, parentIndex == null);
988
+ if (parentIndex != null) link(parentIndex, idx);
989
+ }
990
+ const qScale = gmesh.dequant ? gmesh.dequant.scale : 1;
991
+ for (const s of shear) {
992
+ const fact = factorAffine(s.local);
993
+ const extras = { id: s.id };
994
+ if (!collide) extras.collide = false;
995
+ if (scene.materials[mi].transparent) extras.sort = true;
996
+ if (tess) {
997
+ // The mesh node carries only the second rotation, so the stage's
998
+ // instance axis is 1. Fold this copy's longest axis into the unit
999
+ // the tessellation test divides by, which is how ES3D measures it.
1000
+ let max = 0;
1001
+ for (let c = 0; c < 3; c++) {
1002
+ const len = Math.hypot(s.local[c * 4], s.local[c * 4 + 1], s.local[c * 4 + 2]);
1003
+ if (len > max) max = len;
1004
+ }
1005
+ const det = Math.abs(mat4.determinant3(s.local)) || 1;
1006
+ extras.tess = { ...tess, unit: r7(Math.cbrt(det) * qScale / (max || 1)) };
1007
+ }
1008
+ const holder = addNode(trsParts(fact.translation, fact.rotation, fact.scale), parentIndex == null);
1009
+ if (parentIndex != null) link(parentIndex, holder);
1010
+ const meshNode = {
1011
+ name: label || matName,
1012
+ mesh: gmesh.index,
1013
+ ...trsParts([0, 0, 0], fact.rotation2, [1, 1, 1]),
1014
+ extras,
1015
+ };
1016
+ // Lines cannot be GPU-instanced. A single instance keeps the curved
1017
+ // shapes on the same level-of-detail path as the instanced ones.
1018
+ if (!gmesh.lines) {
1019
+ meshNode.extensions = { EXT_mesh_gpu_instancing: { attributes: identityInstancing() } };
1020
+ if (lodIds) {
1021
+ meshNode.extensions.MSFT_lod = { ids: lodIds };
1022
+ used.add('MSFT_lod');
1023
+ }
1024
+ }
1025
+ link(holder, addNode(meshNode, false));
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ // static rows
1031
+ let run = -1;
1032
+ for (let i = 0; i <= scene.count; i++) {
1033
+ const on = i < scene.count && !covered[i];
1034
+ if (on && run < 0) run = i;
1035
+ if (!on && run >= 0) {
1036
+ emitRange(run, i - run, null, null, null);
1037
+ run = -1;
1038
+ }
1039
+ }
1040
+
1041
+ // uniform-scale shape segments become one instanced mesh with a morph
1042
+ const morphGroups = new Map();
1043
+ const treeSegs = [];
1044
+ for (const seg of scene.dynamic) {
1045
+ if (seg.end === seg.start) continue;
1046
+ const inner = innerAnims(seg.node);
1047
+ const solo = seg.end - seg.start === 1 && seg.node.kind === 'shape' && seg.node.anims.length === 1 && uniformScaleAnim(seg.node.anims[0]) && inner.length === 0;
1048
+ if (solo) {
1049
+ const a = seg.node.anims[0];
1050
+ const sig = JSON.stringify({ d: a.dur, b: a.begin, alt: a.alternate, keys: a.keys, from: a.from, to: a.to, kt: a.keyTimes, ease: Math.round(a.ease(0.3) * 1000) });
1051
+ const i = seg.start;
1052
+ const key = `${scene.geom[i]}|${scene.mat[i]}|${sig}`;
1053
+ let g = morphGroups.get(key);
1054
+ if (!g) morphGroups.set(key, (g = { anim: a, node: seg.node, rows: [] }));
1055
+ g.rows.push(i);
1056
+ } else treeSegs.push(seg);
1057
+ }
1058
+
1059
+ for (const g of morphGroups.values()) {
1060
+ const a = g.anim;
1061
+ const bakedV = sampleAnimation(a, 0, 0);
1062
+ const baked = bakedV ? bakedV[0] : (g.node.base.scale ? g.node.base.scale[0] : 1) || 1;
1063
+ const i0 = g.rows[0];
1064
+ const gi = scene.geom[i0];
1065
+ const mi = scene.mat[i0];
1066
+ const weight0 = baked - 1;
1067
+ const gmesh = gltfMesh(gi, 0, mi, { weight: weight0 });
1068
+ if (!gmesh) continue;
1069
+ const T = new Float32Array(g.rows.length * 3);
1070
+ const R = new Float32Array(g.rows.length * 4);
1071
+ const S = new Float32Array(g.rows.length * 3);
1072
+ const ids = g.rows.map((i) => nameId(scene.uid[i]));
1073
+ for (let k = 0; k < g.rows.length; k++) {
1074
+ const i = g.rows[k];
1075
+ const world = worldAt(i);
1076
+ const local = mat4.clone(world);
1077
+ if (Math.abs(baked) > 1e-8) {
1078
+ local[0] /= baked; local[1] /= baked; local[2] /= baked;
1079
+ local[4] /= baked; local[5] /= baked; local[6] /= baked;
1080
+ local[8] /= baked; local[9] /= baked; local[10] /= baked;
1081
+ }
1082
+ const d = mat4.decompose(local) || { translation: [local[12], local[13], local[14]], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
1083
+ T.set(d.translation, k * 3);
1084
+ R.set(d.rotation, k * 4);
1085
+ S.set(d.scale, k * 3);
1086
+ }
1087
+ const attrs = {
1088
+ TRANSLATION: vecAccessor(T, g.rows.length, 'VEC3', 5126, 3),
1089
+ ROTATION: vecAccessor(R, g.rows.length, 'VEC4', 5126, 4),
1090
+ SCALE: vecAccessor(S, g.rows.length, 'VEC3', 5126, 3),
1091
+ };
1092
+ const same = ids.every((id) => id === ids[0]);
1093
+ const extras = same ? { id: ids[0] } : {};
1094
+ if (!same) attrs._ID = vecAccessor(Float32Array.from(ids), ids.length, 'SCALAR', 5126, 1);
1095
+ if (scene.materials[mi].transparent) extras.sort = true;
1096
+ const idx = addNode({
1097
+ name: `${nodeLabel(g.node)} ×${g.rows.length}`,
1098
+ mesh: gmesh.index,
1099
+ extras,
1100
+ extensions: { EXT_mesh_gpu_instancing: { attributes: attrs } },
1101
+ }, true);
1102
+ const series = sampleChannel(a);
1103
+ const weights = series.times.map((t) => {
1104
+ const v = sampleAt(a, a.begin + t, 0);
1105
+ return (v ? v[0] : baked) - 1;
1106
+ });
1107
+ const clip = clipFor('time', a.begin || 0, series.period, `twinkle (${nodeLabel(g.node)} scale)`);
1108
+ pushTrack(clip, idx, 'weights', series.times, weights, 'SCALAR');
1109
+ }
1110
+
1111
+ function emitTree(node, parentFn, attach, cursor, segEnd) {
1112
+ if (!node || node.visible === false) return;
1113
+ if (cursor.i > segEnd) fail(`row cursor ran past segment at ${nodeLabel(node)}`);
1114
+ const baseLocal = localOf(node, 0, 0);
1115
+ const worldFn = (time, progress) => mul(parentFn(time, progress), node.anims.length ? localOf(node, time, progress) : baseLocal);
1116
+ let nextAttach = attach;
1117
+ if (node.anims.length) {
1118
+ const idx = addNode({ name: nodeLabel(node), ...trsOf(worldFn(0, 0)) }, attach == null);
1119
+ if (attach != null) {
1120
+ const p = json.nodes[attach];
1121
+ p.children = p.children || [];
1122
+ p.children.push(idx);
1123
+ }
1124
+ animateWorld(idx, node, parentFn);
1125
+ nextAttach = idx;
1126
+ }
1127
+ const deeper = innerAnims(node);
1128
+ const scaleOnly = deeper.length > 0 && deeper.every(({ a }) => uniformScaleAnim(a));
1129
+ if (node.kind === 'scatter' && scaleOnly) {
1130
+ const n = countRows(node);
1131
+ emitRange(cursor.i, n, nextAttach, worldFn(0, 0), nodeLabel(node));
1132
+ // morph weights: the shape anim inside, applied as a weight track on the meshes just added is hard
1133
+ // because emitRange made several meshes. Sample is identical if we divided scale; fireflies pulse.
1134
+ // Attach the scale track to each child mesh node created for this range.
1135
+ const anim = deeper[0].a;
1136
+ const bakedV = sampleAnimation(anim, 0, 0);
1137
+ const baked = bakedV ? bakedV[0] : 1;
1138
+ const series = sampleChannel(anim);
1139
+ const weights = series.times.map((t) => ((sampleAt(anim, anim.begin + t, 0) || [baked])[0]) - 1);
1140
+ const parent = json.nodes[nextAttach];
1141
+ for (const child of parent.children || []) {
1142
+ const cn = json.nodes[child];
1143
+ if (cn.mesh == null) continue;
1144
+ const mesh = json.meshes[cn.mesh];
1145
+ if (!mesh.weights) continue;
1146
+ const clip = clipFor('time', anim.begin || 0, series.period, `twinkle (${nodeLabel(deeper[0].node)} scale)`);
1147
+ pushTrack(clip, child, 'weights', series.times, weights, 'SCALAR');
1148
+ }
1149
+ // The meshes from emitRange are not morphed. Rebuild is required.
1150
+ // Mark a flag by using a dedicated path below instead. This branch is replaced.
1151
+ cursor.i += n;
1152
+ return;
1153
+ }
1154
+ if (!deeper.length && node.kind !== 'repeat') {
1155
+ const n = countRows(node);
1156
+ if (node.anims.length || node.kind === 'shape' || node.kind === 'group' || node.kind === 'lod' || node.kind === 'use' || node.kind === 'scatter') {
1157
+ if (n) emitRange(cursor.i, n, nextAttach, worldFn(0, 0), node.anims.length ? null : nodeLabel(node));
1158
+ cursor.i += n;
1159
+ return;
1160
+ }
1161
+ }
1162
+ if (!deeper.length) {
1163
+ const n = countRows(node);
1164
+ if (n) emitRange(cursor.i, n, nextAttach, worldFn(0, 0), null);
1165
+ cursor.i += n;
1166
+ return;
1167
+ }
1168
+ if (node.kind === 'repeat') {
1169
+ const acc = mat4.createF64();
1170
+ for (let i = 0; i < node.count; i++) {
1171
+ const accNow = mat4.clone(acc);
1172
+ const childFn = (time, progress) => mul(worldFn(time, progress), accNow);
1173
+ for (const c of node.children || []) emitTree(c, childFn, nextAttach, cursor, segEnd);
1174
+ const tmp = mat4.createF64();
1175
+ mat4.multiply(tmp, acc, node.step);
1176
+ acc.set(tmp);
1177
+ }
1178
+ return;
1179
+ }
1180
+ for (const c of kids(node)) emitTree(c, worldFn, nextAttach, cursor, segEnd);
1181
+ }
1182
+
1183
+ // The scatter+scale branch above emits non-morph meshes and then looks for weights.
1184
+ // Do fireflies properly: if a scatter's only inner anim is uniform scale, emit morphed instances.
1185
+ function emitMorphRange(row0, n, parentIndex, parentWorld, anim) {
1186
+ const bakedV = sampleAnimation(anim, 0, 0);
1187
+ const baked = (bakedV ? bakedV[0] : 1) || 1;
1188
+ const groups = new Map();
1189
+ for (let i = row0; i < row0 + n; i++) {
1190
+ const key = `${scene.geom[i]}|${scene.mat[i]}`;
1191
+ let g = groups.get(key);
1192
+ if (!g) groups.set(key, (g = []));
1193
+ g.push(i);
1194
+ }
1195
+ const made = [];
1196
+ const invP = parentWorld ? inv(parentWorld) : null;
1197
+ for (const rows of groups.values()) {
1198
+ const gi = scene.geom[rows[0]];
1199
+ const mi = scene.mat[rows[0]];
1200
+ const gmesh = gltfMesh(gi, 0, mi, { weight: baked - 1 });
1201
+ if (!gmesh) continue;
1202
+ const T = new Float32Array(rows.length * 3);
1203
+ const R = new Float32Array(rows.length * 4);
1204
+ const S = new Float32Array(rows.length * 3);
1205
+ for (let k = 0; k < rows.length; k++) {
1206
+ const world = worldAt(rows[k]);
1207
+ let local = invP ? mul(invP, world) : mat4.clone(world);
1208
+ if (Math.abs(baked) > 1e-8) {
1209
+ local[0] /= baked; local[1] /= baked; local[2] /= baked;
1210
+ local[4] /= baked; local[5] /= baked; local[6] /= baked;
1211
+ local[8] /= baked; local[9] /= baked; local[10] /= baked;
1212
+ }
1213
+ const d = mat4.decompose(local) || { translation: [local[12], local[13], local[14]], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
1214
+ T.set(d.translation, k * 3);
1215
+ R.set(d.rotation, k * 4);
1216
+ S.set(d.scale, k * 3);
1217
+ }
1218
+ const idx = addNode({
1219
+ name: `${json.materials[mi].name || 'pulse'} ×${rows.length}`,
1220
+ mesh: gmesh.index,
1221
+ extras: { id: nameId(scene.uid[rows[0]]), ...(scene.materials[mi].transparent ? { sort: true } : {}) },
1222
+ extensions: {
1223
+ EXT_mesh_gpu_instancing: {
1224
+ attributes: {
1225
+ TRANSLATION: vecAccessor(T, rows.length, 'VEC3', 5126, 3),
1226
+ ROTATION: vecAccessor(R, rows.length, 'VEC4', 5126, 4),
1227
+ SCALE: vecAccessor(S, rows.length, 'VEC3', 5126, 3),
1228
+ },
1229
+ },
1230
+ },
1231
+ }, parentIndex == null);
1232
+ if (parentIndex != null) {
1233
+ const p = json.nodes[parentIndex];
1234
+ p.children = p.children || [];
1235
+ p.children.push(idx);
1236
+ }
1237
+ made.push(idx);
1238
+ }
1239
+ const series = sampleChannel(anim);
1240
+ const weights = series.times.map((t) => ((sampleAt(anim, anim.begin + t, 0) || [baked])[0]) - 1);
1241
+ const clip = clipFor('time', anim.begin || 0, series.period, `twinkle (${nodeLabel(anim && anim) || 'scale'})`);
1242
+ for (const idx of made) pushTrack(clip, idx, 'weights', series.times, weights, 'SCALAR');
1243
+ }
1244
+
1245
+ // Replace emitTree's scatter shortcut by redefining the walk to call emitMorphRange.
1246
+ // The function above already advances the cursor and emits non-morph meshes; fix by
1247
+ // not using that branch. Patch: tree segments call emitTreeFixed.
1248
+ function emitFixed(node, parentFn, attach, attachWorld, cursor, segEnd) {
1249
+ if (!node || node.visible === false) return;
1250
+ const baseLocal = localOf(node, 0, 0);
1251
+ const worldFn = (time, progress) => mul(parentFn(time, progress), node.anims.length ? localOf(node, time, progress) : baseLocal);
1252
+ // Animated nodes are scene roots with world-space tracks. Parenting them
1253
+ // and also sampling world matrices would apply the parent motion twice.
1254
+ let next = attach;
1255
+ let nextWorld = attachWorld;
1256
+ if (node.anims.length) {
1257
+ const rest = worldFn(0, 0);
1258
+ const idx = addNode({ name: nodeLabel(node), ...trsOf(rest) }, true);
1259
+ animateWorld(idx, node, parentFn);
1260
+ next = idx;
1261
+ nextWorld = rest;
1262
+ }
1263
+ const deeper = innerAnims(node);
1264
+ if (deeper.length && deeper.every(({ a }) => uniformScaleAnim(a)) && (node.kind === 'scatter' || node.kind === 'repeat' || node.kind === 'group' || node.kind === 'use')) {
1265
+ if (node.kind === 'scatter' || node.kind === 'repeat') {
1266
+ const n = countRows(node);
1267
+ emitMorphRange(cursor.i, n, next, nextWorld, deeper[0].a);
1268
+ cursor.i += n;
1269
+ return;
1270
+ }
1271
+ }
1272
+ if (!deeper.length) {
1273
+ const n = countRows(node);
1274
+ if (n) emitRange(cursor.i, n, next, nextWorld, null);
1275
+ cursor.i += n;
1276
+ return;
1277
+ }
1278
+ if (node.kind === 'repeat') {
1279
+ const acc = mat4.createF64();
1280
+ for (let i = 0; i < (node.count || 0); i++) {
1281
+ const accNow = mat4.clone(acc);
1282
+ const childFn = (time, progress) => mul(worldFn(time, progress), accNow);
1283
+ for (const c of node.children || []) emitFixed(c, childFn, next, nextWorld, cursor, segEnd);
1284
+ const tmp = mat4.createF64();
1285
+ mat4.multiply(tmp, acc, node.step);
1286
+ acc.set(tmp);
1287
+ }
1288
+ return;
1289
+ }
1290
+ for (const c of kids(node)) emitFixed(c, worldFn, next, nextWorld, cursor, segEnd);
1291
+ }
1292
+
1293
+ for (const seg of treeSegs) {
1294
+ const cursor = { i: seg.start };
1295
+ const parent = seg.parent;
1296
+ emitFixed(seg.node, () => parent, null, null, cursor, seg.end);
1297
+ if (cursor.i !== seg.end) {
1298
+ fail(`segment ${nodeLabel(seg.node)} covered ${cursor.i - seg.start} of ${seg.end - seg.start} rows`);
1299
+ }
1300
+ }
1301
+
1302
+ // lights
1303
+ const lightNodes = [];
1304
+ const walkLights = (node) => {
1305
+ if (!node) return;
1306
+ if (node.kind === 'light') lightNodes.push(node);
1307
+ for (const c of kids(node)) walkLights(c);
1308
+ };
1309
+ walkLights(graph.root);
1310
+ lightNodes.forEach((node, i) => {
1311
+ const L = scene.lights[i];
1312
+ const p = node.params;
1313
+ const color = colorToLinear(p.color);
1314
+ const light = {
1315
+ name: p.name || nodeLabel(node),
1316
+ type: p.type === 'point' ? 'point' : p.type === 'spot' ? 'spot' : 'directional',
1317
+ color: [r7(color[0]), r7(color[1]), r7(color[2])],
1318
+ intensity: r7((p.intensity || 1) * PI),
1319
+ };
1320
+ if (p.range) light.range = r7(p.range);
1321
+ if (light.type === 'spot') {
1322
+ light.spot = { outerConeAngle: r7(((p.angle || 40) * PI) / 180 / 2) };
1323
+ }
1324
+ const li = json.extensions.KHR_lights_punctual.lights.length;
1325
+ json.extensions.KHR_lights_punctual.lights.push(light);
1326
+ const def = { name: light.name, extensions: { KHR_lights_punctual: { light: li } } };
1327
+ if (light.type === 'directional') def.rotation = quatFacing(L.direction).map(r7);
1328
+ else def.translation = L.position.map(r7);
1329
+ addNode(def, true);
1330
+ });
1331
+
1332
+ // cameras
1333
+ const camNodes = [];
1334
+ const walkCams = (node) => {
1335
+ if (!node) return;
1336
+ if (node.kind === 'camera') camNodes.push(node);
1337
+ for (const c of kids(node)) walkCams(c);
1338
+ };
1339
+ walkCams(graph.root);
1340
+ const STEPS = 600;
1341
+ camNodes.forEach((node, ci) => {
1342
+ const seg = scene.dynamic.find((s) => s.node === node);
1343
+ const parent = seg ? seg.parent : mat4.createF64();
1344
+ const times = [];
1345
+ const fov = [];
1346
+ const distance = [];
1347
+ const T = [];
1348
+ const R = [];
1349
+ let prevQ = null;
1350
+ for (let s = 0; s <= STEPS; s++) {
1351
+ const progress = s / STEPS;
1352
+ const ov = {};
1353
+ for (const a of node.anims) {
1354
+ const v = sampleAnimation(a, 0, progress);
1355
+ if (v) ov[a.attr] = v;
1356
+ }
1357
+ const p = node.params;
1358
+ const cw = mat4.clone(parent);
1359
+ if (p.transform) mat4.multiply(cw, cw, p.transform);
1360
+ const pos = ov.position || p.position;
1361
+ const aim = ov.target || p.target;
1362
+ const eye = transformPoint(cw, pos);
1363
+ const target = transformPoint(cw, aim);
1364
+ const f = ov.fov ? ov.fov[0] : p.fov;
1365
+ const delta = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
1366
+ let q = quatFacing(delta, p.up || [0, 1, 0]);
1367
+ if (prevQ && dot4(prevQ, q) < 0) q = q.map((v) => -v);
1368
+ prevQ = q;
1369
+ times.push(r7(progress));
1370
+ fov.push(r7(f));
1371
+ distance.push(r7(Math.hypot(delta[0], delta[1], delta[2])));
1372
+ T.push(eye);
1373
+ R.push(q);
1374
+ }
1375
+ const cam = {
1376
+ name: node.params.name,
1377
+ type: 'perspective',
1378
+ perspective: {
1379
+ znear: node.params.near || 0.1,
1380
+ zfar: node.params.far || 2000,
1381
+ yfov: (node.params.fov * PI) / 180,
1382
+ aspectRatio: node.params.aspect || 1,
1383
+ },
1384
+ };
1385
+ const camera = json.cameras.length;
1386
+ json.cameras.push(cam);
1387
+ const idx = addNode({
1388
+ name: cam.name,
1389
+ camera,
1390
+ translation: T[0].map(r7),
1391
+ rotation: R[0].map(r7),
1392
+ extras: {
1393
+ fit: node.params.fit || 'contain',
1394
+ aspect: node.params.aspect || 1,
1395
+ track: { times, fov, distance },
1396
+ },
1397
+ }, true);
1398
+ const clip = clipFor('scroll', 0, 1, 'Scroll');
1399
+ pushTrack(clip, idx, 'translation', times, T.flat(), 'VEC3');
1400
+ pushTrack(clip, idx, 'rotation', times, R.flat(), 'VEC4');
1401
+ });
1402
+
1403
+ const env = graph.environment;
1404
+ const sky = colorToLinear(env.sky);
1405
+ const ground = colorToLinear(env.ground);
1406
+ json.scenes[0].extras = {
1407
+ generator: `es3d glb from ${sourceName}`,
1408
+ environment: {
1409
+ background: env.background.slice(0, 3).map(r7),
1410
+ sky: sky.slice(0, 3).map(r7),
1411
+ ground: ground.slice(0, 3).map(r7),
1412
+ intensity: env.intensity,
1413
+ exposure: env.exposure,
1414
+ },
1415
+ names: nameList,
1416
+ bounds: { min: scene.bounds.min.map(r7), max: scene.bounds.max.map(r7) },
1417
+ instances: scene.count,
1418
+ geometries: scene.geometries.length,
1419
+ };
1420
+ json.extensionsUsed = [...used];
1421
+ json.animations = json.animations.filter((a) => a.channels.length);
1422
+ if (!json.animations.length) delete json.animations;
1423
+
1424
+ const binBytes = bin.bytes();
1425
+ json.buffers[0].byteLength = binBytes.length;
1426
+ json.buffers[1].byteLength = bin.views.filter((v) => v.buffer === 1).reduce((s, v) => s + v.byteLength, 0);
1427
+ const jsonBytes = Buffer.from(JSON.stringify(json));
1428
+ const jsonPad = Buffer.alloc((4 - (jsonBytes.length % 4)) % 4, 0x20);
1429
+ const total = 12 + 8 + jsonBytes.length + jsonPad.length + 8 + binBytes.length;
1430
+ const file = Buffer.alloc(total);
1431
+ file.writeUInt32LE(0x46546c67, 0);
1432
+ file.writeUInt32LE(2, 4);
1433
+ file.writeUInt32LE(total, 8);
1434
+ const jlen = jsonBytes.length + jsonPad.length;
1435
+ file.writeUInt32LE(jlen, 12);
1436
+ file.writeUInt32LE(0x4e4f534a, 16);
1437
+ jsonBytes.copy(file, 20);
1438
+ jsonPad.copy(file, 20 + jsonBytes.length);
1439
+ const bo = 20 + jlen;
1440
+ file.writeUInt32LE(binBytes.length, bo);
1441
+ file.writeUInt32LE(0x004e4942, bo + 4);
1442
+ Buffer.from(binBytes.buffer, binBytes.byteOffset, binBytes.byteLength).copy(file, bo + 8);
1443
+ const kb = Math.round(file.length / 1000);
1444
+ const srcKb = Math.round(Buffer.byteLength(text) / 1000);
1445
+ console.error(JSON.stringify({
1446
+ bytes: file.length,
1447
+ kb,
1448
+ es3dBytes: Buffer.byteLength(text),
1449
+ es3dKb: srcKb,
1450
+ ratio: Math.round(kb / srcKb),
1451
+ instances: scene.count,
1452
+ geometries: scene.geometries.length,
1453
+ materials: scene.materials.length,
1454
+ lights: scene.lights.length,
1455
+ cameras: scene.cameras.length,
1456
+ animations: (json.animations || []).length,
1457
+ nodes: json.nodes.length,
1458
+ images: json.images.length,
1459
+ }));
1460
+ return file;
1461
+ }