@aardworx/wombat.rendering 0.20.2 → 0.21.1

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.
Files changed (33) hide show
  1. package/dist/runtime/heapAdapter.d.ts +1 -1
  2. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  3. package/dist/runtime/heapAdapter.js +13 -0
  4. package/dist/runtime/heapAdapter.js.map +1 -1
  5. package/dist/runtime/heapDecoder.d.ts +4 -0
  6. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  7. package/dist/runtime/heapDecoder.js +44 -3
  8. package/dist/runtime/heapDecoder.js.map +1 -1
  9. package/dist/runtime/heapEffect.d.ts +1 -1
  10. package/dist/runtime/heapEffect.js +1 -1
  11. package/dist/runtime/heapEffect.js.map +1 -1
  12. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  13. package/dist/runtime/heapEffectIR.js +172 -11
  14. package/dist/runtime/heapEffectIR.js.map +1 -1
  15. package/dist/runtime/heapScene.d.ts +1 -1
  16. package/dist/runtime/heapScene.d.ts.map +1 -1
  17. package/dist/runtime/heapScene.js +33 -19
  18. package/dist/runtime/heapScene.js.map +1 -1
  19. package/dist/runtime/hybridScene.d.ts.map +1 -1
  20. package/dist/runtime/hybridScene.js +11 -2
  21. package/dist/runtime/hybridScene.js.map +1 -1
  22. package/dist/runtime/textureAtlas/atlasPool.d.ts +6 -2
  23. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  24. package/dist/runtime/textureAtlas/atlasPool.js +36 -1
  25. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/runtime/heapAdapter.ts +18 -1
  28. package/src/runtime/heapDecoder.ts +45 -3
  29. package/src/runtime/heapEffect.ts +1 -1
  30. package/src/runtime/heapEffectIR.ts +180 -11
  31. package/src/runtime/heapScene.ts +15 -2
  32. package/src/runtime/hybridScene.ts +10 -2
  33. package/src/runtime/textureAtlas/atlasPool.ts +39 -5
@@ -22,7 +22,7 @@ import { isInlineHeaderField } from "./heapEffect.js";
22
22
  import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
23
23
  import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns, uniformsToInputs, resolveHoles } from "@aardworx/wombat.shader/passes";
24
24
  import type { HoleValue } from "@aardworx/wombat.shader/passes";
