@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.
- package/dist/runtime/heapEffect.d.ts +19 -16
- package/dist/runtime/heapEffect.d.ts.map +1 -1
- package/dist/runtime/heapEffect.js +35 -37
- package/dist/runtime/heapEffect.js.map +1 -1
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +23 -31
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/runtime.d.ts +3 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +3 -1
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +2 -2
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +6 -6
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.d.ts +30 -8
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.js +91 -38
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/heapEffect.ts +35 -37
- package/src/runtime/heapScene.ts +23 -31
- package/src/runtime/runtime.ts +5 -1
- package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +6 -3
- package/src/runtime/textureAtlas/atlasPool.ts +102 -40
|
@@ -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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// (
|
|
55
|
-
//
|
|
56
|
-
export const ATLAS_MAX_PAGES_PER_FORMAT =
|
|
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
|
|
84
|
-
*
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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 <
|
|
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
|
-
|
|
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*
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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}
|
|
366
|
-
size: { width: atlasPageSizeNow(), height: atlasPageSizeNow(), depthOrArrayLayers:
|
|
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
|
-
|
|
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
|
|
519
|
-
if (pages.length >=
|
|
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
|
|
782
|
+
/** Destroy the per-format array textures. The pool becomes unusable. */
|
|
724
783
|
dispose(): void {
|
|
725
|
-
for (const [,
|
|
726
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 },
|