@aardworx/wombat.rendering 0.8.0 → 0.9.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.
Files changed (136) hide show
  1. package/dist/core/command.d.ts +7 -9
  2. package/dist/core/command.d.ts.map +1 -1
  3. package/dist/core/renderTask.d.ts +64 -1
  4. package/dist/core/renderTask.d.ts.map +1 -1
  5. package/dist/core/renderTask.js +3 -1
  6. package/dist/core/renderTask.js.map +1 -1
  7. package/dist/resources/preparedRenderObject.js +3 -3
  8. package/dist/resources/preparedRenderObject.js.map +1 -1
  9. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  10. package/dist/resources/uniformBuffer.js +17 -0
  11. package/dist/resources/uniformBuffer.js.map +1 -1
  12. package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
  13. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
  14. package/dist/runtime/derivedUniforms/dispatch.js +355 -0
  15. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
  16. package/dist/runtime/derivedUniforms/index.d.ts +6 -0
  17. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
  18. package/dist/runtime/derivedUniforms/index.js +26 -0
  19. package/dist/runtime/derivedUniforms/index.js.map +1 -0
  20. package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
  22. package/dist/runtime/derivedUniforms/recipes.js +134 -0
  23. package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
  24. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
  25. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
  26. package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
  27. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
  28. package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
  29. package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
  30. package/dist/runtime/derivedUniforms/slotId.js +30 -0
  31. package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
  32. package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
  33. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
  34. package/dist/runtime/derivedUniforms/slots.js +123 -0
  35. package/dist/runtime/derivedUniforms/slots.js.map +1 -0
  36. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
  37. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
  38. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
  39. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
  40. package/dist/runtime/flattenTree.d.ts +5 -0
  41. package/dist/runtime/flattenTree.d.ts.map +1 -0
  42. package/dist/runtime/flattenTree.js +45 -0
  43. package/dist/runtime/flattenTree.js.map +1 -0
  44. package/dist/runtime/heapAdapter.d.ts +16 -0
  45. package/dist/runtime/heapAdapter.d.ts.map +1 -0
  46. package/dist/runtime/heapAdapter.js +263 -0
  47. package/dist/runtime/heapAdapter.js.map +1 -0
  48. package/dist/runtime/heapEffect.d.ts +235 -0
  49. package/dist/runtime/heapEffect.d.ts.map +1 -0
  50. package/dist/runtime/heapEffect.js +518 -0
  51. package/dist/runtime/heapEffect.js.map +1 -0
  52. package/dist/runtime/heapEffectIR.d.ts +34 -0
  53. package/dist/runtime/heapEffectIR.d.ts.map +1 -0
  54. package/dist/runtime/heapEffectIR.js +1161 -0
  55. package/dist/runtime/heapEffectIR.js.map +1 -0
  56. package/dist/runtime/heapEligibility.d.ts +9 -0
  57. package/dist/runtime/heapEligibility.d.ts.map +1 -0
  58. package/dist/runtime/heapEligibility.js +195 -0
  59. package/dist/runtime/heapEligibility.js.map +1 -0
  60. package/dist/runtime/heapScene.d.ts +240 -31
  61. package/dist/runtime/heapScene.d.ts.map +1 -1
  62. package/dist/runtime/heapScene.js +3334 -265
  63. package/dist/runtime/heapScene.js.map +1 -1
  64. package/dist/runtime/heapShaderFamily.d.ts +132 -0
  65. package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
  66. package/dist/runtime/heapShaderFamily.js +866 -0
  67. package/dist/runtime/heapShaderFamily.js.map +1 -0
  68. package/dist/runtime/hybridScene.d.ts +133 -0
  69. package/dist/runtime/hybridScene.d.ts.map +1 -0
  70. package/dist/runtime/hybridScene.js +159 -0
  71. package/dist/runtime/hybridScene.js.map +1 -0
  72. package/dist/runtime/index.d.ts +8 -0
  73. package/dist/runtime/index.d.ts.map +1 -1
  74. package/dist/runtime/index.js +9 -0
  75. package/dist/runtime/index.js.map +1 -1
  76. package/dist/runtime/renderTask.d.ts +21 -4
  77. package/dist/runtime/renderTask.d.ts.map +1 -1
  78. package/dist/runtime/renderTask.js +127 -32
  79. package/dist/runtime/renderTask.js.map +1 -1
  80. package/dist/runtime/runtime.d.ts +33 -3
  81. package/dist/runtime/runtime.d.ts.map +1 -1
  82. package/dist/runtime/runtime.js +12 -3
  83. package/dist/runtime/runtime.js.map +1 -1
  84. package/dist/runtime/scenePass.d.ts +28 -3
  85. package/dist/runtime/scenePass.d.ts.map +1 -1
  86. package/dist/runtime/scenePass.js +37 -4
  87. package/dist/runtime/scenePass.js.map +1 -1
  88. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
  89. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
  90. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
  91. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
  92. package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
  93. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
  94. package/dist/runtime/textureAtlas/atlasPool.js +798 -0
  95. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
  96. package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
  97. package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
  98. package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
  99. package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
  100. package/dist/runtime/textureAtlas/packer.d.ts +50 -0
  101. package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
  102. package/dist/runtime/textureAtlas/packer.js +390 -0
  103. package/dist/runtime/textureAtlas/packer.js.map +1 -0
  104. package/dist/window/loop.d.ts +14 -0
  105. package/dist/window/loop.d.ts.map +1 -1
  106. package/dist/window/loop.js +18 -5
  107. package/dist/window/loop.js.map +1 -1
  108. package/package.json +5 -4
  109. package/src/core/command.ts +9 -9
  110. package/src/core/renderTask.ts +55 -2
  111. package/src/resources/preparedRenderObject.ts +3 -3
  112. package/src/resources/uniformBuffer.ts +15 -0
  113. package/src/runtime/derivedUniforms/dispatch.ts +407 -0
  114. package/src/runtime/derivedUniforms/index.ts +52 -0
  115. package/src/runtime/derivedUniforms/recipes.ts +159 -0
  116. package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
  117. package/src/runtime/derivedUniforms/slotId.ts +35 -0
  118. package/src/runtime/derivedUniforms/slots.ts +155 -0
  119. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
  120. package/src/runtime/flattenTree.ts +46 -0
  121. package/src/runtime/heapAdapter.ts +314 -0
  122. package/src/runtime/heapEffect.ts +719 -0
  123. package/src/runtime/heapEffectIR.ts +1211 -0
  124. package/src/runtime/heapEligibility.ts +189 -0
  125. package/src/runtime/heapScene.ts +3981 -349
  126. package/src/runtime/heapShaderFamily.ts +1102 -0
  127. package/src/runtime/hybridScene.ts +310 -0
  128. package/src/runtime/index.ts +46 -0
  129. package/src/runtime/renderTask.ts +167 -32
  130. package/src/runtime/runtime.ts +37 -4
  131. package/src/runtime/scenePass.ts +39 -4
  132. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
  133. package/src/runtime/textureAtlas/atlasPool.ts +991 -0
  134. package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
  135. package/src/runtime/textureAtlas/packer.ts +429 -0
  136. package/src/window/loop.ts +32 -6