25
- import { synthesizeHeapDecoderModule } from "./heapDecoder.js";
25
+ import { synthesizeHeapDecoderModule, HEAP_FS_DRAWIDX, HEAP_FS_INSTID } from "./heapDecoder.js";
26
26
  import {
27
27
  type Module, type Expr, type Stmt, type Type, type ValueDef,
28
28
  type EntryDef, type EntryParameter, type ParamDecoration,
@@ -33,6 +33,7 @@ import {
33
33
  generateAtlasBindings, generateAtlasSwitch, generateAtlasPrelude,
34
34
  HEAP_PERSIST_VERSION, persistKey, lsLoad, lsStore,
35
35
  type BucketLayout,
36
+ type DrawHeaderField,
36
37
  } from "./heapEffect.js";
37
38
  import type { IntrinsicRef } from "@aardworx/wombat.shader/ir";
38
39
 
@@ -348,6 +349,23 @@ function addInterstageParams(
348
349
  return { ...m, values };
349
350
  }
350
351
 
352
+ /**
353
+ * Declare extra FRAGMENT inputs, leaving the vertex side alone. The decoder
354
+ * path already writes its carrier on the VS; the FS just needs the matching
355
+ * declaration to read it.
356
+ */
357
+ function addFsInputs(m: Module, extras: readonly EntryParameter[]): Module {
358
+ if (extras.length === 0) return m;
359
+ const values = m.values.map((v): typeof v => {
360
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") return v;
361
+ const have = new Set(v.entry.inputs.map(p => p.name));
362
+ const add = extras.filter(p => !have.has(p.name));
363
+ if (add.length === 0) return v;
364
+ return { ...v, entry: { ...v.entry, inputs: [...v.entry.inputs, ...add] } };
365
+ });
366
+ return { ...m, values };
367
+ }
368
+
351
369
  /**
352
370
  * Prepend `stmts` to every VS entry's body. Flatten when the body is
353
371
  * already a `Sequential` — CSE numbers `_cse0..N` per Sequential, so
@@ -407,7 +425,15 @@ function wgslTypeFromHeapField(wgslType: string): Type {
407
425
  * emit `applyFamilyMemberFsShape` rewires the FS entry signature to
408
426
  * surface them as plain u32 fn parameters.
409
427
  */
410
- function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
428
+ function rewriteFsUniformsDirect(
429
+ m: Module,
430
+ layout: BucketLayout,
431
+ // Where the FS gets the per-draw header index from. `family-member` receives
432
+ // it as a plain u32 fn parameter (a Var); `standalone` reads it from the one
433
+ // flat varying it threads. Everything else about the direct load is identical.
434
+ drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr,
435
+ instIdExpr: Expr = { kind: "Var", var: { name: "instId", type: Tu32, mutable: false } } as Expr,
436
+ ): Module {
411
437
  const used = new Set<string>();
412
438
  for (const v of m.values) {
413
439
  if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
@@ -418,8 +444,6 @@ function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
418
444
  if (used.size === 0) return m;
419
445
 
420
446
  const fieldByName = new Map(layout.drawHeaderFields.map(f => [f.name, f]));
421
- const drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
422
- const instIdExpr: Expr = { kind: "Var", var: { name: "instId", type: Tu32, mutable: false } } as Expr;
423
447
  const fsSubst = new Map<string, Expr>();
424
448
  for (const name of used) {
425
449
  const f = fieldByName.get(name);
@@ -445,7 +469,11 @@ function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
445
469
  * `heap_drawIdx`. The wrapper-supplied `heap_drawIdx` u32 fn parameter
446
470
  * carries the per-draw header index.
447
471
  */
448
- function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
472
+ function rewriteFsAtlasTexturesDirect(
473
+ m: Module,
474
+ layout: BucketLayout,
475
+ drawIdxExprIn: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr,
476
+ ): Module {
449
477
  if (layout.atlasTextureBindings.size === 0) return m;
450
478
  const used = new Set<string>();
451
479
  for (const v of m.values) {
@@ -466,7 +494,7 @@ function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
466
494
  inner.set(sub, byteOffsetOf(f.byteOffset));
467
495
  }
468
496
 
469
- const drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
497
+ const drawIdxExpr: Expr = drawIdxExprIn;
470
498
  const stride = layout.strideU32;
471
499
  const headerU32At = (offU32: number): Expr =>
472
500
  item(headersU32, add(mul(drawIdxExpr, constU32(stride), Tu32), constU32(offU32), Tu32), Tu32);
@@ -499,6 +527,78 @@ function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
499
527
  }));
500
528
  }
501
529
 
530
+ /**
531
+ * STANDALONE FS heap access — one varying, not N.
532
+ *
533
+ * The old standalone path threaded a VS→FS varying per FS-used uniform AND
534
+ * FOUR per atlas texture (`_h_<t>{PageRef,FormatBits,Origin,Size}`). WebGPU
535
+ * caps inter-stage variables at 16 locations, so a shader with a handful of
536
+ * per-draw uniforms and a texture ran into the ceiling — and blowing it does
537
+ * not fail loudly: the pipeline is created INVALID, which poisons the whole
538
+ * command buffer, so the entire pass silently disappears. It made the varying
539
+ * budget a design constraint on every shader written against the heap.
540
+ *
541
+ * `family-member` never had this problem: it loads uniforms and atlas headers
542
+ * straight out of the heap arena in the FS, keyed by the draw index. Standalone
543
+ * now does the same — it just has to carry the draw index itself, so it threads
544
+ * exactly ONE flat u32 varying (`_h_drawIdx`), plus `_iidx` when a per-instance
545
+ * uniform is actually read from the FS. Varying count is now O(1) in the number
546
+ * of uniforms and textures.
547
+ *
548
+ * The trade is per-fragment storage reads instead of per-vertex ones. They are
549
+ * scalar, flat, and uniform across a draw (so they hit cache), and this is the
550
+ * path `family-member` has been running in production all along.
551
+ */
552
+ function rewriteFsHeapDirectStandalone(m: Module, layout: BucketLayout): Module {
553
+ // What does the FS actually touch?
554
+ const usedUniforms = new Set<string>();
555
+ const usedAtlas = new Set<string>();
556
+ for (const v of m.values) {
557
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
558
+ for (const sn of readInputs(v.entry.body).values()) {
559
+ if (sn.scope === "Uniform") usedUniforms.add(sn.name);
560
+ }
561
+ visitStmtExprs(v.entry.body, e => {
562
+ const hit = isAtlasSampleCall(e, layout.atlasTextureBindings);
563
+ if (hit !== null) usedAtlas.add(hit.name);
564
+ });
565
+ }
566
+ if (usedUniforms.size === 0 && usedAtlas.size === 0) return m;
567
+
568
+ // Thread the carrier(s). `_iidx` only when a per-instance uniform is read —
569
+ // otherwise it is a varying nobody needs.
570
+ const needsIidx = [...usedUniforms].some(n => layout.perInstanceUniforms.has(n));
571
+ const threadParams: EntryParameter[] = [{
572
+ name: "_h_drawIdx", type: Tu32, semantic: "_h_drawIdx",
573
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
574
+ }];
575
+ const vsWrites: Stmt[] = [{
576
+ kind: "WriteOutput", name: "_h_drawIdx",
577
+ value: { kind: "Expr", value: drawIdxExprFor(layout) },
578
+ }];
579
+ if (needsIidx) {
580
+ threadParams.push({
581
+ name: "_iidx", type: Tu32, semantic: "_iidx",
582
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
583
+ });
584
+ vsWrites.push({
585
+ kind: "WriteOutput", name: "_iidx",
586
+ value: { kind: "Expr", value: readScope("Builtin", "instance_index", Tu32) },
587
+ });
588
+ }
589
+
590
+ const fsDrawIdx = readScope("Input", "_h_drawIdx", Tu32);
591
+ const fsInstId = readScope("Input", "_iidx", Tu32);
592
+
593
+ let out = m;
594
+ out = addInterstageParams(out, threadParams);
595
+ out = prependVsBodyStmts(out, vsWrites);
596
+ // Same loaders the family path uses — only the source of the draw index differs.
597
+ out = rewriteFsAtlasTexturesDirect(out, layout, fsDrawIdx);
598
+ out = rewriteFsUniformsDirect(out, layout, fsDrawIdx, fsInstId);
599
+ return out;
600
+ }
601
+
502
602
  /**
503
603
  * FS uniform threading: thread `_h_<name>Ref` (and `_iidx`, when
504
604
  * needed) varyings from VS to FS, and rewrite FS `ReadInput("Uniform",
@@ -900,8 +1000,11 @@ export function compileHeapEffectIR(
900
1000
  combined = rewriteFsAtlasTexturesDirect(combined, layout);
901
1001
  combined = rewriteFsUniformsDirect(combined, layout);
902
1002
  } else {
903
- combined = rewriteFsAtlasTextures(combined, layout);
904
- combined = rewriteFsUniforms(combined, layout);
1003
+ // Standalone reads the heap directly too — it just carries the draw index
1004
+ // in ONE flat varying instead of receiving it as a parameter. (The old
1005
+ // per-uniform / 4-per-texture varying threading is gone: it hit WebGPU's
1006
+ // 16-location inter-stage cap and silently invalidated whole pipelines.)
1007
+ combined = rewriteFsHeapDirectStandalone(combined, layout);
905
1008
  }
906
1009
  // Add heap storage buffers as bindings.
907
1010
  combined = { ...combined, values: [...heapStorageBufferDecls(), ...combined.values] };
@@ -974,6 +1077,67 @@ export function compileHeapEffectIR(
974
1077
  * No post-emit string mangling — `applyMegacallToEmittedVs` /
975
1078
  * `applyFamilyMemberShape` are gone for this path.
976
1079
  */
1080
+ /**
1081
+ * FS heap access for the DECODER path: read per-draw uniforms and atlas-texture
1082
+ * headers directly out of the arena, keyed by the draw index the decoder put on
1083
+ * the carrier (`_h_drawIdx`), instead of reading values the decoder pushed
1084
+ * across as one varying each.
1085
+ *
1086
+ * Only `uniform-ref` fields are substituted. `attribute-ref` fields are
1087
+ * per-VERTEX data — they must keep flowing through the carrier, and they were
1088
+ * renamed into `Input` scope by `uniformsToInputs` just like the uniforms, so
1089
+ * the substitution has to be name-driven, not scope-driven.
1090
+ */
1091
+ function rewriteFsHeapDirectViaCarrier(m: Module, layout: BucketLayout): Module {
1092
+ const fsDrawIdx = readScope("Input", HEAP_FS_DRAWIDX, Tu32);
1093
+ const fsInstId = readScope("Input", HEAP_FS_INSTID, Tu32);
1094
+
1095
+ // Declare the carrier(s) as FRAGMENT INPUTS. The decoder already writes them
1096
+ // on the vertex side; without the matching input declaration the emitter has
1097
+ // no struct member to resolve the read against (it reaches for the output
1098
+ // struct and the module fails to parse).
1099
+ const fsInputs: EntryParameter[] = [{
1100
+ name: HEAP_FS_DRAWIDX, type: Tu32, semantic: HEAP_FS_DRAWIDX,
1101
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
1102
+ }];
1103
+ if (layout.perInstanceUniforms.size > 0) {
1104
+ fsInputs.push({
1105
+ name: HEAP_FS_INSTID, type: Tu32, semantic: HEAP_FS_INSTID,
1106
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
1107
+ });
1108
+ }
1109
+ let out = addFsInputs(m, fsInputs);
1110
+
1111
+ // Atlas samples first (they read header sub-fields, not uniform values).
1112
+ out = rewriteFsAtlasTexturesDirect(out, layout, fsDrawIdx);
1113
+
1114
+ // Which per-draw uniforms does the FS still read?
1115
+ const uniformFields = new Map<string, DrawHeaderField>();
1116
+ for (const f of layout.drawHeaderFields) {
1117
+ if (f.kind === "uniform-ref") uniformFields.set(f.name, f);
1118
+ }
1119
+ const used = new Set<string>();
1120
+ for (const v of out.values) {
1121
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
1122
+ for (const sn of readInputs(v.entry.body).values()) {
1123
+ if (sn.scope === "Input" && uniformFields.has(sn.name)) used.add(sn.name);
1124
+ }
1125
+ }
1126
+ if (used.size === 0) return out;
1127
+
1128
+ const subst = new Map<string, Expr>();
1129
+ for (const name of used) {
1130
+ const f = uniformFields.get(name)!;
1131
+ const refExpr = loadHeaderRef(fsDrawIdx, f.byteOffset, layout.strideU32);
1132
+ subst.set(name, isInlineHeaderField(f)
1133
+ ? refExpr
1134
+ : layout.perInstanceUniforms.has(name)
1135
+ ? loadInstanceByRef(refExpr, fsInstId, f.uniformWgslType ?? "")
1136
+ : loadUniformByRef(refExpr, f.uniformWgslType ?? ""));
1137
+ }
1138
+ return substituteInputsInStage(out, "fragment", "Input", n => subst.get(n));
1139
+ }
1140
+
977
1141
  function compileHeapEffectIRViaDecoder(
978
1142
  userEffect: Effect,
979
1143
  layout: BucketLayout,
@@ -1020,9 +1184,14 @@ function compileHeapEffectIRViaDecoder(
1020
1184
  combined = liftReturns(combined);
1021
1185
  combined = injectVsBuiltins(combined);
1022
1186
 
1023
- // 6. FS-side atlas rewrite (texture-sample atlas-sample, reading
1024
- // from the carrier varyings the decoder wrote).
1025
- combined = rewriteFsAtlasTexturesViaCarrier(combined, layout);
1187
+ // 6. FS-side heap access. The decoder no longer ships per-draw VALUES across
1188
+ // the stage boundary (that cost one varying per uniform and four per texture,
1189
+ // against WebGPU's 16-location cap — see heapDecoder). It ships the draw
1190
+ // INDEX, and the FS loads what it needs straight from the arena. Whatever the
1191
+ // fragment stage stops reading, `pruneCrossStage` then drops from the carrier
1192
+ // by itself, so the varying count collapses to what genuinely varies per
1193
+ // vertex.
1194
+ combined = rewriteFsHeapDirectViaCarrier(combined, layout);
1026
1195
 
1027
1196
  // 6b. USER storage buffers (the OIT node pool & co): pin each to the
1028
1197
  // slot the bucket's BGL committed to. `keep: true` makes the emitter
@@ -790,7 +790,7 @@ export type HeapTextureSet =
790
790
  * placement for `sourceAval` without holding a direct pool
791
791
  * reference.
792
792
  */
793
- readonly repack?: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition;
793
+ readonly repack?: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition | null;
794
794
  /**
795
795
  * Host source captured at adapter time, used by the heap-side
796
796
  * re-acquire path when the pool entry was evicted while this spec
@@ -1397,7 +1397,7 @@ export function buildHeapScene(
1397
1397
  interface AtlasAvalRef {
1398
1398
  readonly bucket: Bucket;
1399
1399
  readonly localSlot: number;
1400
- readonly repack: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition;
1400
+ readonly repack: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition | null;
1401
1401
  readonly sampler: ISampler;
1402
1402
  }
1403
1403
  // Content-keyed (`HashTable`, not a JS `Map`) so distinct
@@ -3684,6 +3684,15 @@ export function buildHeapScene(
3684
3684
  ...(host !== undefined ? { source: { width: reW, height: reH, host } } : {}),
3685
3685
  },
3686
3686
  );
3687
+ // ATLAS FULL (acq === null). Every page is packed with textures still
3688
+ // in use, so this one cannot be re-placed right now. Keep the spec's
3689
+ // existing sub-rect and retry on a later cycle, once a released tile
3690
+ // has freed space: the draw shows a stale texture for a frame or two
3691
+ // instead of the correct one. Previously `acquire` THREW here, the
3692
+ // exception escaped into the render loop's frame callback, and the
3693
+ // whole viewer stopped rendering permanently — taking the tile cut
3694
+ // with it, since the worker's camera is driven from `OnRendered`.
3695
+ if (acq !== null) {
3687
3696
  (spec.textures as { pageId: number }).pageId = acq.pageId;
3688
3697
  (spec.textures as { origin: V2f }).origin = acq.origin;
3689
3698
  (spec.textures as { size: V2f }).size = acq.size;
@@ -3703,6 +3712,7 @@ export function buildHeapScene(
3703
3712
  const retarget = (spec.textures as unknown as { __retarget?: (ref: number) => void }).__retarget;
3704
3713
  if (retarget !== undefined) retarget(acq.ref);
3705
3714
  packAtlasTextureFields(bucket, localSlot, spec.textures);
3715
+ }
3706
3716
  }
3707
3717
  }
3708
3718
  bucket.localAtlasReleases[localSlot] = spec.textures.release;
@@ -4506,6 +4516,9 @@ export function buildHeapScene(
4506
4516
  if (refs === undefined || refs.length === 0) continue;
4507
4517
  const newTex = av.getValue(tok);
4508
4518
  const acq = refs[0]!.repack(newTex);
4519
+ // Atlas full → the texture keeps its current sub-rect this cycle; the
4520
+ // draw shows the previous pixels rather than the pass dying.
4521
+ if (acq === null) continue;
4509
4522
  for (const r of refs) {
4510
4523
  const newTextures: HeapTextureSet & { kind: "atlas" } = {
4511
4524
  kind: "atlas",
@@ -248,12 +248,20 @@ export function compileHybridScene(
248
248
  // WeakMap keyed by RO ensures the same RO always maps to the same
249
249
  // spec instance, even if the RO migrates out and back in.
250
250
  const specCache = new WeakMap<RenderObject, HeapDrawSpec>();
251
- const heapSpecAset = heapAset.map((ro: RenderObject) => {
251
+ // A null spec means "cannot be placed right now" — today: the texture atlas is
252
+ // full. Such an RO is dropped from this cycle's heap set instead of rendering,
253
+ // and NOT cached, so the next cycle retries it (by then a released tile will
254
+ // usually have freed atlas space). It costs a missing tile for a frame or two;
255
+ // the alternative was an exception in the frame callback, which killed the
256
+ // render loop outright.
257
+ const heapSpecAset = heapAset.choose((ro: RenderObject) => {
252
258
  let spec = specCache.get(ro);
253
259
  if (spec === undefined) {
254
- spec = renderObjectToHeapSpec(ro, AdaptiveToken.top, atlasPool, {
260
+ const built = renderObjectToHeapSpec(ro, AdaptiveToken.top, atlasPool, {
255
261
  enableDerivedUniforms: opts.enableDerivedUniforms !== false,
256
262
  });
263
+ if (built === null) return undefined;
264
+ spec = built;
257
265
  specCache.set(ro, spec);
258
266
  }
259
267
  return spec;
@@ -275,6 +275,15 @@ export class AtlasPool {
275
275
  * toggle. With the LRU it fires only on first acquire.
276
276
  */
277
277
  private readonly lru = new Map<number, AtlasEntry>();
278
+ /** Formats we've already reported as full (once per format, not per frame). */
279
+ private readonly fullWarned = new Set<AtlasPageFormat>();
280
+ /** True while every page of `format` is packed with live (referenced) entries. */
281
+ isFull(format: AtlasPageFormat): boolean {
282
+ const pages = this.pagesByFormat.get(format);
283
+ if (pages === undefined || pages.length < ATLAS_MAX_PAGES_PER_FORMAT) return false;
284
+ for (const e of this.lru.values()) if (e.format === format) return false; // something is evictable
285
+ return true;
286
+ }
278
287
  private nextRef = 1;
279
288
 
280
289
  private innerKeyOf(t: ITexture): GPUTexture | HostTextureSource {
@@ -378,7 +387,9 @@ export class AtlasPool {
378
387
  width: number,
379
388
  height: number,
380
389
  opts: AtlasAcquireOptions = {},
381
- ): AtlasAcquisition {
390
+ // Returns null when the atlas is FULL (every page packed with live
391
+ // entries). Callers must handle it — see the note at the throw site.
392
+ ): AtlasAcquisition | null {
382
393
  const existing = this.entriesByAval.get(sourceAval);
383
394
  if (existing !== undefined) {
384
395
  const wasIdle = existing.refcount === 0;
@@ -485,9 +496,27 @@ export class AtlasPool {
485
496
 
486
497
  // Allocate a fresh page if we haven't hit the max.
487
498
  if (pages.length >= ATLAS_MAX_PAGES_PER_FORMAT) {
488
- throw new Error(
489
- `atlas: ATLAS_MAX_PAGES_PER_FORMAT (${ATLAS_MAX_PAGES_PER_FORMAT}) exceeded for format ${format}`,
490
- );
499
+ // The atlas is genuinely full: every page is packed with entries that are
500
+ // still referenced, so there is nothing to evict. That is resource
501
+ // pressure, NOT a program error — and throwing turned it into a fatal
502
+ // one: the exception escapes into the render loop's frame callback, which
503
+ // then dies (permanently, before `runFrame` learned to retry; and even
504
+ // with retries the condition repeats every frame until the loop gives up).
505
+ // A viewer that streams an unbounded world will hit this, and the right
506
+ // answer is to skip the texture for now, not to stop rendering.
507
+ //
508
+ // The caller decides what "skip" means (see heapScene: the draw is held
509
+ // back until space frees). We report it once per format so the condition
510
+ // is visible without spamming a frame-rate-long log.
511
+ if (!this.fullWarned.has(format)) {
512
+ this.fullWarned.add(format);
513
+ console.warn(
514
+ `atlas: FULL for ${format} — ${pages.length} pages × ${ATLAS_PAGE_SIZE}² all in use ` +
515
+ `(${this.entriesByRef.size} live entries, ${this.lru.size} evictable). ` +
516
+ `New textures are deferred until space frees.`,
517
+ );
518
+ }
519
+ return null;
491
520
  }
492
521
  const pageId = pages.length;
493
522
  const page = this.allocatePage(format, pageId);
@@ -531,7 +560,8 @@ export class AtlasPool {
531
560
  sourceAval: aval<ITexture>,
532
561
  newTexture: ITexture,
533
562
  opts: AtlasAcquireOptions = {},
534
- ): AtlasAcquisition {
563
+ // null when the atlas is full — caller keeps its existing sub-rect.
564
+ ): AtlasAcquisition | null {
535
565
  const old = this.entriesByAval.get(sourceAval);
536
566
  if (old === undefined) {
537
567
  throw new Error("AtlasPool.repack: source aval has no live entry");
@@ -587,6 +617,10 @@ export class AtlasPool {
587
617
  : {}),
588
618
  };
589
619
  const acq = this.acquire(fmt, sourceAval, desc.width, desc.height, acqOpts);
620
+ // Atlas full: the caller keeps the OLD sub-rect (already freed above is not
621
+ // possible here — repack only frees after a successful acquire), so the
622
+ // texture simply doesn't update this cycle.
623
+ if (acq === null) return null;
590
624
 
591
625
  // 3. Restore the original refcount (acquire set it to 1).
592
626
  const newEntry = this.entriesByRef.get(acq.ref);