@aardworx/wombat.rendering 0.21.23 → 0.22.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.
@@ -46,14 +46,24 @@ export const ATLAS_MAX_DIM = 1024;
46
46
  * `switch pageRef` runs an N-way ladder; the BGL declares N consecutive
47
47
  * texture slots per format. Allocations beyond this throw.
48
48
  */
49
- // The page COUNT is fixed at 8 it must match the shader's binding
50
- // ladder (heapEffect ATLAS_ARRAY_SIZE; N texture_2d slots per format;
51
- // WebGPU per-stage sampled-texture limits rule out more). Capacity
52
- // scales via PAGE SIZE instead: 4096² caps resident textures at ~512
53
- // (Google tiles 64 × 512² per page) which starved tile streaming
54
- // (deferred textures = held draws + black tiles); desktops configure
55
- // 8192² via RuntimeOptions.atlasPageSize (4× capacity, lazy alloc).
56
- export const ATLAS_MAX_PAGES_PER_FORMAT = 8;
49
+ // Pages are LAYERS of one `texture_2d_array` per format: the shader
50
+ // indexes `pageRef` at runtime, so the count is no longer pinned to a
51
+ // binding ladder. Layers grow on demand (new array texture + GPU-side
52
+ // layer copy + bind-group rebuild via onPageAdded); this cap only
53
+ // bounds the growth so a runaway streaming scene can't eat unbounded
54
+ // VRAM (16 × 4096² × rgba8 1 GB per format family, lazily reached).
55
+ // Configurable via RuntimeOptions.atlasMaxPages.
56
+ export const ATLAS_MAX_PAGES_PER_FORMAT = 16;
57
+
58
+ /** Runtime-configurable layer cap (RuntimeOptions.atlasMaxPages).
59
+ * Routed through globalThis like the page size — bundlers can
60
+ * duplicate this module across entry points. */
61
+ export function atlasMaxPagesNow(): number {
62
+ return ((globalThis as { __wombatAtlasMaxPages?: number }).__wombatAtlasMaxPages ?? ATLAS_MAX_PAGES_PER_FORMAT);
63
+ }
64
+ export function setAtlasMaxPages(n: number): void {
65
+ (globalThis as { __wombatAtlasMaxPages?: number }).__wombatAtlasMaxPages = n;
66
+ }
57
67
 
58
68
  /** Page edge in texels. Default suits small-memory devices; desktops
59
69
  * pass RuntimeOptions.atlasPageSize = 8192 (must be set BEFORE any
@@ -80,17 +90,19 @@ export const atlasFormatIndex = (f: AtlasPageFormat): number =>
80
90
  export interface AtlasPage {
81
91
  readonly format: AtlasPageFormat;
82
92
  /**
83
- * The page's own independent `GPUTexture` (dimension: "2d", single
84
- * 4096² image). Stable for the page's lifetime never reallocated.
93
+ * The format family's shared `texture_2d_array` this page is layer
94
+ * `pageId` of it. NOT stable across growth: adding pages past the
95
+ * current layer count reallocates the array (existing layers are
96
+ * GPU-copied across and `onPageAdded` fires so bind groups rebuild).
97
+ * Always read through this getter, never cache the GPUTexture.
85
98
  */
86
99
  readonly texture: GPUTexture;
87
100
  /** Immutable packer state — `tryAdd`/`remove` produce new instances. */
88
101
  packing: TexturePacking<number>;
89
102
  /**
90
- * Page slot index in the format's binding sequence (0..N-1). Same
91
- * value flows into the drawHeader `pageRef` field; the shader's
92
- * `switch pageRef` selects the matching `atlasLinear<i>` /
93
- * `atlasSrgb<i>` declaration.
103
+ * Layer index in the format's array texture (0..N-1). Same value
104
+ * flows into the drawHeader `pageRef` field; the shader indexes the
105
+ * array with it at runtime.
94
106
  */
95
107
  readonly pageId: number;
96
108
  }
