@threenative/core 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,9 +1,86 @@
1
- import { AudioListener, Audio, PositionalAudio, Object3D, AnimationMixer, Vector2, Sprite, Raycaster, Plane, Vector3, PerspectiveCamera, OrthographicCamera, Scene as Scene$1 } from 'three';
2
- import { instancedArray } from 'three/tsl';
3
- import { RenderPipeline } from 'three/webgpu';
1
+ import { AnimationMixer, AudioListener, Audio, PositionalAudio, Object3D, Scene, OrthographicCamera, Sprite, Raycaster, Vector2, Mesh, CatmullRomCurve3, PerspectiveCamera, Plane, Vector3, Matrix4, Uint32BufferAttribute, Matrix3, Float32BufferAttribute, Texture } from 'three';
2
+ import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
3
+ import { instancedArray, attribute, storage, vec4, positionLocal, normalLocal } from 'three/tsl';
4
+ import { StorageBufferAttribute, RenderPipeline } from 'three/webgpu';
5
+ import { MeshBVH } from 'three-mesh-bvh';
4
6
  import { createStore } from 'zustand/vanilla';
5
7
 
6
- // src/audio.ts
8
+ // src/animation.ts
9
+ var AnimationPlayer = class {
10
+ mixer;
11
+ #actions = /* @__PURE__ */ new Map();
12
+ #current;
13
+ #advancedFrames = 0;
14
+ #fadeFrom;
15
+ #fadeElapsed = 0;
16
+ #fadeDuration = 0;
17
+ constructor(options) {
18
+ this.mixer = new AnimationMixer(options.root);
19
+ for (const clip of options.clips) {
20
+ if (this.#actions.has(clip.name)) throw new Error(`Duplicate animation clip '${clip.name}'.`);
21
+ const action = this.mixer.clipAction(clip);
22
+ if (action === null)
23
+ throw new Error(`Animation clip '${clip.name}' could not create an action.`);
24
+ this.#actions.set(clip.name, action);
25
+ }
26
+ }
27
+ get current() {
28
+ return this.#current;
29
+ }
30
+ get advancedFrames() {
31
+ return this.#advancedFrames;
32
+ }
33
+ play(name, options = {}) {
34
+ const next = this.#actions.get(name);
35
+ if (next === void 0) throw new Error(`Unknown animation clip '${name}'.`);
36
+ if (this.#current === name) return;
37
+ const previous = this.#current === void 0 ? void 0 : this.#actions.get(this.#current);
38
+ const fade = Math.max(0, options.fade ?? 0);
39
+ for (const action of this.#actions.values()) {
40
+ if (action !== previous) action.setEffectiveWeight(0).stop();
41
+ }
42
+ previous?.setEffectiveWeight(1).play();
43
+ next.reset().setEffectiveWeight(fade > 0 && previous !== void 0 ? 0 : 1).play();
44
+ if (previous === void 0 || fade === 0) {
45
+ previous?.stop();
46
+ this.#fadeFrom = void 0;
47
+ } else {
48
+ previous.play();
49
+ this.#fadeFrom = previous;
50
+ this.#fadeElapsed = 0;
51
+ this.#fadeDuration = fade;
52
+ }
53
+ this.#current = name;
54
+ this.#advancedFrames = 0;
55
+ }
56
+ update(dt) {
57
+ if (!Number.isFinite(dt) || dt < 0)
58
+ throw new Error("AnimationPlayer.update requires a finite non-negative dt.");
59
+ const before = this.mixer.time;
60
+ this.mixer.update(dt);
61
+ if (this.#fadeFrom !== void 0) {
62
+ this.#fadeElapsed = Math.min(this.#fadeDuration, this.#fadeElapsed + dt);
63
+ const progress = this.#fadeDuration === 0 ? 1 : this.#fadeElapsed / this.#fadeDuration;
64
+ this.#fadeFrom.setEffectiveWeight(1 - progress);
65
+ this.#actions.get(this.#current ?? "")?.setEffectiveWeight(progress);
66
+ if (progress >= 1) {
67
+ this.#fadeFrom.stop();
68
+ this.#fadeFrom = void 0;
69
+ }
70
+ }
71
+ if (this.#current !== void 0 && this.mixer.time !== before) this.#advancedFrames += 1;
72
+ }
73
+ stop() {
74
+ this.mixer.stopAllAction();
75
+ this.#current = void 0;
76
+ this.#advancedFrames = 0;
77
+ this.#fadeFrom = void 0;
78
+ }
79
+ dispose() {
80
+ this.stop();
81
+ this.mixer.uncacheRoot(this.mixer.getRoot());
82
+ }
83
+ };
7
84
  var buses = /* @__PURE__ */ new Set();
8
85
  var AudioBus = class {
9
86
  listener;
@@ -59,6 +136,8 @@ var AudioBus = class {
59
136
  }
60
137
  playAt(buffer, source, options = {}) {
61
138
  assertBuffer(buffer);
139
+ if (typeof this.listener.context.createPanner !== "function")
140
+ throw new Error("AudioBus.playAt needs createPanner(); this runtime has none.");
62
141
  const voice = new PositionalAudio(this.listener);
63
142
  configureVoice(voice, options);
64
143
  voice.setBuffer(buffer);
@@ -134,29 +213,116 @@ function assertBuffer(buffer) {
134
213
  if (buffer === null || buffer === void 0)
135
214
  throw new TypeError("AudioBus requires a non-null AudioBuffer.");
136
215
  }
216
+ var CanvasLayer = class {
217
+ scene = new Scene();
218
+ camera = new OrthographicCamera();
219
+ /** Declares that this layer covers the framebuffer, allowing the world pass to be skipped. */
220
+ opaque = false;
221
+ #stopResize;
222
+ constructor(viewport) {
223
+ this.camera.position.z = 1;
224
+ this.#resize(viewport.size);
225
+ this.#stopResize = viewport.onResize(this.#resize);
226
+ }
227
+ dispose() {
228
+ this.#stopResize();
229
+ this.#stopResize = () => void 0;
230
+ this.scene.clear();
231
+ }
232
+ #resize = ({ height, width }) => {
233
+ this.camera.left = -width / 2;
234
+ this.camera.right = width / 2;
235
+ this.camera.top = height / 2;
236
+ this.camera.bottom = -height / 2;
237
+ this.camera.near = 0;
238
+ this.camera.far = 2;
239
+ this.camera.updateProjectionMatrix();
240
+ };
241
+ };
137
242
 
138
- // src/assets.ts
243
+ // src/random.ts
244
+ function createRandom(seed) {
245
+ if (seed !== void 0 && !Number.isFinite(seed)) throw new TypeError("seed must be finite.");
246
+ let state = seed === void 0 ? 0 : seed >>> 0;
247
+ const deterministic = seed !== void 0;
248
+ const random = (() => {
249
+ if (!deterministic) return Math.random();
250
+ state = Math.imul(1664525, state) + 1013904223 >>> 0;
251
+ return state / 4294967296;
252
+ });
253
+ random.range = (min, max) => {
254
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
255
+ throw new RangeError("range requires finite bounds with max greater than min.");
256
+ }
257
+ return min + random() * (max - min);
258
+ };
259
+ random.pick = (items) => {
260
+ if (items.length === 0) throw new RangeError("pick requires a non-empty list.");
261
+ return items[Math.floor(random() * items.length)];
262
+ };
263
+ Object.defineProperty(random, "state", {
264
+ get: () => {
265
+ if (!deterministic) throw new Error("state is unavailable on an unseeded random.");
266
+ return state;
267
+ },
268
+ set: (value) => {
269
+ if (!deterministic) throw new Error("state is unavailable on an unseeded random.");
270
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
271
+ throw new TypeError("state must be a finite integer.");
272
+ }
273
+ state = value >>> 0;
274
+ }
275
+ });
276
+ return random;
277
+ }
139
278
  function resolvePath(basePath, path) {
140
279
  if (/^(?:[a-z]+:)?\/\//iu.test(path) || path.startsWith("data:")) return path;
141
280
  if (basePath.length === 0) return path;
142
281
  return `${basePath.replace(/\/$/u, "")}/${path.replace(/^\//u, "")}`;
143
282
  }
144
283
  function loadWith(loader, url) {
145
- return new Promise((resolve, reject) => {
146
- loader.load(url, resolve, void 0, reject);
147
- });
284
+ return new Promise((resolve, reject) => loader.load(url, resolve, void 0, reject));
148
285
  }
149
286
  function createAssetLoader(options = {}) {
150
287
  const basePath = options.basePath ?? "";
151
288
  const cache = /* @__PURE__ */ new Map();
289
+ const disposed = {
290
+ geometries: /* @__PURE__ */ new WeakSet(),
291
+ surfaces: /* @__PURE__ */ new WeakSet(),
292
+ textures: /* @__PURE__ */ new WeakSet()
293
+ };
152
294
  const cached = (kind, path, load) => {
153
295
  const url = resolvePath(basePath, path);
154
296
  const key = `${kind}:${url}`;
155
297
  const existing = cache.get(key);
156
- if (existing !== void 0) return existing;
157
- const promise = load(url);
158
- cache.set(key, promise);
159
- return promise;
298
+ if (existing !== void 0) return existing.promise;
299
+ const entry = {
300
+ disposed: false,
301
+ kind,
302
+ loaded: false,
303
+ promise: Promise.resolve().then(() => load(url)),
304
+ released: false
305
+ };
306
+ entry.promise = entry.promise.then((value) => {
307
+ entry.loaded = true;
308
+ entry.value = value;
309
+ if (entry.released) disposeEntry(entry, disposed);
310
+ return value;
311
+ });
312
+ entry.promise.catch(() => {
313
+ if (cache.get(key) === entry) cache.delete(key);
314
+ });
315
+ cache.set(key, entry);
316
+ return entry.promise;
317
+ };
318
+ const releaseEntry = (entry) => {
319
+ entry.released = true;
320
+ if (entry.loaded) disposeEntry(entry, disposed);
321
+ else
322
+ void entry.promise.then(
323
+ () => disposeEntry(entry, disposed),
324
+ () => void 0
325
+ );
160
326
  };
161
327
  return {
162
328
  audio: (path) => cached("audio", path, async (url) => {
@@ -164,93 +330,1285 @@ function createAssetLoader(options = {}) {
164
330
  const { AudioLoader: Loader } = await import('three');
165
331
  return loadWith(new Loader(), url);
166
332
  }),
167
- clear: () => cache.clear(),
333
+ clear: () => {
334
+ const entries = [...cache.values()];
335
+ cache.clear();
336
+ entries.forEach(releaseEntry);
337
+ },
168
338
  model: (path) => cached("model", path, async (url) => {
169
339
  if (options.model !== void 0) return await options.model(url);
170
340
  const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
171
341
  return await loadWith(new GLTFLoader(), url);
172
342
  }),
173
- release: (kind, path) => cache.delete(`${kind}:${resolvePath(basePath, path)}`),
343
+ release: (kind, path) => {
344
+ const key = `${kind}:${resolvePath(basePath, path)}`;
345
+ const entry = cache.get(key);
346
+ if (entry === void 0) return false;
347
+ cache.delete(key);
348
+ releaseEntry(entry);
349
+ return true;
350
+ },
174
351
  texture: (path) => cached("texture", path, async (url) => {
175
352
  if (options.texture !== void 0) return options.texture(url);
353
+ if (typeof Image === "undefined" && typeof createImageBitmap === "function") {
354
+ const response = await fetch(url);
355
+ if (!response.ok) throw new Error(`Failed to load texture '${url}': ${response.status}.`);
356
+ const bitmap = await createImageBitmap(new Blob([await response.arrayBuffer()]));
357
+ return Object.assign(new Texture(bitmap), { needsUpdate: true });
358
+ }
176
359
  const { TextureLoader: Loader } = await import('three');
177
360
  return loadWith(new Loader(), url);
178
361
  })
179
362
  };
180
363
  }
181
- var AnimationPlayer = class {
182
- mixer;
183
- #actions = /* @__PURE__ */ new Map();
184
- #current;
185
- #advancedFrames = 0;
186
- #fadeFrom;
187
- #fadeElapsed = 0;
188
- #fadeDuration = 0;
189
- constructor(options) {
190
- this.mixer = new AnimationMixer(options.root);
191
- for (const clip of options.clips) {
192
- if (this.#actions.has(clip.name)) throw new Error(`Duplicate animation clip '${clip.name}'.`);
193
- const action = this.mixer.clipAction(clip);
194
- if (action === null)
195
- throw new Error(`Animation clip '${clip.name}' could not create an action.`);
196
- this.#actions.set(clip.name, action);
364
+ function disposeEntry(entry, disposed) {
365
+ if (entry.disposed || !entry.loaded) return;
366
+ entry.disposed = true;
367
+ if (entry.kind === "model") disposeModel(entry.value, disposed);
368
+ else if (entry.kind === "texture") disposeTexture(entry.value, disposed);
369
+ }
370
+ function disposeModel(value, disposed) {
371
+ for (const root of modelRoots(value)) {
372
+ root.traverse((object) => {
373
+ const renderable = object;
374
+ disposeResource(renderable.geometry, disposed.geometries);
375
+ disposeSurface(renderable["material"], disposed);
376
+ });
377
+ }
378
+ }
379
+ function modelRoots(value) {
380
+ if (value instanceof Object3D) return [value];
381
+ if (!isRecord(value)) return [];
382
+ const roots = [value.scene, ...Array.isArray(value.scenes) ? value.scenes : []];
383
+ return roots.filter((root) => root instanceof Object3D);
384
+ }
385
+ function disposeSurface(value, disposed) {
386
+ if (Array.isArray(value)) {
387
+ for (const surface of value) disposeSurface(surface, disposed);
388
+ return;
389
+ }
390
+ if (!isRecord(value)) return;
391
+ if (disposed.surfaces.has(value)) return;
392
+ disposeSurfaceTextures(value, disposed, /* @__PURE__ */ new WeakSet());
393
+ disposeResource(value, disposed.surfaces);
394
+ }
395
+ function disposeSurfaceTextures(value, disposed, visited) {
396
+ if (isTexture(value)) {
397
+ disposeTexture(value, disposed);
398
+ return;
399
+ }
400
+ if (typeof value !== "object" || value === null || visited.has(value)) return;
401
+ visited.add(value);
402
+ if (Array.isArray(value)) {
403
+ for (const item of value) disposeSurfaceTextures(item, disposed, visited);
404
+ return;
405
+ }
406
+ for (const item of Object.values(value)) disposeSurfaceTextures(item, disposed, visited);
407
+ }
408
+ function disposeTexture(value, disposed) {
409
+ if (!isDisposable(value) || disposed.textures.has(value)) return;
410
+ disposed.textures.add(value);
411
+ value.dispose();
412
+ }
413
+ function disposeResource(value, disposed) {
414
+ if (!isDisposable(value) || disposed.has(value)) return;
415
+ disposed.add(value);
416
+ value.dispose();
417
+ }
418
+ function isDisposable(value) {
419
+ return typeof value === "object" && value !== null && typeof value.dispose === "function";
420
+ }
421
+ function isTexture(value) {
422
+ return value instanceof Texture || isRecord(value) && value.isTexture === true;
423
+ }
424
+ function isRecord(value) {
425
+ return typeof value === "object" && value !== null;
426
+ }
427
+ var CANONICAL_ATTRIBUTES = ["position", "normal", "uv", "color", "tnOwnerId"];
428
+ var OVERLAY_FLOOR = 12;
429
+ function emptySkippedCounts() {
430
+ return {
431
+ belowMeshFloor: 0,
432
+ cameraOverlay: 0,
433
+ layers: 0,
434
+ renderOrder: 0,
435
+ renderHook: 0,
436
+ transparency: 0,
437
+ sprite: 0,
438
+ points: 0,
439
+ missingPosition: 0,
440
+ missingGeometry: 0,
441
+ missingMaterial: 0
442
+ };
443
+ }
444
+ function addSkipped(skipped, reason, count = 1) {
445
+ skipped[reason] += count;
446
+ }
447
+ function mergeSkippedCounts(target, source) {
448
+ for (const reason of Object.keys(target))
449
+ target[reason] += source[reason];
450
+ }
451
+ function bakeTransform(geometry, matrix, normalMatrix) {
452
+ const position = geometry.getAttribute("position");
453
+ const positions = position?.array;
454
+ if (positions !== void 0) {
455
+ const m = matrix.elements;
456
+ for (let index = 0; index < positions.length; index += 3) {
457
+ const x = positions[index];
458
+ const y = positions[index + 1];
459
+ const z = positions[index + 2];
460
+ const w = 1 / (m[3] * x + m[7] * y + m[11] * z + m[15] || 1);
461
+ positions[index] = (m[0] * x + m[4] * y + m[8] * z + m[12]) * w;
462
+ positions[index + 1] = (m[1] * x + m[5] * y + m[9] * z + m[13]) * w;
463
+ positions[index + 2] = (m[2] * x + m[6] * y + m[10] * z + m[14]) * w;
197
464
  }
198
465
  }
199
- get current() {
200
- return this.#current;
466
+ const normal = geometry.getAttribute("normal");
467
+ const normals = normal?.array;
468
+ if (normals !== void 0) {
469
+ const n = normalMatrix.elements;
470
+ for (let index = 0; index < normals.length; index += 3) {
471
+ const x = normals[index];
472
+ const y = normals[index + 1];
473
+ const z = normals[index + 2];
474
+ const nx = n[0] * x + n[3] * y + n[6] * z;
475
+ const ny = n[1] * x + n[4] * y + n[7] * z;
476
+ const nz = n[2] * x + n[5] * y + n[8] * z;
477
+ const length = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
478
+ normals[index] = nx / length;
479
+ normals[index + 1] = ny / length;
480
+ normals[index + 2] = nz / length;
481
+ }
201
482
  }
202
- get advancedFrames() {
203
- return this.#advancedFrames;
483
+ }
484
+ function containsLight(object) {
485
+ let found = false;
486
+ object.traverse((child) => {
487
+ if (child.isLight === true) found = true;
488
+ });
489
+ return found;
490
+ }
491
+ function containsPreserved(object, preserved) {
492
+ let found = false;
493
+ object.traverse((child) => {
494
+ if (preserved.has(child)) found = true;
495
+ });
496
+ return found;
497
+ }
498
+ var objectKeys = /* @__PURE__ */ new WeakMap();
499
+ function objectKey(value) {
500
+ const cached = objectKeys.get(value);
501
+ if (cached !== void 0) return cached;
502
+ const isColor = value.isColor === true;
503
+ let key = isColor ? `rgb:${value.r},${value.g},${value.b}` : String(value.uuid ?? "obj");
504
+ const image = value.image;
505
+ const data = image?.data;
506
+ if (data !== void 0 && typeof data.length === "number") {
507
+ let hash = 2166136261;
508
+ for (let index = 0; index < data.length; index += 1) {
509
+ hash ^= (data[index] ?? 0) & 255;
510
+ hash = Math.imul(hash, 16777619);
511
+ }
512
+ key = `tex:${image?.width}x${image?.height}:${data.length}:${(hash >>> 0).toString(36)}`;
513
+ for (const field of [
514
+ "wrapS",
515
+ "wrapT",
516
+ "magFilter",
517
+ "minFilter",
518
+ "format",
519
+ "type",
520
+ "colorSpace",
521
+ "flipY"
522
+ ]) {
523
+ key += `:${String(value[field])}`;
524
+ }
204
525
  }
205
- play(name, options = {}) {
206
- const next = this.#actions.get(name);
207
- if (next === void 0) throw new Error(`Unknown animation clip '${name}'.`);
208
- if (this.#current === name) return;
209
- const previous = this.#current === void 0 ? void 0 : this.#actions.get(this.#current);
210
- const fade = Math.max(0, options.fade ?? 0);
211
- for (const action of this.#actions.values()) {
212
- if (action !== previous) action.setEffectiveWeight(0).stop();
526
+ objectKeys.set(value, key);
527
+ return key;
528
+ }
529
+ function colorOf(material) {
530
+ const color = material.color;
531
+ return color === void 0 || typeof color.r !== "number" ? void 0 : color;
532
+ }
533
+ var LOOK_FIELDS = [
534
+ "alphaHash",
535
+ "alphaMap",
536
+ "alphaTest",
537
+ "alphaToCoverage",
538
+ "aoMap",
539
+ "aoMapIntensity",
540
+ "attenuationColor",
541
+ "attenuationDistance",
542
+ "blendAlpha",
543
+ "blendDst",
544
+ "blendEquation",
545
+ "blendSrc",
546
+ "blending",
547
+ "bumpMap",
548
+ "bumpScale",
549
+ "clearcoat",
550
+ "clearcoatMap",
551
+ "clearcoatRoughness",
552
+ "clipIntersection",
553
+ "clipShadows",
554
+ "clippingPlanes",
555
+ "colorWrite",
556
+ "combine",
557
+ "depthFunc",
558
+ "depthTest",
559
+ "depthWrite",
560
+ "displacementBias",
561
+ "displacementMap",
562
+ "displacementScale",
563
+ "dithering",
564
+ "emissive",
565
+ "emissiveIntensity",
566
+ "emissiveMap",
567
+ "envMap",
568
+ "envMapIntensity",
569
+ "flatShading",
570
+ "fog",
571
+ "forceSinglePass",
572
+ "gradientMap",
573
+ "ior",
574
+ "iridescence",
575
+ "lightMap",
576
+ "lightMapIntensity",
577
+ "map",
578
+ "metalness",
579
+ "metalnessMap",
580
+ "normalMap",
581
+ "normalMapType",
582
+ "normalScale",
583
+ "opacity",
584
+ "polygonOffset",
585
+ "polygonOffsetFactor",
586
+ "polygonOffsetUnits",
587
+ "premultipliedAlpha",
588
+ "reflectivity",
589
+ "refractionRatio",
590
+ "roughness",
591
+ "roughnessMap",
592
+ "sheen",
593
+ "sheenColor",
594
+ "shadowSide",
595
+ "shininess",
596
+ "side",
597
+ "specular",
598
+ "specularMap",
599
+ "stencilWrite",
600
+ "toneMapped",
601
+ "transmission",
602
+ "transparent",
603
+ "vertexColors",
604
+ "visible",
605
+ "wireframe"
606
+ ];
607
+ function materialSignature(material) {
608
+ const source = material;
609
+ const parts = [material.type];
610
+ for (const key of LOOK_FIELDS) {
611
+ const value = source[key];
612
+ if (value === void 0) continue;
613
+ if (value === null) {
614
+ parts.push(`${key}:none`);
615
+ continue;
213
616
  }
214
- previous?.setEffectiveWeight(1).play();
215
- next.reset().setEffectiveWeight(fade > 0 && previous !== void 0 ? 0 : 1).play();
216
- if (previous === void 0 || fade === 0) {
217
- previous?.stop();
218
- this.#fadeFrom = void 0;
219
- } else {
220
- previous.play();
221
- this.#fadeFrom = previous;
222
- this.#fadeElapsed = 0;
223
- this.#fadeDuration = fade;
617
+ parts.push(
618
+ typeof value === "object" ? `${key}:${objectKey(value)}` : `${key}:${String(value)}`
619
+ );
620
+ }
621
+ return parts.join("|");
622
+ }
623
+ function materialOf(object) {
624
+ const material = object.material;
625
+ if (material === void 0) return void 0;
626
+ return Array.isArray(material) ? material[0] : material;
627
+ }
628
+ function writeNormalMatrix(target, offset, source) {
629
+ const e = source.elements;
630
+ target[offset] = e[0] ?? 0;
631
+ target[offset + 1] = e[1] ?? 0;
632
+ target[offset + 2] = e[2] ?? 0;
633
+ target[offset + 3] = 0;
634
+ target[offset + 4] = e[3] ?? 0;
635
+ target[offset + 5] = e[4] ?? 0;
636
+ target[offset + 6] = e[5] ?? 0;
637
+ target[offset + 7] = 0;
638
+ target[offset + 8] = e[6] ?? 0;
639
+ target[offset + 9] = e[7] ?? 0;
640
+ target[offset + 10] = e[8] ?? 0;
641
+ target[offset + 11] = 0;
642
+ target[offset + 15] = 1;
643
+ }
644
+ function hasUniformScale(matrix) {
645
+ const e = matrix.elements;
646
+ const x = Math.hypot(e[0] ?? 0, e[1] ?? 0, e[2] ?? 0);
647
+ const y = Math.hypot(e[4] ?? 0, e[5] ?? 0, e[6] ?? 0);
648
+ const z = Math.hypot(e[8] ?? 0, e[9] ?? 0, e[10] ?? 0);
649
+ const largest = Math.max(x, y, z);
650
+ if (largest === 0) return true;
651
+ return Math.max(Math.abs(x - y), Math.abs(y - z), Math.abs(x - z)) / largest < 1e-4;
652
+ }
653
+ function writeNormals(target, part, scratch) {
654
+ const offset = part.index * 16;
655
+ const source = part.detachedLeaf ? part.object.matrix : part.object.matrixWorld;
656
+ if (part.uniformScale) {
657
+ const e = source.elements;
658
+ target[offset] = e[0] ?? 0;
659
+ target[offset + 1] = e[1] ?? 0;
660
+ target[offset + 2] = e[2] ?? 0;
661
+ target[offset + 4] = e[4] ?? 0;
662
+ target[offset + 5] = e[5] ?? 0;
663
+ target[offset + 6] = e[6] ?? 0;
664
+ target[offset + 8] = e[8] ?? 0;
665
+ target[offset + 9] = e[9] ?? 0;
666
+ target[offset + 10] = e[10] ?? 0;
667
+ target[offset + 15] = 1;
668
+ return;
669
+ }
670
+ writeNormalMatrix(target, offset, scratch.getNormalMatrix(source));
671
+ }
672
+ function sameMatrix(a, b) {
673
+ for (let index = 0; index < a.length; index += 1) if (a[index] !== b[index]) return false;
674
+ return true;
675
+ }
676
+ function transformSample(object) {
677
+ const { position, quaternion, scale } = object;
678
+ if (position === void 0 || quaternion === void 0 || scale === void 0) {
679
+ return Float64Array.from(object.matrix.elements);
680
+ }
681
+ return Float64Array.of(
682
+ position.x,
683
+ position.y,
684
+ position.z,
685
+ quaternion.x,
686
+ quaternion.y,
687
+ quaternion.z,
688
+ quaternion.w,
689
+ scale.x,
690
+ scale.y,
691
+ scale.z
692
+ );
693
+ }
694
+ function sharedAttributeNames(chunks) {
695
+ let shared;
696
+ for (const chunk of chunks) {
697
+ const names = Object.keys(chunk.attributes);
698
+ shared = shared === void 0 ? names : shared.filter((name) => names.includes(name));
699
+ }
700
+ return shared ?? [];
701
+ }
702
+ var SceneCollapse = class {
703
+ #scene;
704
+ #observeFrames;
705
+ #minMeshes;
706
+ #onReport;
707
+ #samples = /* @__PURE__ */ new Map();
708
+ #moving = /* @__PURE__ */ new Set();
709
+ #changes = /* @__PURE__ */ new Map();
710
+ #observed = 0;
711
+ #windowOpenedAt;
712
+ #sampleSpentMs = 0;
713
+ #mergeSpentMs = 0;
714
+ #copySpentMs = 0;
715
+ #transformSpentMs = 0;
716
+ #colorSpentMs = 0;
717
+ #prepSpentMs = 0;
718
+ #observeSpanMs = 0;
719
+ #meshCount = -1;
720
+ #reportedSmall = false;
721
+ #lastScan;
722
+ #lastSourceRenderables = 0;
723
+ #lastSourceMaterialIdentities = 0;
724
+ #refreshCount = 0;
725
+ #refreshLastMs = 0;
726
+ #refreshMaxMs = 0;
727
+ #refreshTotalMs = 0;
728
+ #report;
729
+ #bakeBudgetMs;
730
+ #measureTransformRefresh;
731
+ #steps;
732
+ #bakeProgress = 0;
733
+ #bakeSpentMs = 0;
734
+ #settled;
735
+ #markSettled = () => void 0;
736
+ #restore;
737
+ #update;
738
+ /**
739
+ * Roots the bake lifted out of the scene, each with the child count it had when it left.
740
+ *
741
+ * The pass consumes a hierarchy and settles, but the game keeps its references and is entitled
742
+ * to go on using them — a level that streams its far half in after the loading screen adds to
743
+ * the very group this pass detached. Without this watch that geometry lands in a subtree no
744
+ * renderer walks and never draws, with no error to notice: the map is simply missing a piece.
745
+ */
746
+ #adoptable = [];
747
+ #adopted = 0;
748
+ constructor(scene, options = {}) {
749
+ const observeFrames = options.observeFrames ?? 8;
750
+ const minMeshes = options.minMeshes ?? 200;
751
+ const bakeBudgetMs = options.bakeBudgetMs ?? 100;
752
+ if (!Number.isInteger(observeFrames) || observeFrames < 1)
753
+ throw new Error("SceneCollapse.observeFrames must be a positive integer.");
754
+ if (!Number.isInteger(minMeshes) || minMeshes < 1)
755
+ throw new Error("SceneCollapse.minMeshes must be a positive integer.");
756
+ if (!(bakeBudgetMs > 0) || !Number.isFinite(bakeBudgetMs))
757
+ throw new Error("SceneCollapse.bakeBudgetMs must be a positive number.");
758
+ this.#scene = scene;
759
+ this.#observeFrames = observeFrames;
760
+ this.#minMeshes = minMeshes;
761
+ this.#bakeBudgetMs = bakeBudgetMs;
762
+ this.#measureTransformRefresh = options.measureTransformRefresh === true;
763
+ this.#settled = new Promise((resolve) => {
764
+ this.#markSettled = resolve;
765
+ });
766
+ const report = options.onReport ?? ((value) => {
767
+ console.info(`TN_SCENE_COLLAPSE:${JSON.stringify(value)}`);
768
+ });
769
+ this.#onReport = (value) => {
770
+ report(value);
771
+ this.#markSettled();
772
+ };
773
+ }
774
+ /**
775
+ * Detached roots the pass has put back because the game added to them after the collapse.
776
+ *
777
+ * Non-zero means a game is still building into a hierarchy this pass consumed. That is
778
+ * supported, not a fault, but it is worth seeing: each adoption gives one root its per-frame
779
+ * traversal back.
780
+ */
781
+ get adoptedRoots() {
782
+ return this.#adopted;
783
+ }
784
+ /** The pass has reached a verdict — collapsed or declined. */
785
+ get settled() {
786
+ return this.#report !== void 0;
787
+ }
788
+ /**
789
+ * How far through its observation window the pass is, 0 to 1.
790
+ *
791
+ * Real progress, not a guess, for the only part of startup whose length is known in advance. The
792
+ * collapse itself is one blocking call and cannot report from inside; a bar driven by this
793
+ * fills, holds while the bake runs, and completes.
794
+ */
795
+ get progress() {
796
+ if (this.#report !== void 0) return 1;
797
+ const observing = Math.min(1, this.#observed / this.#observeFrames);
798
+ return this.#steps === void 0 && this.#bakeProgress === 0 ? observing * 0.5 : 0.5 + this.#bakeProgress * 0.5;
799
+ }
800
+ /**
801
+ * Resolves once this pass has said something, whatever it said.
802
+ *
803
+ * A game needs this to know when it is safe to show the player the world. Without it the launch
804
+ * looks like the one the operator reported: a half-drawn map for several seconds, because each
805
+ * shader compiles the first time something using it is drawn, and then a multi-second stall when
806
+ * the collapse lands. Both costs are real and have to be paid somewhere; a game that can wait on
807
+ * this pays them behind a loading screen instead of in front of the player.
808
+ *
809
+ * It resolves on the *first* report, including the one that declines a scene too small to be
810
+ * worth collapsing — otherwise a small game would wait forever for a pass that is never going to
811
+ * run.
812
+ */
813
+ whenSettled() {
814
+ return this.#settled;
815
+ }
816
+ get report() {
817
+ return this.#report;
818
+ }
819
+ /**
820
+ * Latest bounded diagnostics snapshot. `report` is the immutable outcome recorded when the pass
821
+ * applied/rejected/deferred; this getter is for per-frame transform-refresh timing after an
822
+ * applied collapse continues updating moving parts.
823
+ */
824
+ get diagnostics() {
825
+ const report = this.#report;
826
+ if (report === void 0) return void 0;
827
+ return { ...report.diagnostics, timings: this.#timings() };
828
+ }
829
+ /**
830
+ * Call once per rendered frame. Samples local matrices until the observation window closes, then
831
+ * collapses; afterwards it refreshes the moving parts' transforms.
832
+ */
833
+ frame() {
834
+ if (this.#report !== void 0) {
835
+ this.#refreshTransforms();
836
+ return;
837
+ }
838
+ if (this.#meshCount < this.#minMeshes) {
839
+ this.#meshCount = this.#countMeshes();
840
+ if (this.#meshCount < this.#minMeshes) {
841
+ if (!this.#reportedSmall) {
842
+ this.#reportedSmall = true;
843
+ this.#onReport(
844
+ this.#makeReport({
845
+ collapsed: false,
846
+ reason: `fewer than ${this.#minMeshes} meshes so far; still watching`,
847
+ reasonCode: "belowMeshFloorStillWatching",
848
+ resultDrawCandidates: 0,
849
+ resultMaterialIdentities: 0,
850
+ skipped: {
851
+ ...emptySkippedCounts(),
852
+ ...this.#lastScan?.skipped ?? {},
853
+ belowMeshFloor: this.#meshCount
854
+ },
855
+ sourceMaterialIdentities: this.#lastSourceMaterialIdentities,
856
+ sourceMeshes: this.#meshCount,
857
+ sourceRenderables: this.#lastSourceRenderables || this.#meshCount,
858
+ status: "deferred"
859
+ })
860
+ );
861
+ }
862
+ return;
863
+ }
864
+ }
865
+ if (this.#steps !== void 0) {
866
+ this.#pumpBake();
867
+ return;
868
+ }
869
+ const now = () => globalThis.performance?.now() ?? 0;
870
+ this.#windowOpenedAt ??= now();
871
+ const sampledAt = now();
872
+ this.#sample();
873
+ this.#sampleSpentMs += now() - sampledAt;
874
+ this.#observed += 1;
875
+ if (this.#observed >= this.#observeFrames) {
876
+ this.#observeSpanMs = now() - this.#windowOpenedAt;
877
+ this.#steps = this.#collapseSteps();
878
+ this.#pumpBake();
224
879
  }
225
- this.#current = name;
226
- this.#advancedFrames = 0;
227
880
  }
228
- update(dt) {
229
- if (!Number.isFinite(dt) || dt < 0)
230
- throw new Error("AnimationPlayer.update requires a finite non-negative dt.");
231
- const before = this.mixer.time;
232
- this.mixer.update(dt);
233
- if (this.#fadeFrom !== void 0) {
234
- this.#fadeElapsed = Math.min(this.#fadeDuration, this.#fadeElapsed + dt);
235
- const progress = this.#fadeDuration === 0 ? 1 : this.#fadeElapsed / this.#fadeDuration;
236
- this.#fadeFrom.setEffectiveWeight(1 - progress);
237
- this.#actions.get(this.#current ?? "")?.setEffectiveWeight(progress);
238
- if (progress >= 1) {
239
- this.#fadeFrom.stop();
240
- this.#fadeFrom = void 0;
881
+ /**
882
+ * Spends this frame's budget on the bake.
883
+ *
884
+ * The budget is checked between meshes rather than enforced inside one, so a single enormous
885
+ * mesh can still overrun it — bounding that would mean splitting a geometry mid-transform, and
886
+ * a half-transformed geometry is a wrong picture rather than a slow one.
887
+ */
888
+ #pumpBake() {
889
+ const steps = this.#steps;
890
+ if (steps === void 0) return;
891
+ const startedAt = globalThis.performance?.now() ?? 0;
892
+ const spend = () => {
893
+ this.#bakeSpentMs += (globalThis.performance?.now() ?? 0) - startedAt;
894
+ };
895
+ for (; ; ) {
896
+ const step = steps.next();
897
+ if (step.done === true) {
898
+ spend();
899
+ this.#steps = void 0;
900
+ this.#bakeProgress = 1;
901
+ if (this.#report === void 0) this.#bakeProgress = 0;
902
+ return;
903
+ }
904
+ this.#bakeProgress = typeof step.value === "number" ? step.value : this.#bakeProgress;
905
+ if ((globalThis.performance?.now() ?? 0) - startedAt >= this.#bakeBudgetMs) {
906
+ spend();
907
+ return;
241
908
  }
242
909
  }
243
- if (this.#current !== void 0 && this.mixer.time !== before) this.#advancedFrames += 1;
244
910
  }
245
- stop() {
246
- this.mixer.stopAllAction();
247
- this.#current = void 0;
248
- this.#advancedFrames = 0;
249
- this.#fadeFrom = void 0;
911
+ /** Put the original scene back. Used when a supposedly static object turns out to move. */
912
+ restore() {
913
+ this.#restore?.();
914
+ this.#restore = void 0;
915
+ this.#update = void 0;
916
+ this.#adoptable.length = 0;
917
+ }
918
+ #countMeshes() {
919
+ const scan = this.#scanSceneRenderables();
920
+ this.#lastScan = scan;
921
+ this.#lastSourceRenderables = scan.sourceRenderables;
922
+ this.#lastSourceMaterialIdentities = scan.materialIdentities.size;
923
+ return scan.meshes.length;
924
+ }
925
+ #skipReason(object) {
926
+ if (this.#excluded(object)) return "cameraOverlay";
927
+ if (object.layers !== void 0 && object.layers.mask !== 1) return "layers";
928
+ if ((object.renderOrder ?? 0) !== 0) return "renderOrder";
929
+ if (Object.hasOwn(object, "onBeforeRender") || Object.hasOwn(object, "onAfterRender"))
930
+ return "renderHook";
931
+ if (object.geometry === void 0) return "missingGeometry";
932
+ if (object.geometry.getAttribute("position") === void 0) return "missingPosition";
933
+ const material = materialOf(object);
934
+ if (material === void 0) return "missingMaterial";
935
+ if (material.transparent === true)
936
+ return "transparency";
937
+ return void 0;
250
938
  }
251
- dispose() {
252
- this.stop();
253
- this.mixer.uncacheRoot(this.mixer.getRoot());
939
+ #scanSceneRenderables() {
940
+ const skipped = emptySkippedCounts();
941
+ const meshes = [];
942
+ const preserved = /* @__PURE__ */ new Set();
943
+ const materialIdentities = /* @__PURE__ */ new Set();
944
+ let sourceRenderables = 0;
945
+ this.#scene.traverse((object) => {
946
+ if (object.isCamera === true) return;
947
+ if (object.isSprite === true) {
948
+ sourceRenderables += 1;
949
+ preserved.add(object);
950
+ addSkipped(skipped, "sprite");
951
+ return;
952
+ }
953
+ if (object.isPoints === true) {
954
+ sourceRenderables += 1;
955
+ preserved.add(object);
956
+ addSkipped(skipped, "points");
957
+ return;
958
+ }
959
+ if (object.isMesh !== true) return;
960
+ sourceRenderables += 1;
961
+ const material = materialOf(object);
962
+ if (material !== void 0) materialIdentities.add(material);
963
+ const reason = this.#skipReason(object);
964
+ if (reason !== void 0) {
965
+ preserved.add(object);
966
+ addSkipped(skipped, reason);
967
+ return;
968
+ }
969
+ meshes.push(object);
970
+ });
971
+ return { materialIdentities, meshes, preserved, skipped, sourceRenderables };
972
+ }
973
+ #sample() {
974
+ this.#scene.traverse((object) => {
975
+ if (object.isCamera === true) return;
976
+ const current = transformSample(object);
977
+ const previous = this.#samples.get(object);
978
+ if (previous === void 0) {
979
+ this.#samples.set(object, current);
980
+ return;
981
+ }
982
+ if (sameMatrix(previous, current)) return;
983
+ previous.set(current);
984
+ const changes = (this.#changes.get(object) ?? 0) + 1;
985
+ this.#changes.set(object, changes);
986
+ if (changes >= 2) this.#moving.add(object);
987
+ });
988
+ }
989
+ /**
990
+ * Folds each camera's own subtree — where every HUD and overlay lives — into one draw per
991
+ * material instance.
992
+ *
993
+ * The scene pass cannot touch these: it bakes world matrices, and a camera-parented mesh baked
994
+ * in world space strands itself wherever the camera happened to be standing. So it skips them,
995
+ * and on a real game that left the HUD as ~93 of ~110 draws, worth 11 ms of a 17 ms frame on a
996
+ * Pixel 8 — the whole gap between 57 fps and the budget, while the scene it did collapse cost
997
+ * 3.6 ms. This pass bakes in *camera* space instead and attaches the result to the camera, so
998
+ * the overlay rides along exactly as it did.
999
+ *
1000
+ * Three things a HUD does that a level does not, and each decides part of the design:
1001
+ *
1002
+ * - **Digits toggle `visible`.** A seven-segment counter hides segments rather than moving them,
1003
+ * and a 45-frame window will not see a clock tick. So nothing here is classified static:
1004
+ * every overlay mesh gets a transform slot, and an invisible one is pushed out of the frustum
1005
+ * the same way a hidden moving part is.
1006
+ * - **Hearts recolour their own material.** So groups are keyed by material *identity*, never
1007
+ * merged by look and never given baked vertex colours. The game keeps writing to the same
1008
+ * material object and the merged draw shows it.
1009
+ * - **The source meshes must keep updating.** They are parked on a render layer nothing draws
1010
+ * rather than detached, so Three.js still refreshes their world matrices and the game's own
1011
+ * `visible` writes still mean what they say.
1012
+ */
1013
+ #collapseCameras() {
1014
+ let overlayMeshes = 0;
1015
+ let overlayDraws = 0;
1016
+ const skipped = emptySkippedCounts();
1017
+ const materialIdentities = /* @__PURE__ */ new Set();
1018
+ const cameras = [];
1019
+ this.#scene.traverse((object) => {
1020
+ if (object.isCamera === true) cameras.push(object);
1021
+ });
1022
+ for (const camera of cameras) {
1023
+ const meshes = [];
1024
+ camera.traverse((object) => {
1025
+ if (object === camera) return;
1026
+ if (object.isSprite === true) {
1027
+ addSkipped(skipped, "sprite");
1028
+ return;
1029
+ }
1030
+ if (object.isPoints === true) {
1031
+ addSkipped(skipped, "points");
1032
+ return;
1033
+ }
1034
+ if (object.isMesh !== true) return;
1035
+ if (object.geometry?.getAttribute("position") === void 0) {
1036
+ addSkipped(skipped, "missingPosition");
1037
+ return;
1038
+ }
1039
+ if (object.layers === void 0) {
1040
+ addSkipped(skipped, "layers");
1041
+ return;
1042
+ }
1043
+ const material = materialOf(object);
1044
+ if (material !== void 0) materialIdentities.add(material);
1045
+ meshes.push(object);
1046
+ });
1047
+ if (meshes.length < OVERLAY_FLOOR) {
1048
+ addSkipped(skipped, "cameraOverlay", meshes.length);
1049
+ continue;
1050
+ }
1051
+ camera.updateMatrixWorld(true);
1052
+ const cameraInverse = new Matrix4().copy(camera.matrixWorld).invert();
1053
+ const groups = /* @__PURE__ */ new Map();
1054
+ const parts = [];
1055
+ let usable = true;
1056
+ for (const mesh of meshes) {
1057
+ const material = materialOf(mesh);
1058
+ const source = mesh.geometry;
1059
+ if (material === void 0 || source === void 0) {
1060
+ usable = false;
1061
+ break;
1062
+ }
1063
+ const index = parts.length;
1064
+ parts.push({ object: mesh, index });
1065
+ const geometry = source.index !== null ? source.toNonIndexed() : source.clone();
1066
+ for (const name of Object.keys(geometry.attributes)) {
1067
+ if (!CANONICAL_ATTRIBUTES.includes(name)) geometry.deleteAttribute(name);
1068
+ }
1069
+ if (geometry.getAttribute("normal") === void 0)
1070
+ geometry.computeVertexNormals();
1071
+ const count = geometry.getAttribute("position")?.count ?? 0;
1072
+ const ids = new Uint32Array(count);
1073
+ ids.fill(index);
1074
+ geometry.setAttribute("tnOwnerId", new Uint32BufferAttribute(ids, 1));
1075
+ const key = objectKey(material);
1076
+ const group = groups.get(key) ?? { material, chunks: [], renderOrder: 0 };
1077
+ group.renderOrder = Math.max(group.renderOrder, mesh.renderOrder ?? 0);
1078
+ group.chunks.push(geometry);
1079
+ groups.set(key, group);
1080
+ }
1081
+ if (!usable) continue;
1082
+ const slots = Math.max(1, parts.length);
1083
+ const transformData = new Float32Array(slots * 16);
1084
+ const normalData = new Float32Array(slots * 16);
1085
+ const transformAttribute = new StorageBufferAttribute(transformData, 16);
1086
+ const normalAttribute = new StorageBufferAttribute(normalData, 16);
1087
+ const ownerIdNode = attribute("tnOwnerId", "uint");
1088
+ const ownerMatrix = storage(transformAttribute, "mat4", slots).element(ownerIdNode);
1089
+ const ownerNormalMatrix = storage(normalAttribute, "mat4", slots).element(ownerIdNode);
1090
+ const added = [];
1091
+ const patched = [];
1092
+ let failed = false;
1093
+ for (const group of groups.values()) {
1094
+ const shared = sharedAttributeNames(group.chunks);
1095
+ if (!shared.includes("position") || !shared.includes("tnOwnerId")) {
1096
+ failed = true;
1097
+ break;
1098
+ }
1099
+ for (const chunk of group.chunks) {
1100
+ for (const name of Object.keys(chunk.attributes)) {
1101
+ if (!shared.includes(name)) chunk.deleteAttribute(name);
1102
+ }
1103
+ }
1104
+ const overlayMergeStartedAt = globalThis.performance?.now() ?? 0;
1105
+ const geometry = mergeGeometries(group.chunks, false);
1106
+ this.#mergeSpentMs += (globalThis.performance?.now() ?? 0) - overlayMergeStartedAt;
1107
+ if (geometry === null) {
1108
+ failed = true;
1109
+ break;
1110
+ }
1111
+ const material = group.material;
1112
+ patched.push({
1113
+ material,
1114
+ position: material.positionNode,
1115
+ normal: material.normalNode
1116
+ });
1117
+ material.positionNode = ownerMatrix.mul(vec4(positionLocal, 1)).xyz;
1118
+ material.normalNode = ownerNormalMatrix.mul(vec4(normalLocal, 0)).xyz.normalize();
1119
+ const mesh = new Mesh(geometry, material);
1120
+ mesh.frustumCulled = false;
1121
+ mesh.renderOrder = group.renderOrder;
1122
+ camera.add(mesh);
1123
+ added.push(mesh);
1124
+ overlayDraws += 1;
1125
+ }
1126
+ if (failed) {
1127
+ for (const entry of patched) {
1128
+ entry.material.positionNode = entry.position;
1129
+ entry.material.normalNode = entry.normal;
1130
+ }
1131
+ for (const mesh of added) camera.remove(mesh);
1132
+ overlayDraws -= added.length;
1133
+ continue;
1134
+ }
1135
+ const masks = /* @__PURE__ */ new Map();
1136
+ for (const part of parts) {
1137
+ const layers = part.object.layers;
1138
+ if (layers === void 0) continue;
1139
+ masks.set(part.object, layers.mask);
1140
+ layers.mask = 0;
1141
+ }
1142
+ overlayMeshes += parts.length;
1143
+ const normalMatrix = new Matrix3();
1144
+ const world = new Matrix4();
1145
+ const writeTransforms = () => {
1146
+ camera.updateMatrixWorld(true);
1147
+ cameraInverse.copy(camera.matrixWorld).invert();
1148
+ for (const part of parts) {
1149
+ world.copy(cameraInverse).multiply(part.object.matrixWorld);
1150
+ transformData.set(world.elements, part.index * 16);
1151
+ writeNormalMatrix(normalData, part.index * 16, normalMatrix.getNormalMatrix(world));
1152
+ if (!this.#drawn(part.object, camera)) {
1153
+ transformData[part.index * 16 + 12] = 1e9;
1154
+ transformData[part.index * 16 + 13] = 1e9;
1155
+ transformData[part.index * 16 + 14] = 1e9;
1156
+ }
1157
+ }
1158
+ transformAttribute.needsUpdate = true;
1159
+ normalAttribute.needsUpdate = true;
1160
+ };
1161
+ writeTransforms();
1162
+ const previousUpdate = this.#update;
1163
+ this.#update = () => {
1164
+ previousUpdate?.();
1165
+ writeTransforms();
1166
+ };
1167
+ const previousRestore = this.#restore;
1168
+ this.#restore = () => {
1169
+ for (const mesh of added) camera.remove(mesh);
1170
+ for (const entry of patched) {
1171
+ entry.material.positionNode = entry.position;
1172
+ entry.material.normalNode = entry.normal;
1173
+ }
1174
+ for (const [object, mask] of masks) {
1175
+ if (object.layers !== void 0) object.layers.mask = mask;
1176
+ }
1177
+ previousRestore?.();
1178
+ };
1179
+ }
1180
+ return { materialIdentities, overlayMeshes, overlayDraws, skipped };
1181
+ }
1182
+ /** True when the game currently wants this overlay mesh on screen, ancestors included. */
1183
+ #drawn(object, camera) {
1184
+ for (let current = object; current !== null; current = current.parent) {
1185
+ if (current.visible !== true) return false;
1186
+ if (current === camera) break;
1187
+ }
1188
+ return true;
1189
+ }
1190
+ #decline(reason, sourceMeshes, reasonCode) {
1191
+ this.#report = this.#makeReport({
1192
+ collapsed: false,
1193
+ reason,
1194
+ reasonCode,
1195
+ resultDrawCandidates: 0,
1196
+ resultMaterialIdentities: 0,
1197
+ skipped: this.#lastScan?.skipped ?? emptySkippedCounts(),
1198
+ sourceMaterialIdentities: this.#lastSourceMaterialIdentities,
1199
+ sourceMeshes,
1200
+ sourceRenderables: this.#lastSourceRenderables || sourceMeshes,
1201
+ status: "rejected"
1202
+ });
1203
+ this.#onReport(this.#report);
1204
+ }
1205
+ #makeReport(input) {
1206
+ return {
1207
+ schemaVersion: 1,
1208
+ status: input.status,
1209
+ reasonCode: input.reasonCode,
1210
+ collapsed: input.collapsed,
1211
+ ...input.reason === void 0 ? {} : { reason: input.reason },
1212
+ sourceMeshes: input.sourceMeshes,
1213
+ mergedMeshes: input.resultDrawCandidates,
1214
+ movingParts: 0,
1215
+ diagnostics: {
1216
+ sourceRenderables: input.sourceRenderables,
1217
+ resultDrawCandidates: input.resultDrawCandidates,
1218
+ sourceMaterialIdentities: input.sourceMaterialIdentities,
1219
+ resultMaterialIdentities: input.resultMaterialIdentities,
1220
+ groups: { staticWorld: 0, movingOwners: 0, cameraOverlays: 0 },
1221
+ skipped: input.skipped,
1222
+ timings: this.#timings()
1223
+ }
1224
+ };
1225
+ }
1226
+ #timings() {
1227
+ return {
1228
+ bakeMs: this.#bakeSpentMs,
1229
+ observeMs: this.#observeSpanMs,
1230
+ mergeMs: this.#mergeSpentMs,
1231
+ copyMs: this.#copySpentMs,
1232
+ transformMs: this.#transformSpentMs,
1233
+ colorMs: this.#colorSpentMs,
1234
+ sampleMs: this.#sampleSpentMs,
1235
+ transformRefresh: {
1236
+ count: this.#refreshCount,
1237
+ lastMs: this.#refreshLastMs,
1238
+ maxMs: this.#refreshMaxMs,
1239
+ meanMs: this.#refreshCount === 0 ? 0 : this.#refreshTotalMs / this.#refreshCount
1240
+ }
1241
+ };
1242
+ }
1243
+ /**
1244
+ * Puts a detached root back in the scene once the game has added to it.
1245
+ *
1246
+ * The bake consumed what was there and removed each baked mesh from its parent, so a root that
1247
+ * comes back draws only what arrived after the collapse — the merged geometry keeps drawing the
1248
+ * rest and nothing is drawn twice. Re-attaching costs that root's traversal again, which is the
1249
+ * whole reason the pass detached it; paying it for a root the game demonstrably still builds
1250
+ * into is the correct trade, and a root nobody touches never pays.
1251
+ *
1252
+ * One integer compare per detached root per frame. There are tens of them, not thousands.
1253
+ */
1254
+ #adoptGrownRoots() {
1255
+ if (this.#adoptable.length === 0) return;
1256
+ let remaining = 0;
1257
+ for (const entry of this.#adoptable) {
1258
+ if (entry.root.children.length === entry.children) {
1259
+ this.#adoptable[remaining] = entry;
1260
+ remaining += 1;
1261
+ continue;
1262
+ }
1263
+ this.#scene.add(entry.root);
1264
+ this.#adopted += 1;
1265
+ }
1266
+ this.#adoptable.length = remaining;
1267
+ }
1268
+ #refreshTransforms() {
1269
+ this.#adoptGrownRoots();
1270
+ if (this.#update === void 0) return;
1271
+ if (!this.#measureTransformRefresh) {
1272
+ this.#update();
1273
+ return;
1274
+ }
1275
+ const startedAt = globalThis.performance?.now() ?? 0;
1276
+ this.#update();
1277
+ const elapsed = (globalThis.performance?.now() ?? 0) - startedAt;
1278
+ this.#refreshCount += 1;
1279
+ this.#refreshLastMs = elapsed;
1280
+ this.#refreshTotalMs += elapsed;
1281
+ this.#refreshMaxMs = Math.max(this.#refreshMaxMs, elapsed);
1282
+ }
1283
+ /**
1284
+ * A camera-parented subtree is not the scene pass's to bake: its world matrix would strand the
1285
+ * subtree wherever the camera happened to be standing. `#collapseCameras` folds it in camera
1286
+ * space instead, once this pass is done.
1287
+ */
1288
+ #excluded(object) {
1289
+ for (let current = object; current !== null; current = current.parent) {
1290
+ if (current.isCamera === true) return true;
1291
+ }
1292
+ return false;
1293
+ }
1294
+ /**
1295
+ * The owner is the mesh's own parent whenever anything above it animates, not the nearest node
1296
+ * observed moving. Picking the nearest mover assumes every node between it and the mesh is
1297
+ * static, and a joint that happens to hold still during the observation window then freezes its
1298
+ * geometry for good — the fox's limbs glitching mid-stride. Uploading the parent's world matrix
1299
+ * carries the whole chain every frame and assumes nothing.
1300
+ */
1301
+ /**
1302
+ * The nearest node at or above `object` that actually moves, which is the one whose transform the
1303
+ * baked draw has to follow. Returning `object.parent` instead looks equivalent on a rig — a mesh
1304
+ * hanging off an animated bone has that bone as its parent either way — but on a flat scene every
1305
+ * independently-moving sibling shares one parent, so they all resolved to it and were baked into a
1306
+ * single rigid group. Four thousand cubes moving on their own became one that did not.
1307
+ */
1308
+ #ownerOf(object) {
1309
+ for (let current = object; current !== null; current = current.parent) {
1310
+ if (this.#moving.has(current)) return current;
1311
+ }
1312
+ return void 0;
1313
+ }
1314
+ /**
1315
+ * The collapse, as a generator that yields between meshes.
1316
+ *
1317
+ * Done in one call this took 3.6 s on a Pixel 8 — a freeze longer than the launch before it.
1318
+ * Yielding lets `frame()` spend a few milliseconds per frame on it instead, so a loading bar
1319
+ * keeps moving and nothing blocks. The merge at the end is not split: it was 138 ms of the
1320
+ * original 2,518 ms and splitting it would mean holding half-merged geometry across frames.
1321
+ */
1322
+ *#collapseSteps() {
1323
+ this.#scene.updateMatrixWorld(true);
1324
+ const scan = this.#lastScan ?? this.#scanSceneRenderables();
1325
+ this.#lastScan = void 0;
1326
+ this.#lastSourceRenderables = scan.sourceRenderables;
1327
+ this.#lastSourceMaterialIdentities = scan.materialIdentities.size;
1328
+ const meshes = [...scan.meshes];
1329
+ if (meshes.length < this.#minMeshes) {
1330
+ this.#meshCount = 0;
1331
+ this.#observed = 0;
1332
+ return;
1333
+ }
1334
+ const movingParts = [];
1335
+ const movingIndex = /* @__PURE__ */ new Map();
1336
+ const groups = /* @__PURE__ */ new Map();
1337
+ const bakedFrom = [];
1338
+ const inverse = new Matrix4();
1339
+ const bakeMatrix = new Matrix4();
1340
+ const bakeNormalMatrix = new Matrix3();
1341
+ for (const [meshIndex, mesh] of meshes.entries()) {
1342
+ yield meshIndex / meshes.length;
1343
+ const material = materialOf(mesh);
1344
+ if (material === void 0) {
1345
+ this.#decline("a mesh has no material", meshes.length, "missingMaterial");
1346
+ return;
1347
+ }
1348
+ const owner = this.#ownerOf(mesh);
1349
+ let ownerId;
1350
+ if (owner !== void 0) {
1351
+ ownerId = movingIndex.get(owner);
1352
+ if (ownerId === void 0) {
1353
+ ownerId = movingParts.length;
1354
+ movingIndex.set(owner, ownerId);
1355
+ movingParts.push({
1356
+ // Decided later, in `#update`'s setup: parts are still parented to the live scene at
1357
+ // this point and only leave it once the bake detaches them.
1358
+ detachedLeaf: false,
1359
+ index: ownerId,
1360
+ object: owner,
1361
+ uniformScale: hasUniformScale(owner.matrixWorld)
1362
+ });
1363
+ }
1364
+ }
1365
+ const local = owner === void 0 ? mesh.matrixWorld : inverse.copy(owner.matrixWorld).invert().multiply(mesh.matrixWorld);
1366
+ const source = mesh.geometry;
1367
+ if (source === void 0) {
1368
+ this.#decline(
1369
+ "a mesh lost its geometry between the scan and the merge",
1370
+ meshes.length,
1371
+ "missingGeometry"
1372
+ );
1373
+ return;
1374
+ }
1375
+ const copyStartedAt = globalThis.performance?.now() ?? 0;
1376
+ const geometry = source.index !== null ? source.toNonIndexed() : source.clone();
1377
+ this.#copySpentMs += (globalThis.performance?.now() ?? 0) - copyStartedAt;
1378
+ for (const name of Object.keys(geometry.attributes)) {
1379
+ if (!CANONICAL_ATTRIBUTES.includes(name)) geometry.deleteAttribute(name);
1380
+ }
1381
+ if (geometry.getAttribute("normal") === void 0)
1382
+ geometry.computeVertexNormals();
1383
+ bakeMatrix.copy(local);
1384
+ const transformStartedAt = globalThis.performance?.now() ?? 0;
1385
+ bakeTransform(geometry, bakeMatrix, bakeNormalMatrix.getNormalMatrix(bakeMatrix));
1386
+ this.#transformSpentMs += (globalThis.performance?.now() ?? 0) - transformStartedAt;
1387
+ if (ownerId !== void 0) {
1388
+ const count2 = geometry.getAttribute("position")?.count ?? 0;
1389
+ const ids = new Uint32Array(count2);
1390
+ ids.fill(ownerId);
1391
+ geometry.setAttribute("tnOwnerId", new Uint32BufferAttribute(ids, 1));
1392
+ }
1393
+ material.transparent === true;
1394
+ const kind = owner === void 0 ? "static" : "moving";
1395
+ const color = colorOf(material);
1396
+ const count = geometry.getAttribute("position")?.count ?? 0;
1397
+ if (geometry.getAttribute("uv") === void 0)
1398
+ geometry.setAttribute("uv", new Float32BufferAttribute(new Float32Array(count * 2), 2));
1399
+ const rgb = color ?? { r: 1, g: 1, b: 1 };
1400
+ const painted = material.vertexColors === true ? geometry.getAttribute("color") : void 0;
1401
+ const paintedArray = painted?.array;
1402
+ const paintedStride = painted?.itemSize;
1403
+ const fastColors = paintedArray instanceof Float32Array && paintedStride !== void 0 && painted?.normalized !== true ? { source: paintedArray, stride: paintedStride } : void 0;
1404
+ const colorStartedAt = globalThis.performance?.now() ?? 0;
1405
+ const colors = new Float32Array(count * 3);
1406
+ if (painted === void 0) {
1407
+ for (let index = 0; index < count; index += 1) {
1408
+ colors[index * 3] = rgb.r;
1409
+ colors[index * 3 + 1] = rgb.g;
1410
+ colors[index * 3 + 2] = rgb.b;
1411
+ }
1412
+ } else if (fastColors !== void 0) {
1413
+ const { source: source2, stride } = fastColors;
1414
+ for (let index = 0; index < count; index += 1) {
1415
+ const at = index * stride;
1416
+ colors[index * 3] = rgb.r * (source2[at] ?? 0);
1417
+ colors[index * 3 + 1] = rgb.g * (source2[at + 1] ?? 0);
1418
+ colors[index * 3 + 2] = rgb.b * (source2[at + 2] ?? 0);
1419
+ }
1420
+ } else {
1421
+ for (let index = 0; index < count; index += 1) {
1422
+ colors[index * 3] = rgb.r * painted.getX(index);
1423
+ colors[index * 3 + 1] = rgb.g * painted.getY(index);
1424
+ colors[index * 3 + 2] = rgb.b * painted.getZ(index);
1425
+ }
1426
+ }
1427
+ geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
1428
+ this.#colorSpentMs += (globalThis.performance?.now() ?? 0) - colorStartedAt;
1429
+ const castShadow = mesh.castShadow === true;
1430
+ const receiveShadow = mesh.receiveShadow === true;
1431
+ const key = `${kind}|${materialSignature(material)}|cast:${castShadow}|receive:${receiveShadow}`;
1432
+ const group = groups.get(key) ?? {
1433
+ material,
1434
+ chunks: [],
1435
+ dynamic: owner !== void 0,
1436
+ castShadow,
1437
+ receiveShadow
1438
+ };
1439
+ group.chunks.push(geometry);
1440
+ groups.set(key, group);
1441
+ if (mesh.parent !== null) bakedFrom.push({ mesh, parent: mesh.parent });
1442
+ }
1443
+ const slots = Math.max(1, movingParts.length);
1444
+ const transformData = new Float32Array(slots * 16);
1445
+ const uniformNormals = movingParts.every((part) => part.uniformScale);
1446
+ const normalData = uniformNormals ? transformData : new Float32Array(slots * 16);
1447
+ const normalMatrix = new Matrix3();
1448
+ for (const part of movingParts) {
1449
+ transformData.set(part.object.matrixWorld.elements, part.index * 16);
1450
+ if (!uniformNormals) writeNormals(normalData, part, normalMatrix);
1451
+ }
1452
+ const transformAttribute = new StorageBufferAttribute(transformData, 16);
1453
+ const normalAttribute = uniformNormals ? transformAttribute : new StorageBufferAttribute(normalData, 16);
1454
+ const transformNode = storage(transformAttribute, "mat4", slots);
1455
+ const normalNodes = uniformNormals ? transformNode : storage(normalAttribute, "mat4", slots);
1456
+ const ownerIdNode = attribute("tnOwnerId", "uint");
1457
+ const ownerMatrix = transformNode.element(ownerIdNode);
1458
+ const ownerNormalMatrix = normalNodes.element(ownerIdNode);
1459
+ const added = [];
1460
+ let mergedMeshes = 0;
1461
+ for (const group of groups.values()) {
1462
+ const shared = sharedAttributeNames(group.chunks);
1463
+ if (!shared.includes("position")) {
1464
+ this.#decline(
1465
+ "a merge group disagrees on its position attribute",
1466
+ meshes.length,
1467
+ "mergeGroupMissingPosition"
1468
+ );
1469
+ return;
1470
+ }
1471
+ for (const chunk of group.chunks) {
1472
+ for (const name of Object.keys(chunk.attributes)) {
1473
+ if (!shared.includes(name)) chunk.deleteAttribute(name);
1474
+ }
1475
+ }
1476
+ const mergeStartedAt = globalThis.performance?.now() ?? 0;
1477
+ const geometry = mergeGeometries(group.chunks, false);
1478
+ this.#mergeSpentMs += (globalThis.performance?.now() ?? 0) - mergeStartedAt;
1479
+ if (geometry === null) {
1480
+ this.#decline("three.js refused to merge a group", meshes.length, "mergeRefused");
1481
+ return;
1482
+ }
1483
+ let material = group.material;
1484
+ if (geometry.getAttribute("color") !== void 0) {
1485
+ material = material.clone();
1486
+ const tinted = material;
1487
+ tinted.vertexColors = true;
1488
+ tinted.color?.setRGB(1, 1, 1);
1489
+ }
1490
+ if (geometry.getAttribute("normal") === void 0) {
1491
+ this.#decline("a merge group lost its normals", meshes.length, "mergeLostNormals");
1492
+ return;
1493
+ }
1494
+ if (group.dynamic && geometry.getAttribute("tnOwnerId") === void 0) {
1495
+ this.#decline(
1496
+ "a moving group lost its per-vertex transform id in the merge",
1497
+ meshes.length,
1498
+ "movingGroupLostOwnerId"
1499
+ );
1500
+ return;
1501
+ }
1502
+ if (group.dynamic) {
1503
+ const node = material.clone();
1504
+ node.positionNode = ownerMatrix.mul(vec4(positionLocal, 1)).xyz;
1505
+ node.normalNode = ownerNormalMatrix.mul(vec4(normalLocal, 0)).xyz.normalize();
1506
+ material = node;
1507
+ }
1508
+ const mesh = new Mesh(geometry, material);
1509
+ mesh.frustumCulled = false;
1510
+ mesh.castShadow = group.castShadow;
1511
+ mesh.receiveShadow = group.receiveShadow;
1512
+ this.#scene.add(mesh);
1513
+ added.push(mesh);
1514
+ mergedMeshes += 1;
1515
+ }
1516
+ for (const entry of bakedFrom) entry.parent.remove(entry.mesh);
1517
+ const detached = [];
1518
+ for (const child of [...this.#scene.children]) {
1519
+ if (child.isCamera === true || added.includes(child) || scan.preserved.has(child)) continue;
1520
+ if (containsLight(child)) continue;
1521
+ if (containsPreserved(child, scan.preserved)) continue;
1522
+ detached.push(child);
1523
+ this.#scene.remove(child);
1524
+ this.#adoptable.push({ children: child.children.length, root: child });
1525
+ }
1526
+ const movingRoots = [];
1527
+ for (const part of movingParts) {
1528
+ let root = part.object;
1529
+ while (root.parent !== null) root = root.parent;
1530
+ if (!movingRoots.includes(root)) movingRoots.push(root);
1531
+ }
1532
+ for (const part of movingParts) {
1533
+ part.detachedLeaf = part.object.parent === null && part.object.children.length === 0 && typeof part.object.updateMatrix === "function";
1534
+ }
1535
+ const detachedLeaves = new Set(
1536
+ movingParts.filter((part) => part.detachedLeaf).map((part) => part.object)
1537
+ );
1538
+ const walkedRoots = movingRoots.filter((root) => !detachedLeaves.has(root));
1539
+ const leafParts = movingParts.filter((part) => part.detachedLeaf);
1540
+ const nestedParts = movingParts.filter((part) => !part.detachedLeaf);
1541
+ const hide = (offset) => {
1542
+ transformData[offset + 12] = 1e9;
1543
+ transformData[offset + 13] = 1e9;
1544
+ transformData[offset + 14] = 1e9;
1545
+ };
1546
+ this.#update = () => {
1547
+ for (const root of walkedRoots) root.updateMatrixWorld(true);
1548
+ for (const part of leafParts) {
1549
+ const object = part.object;
1550
+ object.updateMatrix?.();
1551
+ const offset = part.index * 16;
1552
+ transformData.set(object.matrix.elements, offset);
1553
+ if (!uniformNormals) writeNormals(normalData, part, normalMatrix);
1554
+ if (object.visible !== true) hide(offset);
1555
+ }
1556
+ for (const part of nestedParts) {
1557
+ const object = part.object;
1558
+ const offset = part.index * 16;
1559
+ transformData.set(object.matrixWorld.elements, offset);
1560
+ if (!uniformNormals) writeNormals(normalData, part, normalMatrix);
1561
+ if (object.visible !== true) hide(offset);
1562
+ }
1563
+ if (movingParts.length > 0) {
1564
+ transformAttribute.needsUpdate = true;
1565
+ if (!uniformNormals) normalAttribute.needsUpdate = true;
1566
+ }
1567
+ };
1568
+ this.#restore = () => {
1569
+ for (const mesh of added) this.#scene.remove(mesh);
1570
+ for (const entry of bakedFrom) entry.parent.add(entry.mesh);
1571
+ for (const child of detached) this.#scene.add(child);
1572
+ };
1573
+ const overlay = this.#collapseCameras();
1574
+ const skipped = emptySkippedCounts();
1575
+ mergeSkippedCounts(skipped, scan.skipped);
1576
+ mergeSkippedCounts(skipped, overlay.skipped);
1577
+ const resultMaterialIdentities = /* @__PURE__ */ new Set();
1578
+ for (const group of groups.values()) resultMaterialIdentities.add(group.material);
1579
+ for (const material of overlay.materialIdentities) resultMaterialIdentities.add(material);
1580
+ this.#report = {
1581
+ schemaVersion: 1,
1582
+ status: "applied",
1583
+ reasonCode: "applied",
1584
+ collapsed: true,
1585
+ sourceMeshes: meshes.length,
1586
+ mergedMeshes,
1587
+ movingParts: movingParts.length,
1588
+ overlayMeshes: overlay.overlayMeshes,
1589
+ overlayDraws: overlay.overlayDraws,
1590
+ diagnostics: {
1591
+ sourceRenderables: scan.sourceRenderables,
1592
+ resultDrawCandidates: mergedMeshes + overlay.overlayDraws,
1593
+ sourceMaterialIdentities: scan.materialIdentities.size + overlay.materialIdentities.size,
1594
+ resultMaterialIdentities: resultMaterialIdentities.size,
1595
+ groups: {
1596
+ staticWorld: [...groups.values()].filter((group) => !group.dynamic).length,
1597
+ movingOwners: movingParts.length,
1598
+ cameraOverlays: overlay.overlayDraws
1599
+ },
1600
+ skipped,
1601
+ timings: this.#timings()
1602
+ },
1603
+ bakeMs: this.#bakeSpentMs,
1604
+ observeMs: this.#observeSpanMs,
1605
+ mergeMs: this.#mergeSpentMs,
1606
+ copyMs: this.#copySpentMs,
1607
+ transformMs: this.#transformSpentMs,
1608
+ colorMs: this.#colorSpentMs,
1609
+ sampleMs: this.#sampleSpentMs
1610
+ };
1611
+ this.#onReport(this.#report);
254
1612
  }