@@ -0,0 +1,991 @@
1
+ // AtlasPool — aval-keyed refcounted sub-rect allocator over shared
2
+ // 4096×4096 atlas pages.
3
+ //
4
+ // Mirrors the UniformPool / IndexPool pattern in heapScene: identity
5
+ // keyed on the source `aval<ITexture>`; multiple ROs referencing the
6
+ // same texture share one sub-rect; refcount drives free-on-release.
7
+ //
8
+ // Storage shape: per format, N independent 2D `GPUTexture` "pages",
9
+ // each its own 4096² image. The shader binds N consecutive single-
10
+ // texture BGL slots per format (linear/srgb) and switches on
11
+ // `pageRef` (0..N-1). Adding a new page = allocate a fresh
12
+ // GPUTexture and slot it into the next bind-group entry — no GPU-
13
+ // side `copyTextureToTexture` of the array, unlike a `texture_2d_array`
14
+ // design.
15
+ //
16
+ // `binding_array<texture_2d<f32>, N>` would be simpler in the shader
17
+ // but requires Chrome's experimental `bindless-textures` feature;
18
+ // WebGPU 1.0's `GPUBindGroupEntry.resource` doesn't accept arrays of
19
+ // views. The N-consecutive-bindings approach is core WebGPU 1.0.
20
+ //
21
+ // MVP scope (per docs/heap-textures-plan.md):
22
+ // - Tier S/M: source ≤ 1024×1024.
23
+ // - Two formats: rgba8unorm, rgba8unorm-srgb.
24
+ // - No eviction. New pages are allocated lazily up to
25
+ // ATLAS_MAX_PAGES_PER_FORMAT; allocations past that throw.
26
+ //
27
+ // Mipped textures use the classic Iliffe / id Tech 1.5×1 layout: the
28
+ // packer reserves a `1.5W × H` rect, mip 0 occupies the left W×H block
29
+ // and mips 1..N stack vertically in the right W/2-wide column. CPU
30
+ // mip generation (canvas-2d) for v1; compute-shader path is future.
31
+
32
+ import { V2f, V2i } from "@aardworx/wombat.base";
33
+ import type { aval } from "@aardworx/wombat.adaptive";
34
+ import type { ITexture, HostTextureSource } from "../../core/texture.js";
35
+ import { TexturePacking } from "./packer.js";
36
+ import { buildMipsAndGutterOnGpu, type MipSlot } from "./atlasMipGutterKernel.js";
37
+
38
+ export const ATLAS_PAGE_SIZE = 4096;
39
+ /** Tier S/M source-side dimension cap. */
40
+ export const ATLAS_MAX_DIM = 1024;
41
+ /**
42
+ * Maximum number of independent pages allowed per format. The shader's
43
+ * `switch pageRef` runs an N-way ladder; the BGL declares N consecutive
44
+ * texture slots per format. Allocations beyond this throw.
45
+ */
46
+ export const ATLAS_MAX_PAGES_PER_FORMAT = 8;
47
+
48
+ export type AtlasPageFormat = "rgba8unorm" | "rgba8unorm-srgb";
49
+
50
+ export const ATLAS_PAGE_FORMATS: readonly AtlasPageFormat[] = ["rgba8unorm", "rgba8unorm-srgb"];
51
+
52
+ /** Format index used in the drawHeader: 0 = linear, 1 = srgb. */
53
+ export const atlasFormatIndex = (f: AtlasPageFormat): number =>
54
+ f === "rgba8unorm-srgb" ? 1 : 0;
55
+
56
+ export interface AtlasPage {
57
+ readonly format: AtlasPageFormat;
58
+ /**
59
+ * The page's own independent `GPUTexture` (dimension: "2d", single
60
+ * 4096² image). Stable for the page's lifetime — never reallocated.
61
+ */
62
+ readonly texture: GPUTexture;
63
+ /** Immutable packer state — `tryAdd`/`remove` produce new instances. */
64
+ packing: TexturePacking<number>;
65
+ /**
66
+ * Page slot index in the format's binding sequence (0..N-1). Same
67
+ * value flows into the drawHeader `pageRef` field; the shader's
68
+ * `switch pageRef` selects the matching `atlasLinear<i>` /
69
+ * `atlasSrgb<i>` declaration.
70
+ */
71
+ readonly pageId: number;
72
+ }
73
+
74
+ export interface AtlasAcquisition {
75
+ /** Page slot index within the format's binding sequence (0..N-1). */
76
+ readonly pageId: number;
77
+ /** Top-left of mip 0's interior in the atlas, in atlas pixels. */
78
+ readonly origin: V2f;
79
+ /** Size of mip 0's interior in the atlas, in atlas pixels. */
80
+ readonly size: V2f;
81
+ /** Number of mip levels stored for this acquisition (1 = no pyramid). */
82
+ readonly numMips: number;
83
+ /** Stable refcount handle: pass back to `release`. */
84
+ readonly ref: number;
85
+ /** The page's GPUTexture (so the caller can pin it in the bind group). */
86
+ readonly page: AtlasPage;
87
+ }
88
+
89
+ interface AtlasEntry {
90
+ /**
91
+ * Avals pointing to this entry. With §5b value-equality dedup,
92
+ * multiple distinct constant avals can alias the same sub-rect —
93
+ * `aliases` carries every one of them so final release can clear
94
+ * all entriesByAval bindings. Length == 1 in the non-deduped case.
95
+ */
96
+ aliases: aval<ITexture>[];
97
+ /**
98
+ * Inner-resource reference used as the byValueKey for §5b dedup
99
+ * (GPUTexture for kind:"gpu", HostTextureSource for kind:"host").
100
+ * Undefined for non-constant avals (identity-only path).
101
+ */
102
+ readonly valueKey: GPUTexture | HostTextureSource | undefined;
103
+ readonly format: AtlasPageFormat;
104
+ readonly pageId: number;
105
+ /** Reserved rect in the atlas (covers the full 1.5W×H pyramid for mipped). */
106
+ readonly subRect: { x: number; y: number; w: number; h: number };
107
+ /** Mip-0 size in atlas pixels (== logical source W×H). */
108
+ readonly mip0: { w: number; h: number };
109
+ readonly numMips: number;
110
+ refcount: number;
111
+ /** Stable handle returned to callers. */
112
+ readonly ref: number;
113
+ }
114
+
115
+ /** Source descriptor accepted by `acquire` (small enough not to need a full ITexture). */
116
+ export interface AtlasSource {
117
+ readonly width: number;
118
+ readonly height: number;
119
+ /**
120
+ * The host source for upload. The pool calls
121
+ * `device.queue.copyExternalImageToTexture` (external) or
122
+ * `writeTexture` (raw) into the page region the first time the
123
+ * sub-rect is allocated. For mipped uploads the pool downscales
124
+ * via canvas-2d and uploads each level into the pyramid sub-region.
125
+ */
126
+ readonly host?: HostTextureSource;
127
+ }
128
+
129
+ export interface AtlasAcquireOptions {
130
+ readonly source?: AtlasSource;
131
+ /** When true, reserve a 1.5W×H rect and store an embedded mip pyramid. */
132
+ readonly wantsMips?: boolean;
133
+ /**
134
+ * Number of mip levels to store. Defaults to
135
+ * `floor(log2(max(w,h))) + 1` (full chain to 1×1).
136
+ */
137
+ readonly numMips?: number;
138
+ }
139
+
140
+ /**
141
+ * Default mip count for a `w×h` source: full chain down to 1×1.
142
+ */
143
+ export const defaultMipCount = (w: number, h: number): number =>
144
+ Math.floor(Math.log2(Math.max(w, h))) + 1;
145
+
146
+ /**
147
+ * Mip-k pixel size given mip-0 size `(w,h)`. Halves each level and
148
+ * floors at 1×1.
149
+ */
150
+ export const mipPixelSize = (w: number, h: number, k: number): { w: number; h: number } => ({
151
+ w: Math.max(1, w >> k),
152
+ h: Math.max(1, h >> k),
153
+ });
154
+
155
+ /**
156
+ * Mip-k interior offset from mip-0 interior, both inside the embedded
157
+ * Iliffe pyramid. Mip 0 is at (0,0). Mips 1..N stack vertically in
158
+ * the column at x = W + 4 (mip-0 width + 2 px right gutter of mip-0
159
+ * + 2 px left gutter of the mip column). Each mip k>=1 sits below
160
+ * mip k-1 with 4 px of vertical gutter between them (2 px bottom of
161
+ * mip k-1 + 2 px top of mip k):
162
+ * y_k = sum_{j=1..k-1} (max(1, H >> j) + 4).
163
+ * Caller writes at `(uploadOrigin + mipOffsetInPyramid(w, h, k))`
164
+ * where uploadOrigin is mip-0's interior pixel position.
165
+ */
166
+ export const mipOffsetInPyramid = (
167
+ w: number,
168
+ h: number,
169
+ k: number,
170
+ ): { x: number; y: number } => {
171
+ if (k === 0) return { x: 0, y: 0 };
172
+ let y = 0;
173
+ for (let j = 1; j < k; j++) y += Math.max(1, h >> j) + 4;
174
+ return { x: w + 4, y };
175
+ };
176
+
177
+ /**
178
+ * Pull `(format, width, height, mipLevelCount, host?)` out of an
179
+ * ITexture for atlas eligibility. Mirrors `heapAdapter.describeTexture`
180
+ * — kept local so `repack` can validate without importing the adapter.
181
+ * Returns `null` for sources we can't measure.
182
+ */
183
+ function describeAtlasTexture(t: ITexture): {
184
+ format: GPUTextureFormat; width: number; height: number;
185
+ mipLevelCount: number; host?: HostTextureSource;
186
+ } | null {
187
+ if (t.kind === "gpu") {
188
+ const tex = t.texture;
189
+ return {
190
+ format: tex.format, width: tex.width, height: tex.height,
191
+ mipLevelCount: tex.mipLevelCount,
192
+ };
193
+ }
194
+ const src = t.source;
195
+ if (src.kind === "raw") {
196
+ return {
197
+ format: src.format, width: src.width, height: src.height,
198
+ mipLevelCount: src.mipLevelCount ?? 1, host: src,
199
+ };
200
+ }
201
+ const ext = src.source as unknown;
202
+ let w = 0, h = 0;
203
+ if (typeof HTMLVideoElement !== "undefined" && ext instanceof HTMLVideoElement) {
204
+ w = ext.videoWidth; h = ext.videoHeight;
205
+ } else if (typeof ImageData !== "undefined" && ext instanceof ImageData) {
206
+ w = ext.width; h = ext.height;
207
+ } else {
208
+ const any = ext as { width?: number; height?: number };
209
+ w = any.width ?? 0; h = any.height ?? 0;
210
+ }
211
+ if (w <= 0 || h <= 0) return null;
212
+ return {
213
+ format: src.format ?? "rgba8unorm",
214
+ width: w, height: h,
215
+ mipLevelCount: src.generateMips ? Math.floor(Math.log2(Math.max(w, h))) + 1 : 1,
216
+ host: src,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Aval-identity keyed pool over the per-format page sets. Multiple
222
+ * acquisitions of the same `aval<ITexture>` share one sub-rect; the
223
+ * refcount drives release.
224
+ */
225
+ export class AtlasPool {
226
+ private readonly pagesByFormat = new Map<AtlasPageFormat, AtlasPage[]>();
227
+ private readonly pageAddedListeners = new Map<AtlasPageFormat, Set<(pageId: number) => void>>();
228
+ private readonly entriesByAval = new Map<aval<ITexture>, AtlasEntry>();
229
+ private readonly entriesByRef = new Map<number, AtlasEntry>();
230
+ /**
231
+ * §5b value-equality dedup map. Constant avals (`isConstant ===
232
+ * true`) wrapping the same inner GPUTexture (kind:"gpu") or
233
+ * HostTextureSource (kind:"host") collapse to one sub-rect.
234
+ * Reactive avals fall through to identity-only since their
235
+ * content can change.
236
+ */
237
+ private readonly entriesByValueKey = new Map<GPUTexture | HostTextureSource, AtlasEntry>();
238
+ private nextRef = 1;
239
+
240
+ private innerKeyOf(t: ITexture): GPUTexture | HostTextureSource {
241
+ return t.kind === "gpu" ? t.texture : t.source;
242
+ }
243
+
244
+ constructor(private readonly device: GPUDevice) {
245
+ for (const f of ATLAS_PAGE_FORMATS) {
246
+ this.pagesByFormat.set(f, []);
247
+ this.pageAddedListeners.set(f, new Set());
248
+ }
249
+ }
250
+
251
+ /** All currently allocated pages for a given format. */
252
+ pagesFor(format: AtlasPageFormat): readonly AtlasPage[] {
253
+ return this.pagesByFormat.get(format) ?? [];
254
+ }
255
+
256
+ /**
257
+ * Subscribe to page-allocation events. Fires after a fresh
258
+ * `AtlasPage` is appended to the format's page list (its `pageId`
259
+ * is the slot the bind group should now wire up).
260
+ */
261
+ onPageAdded(format: AtlasPageFormat, cb: (pageId: number) => void): { dispose(): void } {
262
+ const set = this.pageAddedListeners.get(format)!;
263
+ set.add(cb);
264
+ return { dispose: () => { set.delete(cb); } };
265
+ }
266
+
267
+ /** True for pages we can drive via the compute mip+gutter kernel.
268
+ * The kernel is buffer-based: it allocates a scratch storage
269
+ * buffer matching the sub-rect's bounding box, runs all mip
270
+ * downscale + gutter fill in compute passes over the buffer, then
271
+ * one `copyBufferToTexture` uploads the whole region to the page.
272
+ * No texture storage bindings are needed and no device features
273
+ * are required — works in core WebGPU 1.0. The only constraint is
274
+ * that JS-side pixel extraction succeeds, which falls back to the
275
+ * CPU path on headless platforms without canvas.
276
+ *
277
+ * Disabled in node mock-GPU mode (which lacks queue.onSubmittedWorkDone). */
278
+ private canUseGpuKernel(_page: AtlasPage): boolean {
279
+ return typeof (this.device.queue as { onSubmittedWorkDone?: () => Promise<void> }).onSubmittedWorkDone === "function";
280
+ }
281
+
282
+ private allocatePage(format: AtlasPageFormat, pageId: number): AtlasPage {
283
+ // rgba8unorm pages gain STORAGE_BINDING for the compute mip+gutter
284
+ // kernel. rgba8unorm-srgb isn't a storage-capable format in WebGPU
285
+ // 1.0 (without an unportable feature), so srgb pages fall back to
286
+ // the CPU buildExtendedWithGutter path. Mixed strategy: linear
287
+ // textures get the fast compute path; srgb textures take the CPU
288
+ // path. Both produce identical visible output — only the kernel
289
+ // path differs.
290
+ const texture = this.device.createTexture({
291
+ label: `atlas/${format}/${pageId}`,
292
+ size: { width: ATLAS_PAGE_SIZE, height: ATLAS_PAGE_SIZE, depthOrArrayLayers: 1 },
293
+ format,
294
+ mipLevelCount: 1,
295
+ usage:
296
+ GPUTextureUsage.TEXTURE_BINDING |
297
+ GPUTextureUsage.COPY_DST |
298
+ GPUTextureUsage.COPY_SRC |
299
+ GPUTextureUsage.RENDER_ATTACHMENT,
300
+ });
301
+ return {
302
+ format,
303
+ texture,
304
+ packing: TexturePacking.empty<number>(new V2i(ATLAS_PAGE_SIZE, ATLAS_PAGE_SIZE), false),
305
+ pageId,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Tier-S eligibility: dimension threshold + supported format. If
311
+ * this returns null, the caller should route the texture down the
312
+ * Tier L (standalone) path.
313
+ */
314
+ static eligibleFormat(format: GPUTextureFormat): AtlasPageFormat | null {
315
+ if (format === "rgba8unorm") return "rgba8unorm";
316
+ if (format === "rgba8unorm-srgb") return "rgba8unorm-srgb";
317
+ return null;
318
+ }
319
+
320
+ static eligibleSize(width: number, height: number): boolean {
321
+ return width > 0 && height > 0 && width <= ATLAS_MAX_DIM && height <= ATLAS_MAX_DIM;
322
+ }
323
+
324
+ /**
325
+ * Acquire (or share) a sub-rect. The returned `AtlasAcquisition`
326
+ * gives the page slot index plus the mip-0 `origin`/`size` (in
327
+ * normalized atlas coords) and `numMips`. If the texture is already
328
+ * known (same aval), refcount bumps and we return the existing
329
+ * placement.
330
+ */
331
+ acquire(
332
+ format: AtlasPageFormat,
333
+ sourceAval: aval<ITexture>,
334
+ width: number,
335
+ height: number,
336
+ opts: AtlasAcquireOptions = {},
337
+ ): AtlasAcquisition {
338
+ const existing = this.entriesByAval.get(sourceAval);
339
+ if (existing !== undefined) {
340
+ existing.refcount++;
341
+ return this.makeResult(existing);
342
+ }
343
+
344
+ // §5b: value-equality dedup for constant avals. Two distinct
345
+ // constant avals wrapping the same inner GPUTexture or
346
+ // HostTextureSource share one sub-rect. We need a *value* (not
347
+ // just dimensions) to compute the key; pull from `opts.source`
348
+ // if provided, otherwise skip dedup (fresh allocation).
349
+ let valueKey: GPUTexture | HostTextureSource | undefined;
350
+ if (sourceAval.isConstant) {
351
+ const sourceVal = opts.source;
352
+ if (sourceVal !== undefined && sourceVal.host !== undefined) {
353
+ valueKey = sourceVal.host;
354
+ }
355
+ // For kind:"gpu" sources, opts.source.host is undefined; the
356
+ // caller (heapAdapter) provides the GPUTexture via a different
357
+ // path and we'd need to thread it through. v1: skip dedup for
358
+ // GPU-backed constants, dedup only host-backed constants. The
359
+ // realistic dedup case is shared ImageBitmap / ImageData; GPU
360
+ // textures are typically already deduped at the user layer.
361
+ if (valueKey !== undefined) {
362
+ const shared = this.entriesByValueKey.get(valueKey);
363
+ if (shared !== undefined) {
364
+ shared.refcount++;
365
+ shared.aliases.push(sourceAval);
366
+ this.entriesByAval.set(sourceAval, shared);
367
+ return this.makeResult(shared);
368
+ }
369
+ }
370
+ }
371
+
372
+ const wantsMips = opts.wantsMips === true;
373
+ const numMips = wantsMips
374
+ ? Math.max(1, Math.min(opts.numMips ?? defaultMipCount(width, height),
375
+ defaultMipCount(width, height)))
376
+ : 1;
377
+
378
+ // Every sub-rect (and every mip slot in mipped pyramids) carries a
379
+ // 2-px gutter on each side. Inner ring = clamp-replicate edge
380
+ // texel, outer ring = wrap (opposite-edge texel). Required so the
381
+ // shader's hardware-bilinear at the sub-rect edge doesn't bleed
382
+ // into a neighboring sub-rect and so the repeat-mode `shift ±1`
383
+ // can land on the opposite-edge data.
384
+ //
385
+ // Non-mipped layout: (W+4) × (H+4); mip-0 interior at (+2, +2).
386
+ // Mipped Iliffe layout: mip-0 owns (W+4) × (H+4); mips 1..N stack
387
+ // vertically in the next column starting at x = W + 4, each with
388
+ // its own 4-px gutter padding on each axis. Pyramid bounding rect
389
+ // is `(W + 4 + maxMipW + 4) × max(H+4, sumMipH+gaps)` — sized
390
+ // generously to ensure non-overlap.
391
+ const reservedW = wantsMips
392
+ ? (width + 4) + Math.max(1, width >> 1) + 4
393
+ : width + 4;
394
+ let reservedH = height + 4;
395
+ if (wantsMips) {
396
+ let stackedH = 0;
397
+ for (let k = 1; k < numMips; k++) {
398
+ stackedH += Math.max(1, height >> k) + 4;
399
+ }
400
+ reservedH = Math.max(reservedH, stackedH);
401
+ }
402
+ const size = new V2i(reservedW, reservedH);
403
+
404
+ const pages = this.pagesByFormat.get(format)!;
405
+
406
+ // Try fitting into existing pages first.
407
+ for (let i = 0; i < pages.length; i++) {
408
+ const page = pages[i]!;
409
+ const next = page.packing.tryAdd(this.nextRef, size);
410
+ if (next !== null) {
411
+ page.packing = next;
412
+ return this.finalize(sourceAval, format, i, page, opts.source, width, height, numMips, valueKey);
413
+ }
414
+ }
415
+
416
+ // Allocate a fresh page if we haven't hit the max.
417
+ if (pages.length >= ATLAS_MAX_PAGES_PER_FORMAT) {
418
+ throw new Error(
419
+ `atlas: ATLAS_MAX_PAGES_PER_FORMAT (${ATLAS_MAX_PAGES_PER_FORMAT}) exceeded for format ${format}`,
420
+ );
421
+ }
422
+ const pageId = pages.length;
423
+ const page = this.allocatePage(format, pageId);
424
+ const placed = page.packing.tryAdd(this.nextRef, size);
425
+ if (placed === null) {
426
+ throw new Error(
427
+ `AtlasPool: ${reservedW}×${reservedH} doesn't fit a fresh ${ATLAS_PAGE_SIZE}² page`,
428
+ );
429
+ }
430
+ page.packing = placed;
431
+ pages.push(page);
432
+ for (const cb of this.pageAddedListeners.get(format)!) cb(pageId);
433
+ return this.finalize(sourceAval, format, pageId, page, opts.source, width, height, numMips, valueKey);
434
+ }
435
+
436
+ /**
437
+ * Look up the existing acquisition for a source aval, or `undefined`
438
+ * if the aval is not currently held in the pool. Mirrors
439
+ * `UniformPool.entry` — used by the heap path's reactivity loop to
440
+ * detect "this aval has live refs and just marked".
441
+ */
442
+ entry(sourceAval: aval<ITexture>): AtlasAcquisition | undefined {
443
+ const e = this.entriesByAval.get(sourceAval);
444
+ return e === undefined ? undefined : this.makeResult(e);
445
+ }
446
+
447
+ /**
448
+ * Re-pack an aval's atlas placement against a new ITexture value.
449
+ * Mirrors `UniformPool.repack(av, newValue)` — used when an atlas-
450
+ * routed `aval<ITexture>` source swaps. Frees the old sub-rect (and
451
+ * the page slot in the packer) and acquires a fresh one for the new
452
+ * texture; the returned `AtlasAcquisition` carries the (potentially
453
+ * different) `pageId`/`origin`/`size`/`numMips`/`ref`. Refcount is
454
+ * preserved across the swap so multi-RO sharing keeps working.
455
+ *
456
+ * Throws if the new texture isn't Tier-S eligible (different format
457
+ * outside rgba8unorm/srgb, or larger than the cap). Callers should
458
+ * rely on the heap eligibility classifier, but this is a safety net.
459
+ */
460
+ repack(
461
+ sourceAval: aval<ITexture>,
462
+ newTexture: ITexture,
463
+ opts: AtlasAcquireOptions = {},
464
+ ): AtlasAcquisition {
465
+ const old = this.entriesByAval.get(sourceAval);
466
+ if (old === undefined) {
467
+ throw new Error("AtlasPool.repack: source aval has no live entry");
468
+ }
469
+ // Resolve the new texture's eligibility + dimensions.
470
+ const desc = describeAtlasTexture(newTexture);
471
+ if (desc === null) {
472
+ throw new Error("AtlasPool.repack: new ITexture has no measurable dimensions");
473
+ }
474
+ const fmt = AtlasPool.eligibleFormat(desc.format);
475
+ if (fmt === null) {
476
+ throw new Error(
477
+ `AtlasPool.repack: new texture format ${desc.format} is not Tier-S eligible`,
478
+ );
479
+ }
480
+ if (!AtlasPool.eligibleSize(desc.width, desc.height)) {
481
+ throw new Error(
482
+ `AtlasPool.repack: new texture ${desc.width}×${desc.height} exceeds Tier-S cap`,
483
+ );
484
+ }
485
+
486
+ // Repack on a deduped entry (multiple aliases sharing one
487
+ // sub-rect) is undefined: we'd be free-and-realloc'ing the
488
+ // sub-rect underneath the OTHER aliases. The dedup path only
489
+ // engages for `isConstant` avals, which can't fire mark
490
+ // callbacks → repack(constant) shouldn't ever happen via the
491
+ // reactivity loop. If a caller invokes it manually we'd rather
492
+ // throw than silently corrupt the other aliases.
493
+ if (old.aliases.length > 1) {
494
+ throw new Error(
495
+ "AtlasPool.repack: cannot repack a deduped entry shared by multiple constant avals",
496
+ );
497
+ }
498
+
499
+ // 1. Free the old rect from its page's packer + drop the maps.
500
+ const savedRefcount = old.refcount;
501
+ const oldPages = this.pagesByFormat.get(old.format)!;
502
+ const oldPage = oldPages[old.pageId];
503
+ if (oldPage !== undefined) {
504
+ oldPage.packing = oldPage.packing.remove(old.ref);
505
+ }
506
+ this.entriesByRef.delete(old.ref);
507
+ this.entriesByAval.delete(sourceAval);
508
+ if (old.valueKey !== undefined) this.entriesByValueKey.delete(old.valueKey);
509
+
510
+ // 2. Acquire a fresh placement for the new texture. Reuse the
511
+ // `acquire` algorithm — entriesByAval no longer has us, so it
512
+ // picks the existing-pages-first path naturally.
513
+ const acqOpts: AtlasAcquireOptions = {
514
+ ...opts,
515
+ ...(opts.source === undefined && desc.host !== undefined
516
+ ? { source: { width: desc.width, height: desc.height, host: desc.host } }
517
+ : {}),
518
+ };
519
+ const acq = this.acquire(fmt, sourceAval, desc.width, desc.height, acqOpts);
520
+
521
+ // 3. Restore the original refcount (acquire set it to 1).
522
+ const newEntry = this.entriesByRef.get(acq.ref);
523
+ if (newEntry !== undefined) newEntry.refcount = savedRefcount;
524
+ return acq;
525
+ }
526
+
527
+ /** Decrement the refcount; on zero, free the sub-rect. */
528
+ release(ref: number): void {
529
+ const e = this.entriesByRef.get(ref);
530
+ if (e === undefined) return;
531
+ e.refcount--;
532
+ if (e.refcount > 0) return;
533
+ const pages = this.pagesByFormat.get(e.format)!;
534
+ const page = pages[e.pageId];
535
+ if (page !== undefined) {
536
+ page.packing = page.packing.remove(e.ref);
537
+ }
538
+ this.entriesByRef.delete(e.ref);
539
+ for (const a of e.aliases) this.entriesByAval.delete(a);
540
+ if (e.valueKey !== undefined) this.entriesByValueKey.delete(e.valueKey);
541
+ }
542
+
543
+ /** Destroy every page texture. The pool becomes unusable. */
544
+ dispose(): void {
545
+ for (const [, pages] of this.pagesByFormat) {
546
+ for (const p of pages) p.texture.destroy();
547
+ }
548
+ this.pagesByFormat.clear();
549
+ this.pageAddedListeners.clear();
550
+ this.entriesByAval.clear();
551
+ this.entriesByRef.clear();
552
+ this.entriesByValueKey.clear();
553
+ }
554
+
555
+ private finalize(
556
+ aval: aval<ITexture>,
557
+ format: AtlasPageFormat,
558
+ pageId: number,
559
+ page: AtlasPage,
560
+ source: AtlasSource | undefined,
561
+ mip0W: number,
562
+ mip0H: number,
563
+ numMips: number,
564
+ valueKey: GPUTexture | HostTextureSource | undefined,
565
+ ): AtlasAcquisition {
566
+ const ref = this.nextRef++;
567
+ const placed = page.packing.used.get(ref);
568
+ if (placed === undefined) {
569
+ throw new Error("AtlasPool: invariant — placed sub-rect missing from packer");
570
+ }
571
+ const x = placed.min.x;
572
+ const y = placed.min.y;
573
+ const w = placed.max.x - placed.min.x + 1;
574
+ const h = placed.max.y - placed.min.y + 1;
575
+ const entry: AtlasEntry = {
576
+ aliases: [aval],
577
+ valueKey,
578
+ format,
579
+ pageId,
580
+ subRect: { x, y, w, h },
581
+ mip0: { w: mip0W, h: mip0H },
582
+ numMips,
583
+ refcount: 1,
584
+ ref,
585
+ };
586
+ this.entriesByAval.set(aval, entry);
587
+ this.entriesByRef.set(ref, entry);
588
+ if (valueKey !== undefined) this.entriesByValueKey.set(valueKey, entry);
589
+ if (source !== undefined && source.host !== undefined) {
590
+ // Upload places mip-0 at the interior (+2, +2) position. The
591
+ // surrounding 2-px gutter (inner clamp ring + outer wrap ring)
592
+ // should be filled here, but Dawn's WebGPU implementation
593
+ // currently rejects same-texture copyTextureToTexture even
594
+ // when the source and destination regions don't overlap
595
+ // (validation says "overlapping layer ranges" — overly strict
596
+ // vs §22.5.5). Until the planned compute mip+gutter kernel
597
+ // lands, gutter cells stay uninitialized → edge bilinear
598
+ // samples bleed at exact uv=0 / uv=1. For most atlas content
599
+ // (low-frequency icon edges) this is visually invisible.
600
+ this.upload(page, x + 2, y + 2, source.host, mip0W, mip0H, numMips);
601
+ }
602
+ return this.makeResult(entry);
603
+ }
604
+
605
+ private upload(
606
+ page: AtlasPage,
607
+ x: number,
608
+ y: number,
609
+ host: HostTextureSource,
610
+ w: number,
611
+ h: number,
612
+ numMips: number,
613
+ ): void {
614
+ // For storage-capable pages (rgba8unorm): use the GPU compute
615
+ // kernel to build mips + gutter in one pass. Mip 0 interior is
616
+ // uploaded raw via writeTexture; the kernel reads from there to
617
+ // both downsample mip 1..N-1 and fill every mip's 2-px gutter
618
+ // ring. No CPU canvas-2d, no per-mip extended-buffer build.
619
+ if (this.canUseGpuKernel(page)) {
620
+ const px = this.extractPixels(host, w, h);
621
+ if (px !== null) {
622
+ // Bounding rect that the sub-rect occupies — caller passed
623
+ // (x, y) as the interior position (sub-rect origin + 2/+2).
624
+ // The scratch buffer covers this bounding rect.
625
+ const boundsX = x - 2;
626
+ const boundsY = y - 2;
627
+ // Reserved size matches the packer's allocation.
628
+ const reservedW = numMips > 1
629
+ ? (w + 4) + Math.max(1, w >> 1) + 4
630
+ : w + 4;
631
+ let reservedH = h + 4;
632
+ if (numMips > 1) {
633
+ let stackedH = 0;
634
+ for (let k = 1; k < numMips; k++) stackedH += Math.max(1, h >> k) + 4;
635
+ reservedH = Math.max(reservedH, stackedH);
636
+ }
637
+ // Mip slot offsets, in bounding-rect-relative pixel coords.
638
+ // mip-0 interior is at (+2, +2) inside the bounds.
639
+ const slots: MipSlot[] = [];
640
+ for (let k = 0; k < numMips; k++) {
641
+ const off = mipOffsetInPyramid(w, h, k);
642
+ slots.push({
643
+ origin: { x: 2 + off.x, y: 2 + off.y },
644
+ size: { w: Math.max(1, w >> k), h: Math.max(1, h >> k) },
645
+ });
646
+ }
647
+ buildMipsAndGutterOnGpu(
648
+ this.device, page.texture,
649
+ boundsX, boundsY, reservedW, reservedH,
650
+ px, w, h, slots,
651
+ );
652
+ return;
653
+ }
654
+ // Pixel extraction failed (headless without canvas for external
655
+ // sources) — fall through to CPU path below.
656
+ }
657
+ // Otherwise (srgb pages): keep the CPU path.
658
+ // Mip 0 lands at (x, y) — already the interior position (caller
659
+ // offset by +2/+2 for the gutter ring). Use the gutter-aware
660
+ // upload path: builds (w+4)×(h+4) with both gutter rings
661
+ // pre-filled CPU-side, then a single writeTexture covers the
662
+ // whole rect. Falls back to plain uploadLevel if pixel
663
+ // extraction fails (headless without canvas).
664
+ if (!this.uploadLevelWithGutter(page, x, y, host, w, h)) {
665
+ this.uploadLevel(page, x, y, host, w, h);
666
+ }
667
+ if (numMips <= 1) return;
668
+
669
+ // Mip k≥1: downscale via canvas-2d, upload at the pyramid offset.
670
+ const src = this.toDrawable(host, w, h);
671
+ if (src === null) {
672
+ // Headless / unsupported source — skip mip generation; level 0
673
+ // is still in place. Callers that need mips should provide a
674
+ // host source the pool can render-2d.
675
+ return;
676
+ }
677
+ for (let k = 1; k < numMips; k++) {
678
+ const off = mipOffsetInPyramid(w, h, k);
679
+ const mw = Math.max(1, w >> k);
680
+ const mh = Math.max(1, h >> k);
681
+ const mip = this.makeMipCanvas(src, mw, mh);
682
+ if (mip === null) continue;
683
+ const mx = x + off.x;
684
+ const my = y + off.y;
685
+ // Render the downsampled mip into a canvas, extract pixels,
686
+ // build gutter-extended buffer, writeTexture once.
687
+ const mipCanvas = this.makeCanvas(mw, mh);
688
+ if (mipCanvas === null) {
689
+ this.device.queue.copyExternalImageToTexture(
690
+ { source: mip as GPUImageCopyExternalImageSource },
691
+ { texture: page.texture, origin: { x: mx, y: my, z: 0 } },
692
+ { width: mw, height: mh, depthOrArrayLayers: 1 },
693
+ );
694
+ continue;
695
+ }
696
+ const ctx = mipCanvas.getContext("2d") as
697
+ | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
698
+ if (ctx === null) continue;
699
+ ctx.drawImage(mip as CanvasImageSource, 0, 0, mw, mh);
700
+ const mipPx = new Uint8Array(ctx.getImageData(0, 0, mw, mh).data.buffer);
701
+ const ext = AtlasPool.buildExtendedWithGutter(mipPx, mw, mh);
702
+ this.writeRgba8Padded(page.texture, mx - 2, my - 2, ext, mw + 4, mh + 4);
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Build a (w+4)×(h+4) extended pixel buffer from `src` (w×h, RGBA8)
708
+ * with the 2-px gutter pre-filled:
709
+ * - Inner ring (1 px) = clamp-replicate (edge texel).
710
+ * - Outer ring (1 px) = wrap (opposite-edge texel).
711
+ * The shader needs both: clamp-replicate absorbs FP-edge bleed for
712
+ * any wrap mode; the outer wrap ring is sampled by the repeat
713
+ * seam-shift ±1 path. By pre-baking these CPU-side we get a single
714
+ * writeTexture call per slot — much faster than per-cell GPU
715
+ * copies, and dodges Dawn's overly-strict same-texture copy
716
+ * validation.
717
+ *
718
+ * Layout matches the shader expectations and `placeWithGutter` in
719
+ * the conformance test. Corner cells are populated by independent
720
+ * per-axis interpretation (e.g. outer-X × outer-Y = diagonal wrap;
721
+ * inner-X × outer-Y = clamp-X × wrap-Y).
722
+ */
723
+ private static buildExtendedWithGutter(
724
+ src: Uint8Array, w: number, h: number,
725
+ ): Uint8Array {
726
+ const ew = w + 4;
727
+ const eh = h + 4;
728
+ const out = new Uint8Array(ew * eh * 4);
729
+ // pickSrcCoord: dx ∈ [-2..w+1] → (srcX, mode) per axis.
730
+ const srcX = (dx: number): number => {
731
+ if (dx === -2) return w - 1; // outer wrap left = opposite edge
732
+ if (dx === -1) return 0; // inner clamp left = nearest edge
733
+ if (dx === w) return w - 1; // inner clamp right
734
+ if (dx === w + 1) return 0; // outer wrap right
735
+ return dx; // interior
736
+ };
737
+ const srcY = (dy: number): number => {
738
+ if (dy === -2) return h - 1;
739
+ if (dy === -1) return 0;
740
+ if (dy === h) return h - 1;
741
+ if (dy === h + 1) return 0;
742
+ return dy;
743
+ };
744
+ for (let dy = -2; dy < h + 2; dy++) {
745
+ const sy = srcY(dy);
746
+ const ey = dy + 2;
747
+ for (let dx = -2; dx < w + 2; dx++) {
748
+ const sx = srcX(dx);
749
+ const ex = dx + 2;
750
+ const si = (sy * w + sx) * 4;
751
+ const oi = (ey * ew + ex) * 4;
752
+ out[oi + 0] = src[si + 0]!;
753
+ out[oi + 1] = src[si + 1]!;
754
+ out[oi + 2] = src[si + 2]!;
755
+ out[oi + 3] = src[si + 3]!;
756
+ }
757
+ }
758
+ return out;
759
+ }
760
+
761
+ /**
762
+ * Extract raw RGBA8 pixels from `host` at logical size (w, h).
763
+ * Falls back to a canvas draw + getImageData for external sources;
764
+ * raw sources are copied straight. Returns null when the runtime
765
+ * can't synthesise pixels (headless without canvas, etc.).
766
+ */
767
+ private extractPixels(host: HostTextureSource, w: number, h: number): Uint8Array | null {
768
+ if (host.kind !== "external") {
769
+ const ab = host.data instanceof ArrayBuffer
770
+ ? new Uint8Array(host.data)
771
+ : new Uint8Array(host.data.buffer, host.data.byteOffset, host.data.byteLength);
772
+ return ab.slice(0, w * h * 4);
773
+ }
774
+ const c = this.makeCanvas(w, h);
775
+ if (c === null) return null;
776
+ const ctx = c.getContext("2d") as
777
+ | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
778
+ if (ctx === null) return null;
779
+ ctx.drawImage(host.source as CanvasImageSource, 0, 0, w, h);
780
+ return new Uint8Array(ctx.getImageData(0, 0, w, h).data.buffer);
781
+ }
782
+
783
+ /**
784
+ * Upload a mip level INCLUDING its 2-px gutter ring. Writes a
785
+ * single (w+4)×(h+4) block to (x-2, y-2) — caller passes the
786
+ * interior position (x, y), this routine offsets by -2/-2 to
787
+ * cover the gutter. Returns false if pixel extraction failed (very
788
+ * rare; only on platforms without canvas support).
789
+ */
790
+ private uploadLevelWithGutter(
791
+ page: AtlasPage, x: number, y: number,
792
+ host: HostTextureSource, w: number, h: number,
793
+ ): boolean {
794
+ const px = this.extractPixels(host, w, h);
795
+ if (px === null) return false;
796
+ const ext = AtlasPool.buildExtendedWithGutter(px, w, h);
797
+ this.writeRgba8Padded(page.texture, x - 2, y - 2, ext, w + 4, h + 4);
798
+ return true;
799
+ }
800
+
801
+ /** Unused stub kept for compatibility — see uploadLevelWithGutter. */
802
+ private fillClampGutter(
803
+ page: AtlasPage,
804
+ ix: number, iy: number, w: number, h: number,
805
+ ): void {
806
+ void page; void ix; void iy; void w; void h;
807
+ const t = page.texture;
808
+ const enc = this.device.createCommandEncoder({ label: "atlas/gutter" });
809
+ const cp = (sx: number, sy: number, sw: number, sh: number, dx: number, dy: number): void => {
810
+ enc.copyTextureToTexture(
811
+ { texture: t, origin: { x: sx, y: sy, z: 0 } },
812
+ { texture: t, origin: { x: dx, y: dy, z: 0 } },
813
+ { width: sw, height: sh, depthOrArrayLayers: 1 },
814
+ );
815
+ };
816
+ // ─── Inner clamp-replicate ring ───────────────────────────────
817
+ // Edge strips (clamp Y or clamp X = nearest interior row/col)
818
+ cp(ix, iy, w, 1, ix, iy - 1); // top : row 0
819
+ cp(ix, iy + h - 1, w, 1, ix, iy + h); // bottom : row h-1
820
+ cp(ix, iy, 1, h, ix - 1, iy); // left : col 0
821
+ cp(ix + w - 1, iy, 1, h, ix + w, iy); // right : col w-1
822
+ // Inner corners (clamp X × clamp Y = the diagonal interior texel)
823
+ cp(ix, iy, 1, 1, ix - 1, iy - 1); // TL T[0, 0]
824
+ cp(ix + w - 1, iy, 1, 1, ix + w, iy - 1); // TR T[0, w-1]
825
+ cp(ix, iy + h - 1, 1, 1, ix - 1, iy + h); // BL T[h-1, 0]
826
+ cp(ix + w - 1, iy + h - 1, 1, 1, ix + w, iy + h); // BR T[h-1, w-1]
827
+ // ─── Outer wrap ring ──────────────────────────────────────────
828
+ // Edge strips (wrap Y or wrap X = opposite interior row/col)
829
+ cp(ix, iy + h - 1, w, 1, ix, iy - 2); // top wrap-Y: row h-1
830
+ cp(ix, iy, w, 1, ix, iy + h + 1); // bot wrap-Y: row 0
831
+ cp(ix + w - 1, iy, 1, h, ix - 2, iy); // left wrap-X: col w-1
832
+ cp(ix, iy, 1, h, ix + w + 1, iy); // right wrap-X: col 0
833
+ // Outer corners — each of 4 outer 2×2 blocks contains 3 outer-ring
834
+ // cells. Source rules per cell:
835
+ // (-2, -2) = wrap-X wrap-Y → diagonal opposite corner = T[h-1, w-1]
836
+ // (-1, -2) = clamp-X wrap-Y → T[h-1, 0]
837
+ // (-2, -1) = wrap-X clamp-Y → T[0, w-1]
838
+ // Top-left
839
+ cp(ix + w - 1, iy + h - 1, 1, 1, ix - 2, iy - 2); // T[h-1, w-1]
840
+ cp(ix, iy + h - 1, 1, 1, ix - 1, iy - 2); // T[h-1, 0]
841
+ cp(ix + w - 1, iy, 1, 1, ix - 2, iy - 1); // T[0, w-1]
842
+ // Top-right
843
+ cp(ix, iy + h - 1, 1, 1, ix + w + 1, iy - 2); // T[h-1, 0]
844
+ cp(ix + w - 1, iy + h - 1, 1, 1, ix + w, iy - 2); // T[h-1, w-1]
845
+ cp(ix, iy, 1, 1, ix + w + 1, iy - 1); // T[0, 0]
846
+ // Bottom-left
847
+ cp(ix + w - 1, iy, 1, 1, ix - 2, iy + h + 1); // T[0, w-1]
848
+ cp(ix, iy, 1, 1, ix - 1, iy + h + 1); // T[0, 0]
849
+ cp(ix + w - 1, iy + h - 1, 1, 1, ix - 2, iy + h); // T[h-1, w-1]
850
+ // Bottom-right
851
+ cp(ix, iy, 1, 1, ix + w + 1, iy + h + 1); // T[0, 0]
852
+ cp(ix + w - 1, iy, 1, 1, ix + w, iy + h + 1); // T[0, w-1]
853
+ cp(ix, iy + h - 1, 1, 1, ix + w + 1, iy + h); // T[h-1, 0]
854
+ this.device.queue.submit([enc.finish()]);
855
+ }
856
+
857
+ private uploadLevel(
858
+ page: AtlasPage,
859
+ x: number,
860
+ y: number,
861
+ host: HostTextureSource,
862
+ w: number,
863
+ h: number,
864
+ ): void {
865
+ if (host.kind === "external") {
866
+ this.device.queue.copyExternalImageToTexture(
867
+ { source: host.source as GPUImageCopyExternalImageSource },
868
+ { texture: page.texture, origin: { x, y, z: 0 } },
869
+ { width: w, height: h, depthOrArrayLayers: 1 },
870
+ );
871
+ return;
872
+ }
873
+ const data = host.data instanceof ArrayBuffer
874
+ ? new Uint8Array(host.data)
875
+ : new Uint8Array(host.data.buffer, host.data.byteOffset, host.data.byteLength);
876
+ this.writeRgba8Padded(page.texture, x, y, data, w, h);
877
+ }
878
+
879
+ /**
880
+ * Upload `src` (RGBA8, w×h) to `texture` at (x, y) via `writeTexture`.
881
+ * `writeTexture` requires `bytesPerRow` to be a multiple of 256 when
882
+ * the source has more than one row, so non-256-aligned widths need
883
+ * a padded buffer.
884
+ */
885
+ private writeRgba8Padded(
886
+ texture: GPUTexture, x: number, y: number,
887
+ src: Uint8Array, w: number, h: number,
888
+ ): void {
889
+ const srcStride = w * 4;
890
+ if (h === 1 || srcStride % 256 === 0) {
891
+ // Single row or already aligned — pass straight through.
892
+ this.device.queue.writeTexture(
893
+ { texture, origin: { x, y, z: 0 } },
894
+ src as unknown as GPUAllowSharedBufferSource,
895
+ { bytesPerRow: srcStride, rowsPerImage: h },
896
+ { width: w, height: h, depthOrArrayLayers: 1 },
897
+ );
898
+ return;
899
+ }
900
+ const dstStride = Math.ceil(srcStride / 256) * 256;
901
+ const padded = new Uint8Array(dstStride * h);
902
+ for (let row = 0; row < h; row++) {
903
+ padded.set(src.subarray(row * srcStride, (row + 1) * srcStride), row * dstStride);
904
+ }
905
+ this.device.queue.writeTexture(
906
+ { texture, origin: { x, y, z: 0 } },
907
+ padded as unknown as GPUAllowSharedBufferSource,
908
+ { bytesPerRow: dstStride, rowsPerImage: h },
909
+ { width: w, height: h, depthOrArrayLayers: 1 },
910
+ );
911
+ }
912
+
913
+ /**
914
+ * Produce a `CanvasImageSource` we can feed to `drawImage` for
915
+ * downscaling. Returns null when the runtime can't synthesise one
916
+ * (no DOM / OffscreenCanvas / ImageBitmap support).
917
+ */
918
+ private toDrawable(
919
+ host: HostTextureSource,
920
+ w: number,
921
+ h: number,
922
+ ): CanvasImageSource | null {
923
+ if (host.kind === "external") {
924
+ const s = host.source as unknown;
925
+ if (typeof ImageData !== "undefined" && s instanceof ImageData) {
926
+ return this.imageDataToCanvas(s);
927
+ }
928
+ return s as CanvasImageSource;
929
+ }
930
+ if (typeof ImageData === "undefined") return null;
931
+ const bytes = host.data instanceof ArrayBuffer
932
+ ? new Uint8Array(host.data)
933
+ : new Uint8Array(host.data.buffer, host.data.byteOffset, host.data.byteLength);
934
+ const out = new Uint8ClampedArray(bytes.length);
935
+ out.set(bytes);
936
+ const img = new ImageData(out, w, h);
937
+ return this.imageDataToCanvas(img);
938
+ }
939
+
940
+ private imageDataToCanvas(img: ImageData): CanvasImageSource | null {
941
+ const c = this.makeCanvas(img.width, img.height);
942
+ if (c === null) return null;
943
+ const ctx = c.getContext("2d");
944
+ if (ctx === null) return null;
945
+ (ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).putImageData(img, 0, 0);
946
+ return c as unknown as CanvasImageSource;
947
+ }
948
+
949
+ private makeMipCanvas(
950
+ src: CanvasImageSource,
951
+ w: number,
952
+ h: number,
953
+ ): CanvasImageSource | null {
954
+ const c = this.makeCanvas(w, h);
955
+ if (c === null) return null;
956
+ const ctx = c.getContext("2d") as
957
+ | CanvasRenderingContext2D
958
+ | OffscreenCanvasRenderingContext2D
959
+ | null;
960
+ if (ctx === null) return null;
961
+ ctx.imageSmoothingEnabled = true;
962
+ (ctx as CanvasRenderingContext2D).imageSmoothingQuality = "high";
963
+ ctx.drawImage(src, 0, 0, w, h);
964
+ return c as unknown as CanvasImageSource;
965
+ }
966
+
967
+ private makeCanvas(w: number, h: number): HTMLCanvasElement | OffscreenCanvas | null {
968
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(w, h);
969
+ if (typeof document !== "undefined") {
970
+ const c = document.createElement("canvas");
971
+ c.width = w;
972
+ c.height = h;
973
+ return c;
974
+ }
975
+ return null;
976
+ }
977
+
978
+ private makeResult(e: AtlasEntry): AtlasAcquisition {
979
+ const page = this.pagesByFormat.get(e.format)![e.pageId]!;
980
+ // Mip-0 interior sits at +2/+2 inside the reserved rect (skipping
981
+ // the 2-px gutter ring on the top and left).
982
+ return {
983
+ pageId: e.pageId,
984
+ origin: new V2f(e.subRect.x + 2, e.subRect.y + 2),
985
+ size: new V2f(e.mip0.w, e.mip0.h),
986
+ numMips: e.numMips,
987
+ ref: e.ref,
988
+ page,
989
+ };
990
+ }
991
+ }