@@ -251,6 +263,8 @@ function describeAtlasTexture(t: ITexture): {
251
263
  export class AtlasPool {
252
264
  private readonly pagesByFormat = new Map<AtlasPageFormat, AtlasPage[]>();
253
265
  private readonly pageAddedListeners = new Map<AtlasPageFormat, Set<(pageId: number) => void>>();
266
+ /** Per-format shared array texture (pages = layers). */
267
+ private readonly storesByFormat = new Map<AtlasPageFormat, { texture: GPUTexture; layers: number }>();
254
268
  /**
255
269
  * Keyed by `aval<ITexture>` under the aval equality protocol: a
256
270
  * `HashTable` (not a JS `Map`) so two distinct `AVal.constant(tex)`
@@ -301,7 +315,7 @@ export class AtlasPool {
301
315
  /** True while every page of `format` is packed with live (referenced) entries. */
302
316
  isFull(format: AtlasPageFormat): boolean {
303
317
  const pages = this.pagesByFormat.get(format);
304
- if (pages === undefined || pages.length < ATLAS_MAX_PAGES_PER_FORMAT) return false;
318
+ if (pages === undefined || pages.length < atlasMaxPagesNow()) return false;
305
319
  for (const e of this.lru.values()) if (e.format === format) return false; // something is evictable
306
320
  return true;
307
321
  }
@@ -351,19 +365,32 @@ export class AtlasPool {
351
365
  return typeof (this.device.queue as { onSubmittedWorkDone?: () => Promise<void> }).onSubmittedWorkDone === "function";
352
366
  }
353
367
 
354
- private allocatePage(format: AtlasPageFormat, pageId: number): AtlasPage {
368
+ /**
369
+ * Ensure the format's array texture has at least `layers` layers.
370
+ * Growth allocates a fresh array (pow2 steps up to 8, then +2, capped
371
+ * at `atlasMaxPagesNow()`), copies every existing layer across in one
372
+ * `copyTextureToTexture`, and destroys the old texture (safe right
373
+ * after submit — already-submitted work completes before destruction).
374
+ * Callers fire `onPageAdded` afterwards, which rebuilds bind groups —
375
+ * so the stale-texture window never reaches a render pass.
376
+ */
377
+ private ensureLayers(format: AtlasPageFormat, layers: number): { texture: GPUTexture; layers: number } {
378
+ const cap = atlasMaxPagesNow();
379
+ if (layers > cap) {
380
+ throw new Error(`AtlasPool: layer request ${layers} exceeds atlasMaxPages ${cap}`);
381
+ }
382
+ const store = this.storesByFormat.get(format);
383
+ if (store !== undefined && store.layers >= layers) return store;
384
+ let next = store?.layers ?? 0;
385
+ while (next < layers) next = next === 0 ? 1 : next < 8 ? next * 2 : next + 2;
386
+ next = Math.min(next, cap);
355
387
  // The compute mip+gutter kernel works for *all* page formats: it
356
- // builds the pyramid + gutters in a scratch storage *buffer* (rgba8
357
- // u32s) and finishes with `copyBufferToTexture` into the page, so
358
- // the page itself never needs to be a storage texture — srgb pages
359
- // included (the copy reinterprets the bytes; no colour conversion).
360
- // Pages therefore only need TEXTURE_BINDING | COPY_DST | COPY_SRC |
361
- // RENDER_ATTACHMENT (no STORAGE_BINDING). The CPU `buildExtended-
362
- // WithGutter` path remains only as a fallback for the node mock-GPU
363
- // device and headless-without-canvas edge cases.
388
+ // builds the pyramid + gutters in a scratch storage *buffer* and
389
+ // finishes with `copyBufferToTexture` into the layer, so pages
390
+ // never need STORAGE_BINDING — srgb included.
364
391
  const texture = this.device.createTexture({
365
- label: `atlas/${format}/${pageId}`,
366
- size: { width: atlasPageSizeNow(), height: atlasPageSizeNow(), depthOrArrayLayers: 1 },
392
+ label: `atlas/${format}[${next}]`,
393
+ size: { width: atlasPageSizeNow(), height: atlasPageSizeNow(), depthOrArrayLayers: next },
367
394
  format,
368
395
  mipLevelCount: 1,
369
396
  usage:
@@ -372,14 +399,46 @@ export class AtlasPool {
372
399
  GPUTextureUsage.COPY_SRC |
373
400
  GPUTextureUsage.RENDER_ATTACHMENT,
374
401
  });
402
+ if (store !== undefined && store.layers > 0) {
403
+ // An OPEN upload batch may hold copies targeting the old texture —
404
+ // submit it first, or those uploads land in the abandoned array
405
+ // (and its destroy below invalidates their submit).
406
+ this.flushUploadBatch();
407
+ const enc = this.device.createCommandEncoder({ label: `atlas/${format}/grow` });
408
+ enc.copyTextureToTexture(
409
+ { texture: store.texture },
410
+ { texture },
411
+ { width: atlasPageSizeNow(), height: atlasPageSizeNow(), depthOrArrayLayers: store.layers },
412
+ );
413
+ this.device.queue.submit([enc.finish()]);
414
+ const old = store.texture;
415
+ const done = (this.device.queue as { onSubmittedWorkDone?: () => Promise<void> }).onSubmittedWorkDone;
416
+ if (typeof done === "function") void done.call(this.device.queue).then(() => old.destroy());
417
+ else old.destroy(); // mock device: no pending GPU work semantics
418
+ }
419
+ const fresh = { texture, layers: next };
420
+ this.storesByFormat.set(format, fresh);
421
+ return fresh;
422
+ }
423
+
424
+ private allocatePage(format: AtlasPageFormat, pageId: number): AtlasPage {
425
+ const pool = this;
426
+ this.ensureLayers(format, pageId + 1);
375
427
  return {
376
428
  format,
377
- texture,
429
+ // getter: growth reallocates the array; pages must never cache it
430
+ get texture(): GPUTexture { return pool.storesByFormat.get(format)!.texture; },
378
431
  packing: TexturePacking.empty<number>(new V2i(atlasPageSizeNow(), atlasPageSizeNow()), false),
379
432
  pageId,
380
433
  };
381
434
  }
382
435
 
436
+ /** The format family's current array texture (undefined before the
437
+ * first page). Bind-group building reads this — pages are layers. */
438
+ textureFor(format: AtlasPageFormat): GPUTexture | undefined {
439
+ return this.storesByFormat.get(format)?.texture;
440
+ }
441
+
383
442
  /**
384
443
  * Tier-S eligibility: dimension threshold + supported format. If
385
444
  * this returns null, the caller should route the texture down the
@@ -515,8 +574,8 @@ export class AtlasPool {
515
574
  if (fit !== null) return fit;
516
575
  }
517
576
 
518
- // Allocate a fresh page if we haven't hit the max.
519
- if (pages.length >= ATLAS_MAX_PAGES_PER_FORMAT) {
577
+ // Allocate a fresh page (array layer) if we haven't hit the cap.
578
+ if (pages.length >= atlasMaxPagesNow()) {
520
579
  // The atlas is genuinely full: every page is packed with entries that are
521
580
  // still referenced, so there is nothing to evict. That is resource
522
581
  // pressure, NOT a program error — and throwing turned it into a fatal
@@ -720,11 +779,10 @@ export class AtlasPool {
720
779
  return n;
721
780
  }
722
781
 
723
- /** Destroy every page texture. The pool becomes unusable. */
782
+ /** Destroy the per-format array textures. The pool becomes unusable. */
724
783
  dispose(): void {
725
- for (const [, pages] of this.pagesByFormat) {
726
- for (const p of pages) p.texture.destroy();
727
- }
784
+ for (const [, store] of this.storesByFormat) store.texture.destroy();
785
+ this.storesByFormat.clear();
728
786
  this.pagesByFormat.clear();
729
787
  this.pageAddedListeners.clear();
730
788
  this.entriesByAval.clear();
@@ -884,6 +942,7 @@ export class AtlasPool {
884
942
  boundsX, boundsY, reservedW, reservedH,
885
943
  staging, w, h, slots,
886
944
  this.uploadBatch ?? undefined,
945
+ page.pageId,
887
946
  );
888
947
  if (this.uploadBatch !== null) this.uploadBatch.trashTextures.push(staging);
889
948
  else void this.device.queue.onSubmittedWorkDone().then(() => staging.destroy());
@@ -899,6 +958,8 @@ export class AtlasPool {
899
958
  this.device, page.texture,
900
959
  boundsX, boundsY, reservedW, reservedH,
901
960
  px, w, h, slots,
961
+ undefined,
962
+ page.pageId,
902
963
  );
903
964
  return;
904
965
  }
@@ -938,7 +999,7 @@ export class AtlasPool {
938
999
  if (mipCanvas === null) {
939
1000
  this.device.queue.copyExternalImageToTexture(
940
1001
  { source: mip as GPUImageCopyExternalImageSource },
941
- { texture: page.texture, origin: { x: mx, y: my, z: 0 } },
1002
+ { texture: page.texture, origin: { x: mx, y: my, z: page.pageId } },
942
1003
  { width: mw, height: mh, depthOrArrayLayers: 1 },
943
1004
  );
944
1005
  continue;
@@ -949,7 +1010,7 @@ export class AtlasPool {
949
1010
  ctx.drawImage(mip as CanvasImageSource, 0, 0, mw, mh);
950
1011
  const mipPx = new Uint8Array(ctx.getImageData(0, 0, mw, mh).data.buffer);
951
1012
  const ext = AtlasPool.buildExtendedWithGutter(mipPx, mw, mh);
952
- this.writeRgba8Padded(page.texture, mx - 2, my - 2, ext, mw + 4, mh + 4);
1013
+ this.writeRgba8Padded(page.texture, mx - 2, my - 2, ext, mw + 4, mh + 4, page.pageId);
953
1014
  }
954
1015
  }
955
1016
 
@@ -1044,7 +1105,7 @@ export class AtlasPool {
1044
1105
  const px = this.extractPixels(host, w, h);
1045
1106
  if (px === null) return false;
1046
1107
  const ext = AtlasPool.buildExtendedWithGutter(px, w, h);
1047
- this.writeRgba8Padded(page.texture, x - 2, y - 2, ext, w + 4, h + 4);
1108
+ this.writeRgba8Padded(page.texture, x - 2, y - 2, ext, w + 4, h + 4, page.pageId);
1048
1109
  return true;
1049
1110
  }
1050
1111
 
@@ -1059,7 +1120,7 @@ export class AtlasPool {
1059
1120
  if (host.kind === "external") {
1060
1121
  this.device.queue.copyExternalImageToTexture(
1061
1122
  { source: host.source as GPUImageCopyExternalImageSource },
1062
- { texture: page.texture, origin: { x, y, z: 0 } },
1123
+ { texture: page.texture, origin: { x, y, z: page.pageId } },
1063
1124
  { width: w, height: h, depthOrArrayLayers: 1 },
1064
1125
  );
1065
1126
  return;
@@ -1067,7 +1128,7 @@ export class AtlasPool {
1067
1128
  const data = host.data instanceof ArrayBuffer
1068
1129
  ? new Uint8Array(host.data)
1069
1130
  : new Uint8Array(host.data.buffer, host.data.byteOffset, host.data.byteLength);
1070
- this.writeRgba8Padded(page.texture, x, y, data, w, h);
1131
+ this.writeRgba8Padded(page.texture, x, y, data, w, h, page.pageId);
1071
1132
  }
1072
1133
 
1073
1134
  /**
@@ -1079,12 +1140,13 @@ export class AtlasPool {
1079
1140
  private writeRgba8Padded(
1080
1141
  texture: GPUTexture, x: number, y: number,
1081
1142
  src: Uint8Array, w: number, h: number,
1143
+ layer = 0,
1082
1144
  ): void {
1083
1145
  const srcStride = w * 4;
1084
1146
  if (h === 1 || srcStride % 256 === 0) {
1085
1147
  // Single row or already aligned — pass straight through.
1086
1148
  this.device.queue.writeTexture(
1087
- { texture, origin: { x, y, z: 0 } },
1149
+ { texture, origin: { x, y, z: layer } },
1088
1150
  src as unknown as GPUAllowSharedBufferSource,
1089
1151
  { bytesPerRow: srcStride, rowsPerImage: h },
1090
1152
  { width: w, height: h, depthOrArrayLayers: 1 },
@@ -1097,7 +1159,7 @@ export class AtlasPool {
1097
1159
  padded.set(src.subarray(row * srcStride, (row + 1) * srcStride), row * dstStride);
1098
1160
  }
1099
1161
  this.device.queue.writeTexture(
1100
- { texture, origin: { x, y, z: 0 } },
1162
+ { texture, origin: { x, y, z: layer } },
1101
1163
  padded as unknown as GPUAllowSharedBufferSource,
1102
1164
  { bytesPerRow: dstStride, rowsPerImage: h },
1103
1165
  { width: w, height: h, depthOrArrayLayers: 1 },