255
1613
  };
256
1614
 
@@ -275,6 +1633,8 @@ function disposeEntity(entity) {
275
1633
  }
276
1634
  var Registry = class {
277
1635
  #named = /* @__PURE__ */ new Map();
1636
+ #pendingFree = /* @__PURE__ */ new Set();
1637
+ #iterating = false;
278
1638
  add(name, entity) {
279
1639
  if (this.#named.has(name)) throw new Error(`Entity "${name}" already registered.`);
280
1640
  this.#named.set(name, entity);
@@ -284,28 +1644,64 @@ var Registry = class {
284
1644
  return this.#named.get(name);
285
1645
  }
286
1646
  remove(name) {
1647
+ this.#assertNotIterating("remove");
1648
+ this.#pendingFree.delete(name);
287
1649
  const entity = this.#named.get(name);
288
1650
  this.#named.delete(name);
289
1651
  if (entity !== void 0) disposeEntity(entity);
290
1652
  }
1653
+ queueFree(target) {
1654
+ const name = typeof target === "string" ? target : this.#nameOf(target);
1655
+ if (name === void 0 || !this.#named.has(name))
1656
+ throw new Error("Cannot queue an entity that is not registered.");
1657
+ this.#pendingFree.add(name);
1658
+ }
1659
+ sweep() {
1660
+ this.#assertNotIterating("sweep");
1661
+ const pending = [...this.#pendingFree];
1662
+ this.#pendingFree.clear();
1663
+ for (const name of pending) {
1664
+ const entity = this.#named.get(name);
1665
+ if (entity === void 0) continue;
1666
+ this.#named.delete(name);
1667
+ disposeEntity(entity);
1668
+ }
1669
+ }
291
1670
  clear() {
1671
+ this.#assertNotIterating("clear");
1672
+ this.#pendingFree.clear();
292
1673
  const entities = [...new Set(this.#named.values())];
293
1674
  this.#named.clear();
294
1675
  for (const entity of entities) disposeEntity(entity);
295
1676
  }
296
1677
  snapshot() {
297
1678
  const result = {};
298
- for (const [name, entity] of this.#named) {
299
- const debug = entity.debug;
300
- const fields = typeof debug === "function" ? debug.call(entity) : autoFields(entity);
301
- const tags = entity.tags;
302
- result[name] = {
303
- ...fields,
304
- ...Array.isArray(tags) && tags.every((tag) => typeof tag === "string") ? { tags: [...tags] } : {}
305
- };
1679
+ this.#iterating = true;
1680
+ try {
1681
+ for (const [name, entity] of this.#named) {
1682
+ const debug = entity.debug;
1683
+ const fields = typeof debug === "function" ? debug.call(entity) : autoFields(entity);
1684
+ const tags = entity.tags;
1685
+ result[name] = {
1686
+ ...fields,
1687
+ ...Array.isArray(tags) && tags.every((tag) => typeof tag === "string") ? { tags: [...tags] } : {}
1688
+ };
1689
+ }
1690
+ } finally {
1691
+ this.#iterating = false;
306
1692
  }
307
1693
  return result;
308
1694
  }
1695
+ #nameOf(entity) {
1696
+ for (const [name, registered] of this.#named) {
1697
+ if (registered === entity) return name;
1698
+ }
1699
+ return void 0;
1700
+ }
1701
+ #assertNotIterating(operation) {
1702
+ if (this.#iterating)
1703
+ throw new TypeError(`Registry.${operation}() cannot mutate during snapshot.`);
1704
+ }
309
1705
  };
310
1706
  var DEFAULT_BINDINGS = {
311
1707
  move: {
@@ -326,8 +1722,8 @@ var InputMap = class {
326
1722
  #target;
327
1723
  #heldKeys = /* @__PURE__ */ new Set();
328
1724
  #pointerPosition = new Vector2();
329
- #pointerDown = false;
330
1725
  #pointerButtons = 0;
1726
+ #pointers = /* @__PURE__ */ new Map();
331
1727
  #gamepadAxes = [];
332
1728
  #gamepadButtons = [];
333
1729
  #previousPressed = /* @__PURE__ */ new Map();
@@ -346,9 +1742,10 @@ var InputMap = class {
346
1742
  keys: this.#heldKeys,
347
1743
  pointer: {
348
1744
  buttons: this.#pointerButtons,
349
- down: this.#pointerDown,
1745
+ down: false,
350
1746
  position: this.#pointerPosition
351
- }
1747
+ },
1748
+ pointers: this.#pointers
352
1749
  };
353
1750
  this.#listen(this.#target, "keydown", (event2) => {
354
1751
  const code = eventCode(event2);
@@ -359,14 +1756,16 @@ var InputMap = class {
359
1756
  if (code !== void 0) this.#heldKeys.delete(code);
360
1757
  });
361
1758
  this.#listen(this.#pointerTarget, "pointerdown", (event2) => this.#pointerEvent(event2, true));
362
- this.#listen(
363
- this.#pointerTarget,
364
- "pointermove",
365
- (event2) => this.#pointerEvent(event2, this.#pointerDown)
366
- );
1759
+ this.#listen(this.#pointerTarget, "pointermove", (event2) => this.#pointerEvent(event2));
367
1760
  this.#listen(this.#pointerTarget, "pointerup", (event2) => this.#pointerEvent(event2, false));
1761
+ this.#listen(this.#pointerTarget, "pointercancel", (event2) => this.#pointerEvent(event2, false));
368
1762
  this.#listen(this.#target, "blur", () => this.clear());
369
1763
  }
1764
+ /**
1765
+ * Returns a 2D action vector where +y is up. On a conventional XZ ground plane whose
1766
+ * forward direction is -z, map `vector.y` to `-z`. This differs from Godot's
1767
+ * `Input.get_vector`, where up is -y.
1768
+ */
370
1769
  vector(name) {
371
1770
  const binding = this.#bindings[name];
372
1771
  if (binding === void 0) return new Vector2();
@@ -385,7 +1784,7 @@ var InputMap = class {
385
1784
  pressed(name) {
386
1785
  const binding = this.#bindings[name];
387
1786
  if (binding === void 0) return false;
388
- return this.#isHeld(binding.down) || this.#isHeld(binding.left) || this.#isHeld(binding.right) || this.#isHeld(binding.up) || binding.pointer === true && this.#pointerDown || (binding.buttons?.some((button) => this.#gamepadButtons[button] === true) ?? false);
1787
+ return this.#isHeld(binding.keys) || this.#isHeld(binding.down) || this.#isHeld(binding.left) || this.#isHeld(binding.right) || this.#isHeld(binding.up) || binding.pointer === true && this.#pointers.size > 0 || (binding.buttons?.some((button) => this.#gamepadButtons[button] === true) ?? false);
389
1788
  }
390
1789
  justPressed(name) {
391
1790
  return this.#justPressed.has(name);
@@ -411,7 +1810,7 @@ var InputMap = class {
411
1810
  }
412
1811
  clear() {
413
1812
  this.#heldKeys.clear();
414
- this.#pointerDown = false;
1813
+ this.#pointers.clear();
415
1814
  this.#pointerButtons = 0;
416
1815
  this.#pointerPosition.set(0, 0);
417
1816
  this.raw.pointer.buttons = 0;
@@ -435,21 +1834,44 @@ var InputMap = class {
435
1834
  }
436
1835
  #pointerEvent(event2, down) {
437
1836
  const pointer = event2;
438
- this.#pointerDown = down;
439
- this.#pointerButtons = pointer.buttons ?? 0;
440
- this.#pointerPosition.set(pointer.clientX ?? 0, pointer.clientY ?? 0);
1837
+ const id = pointer.pointerId ?? 0;
1838
+ const buttons = pointer.buttons ?? 0;
1839
+ const x = pointer.clientX ?? 0;
1840
+ const y = pointer.clientY ?? 0;
1841
+ const tracked = this.#pointers.get(id);
1842
+ if (down === true) {
1843
+ if (tracked === void 0) {
1844
+ this.#pointers.set(id, { buttons, id, position: new Vector2(x, y) });
1845
+ } else {
1846
+ tracked.buttons = buttons;
1847
+ tracked.position.set(x, y);
1848
+ }
1849
+ } else if (down === false) {
1850
+ this.#pointers.delete(id);
1851
+ } else if (tracked !== void 0) {
1852
+ tracked.buttons = buttons;
1853
+ tracked.position.set(x, y);
1854
+ }
1855
+ const primary = this.#pointers.values().next().value;
1856
+ if (primary !== void 0) {
1857
+ this.#pointerButtons = primary.buttons;
1858
+ this.#pointerPosition.copy(primary.position);
1859
+ } else if (down !== false || tracked !== void 0) {
1860
+ this.#pointerButtons = buttons;
1861
+ this.#pointerPosition.set(x, y);
1862
+ }
441
1863
  this.raw.pointer.buttons = this.#pointerButtons;
442
- this.raw.pointer.down = down;
1864
+ this.raw.pointer.down = primary !== void 0;
443
1865
  const capture = this.#pointerTarget;
444
- if (down) capture.setPointerCapture?.(pointer.pointerId ?? 0);
445
- else capture.releasePointerCapture?.(pointer.pointerId ?? 0);
1866
+ if (pointer.isTrusted !== false) {
1867
+ if (down === true) capture.setPointerCapture?.(id);
1868
+ if (down === false) capture.releasePointerCapture?.(id);
1869
+ }
446
1870
  }
447
1871
  };
448
- function input(bindings, target, pointerTarget, source) {
449
- return new InputMap(bindings, target, pointerTarget, source);
450
- }
451
1872
 
452
1873
  // src/loop.ts
1874
+ var MAX_RENDER_PERFORMANCE_SAMPLES = 1024;
453
1875
  var FixedStepLoop = class {
454
1876
  step;
455
1877
  maxSteps;
@@ -461,8 +1883,10 @@ var FixedStepLoop = class {
461
1883
  #lastTime;
462
1884
  #frameHandle;
463
1885
  #running = false;
1886
+ #tick = 0;
464
1887
  #fps = 0;
465
1888
  #lastRenderTime;
1889
+ #renderPerformanceSamples = [];
466
1890
  constructor(options) {
467
1891
  this.step = options.step ?? 1 / 60;
468
1892
  this.maxSteps = options.maxSteps ?? 5;
@@ -480,12 +1904,18 @@ var FixedStepLoop = class {
480
1904
  get fps() {
481
1905
  return this.#fps;
482
1906
  }
1907
+ runtimeDiagnosticsSeries() {
1908
+ return this.#renderPerformanceSamples.map((sample) => ({ ...sample }));
1909
+ }
1910
+ tick = () => this.#tick;
483
1911
  start(now = globalThis.performance?.now() ?? 0) {
484
1912
  if (this.#running) return;
485
1913
  this.#running = true;
486
1914
  this.#lastTime = now;
487
1915
  this.#lastRenderTime = void 0;
1916
+ this.#tick = 0;
488
1917
  this.#fps = 0;
1918
+ this.#renderPerformanceSamples = [];
489
1919
  this.#frameHandle = this.#requestFrame((time) => this.#frame(time));
490
1920
  }
491
1921
  stop() {
@@ -496,34 +1926,46 @@ var FixedStepLoop = class {
496
1926
  this.#accumulator = 0;
497
1927
  }
498
1928
  stepFrame(now) {
499
- const last = this.#lastTime ?? now;
500
- const elapsed = Math.max(0, (now - last) / 1e3);
501
- this.#lastTime = now;
1929
+ const elapsed = Math.max(0, (now - (this.#lastTime ?? now)) / 1e3);
1930
+ this.#lastTime = Math.max(this.#lastTime ?? now, now);
502
1931
  this.#accumulator += elapsed;
503
1932
  let updates = 0;
504
1933
  while (this.#accumulator + Number.EPSILON >= this.step && updates < this.maxSteps) {
505
1934
  this.#onUpdate(this.step);
1935
+ this.#tick += 1;
506
1936
  this.#accumulator -= this.step;
507
1937
  updates += 1;
508
1938
  }
509
1939
  if (updates === this.maxSteps && this.#accumulator >= this.step) this.#accumulator = 0;
1940
+ let frameMs;
510
1941
  if (Number.isFinite(now)) {
511
1942
  if (this.#lastRenderTime !== void 0) {
512
- const frameMs = now - this.#lastRenderTime;
1943
+ frameMs = now - this.#lastRenderTime;
513
1944
  if (frameMs > 0) this.#fps += (1e3 / frameMs - this.#fps) * 0.1;
514
1945
  }
515
1946
  this.#lastRenderTime = now;
516
1947
  }
517
- this.#onRender();
1948
+ const metrics = this.#onRender();
1949
+ if (frameMs !== void 0 && frameMs > 0) {
1950
+ const sample = {
1951
+ frameMs,
1952
+ ...metrics === void 0 || metrics.drawCalls === void 0 ? {} : { drawCalls: metrics.drawCalls },
1953
+ ...metrics === void 0 || metrics.triangles === void 0 ? {} : { triangles: metrics.triangles }
1954
+ };
1955
+ this.#renderPerformanceSamples.push(sample);
1956
+ if (this.#renderPerformanceSamples.length > MAX_RENDER_PERFORMANCE_SAMPLES)
1957
+ this.#renderPerformanceSamples.shift();
1958
+ }
518
1959
  return updates;
519
1960
  }
520
1961
  advance(ticks) {
521
1962
  if (!this.#running) throw new Error("Cannot advance a stopped loop.");
522
1963
  if (!Number.isInteger(ticks) || ticks <= 0)
523
1964
  throw new Error("advance ticks must be a positive integer.");
1965
+ this.#lastTime = Number.POSITIVE_INFINITY;
524
1966
  for (let index = 0; index < ticks; index += 1) {
525
- this.#lastTime = (this.#lastTime ?? 0) + this.step * 1e3;
526
1967
  this.#onUpdate(this.step);
1968
+ this.#tick += 1;
527
1969
  }
528
1970
  return ticks;
529
1971
  }
@@ -604,42 +2046,70 @@ var GPUParticles3D = class extends Sprite {
604
2046
  }
605
2047
  #onRemoved = () => this.detach();
606
2048
  };
607
-
608
- // src/random.ts
609
- function createRandom(seed) {
610
- if (seed !== void 0 && !Number.isFinite(seed)) throw new TypeError("seed must be finite.");
611
- let state = seed === void 0 ? 0 : seed >>> 0;
612
- const deterministic = seed !== void 0;
613
- const random = (() => {
614
- if (!deterministic) return Math.random();
615
- state = Math.imul(1664525, state) + 1013904223 >>> 0;
616
- return state / 4294967296;
617
- });
618
- random.range = (min, max) => {
619
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
620
- throw new RangeError("range requires finite bounds with max greater than min.");
2049
+ var ScenePicker = class {
2050
+ #camera;
2051
+ #pointer;
2052
+ #scene;
2053
+ #viewport;
2054
+ #raycaster = new Raycaster();
2055
+ #ndc = new Vector2();
2056
+ #hits = [];
2057
+ #trees = /* @__PURE__ */ new WeakMap();
2058
+ constructor(options) {
2059
+ this.#camera = options.camera;
2060
+ this.#pointer = options.pointer;
2061
+ this.#scene = options.scene;
2062
+ this.#viewport = options.viewport;
2063
+ this.#raycaster.firstHitOnly = true;
2064
+ }
2065
+ /** The closest hit under the screen point, or `undefined` when the ray hits nothing. */
2066
+ raycast(options = {}) {
2067
+ const screen = options.screen ?? this.#pointer();
2068
+ if (!Number.isFinite(screen.x) || !Number.isFinite(screen.y))
2069
+ throw new Error("ICtx.raycast screen point must be finite.");
2070
+ const { height, width } = this.#viewport.size;
2071
+ if (width <= 0 || height <= 0) throw new Error("ICtx.raycast needs a sized viewport.");
2072
+ this.#ndc.set(screen.x / width * 2 - 1, -(screen.y / height) * 2 + 1);
2073
+ this.#raycaster.setFromCamera(this.#ndc, this.#camera);
2074
+ const targets = options.targets ?? this.#scene;
2075
+ const roots = Array.isArray(targets) ? targets : [targets];
2076
+ this.#hits.length = 0;
2077
+ for (const root of roots) {
2078
+ root.updateMatrixWorld();
2079
+ this.#collect(root);
621
2080
  }
622
- return min + random() * (max - min);
623
- };
624
- random.pick = (items) => {
625
- if (items.length === 0) throw new RangeError("pick requires a non-empty list.");
626
- return items[Math.floor(random() * items.length)];
627
- };
628
- Object.defineProperty(random, "state", {
629
- get: () => {
630
- if (!deterministic) throw new Error("state is unavailable on an unseeded random.");
631
- return state;
632
- },
633
- set: (value) => {
634
- if (!deterministic) throw new Error("state is unavailable on an unseeded random.");
635
- if (!Number.isFinite(value) || !Number.isInteger(value)) {
636
- throw new TypeError("state must be a finite integer.");
637
- }
638
- state = value >>> 0;
2081
+ if (this.#hits.length === 0) return void 0;
2082
+ this.#hits.sort((first, second) => first.distance - second.distance);
2083
+ return this.#hits[0];
2084
+ }
2085
+ /** Drops every cached hierarchy. The next `raycast` rebuilds what it needs. */
2086
+ dispose() {
2087
+ this.#trees = /* @__PURE__ */ new WeakMap();
2088
+ this.#hits.length = 0;
2089
+ }
2090
+ #collect(object) {
2091
+ if (object.layers.test(this.#raycaster.layers)) {
2092
+ const tree = this.#treeFor(object);
2093
+ if (tree === void 0) object.raycast(this.#raycaster, this.#hits);
2094
+ else tree.raycastObject3D(object, this.#raycaster, this.#hits);
639
2095
  }
640
- });
641
- return random;
642
- }
2096
+ for (const child of object.children) this.#collect(child);
2097
+ }
2098
+ #treeFor(object) {
2099
+ if (!(object instanceof Mesh)) return void 0;
2100
+ const deformed = object;
2101
+ if (deformed.isSkinnedMesh === true || deformed.isInstancedMesh === true || deformed.isBatchedMesh === true || (object.morphTargetInfluences?.length ?? 0) > 0)
2102
+ return void 0;
2103
+ const geometry = object.geometry;
2104
+ const position = geometry.attributes.position;
2105
+ if (position === void 0 || position.count === 0) return void 0;
2106
+ const cached = this.#trees.get(geometry);
2107
+ if (cached !== void 0 && cached.version === position.version) return cached.tree;
2108
+ const tree = new MeshBVH(geometry);
2109
+ this.#trees.set(geometry, { tree, version: position.version });
2110
+ return tree;
2111
+ }
2112
+ };
643
2113
  function readCanvasSize(canvas) {
644
2114
  return [
645
2115
  Math.max(1, canvas.clientWidth || globalThis.innerWidth || 1),
@@ -662,6 +2132,10 @@ function wrapRenderer(raw, kind) {
662
2132
  domElement: raw.domElement,
663
2133
  kind,
664
2134
  raw,
2135
+ compileAsync: async (scene, camera) => {
2136
+ if (typeof raw.compileAsync !== "function") return;
2137
+ await raw.compileAsync(scene, camera);
2138
+ },
665
2139
  compute: (node) => {
666
2140
  if (kind !== "webgpu") throw new Error(`compute is unavailable on the ${kind} renderer.`);
667
2141
  if (typeof raw.compute !== "function")
@@ -677,6 +2151,17 @@ function wrapRenderer(raw, kind) {
677
2151
  if (outputPipeline === void 0) raw.render(scene, camera);
678
2152
  else outputPipeline.render();
679
2153
  },
2154
+ renderOverlay: (scene, camera) => {
2155
+ const hadOwnAutoClear = Object.hasOwn(raw, "autoClear");
2156
+ const autoClear = raw.autoClear;
2157
+ raw.autoClear = false;
2158
+ try {
2159
+ raw.render(scene, camera);
2160
+ } finally {
2161
+ if (hadOwnAutoClear) raw.autoClear = autoClear;
2162
+ else Reflect.deleteProperty(raw, "autoClear");
2163
+ }
2164
+ },
680
2165
  setOutputNode: (node) => {
681
2166
  if (kind !== "webgpu")
682
2167
  throw new Error(`setOutputNode is unavailable on the ${kind} renderer.`);
@@ -689,16 +2174,23 @@ function wrapRenderer(raw, kind) {
689
2174
  setSize: (width, height, updateStyle = false) => raw.setSize(width, height, updateStyle)
690
2175
  };
691
2176
  }
692
- function addResizeHandling(renderer, source) {
2177
+ function addResizeHandling(renderer, source, resolutionScale) {
693
2178
  const resize = () => {
694
2179
  const [width, height] = source?.readSize(renderer.domElement) ?? readCanvasSize(renderer.domElement);
695
- renderer.setSize(width, height, false);
2180
+ renderer.setSize(
2181
+ Math.max(1, Math.round(width * resolutionScale)),
2182
+ Math.max(1, Math.round(height * resolutionScale)),
2183
+ false
2184
+ );
696
2185
  };
697
2186
  resize();
698
2187
  return source?.observeResize(renderer.domElement, resize) ?? observeCanvasResize(renderer.domElement, resize);
699
2188
  }
700
2189
  async function createRenderer(options = {}) {
701
2190
  const source = options.source;
2191
+ const resolutionScale = options.resolutionScale ?? 1;
2192
+ if (!Number.isFinite(resolutionScale) || resolutionScale <= 0)
2193
+ throw new Error("renderer.resolutionScale must be finite and positive.");
702
2194
  const canvas = options.canvas ?? source?.createCanvas() ?? document.createElement("canvas");
703
2195
  const preferWebGPU = options.preferWebGPU ?? true;
704
2196
  let renderer;
@@ -716,7 +2208,7 @@ async function createRenderer(options = {}) {
716
2208
  const raw = options.webgl2Factory ? options.webgl2Factory(canvas) : new (await import('three')).WebGLRenderer({ canvas, antialias: true });
717
2209
  renderer = wrapRenderer(raw, "webgl2");
718
2210
  }
719
- const stopResize = addResizeHandling(renderer, source);
2211
+ const stopResize = addResizeHandling(renderer, source, resolutionScale);
720
2212
  const dispose = renderer.dispose;
721
2213
  renderer.dispose = () => {
722
2214
  stopResize();
@@ -826,8 +2318,10 @@ function assertDelay(value, label) {
826
2318
  }
827
2319
  function createGameStore(initial, intervalMs = 100) {
828
2320
  const store = createStore(() => ({ ...initial }));
2321
+ const current = { value: store.getState() };
829
2322
  let pending;
830
2323
  let timer;
2324
+ store.subscribe((state) => Object.assign(current, { value: state }));
831
2325
  const flush = () => {
832
2326
  if (pending === void 0) return;
833
2327
  const patch = pending;
@@ -835,9 +2329,11 @@ function createGameStore(initial, intervalMs = 100) {
835
2329
  store.setState(patch);
836
2330
  };
837
2331
  const gameStore = store;
2332
+ gameStore.getState = () => current.value;
838
2333
  gameStore.set = (patch) => {
839
- const next = typeof patch === "function" ? patch(store.getState()) : patch;
2334
+ const next = typeof patch === "function" ? patch(current.value) : patch;
840
2335
  pending = { ...pending, ...next };
2336
+ current.value = { ...current.value, ...next };
841
2337
  };
842
2338
  gameStore.flush = flush;
843
2339
  gameStore.start = () => {
@@ -969,6 +2465,26 @@ function clearScene(scene, particles) {
969
2465
  scene.environment = null;
970
2466
  scene.fog = null;
971
2467
  }
2468
+ function rendererPerformanceMetrics(raw) {
2469
+ if (typeof raw !== "object" || raw === null) return {};
2470
+ const info = raw.info;
2471
+ if (typeof info !== "object" || info === null) return {};
2472
+ const render = info.render;
2473
+ if (typeof render !== "object" || render === null) return {};
2474
+ const drawCalls = render.drawCalls;
2475
+ const calls = drawCalls ?? render.calls;
2476
+ const triangles = render.triangles;
2477
+ return {
2478
+ ...typeof calls === "number" && Number.isFinite(calls) && calls >= 0 ? { drawCalls: calls } : {},
2479
+ ...typeof triangles === "number" && Number.isFinite(triangles) && triangles >= 0 ? { triangles } : {}
2480
+ };
2481
+ }
2482
+ function addRenderPerformanceMetrics(world, overlay) {
2483
+ return {
2484
+ ...world.drawCalls === void 0 || overlay.drawCalls === void 0 ? {} : { drawCalls: world.drawCalls + overlay.drawCalls },
2485
+ ...world.triangles === void 0 || overlay.triangles === void 0 ? {} : { triangles: world.triangles + overlay.triangles }
2486
+ };
2487
+ }
972
2488
  function createCamera(config) {
973
2489
  if (config === void 0) return new PerspectiveCamera(60, 1, 0.1, 2e3);
974
2490
  validateCameraConfig(config);
@@ -990,10 +2506,12 @@ var GameImpl = class {
990
2506
  #state;
991
2507
  #initialState;
992
2508
  #loop;
2509
+ #collapse;
993
2510
  #cleanup = [];
994
2511
  #particles = /* @__PURE__ */ new Set();
995
2512
  #entities;
996
2513
  #random;
2514
+ #picker;
997
2515
  #scheduler;
998
2516
  #activePlugins = [];
999
2517
  #disposedPlugins = /* @__PURE__ */ new Set();
@@ -1043,6 +2561,7 @@ var GameImpl = class {
1043
2561
  for (const plugin of this.#config.plugins ?? []) {
1044
2562
  if (typeof plugin !== "function") plugin.sceneExit?.(ctx);
1045
2563
  }
2564
+ this.#collapse?.restore();
1046
2565
  clearScene(ctx.scene, this.#particles);
1047
2566
  const scene = new SceneType();
1048
2567
  this.#scene = scene;
@@ -1102,15 +2621,26 @@ var GameImpl = class {
1102
2621
  const inputTarget = this.#config.inputTarget ?? (platform === void 0 ? typeof window === "undefined" ? canvas : window : platform.inputTarget ?? canvas);
1103
2622
  this.#input = new InputMap(this.#config.input, inputTarget, canvas, platform?.input);
1104
2623
  this.#state.start();
1105
- const threeScene = new Scene$1();
2624
+ const threeScene = new Scene();
1106
2625
  const camera = createCamera(this.#config.camera);
1107
2626
  const viewport = new Viewport({ camera, renderer: this.#renderer, source: platform?.viewport });
2627
+ const canvasLayer = new CanvasLayer(viewport);
1108
2628
  this.#viewport = viewport;
1109
2629
  const assets = createAssetLoader(this.#config.assets);
1110
2630
  const entities = new Registry();
1111
2631
  const random = createRandom(this.#config.seed);
1112
2632
  const scheduler = new Scheduler();
2633
+ const input = this.#input;
2634
+ const picker = new ScenePicker({
2635
+ camera,
2636
+ pointer: () => input.raw.pointer.position,
2637
+ scene: threeScene,
2638
+ viewport
2639
+ });
2640
+ this.#picker = picker;
1113
2641
  const loopState = {};
2642
+ const collapse = new SceneCollapse(threeScene);
2643
+ this.#collapse = collapse;
1114
2644
  const ctx = {
1115
2645
  add: (object) => {
1116
2646
  threeScene.add(object);
@@ -1126,6 +2656,7 @@ var GameImpl = class {
1126
2656
  assets,
1127
2657
  after: (delay, callback) => scheduler.after(delay, callback),
1128
2658
  camera,
2659
+ canvasLayer,
1129
2660
  entities,
1130
2661
  every: (callback) => scheduler.every(callback),
1131
2662
  get fps() {
@@ -1135,6 +2666,17 @@ var GameImpl = class {
1135
2666
  input: this.#input,
1136
2667
  physics: void 0,
1137
2668
  random,
2669
+ raycast: (options) => picker.raycast(options),
2670
+ startup: {
2671
+ get phase() {
2672
+ if (collapse.settled) return "ready";
2673
+ return collapse.progress >= 1 ? "collapsing" : "observing";
2674
+ },
2675
+ get progress() {
2676
+ return collapse.progress;
2677
+ },
2678
+ whenReady: () => collapse.whenSettled()
2679
+ },
1138
2680
  renderer: this.#renderer,
1139
2681
  viewport,
1140
2682
  scene: threeScene,
@@ -1159,8 +2701,19 @@ var GameImpl = class {
1159
2701
  particle.process(this.#renderer);
1160
2702
  }
1161
2703
  }
1162
- renderer.render(threeScene, camera);
1163
- this.#scene?.render(ctx);
2704
+ this.#collapse?.frame();
2705
+ let worldMetrics;
2706
+ if (!canvasLayer.opaque) {
2707
+ renderer.render(threeScene, camera);
2708
+ this.#scene?.render(ctx);
2709
+ worldMetrics = rendererPerformanceMetrics(renderer.raw);
2710
+ }
2711
+ if (canvasLayer.scene.children.length > 0) {
2712
+ renderer.renderOverlay(canvasLayer.scene, canvasLayer.camera);
2713
+ const overlayMetrics = rendererPerformanceMetrics(renderer.raw);
2714
+ return worldMetrics === void 0 ? overlayMetrics : addRenderPerformanceMetrics(worldMetrics, overlayMetrics);
2715
+ }
2716
+ return worldMetrics ?? {};
1164
2717
  },
1165
2718
  onUpdate: (dt) => {
1166
2719
  if (this.#paused) return;
@@ -1173,17 +2726,23 @@ var GameImpl = class {
1173
2726
  else scene2?.update(ctx, dt);
1174
2727
  if (this.#scene !== scene2 || this.#sceneFrame !== frame) return;
1175
2728
  for (const plugin of this.#activePlugins) plugin.update?.(ctx, dt);
2729
+ this.#entities?.sweep();
1176
2730
  },
1177
2731
  step: this.#config.step
1178
2732
  });
1179
2733
  loopState.current = gameLoop;
1180
2734
  this.#loop = gameLoop;
2735
+ const startGates = [];
1181
2736
  const runtime = {
1182
2737
  fixedStep: (ticks) => gameLoop.advance(ticks),
2738
+ holdStart: (gate) => startGates.push(gate),
2739
+ observations: createRuntimeObservations(),
2740
+ tick: gameLoop.tick,
1183
2741
  random,
1184
2742
  rapier: null,
1185
2743
  seed: this.#config.seed ?? null,
1186
- step: gameLoop.step
2744
+ step: gameLoop.step,
2745
+ runtimeDiagnosticsSeries: () => gameLoop.runtimeDiagnosticsSeries()
1187
2746
  };
1188
2747
  for (const plugin of this.#config.plugins ?? []) {
1189
2748
  const cleanup = typeof plugin === "function" ? plugin(ctx) : await plugin.setup?.(ctx, runtime);
@@ -1210,6 +2769,18 @@ var GameImpl = class {
1210
2769
  this.#teardown(ctx);
1211
2770
  return;
1212
2771
  }
2772
+ if (startGates.length > 0) {
2773
+ try {
2774
+ await Promise.all(startGates);
2775
+ } catch (error) {
2776
+ this.#teardown(ctx);
2777
+ throw error;
2778
+ }
2779
+ if (this.#aborted) {
2780
+ this.#teardown(ctx);
2781
+ return;
2782
+ }
2783
+ }
1213
2784
  this.#started = true;
1214
2785
  gameLoop.start();
1215
2786
  }
@@ -1244,6 +2815,7 @@ var GameImpl = class {
1244
2815
  if (ctx !== void 0) clearScene(ctx.scene, this.#particles);
1245
2816
  this.#input?.dispose();
1246
2817
  this.#state.stop();
2818
+ ctx?.canvasLayer.dispose();
1247
2819
  this.#viewport?.dispose();
1248
2820
  const renderer = this.#renderer;
1249
2821
  renderer?.dispose();
@@ -1258,15 +2830,126 @@ var GameImpl = class {
1258
2830
  this.#ctx = void 0;
1259
2831
  this.#loop = void 0;
1260
2832
  this.#random = void 0;
2833
+ this.#picker?.dispose();
2834
+ this.#picker = void 0;
1261
2835
  this.#scheduler = void 0;
1262
2836
  this.#disposedPlugins.clear();
1263
2837
  this.#paused = false;
1264
2838
  this.#started = false;
2839
+ if ((ctx?.scene.children.length ?? 0) > 0)
2840
+ throw new Error("IGame teardown leaked scene objects.");
1265
2841
  }
1266
2842
  };
2843
+ function createRuntimeObservations() {
2844
+ const contributions = /* @__PURE__ */ new Set();
2845
+ return {
2846
+ contribute: (contribution) => {
2847
+ contributions.add(contribution);
2848
+ return () => contributions.delete(contribution);
2849
+ },
2850
+ contributions: () => [...contributions]
2851
+ };
2852
+ }
1267
2853
  function defineGame(config) {
1268
2854
  return new GameImpl(config);
1269
2855
  }
2856
+ var CURVE_DIVISIONS = 128;
2857
+ var PathFollow3D = class {
2858
+ curve;
2859
+ loop;
2860
+ totalLength;
2861
+ #samples;
2862
+ #progress = 0;
2863
+ #speed;
2864
+ constructor(options) {
2865
+ if (options.points.length < 3) throw new Error("PathFollow3D requires at least three points.");
2866
+ if (options.speed !== void 0 && (!Number.isFinite(options.speed) || options.speed < 0))
2867
+ throw new Error("PathFollow3D speed must be a finite non-negative number.");
2868
+ const points = options.points.map((point) => {
2869
+ if (![point.x, point.y, point.z].every(Number.isFinite))
2870
+ throw new Error("PathFollow3D points must be finite.");
2871
+ return point.clone();
2872
+ });
2873
+ this.loop = options.loop ?? false;
2874
+ this.#speed = options.speed ?? 0;
2875
+ this.curve = new CatmullRomCurve3(points, this.loop, "centripetal", 0.5);
2876
+ this.totalLength = this.curve.getLengths(CURVE_DIVISIONS).at(-1) ?? 0;
2877
+ if (!(this.totalLength > 0)) throw new Error("PathFollow3D requires a positive-length path.");
2878
+ const spaced = this.curve.getSpacedPoints(CURVE_DIVISIONS);
2879
+ this.#samples = this.loop ? spaced.slice(0, CURVE_DIVISIONS) : spaced;
2880
+ }
2881
+ get completed() {
2882
+ return !this.loop && this.#progress >= this.totalLength;
2883
+ }
2884
+ get progress() {
2885
+ return this.#progress;
2886
+ }
2887
+ get speed() {
2888
+ return this.#speed;
2889
+ }
2890
+ set speed(value) {
2891
+ if (!Number.isFinite(value) || value < 0)
2892
+ throw new Error("PathFollow3D speed must be a finite non-negative number.");
2893
+ this.#speed = value;
2894
+ }
2895
+ advance(dt) {
2896
+ if (!Number.isFinite(dt) || dt < 0)
2897
+ throw new Error("PathFollow3D.advance requires a finite non-negative delta.");
2898
+ this.progressTo(this.#progress + this.#speed * dt);
2899
+ return this.sample();
2900
+ }
2901
+ progressTo(distance) {
2902
+ if (!Number.isFinite(distance) || distance < 0)
2903
+ throw new Error("PathFollow3D progress must be a finite non-negative number.");
2904
+ this.#progress = this.loop ? distance % this.totalLength : Math.min(distance, this.totalLength);
2905
+ return this;
2906
+ }
2907
+ sample(distance = this.#progress) {
2908
+ if (!Number.isFinite(distance) || distance < 0)
2909
+ throw new Error("PathFollow3D sample distance must be a finite non-negative number.");
2910
+ const progress = this.loop ? distance % this.totalLength : Math.min(distance, this.totalLength);
2911
+ const u = progress / this.totalLength;
2912
+ return {
2913
+ point: this.curve.getPointAt(u),
2914
+ progress,
2915
+ tangent: this.curve.getTangentAt(u).normalize()
2916
+ };
2917
+ }
2918
+ pointAt(distance) {
2919
+ return this.sample(distance);
2920
+ }
2921
+ project(position) {
2922
+ if (![position.x, position.y, position.z].every(Number.isFinite))
2923
+ throw new Error("PathFollow3D projection position must be finite.");
2924
+ let nearest = Number.POSITIVE_INFINITY;
2925
+ let nearestIndex = 0;
2926
+ for (const [index, sample] of this.#samples.entries()) {
2927
+ const distance = sample.distanceToSquared(position);
2928
+ if (distance < nearest) {
2929
+ nearest = distance;
2930
+ nearestIndex = index;
2931
+ }
2932
+ }
2933
+ const last = this.#samples.length - 1;
2934
+ const segmentCount = this.loop ? this.#samples.length : last;
2935
+ const segment = Math.min(nearestIndex, Math.max(0, segmentCount - 1));
2936
+ const point = this.#samples[nearestIndex];
2937
+ if (point === void 0) throw new Error("PathFollow3D projection sample is missing.");
2938
+ const tangentStart = this.#samples[segment];
2939
+ if (tangentStart === void 0) throw new Error("PathFollow3D projection tangent is missing.");
2940
+ const nextIndex = this.loop ? (segment + 1) % this.#samples.length : Math.min(segment + 1, last);
2941
+ const next = this.#samples[nextIndex];
2942
+ if (next === void 0) throw new Error("PathFollow3D projection tangent is missing.");
2943
+ const distanceFromSample = nearestIndex / segmentCount * this.totalLength;
2944
+ return {
2945
+ distanceFromStart: distanceFromSample,
2946
+ lateralDistance: point.distanceTo(position),
2947
+ point: point.clone(),
2948
+ segment,
2949
+ tangent: next.clone().sub(tangentStart).normalize()
2950
+ };
2951
+ }
2952
+ };
1270
2953
 
1271
2954
  // ../playtest/dist/index.js
1272
2955
  var PLAYTEST_CAPABILITY_REGISTRY = [
@@ -1285,10 +2968,11 @@ var PLAYTEST_CAPABILITY_REGISTRY = [
1285
2968
  capability("runtime.audio", "Samples application-owned audio runtime state."),
1286
2969
  capability("runtime.components", "Samples registered JSON-safe component state."),
1287
2970
  capability("runtime.contacts", "Samples bounded collision and trigger contacts."),
1288
- capability("runtime.diagnostics", "Samples application-owned runtime diagnostics."),
2971
+ capability("runtime.diagnostics", "Captures application runtime errors."),
1289
2972
  capability("runtime.events", "Drains bounded application event observations."),
1290
2973
  capability("runtime.fixedStep", "Advances an application-owned deterministic tick."),
1291
2974
  capability("runtime.physics", "Samples bounded application-owned physics observations."),
2975
+ capability("runtime.performance", "Samples bounded per-render frame cost and renderer counts."),
1292
2976
  capability("runtime.resources", "Reads and writes registered JSON-safe application state."),
1293
2977
  capability("runtime.state", "Samples application-owned state-machine state."),
1294
2978
  capability("runtime.tags", "Samples bounded application-owned entity tags."),
@@ -1320,14 +3004,14 @@ function parseReplayRecording(value) {
1320
3004
  fail("bad header");
1321
3005
  const ticks = root.ticks;
1322
3006
  if (!Number.isInteger(ticks) || ticks < 1) fail("ticks must be positive");
1323
- const input2 = root.input;
1324
- if (!Array.isArray(input2) || input2.length === 0) fail("empty input", "TN_REPLAY_EMPTY");
3007
+ const input = root.input;
3008
+ if (!Array.isArray(input) || input.length === 0) fail("empty input", "TN_REPLAY_EMPTY");
1325
3009
  const runtime = checked(root.runtime, ["agent", "core", "rapier", "step"]);
1326
3010
  const step = runtime.step;
1327
3011
  if (typeof runtime.agent !== "string" || !runtime.agent || typeof runtime.core !== "string" || !runtime.core || runtime.rapier !== null && typeof runtime.rapier !== "string" || !Number.isFinite(step) || step <= 0)
1328
3012
  fail("runtime fingerprint is invalid");
1329
3013
  let previousTick = -1;
1330
- for (const rawSample of input2) {
3014
+ for (const rawSample of input) {
1331
3015
  const sample = checked(rawSample, ["keys", "pointer", "tick"]);
1332
3016
  const tick = sample.tick;
1333
3017
  if (!Number.isInteger(tick) || tick <= previousTick || tick >= ticks) fail("bad tick");
@@ -1493,7 +3177,7 @@ function createReplayDriver(recording, target, pointerTarget = target) {
1493
3177
  }
1494
3178
 
1495
3179
  // src/scene.ts
1496
- var Scene = class {
3180
+ var Scene2 = class {
1497
3181
  static initialState = void 0;
1498
3182
  load(_ctx) {
1499
3183
  }
@@ -1511,4 +3195,4 @@ var Scene = class {
1511
3195
  // src/index.ts
1512
3196
  var version = "0.1.0";
1513
3197
 
1514
- export { AnimationPlayer, AudioBus, FixedStepLoop, GPUParticles3D, InputMap, Registry, Scene, Scheduler, Viewport, autoFields, createAssetLoader, createGameStore, createRandom, createRenderer, createReplayDriver, defineGame, input, replay, version };
3198
+ export { AnimationPlayer, AudioBus, CanvasLayer, GPUParticles3D, PathFollow3D, Scene2 as Scene, ScenePicker, Scheduler, createRandom, createReplayDriver, defineGame, replay, version };