@aardworx/wombat.rendering 0.9.4 → 0.9.6

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.
@@ -45,6 +45,11 @@ struct Params {
45
45
 
46
46
  @group(0) @binding(0) var<storage, read_write> buf: array<u32>;
47
47
  @group(0) @binding(1) var<uniform> P: Params;
48
+ // Optional mip-0 source — bound only by the interiorFromTexture
49
+ // entry point (the staging-texture upload path). The buffer-only
50
+ // entry points (interior, gutter) don't reference it, so their
51
+ // pipeline layouts omit it.
52
+ @group(0) @binding(2) var srcTex: texture_2d<f32>;
48
53
 
49
54
  fn loadRgba(x: u32, y: u32) -> vec4<f32> {
50
55
  let idx = y * P.buf_stride_u32 + x;
@@ -56,6 +61,18 @@ fn storeRgba(x: u32, y: u32, v: vec4<f32>) {
56
61
  buf[idx] = pack4x8unorm(v);
57
62
  }
58
63
 
64
+ // Copy the staging texture's pixels into the buffer at mip-0's
65
+ // interior offset (dst_origin_in_buf). src_size = staging size.
66
+ // Used by the external-source upload path so the source decode
67
+ // happens GPU-side (copyExternalImageToTexture -> staging) instead
68
+ // of via a main-thread canvas-2d getImageData round-trip.
69
+ @compute @workgroup_size(8, 8, 1)
70
+ fn interiorFromTexture(@builtin(global_invocation_id) gid: vec3<u32>) {
71
+ if (gid.x >= P.src_size.x || gid.y >= P.src_size.y) { return; }
72
+ let v = textureLoad(srcTex, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
73
+ storeRgba(P.dst_origin_in_buf.x + gid.x, P.dst_origin_in_buf.y + gid.y, v);
74
+ }
75
+
59
76
  @compute @workgroup_size(8, 8, 1)
60
77
  fn interior(@builtin(global_invocation_id) gid: vec3<u32>) {
61
78
  if (gid.x >= P.dst_size.x || gid.y >= P.dst_size.y) { return; }
@@ -104,9 +121,13 @@ fn gutter(@builtin(global_invocation_id) gid: vec3<u32>) {
104
121
  `;
105
122
 
106
123
  interface KernelCache {
107
- bindGroupLayout: GPUBindGroupLayout;
108
- interiorPipeline: GPUComputePipeline;
109
- gutterPipeline: GPUComputePipeline;
124
+ /** bindings 0 (storage buf) + 1 (uniform params) — buffer-only passes. */
125
+ bufBindGroupLayout: GPUBindGroupLayout;
126
+ /** bindings 0 + 1 + 2 (texture_2d src) — the mip-0-from-texture pass. */
127
+ texBindGroupLayout: GPUBindGroupLayout;
128
+ interiorPipeline: GPUComputePipeline;
129
+ gutterPipeline: GPUComputePipeline;
130
+ interiorFromTexturePipeline: GPUComputePipeline;
110
131
  }
111
132
 
112
133
  const caches = new WeakMap<GPUDevice, KernelCache>();
@@ -115,23 +136,39 @@ function getKernel(device: GPUDevice): KernelCache {
115
136
  const cached = caches.get(device);
116
137
  if (cached !== undefined) return cached;
117
138
  const module = device.createShaderModule({ code: WGSL, label: "atlas/mipGutterKernel" });
118
- const bindGroupLayout = device.createBindGroupLayout({
119
- label: "atlas/mipGutterKernel/bgl",
139
+ const bufBindGroupLayout = device.createBindGroupLayout({
140
+ label: "atlas/mipGutterKernel/bgl.buf",
141
+ entries: [
142
+ { binding: 0, visibility: 0x4 /* COMPUTE */, buffer: { type: "storage" } },
143
+ { binding: 1, visibility: 0x4 /* COMPUTE */, buffer: { type: "uniform" } },
144
+ ],
145
+ });
146
+ const texBindGroupLayout = device.createBindGroupLayout({
147
+ label: "atlas/mipGutterKernel/bgl.tex",
120
148
  entries: [
121
149
  { binding: 0, visibility: 0x4 /* COMPUTE */, buffer: { type: "storage" } },
122
150
  { binding: 1, visibility: 0x4 /* COMPUTE */, buffer: { type: "uniform" } },
151
+ { binding: 2, visibility: 0x4 /* COMPUTE */, texture: { sampleType: "float", viewDimension: "2d" } },
123
152
  ],
124
153
  });
125
- const layout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
154
+ const bufLayout = device.createPipelineLayout({ bindGroupLayouts: [bufBindGroupLayout] });
155
+ const texLayout = device.createPipelineLayout({ bindGroupLayouts: [texBindGroupLayout] });
126
156
  const interiorPipeline = device.createComputePipeline({
127
- layout, compute: { module, entryPoint: "interior" },
157
+ layout: bufLayout, compute: { module, entryPoint: "interior" },
128
158
  label: "atlas/mipGutterKernel/interior",
129
159
  });
130
160
  const gutterPipeline = device.createComputePipeline({
131
- layout, compute: { module, entryPoint: "gutter" },
161
+ layout: bufLayout, compute: { module, entryPoint: "gutter" },
132
162
  label: "atlas/mipGutterKernel/gutter",
133
163
  });
134
- const entry: KernelCache = { bindGroupLayout, interiorPipeline, gutterPipeline };
164
+ const interiorFromTexturePipeline = device.createComputePipeline({
165
+ layout: texLayout, compute: { module, entryPoint: "interiorFromTexture" },
166
+ label: "atlas/mipGutterKernel/interiorFromTexture",
167
+ });
168
+ const entry: KernelCache = {
169
+ bufBindGroupLayout, texBindGroupLayout,
170
+ interiorPipeline, gutterPipeline, interiorFromTexturePipeline,
171
+ };
135
172
  caches.set(device, entry);
136
173
  return entry;
137
174
  }
@@ -148,67 +185,41 @@ export interface MipSlot {
148
185
  readonly size: { w: number; h: number };
149
186
  }
150
187
 
188
+ /** How mip-0's interior pixels get into the scratch buffer. */
189
+ type Mip0Source =
190
+ // The caller already filled the buffer's mip-0 region at creation
191
+ // (via `mappedAtCreation`) — no extra pass needed.
192
+ | { readonly kind: "prefilled" }
193
+ // Add an `interiorFromTexture` compute pass that reads the staging
194
+ // texture and writes mip-0's interior into the buffer.
195
+ | { readonly kind: "texture"; readonly tex: GPUTexture };
196
+
151
197
  /**
152
- * Build the mip pyramid + gutters on GPU and upload the result to a
153
- * sub-rect of `page`. `srcPixels` is the raw RGBA8 source for mip 0
154
- * (`srcW × srcH` pixels). The kernel:
155
- * - Allocates a per-acquire scratch buffer.
156
- * - Initialises the buffer at creation with mip-0 source pixels at
157
- * the mip-0 interior offset.
158
- * - Dispatches per-mip interior + gutter compute passes.
159
- * - copyBufferToTexture into the page at (subRectX, subRectY).
160
- *
161
- * Caller must ensure the page has COPY_DST usage (atlas pages
162
- * always do).
198
+ * Shared orchestration: allocate a scratch buffer laid out as the
199
+ * sub-rect's bounding box, initialise mip-0 (per `mip0Src`), run the
200
+ * per-mip interior-downscale + gutter compute passes over the buffer,
201
+ * then `copyBufferToTexture` into `page` at (subRectX, subRectY). The
202
+ * scratch buffer + per-pass UBOs are freed once the submission
203
+ * completes. Caller owns `mip0Src.tex` (if any) and its lifetime.
163
204
  *
164
- * Buffer is destroyed once submitted work completes (fire-and-forget
165
- * via `onSubmittedWorkDone`).
205
+ * `makeScratch` lets the `prefilled` path create the buffer with
206
+ * `mappedAtCreation` and fill it before we run; the `texture` path
207
+ * passes a plain (already-created) buffer.
166
208
  */
167
- export function buildMipsAndGutterOnGpu(
209
+ function runMipGutter(
168
210
  device: GPUDevice,
169
211
  page: GPUTexture,
170
212
  subRectX: number, subRectY: number,
171
213
  boundsW: number, boundsH: number,
172
- srcPixels: Uint8Array,
214
+ rowBytes: number,
215
+ scratch: GPUBuffer,
173
216
  srcW: number, srcH: number,
174
217
  mips: readonly MipSlot[],
218
+ mip0Src: Mip0Source,
175
219
  ): void {
176
- if (mips.length === 0) return;
177
- const { bindGroupLayout, interiorPipeline, gutterPipeline } = getKernel(device);
178
-
179
- // Buffer layout: rows padded to 256 bytes for the final
180
- // copyBufferToTexture. Stride in u32: 256 / 4 = 64 minimum, or
181
- // ceil(boundsW * 4 / 256) * 256 / 4.
182
- const rowBytes = Math.max(256, Math.ceil(boundsW * 4 / 256) * 256);
220
+ const k = getKernel(device);
183
221
  const bufStrideU32 = rowBytes / 4;
184
- const bufSize = rowBytes * boundsH;
185
-
186
- const scratch = device.createBuffer({
187
- label: `atlas/mipGutter/scratch(${boundsW}x${boundsH})`,
188
- size: bufSize,
189
- usage: 0x80 /* STORAGE */ | 0x04 /* COPY_SRC */ | 0x08 /* COPY_DST */,
190
- mappedAtCreation: true,
191
- });
192
-
193
- // CPU init: write mip-0 source pixels at the mip-0 interior offset
194
- // inside the buffer. mips[0].origin is the interior offset.
195
222
  const mip0 = mips[0]!;
196
- {
197
- const mapped = new Uint32Array(scratch.getMappedRange());
198
- for (let y = 0; y < srcH; y++) {
199
- for (let x = 0; x < srcW; x++) {
200
- const si = (y * srcW + x) * 4;
201
- const r = srcPixels[si]!;
202
- const g = srcPixels[si + 1]!;
203
- const b = srcPixels[si + 2]!;
204
- const a = srcPixels[si + 3]!;
205
- // little-endian rgba8: byte0=r, byte1=g, byte2=b, byte3=a.
206
- const packed = (a << 24) | (b << 16) | (g << 8) | r;
207
- mapped[(mip0.origin.y + y) * bufStrideU32 + (mip0.origin.x + x)] = packed >>> 0;
208
- }
209
- }
210
- }
211
- scratch.unmap();
212
223
 
213
224
  const enc = device.createCommandEncoder({ label: "atlas/mipGutterKernel" });
214
225
 
@@ -233,38 +244,59 @@ export function buildMipsAndGutterOnGpu(
233
244
  ubos.push(buf);
234
245
  return buf;
235
246
  };
236
- const makeBg = (ubo: GPUBuffer): GPUBindGroup =>
247
+ const makeBufBg = (ubo: GPUBuffer): GPUBindGroup =>
237
248
  device.createBindGroup({
238
- layout: bindGroupLayout,
249
+ layout: k.bufBindGroupLayout,
239
250
  entries: [
240
251
  { binding: 0, resource: { buffer: scratch } },
241
252
  { binding: 1, resource: { buffer: ubo } },
242
253
  ],
243
254
  });
244
255
 
256
+ // Mip-0 init pass — only the staging-texture path needs one. Reads
257
+ // `srcTex[gid]` for gid in [0,srcW)×[0,srcH), writes into the buffer
258
+ // at mip0.origin. Its own pass so the write is visible to the
259
+ // downstream interior/gutter passes (the pass boundary is a barrier).
260
+ if (mip0Src.kind === "texture") {
261
+ const ubo = makeUbo(mip0.origin, { w: srcW, h: srcH }, mip0.origin, { w: srcW, h: srcH });
262
+ const bg = device.createBindGroup({
263
+ layout: k.texBindGroupLayout,
264
+ entries: [
265
+ { binding: 0, resource: { buffer: scratch } },
266
+ { binding: 1, resource: { buffer: ubo } },
267
+ { binding: 2, resource: mip0Src.tex.createView() },
268
+ ],
269
+ });
270
+ const pass = enc.beginComputePass({ label: "atlas/mipGutter/interiorFromTexture" });
271
+ pass.setPipeline(k.interiorFromTexturePipeline);
272
+ pass.setBindGroup(0, bg);
273
+ pass.dispatchWorkgroups(Math.ceil(srcW / 8), Math.ceil(srcH / 8), 1);
274
+ pass.end();
275
+ }
276
+
245
277
  // Interior passes: one compute pass per mip > 0 (pass boundary
246
278
  // gives us the barrier ensuring mip-(k-1) is fully written before
247
279
  // mip-k reads it).
248
- for (let k = 1; k < mips.length; k++) {
249
- const src = mips[k - 1]!;
250
- const dst = mips[k]!;
280
+ for (let m = 1; m < mips.length; m++) {
281
+ const src = mips[m - 1]!;
282
+ const dst = mips[m]!;
251
283
  const ubo = makeUbo(src.origin, src.size, dst.origin, dst.size);
252
- const pass = enc.beginComputePass({ label: `atlas/mipGutter/interior/${k}` });
253
- pass.setPipeline(interiorPipeline);
254
- pass.setBindGroup(0, makeBg(ubo));
284
+ const pass = enc.beginComputePass({ label: `atlas/mipGutter/interior/${m}` });
285
+ pass.setPipeline(k.interiorPipeline);
286
+ pass.setBindGroup(0, makeBufBg(ubo));
255
287
  pass.dispatchWorkgroups(Math.ceil(dst.size.w / 8), Math.ceil(dst.size.h / 8), 1);
256
288
  pass.end();
257
289
  }
258
290
 
259
- // Gutter passes: one per mip (each pass reads mip-k interior,
260
- // which was either CPU-uploaded for k=0 or written by the interior
261
- // pass for k>0).
262
- for (let k = 0; k < mips.length; k++) {
263
- const dst = mips[k]!;
291
+ // Gutter passes: one per mip (each pass reads mip-m interior, which
292
+ // was either filled at buffer creation / by the texture pass for
293
+ // m=0, or by the interior pass for m>0).
294
+ for (let m = 0; m < mips.length; m++) {
295
+ const dst = mips[m]!;
264
296
  const ubo = makeUbo(dst.origin, dst.size, dst.origin, dst.size);
265
- const pass = enc.beginComputePass({ label: `atlas/mipGutter/gutter/${k}` });
266
- pass.setPipeline(gutterPipeline);
267
- pass.setBindGroup(0, makeBg(ubo));
297
+ const pass = enc.beginComputePass({ label: `atlas/mipGutter/gutter/${m}` });
298
+ pass.setPipeline(k.gutterPipeline);
299
+ pass.setBindGroup(0, makeBufBg(ubo));
268
300
  pass.dispatchWorkgroups(
269
301
  Math.ceil((dst.size.w + 4) / 8),
270
302
  Math.ceil((dst.size.h + 4) / 8),
@@ -289,3 +321,103 @@ export function buildMipsAndGutterOnGpu(
289
321
  for (const b of ubos) b.destroy();
290
322
  });
291
323
  }
324
+
325
+ /** Scratch-buffer geometry for a sub-rect's bounding box. Rows are
326
+ * padded to 256 bytes for the final `copyBufferToTexture`. */
327
+ function scratchGeom(boundsW: number, boundsH: number): { rowBytes: number; bufSize: number } {
328
+ const rowBytes = Math.max(256, Math.ceil(boundsW * 4 / 256) * 256);
329
+ return { rowBytes, bufSize: rowBytes * boundsH };
330
+ }
331
+
332
+ /**
333
+ * Build the mip pyramid + gutters on GPU from a **CPU pixel array**
334
+ * and upload the result to a sub-rect of `page`. `srcPixels` is the
335
+ * raw RGBA8 source for mip 0 (`srcW × srcH` pixels). The scratch
336
+ * buffer's mip-0 region is filled at creation (`mappedAtCreation`),
337
+ * then the compute passes downscale the remaining mips and fill every
338
+ * mip's 2-px gutter ring. Used for `raw` host sources (where the
339
+ * pixels are already in memory — no extraction cost) and as the
340
+ * fallback for `external` sources when the GPU staging path is
341
+ * unavailable. Caller must ensure `page` has COPY_DST usage.
342
+ */
343
+ export function buildMipsAndGutterOnGpu(
344
+ device: GPUDevice,
345
+ page: GPUTexture,
346
+ subRectX: number, subRectY: number,
347
+ boundsW: number, boundsH: number,
348
+ srcPixels: Uint8Array,
349
+ srcW: number, srcH: number,
350
+ mips: readonly MipSlot[],
351
+ ): void {
352
+ if (mips.length === 0) return;
353
+ const { rowBytes, bufSize } = scratchGeom(boundsW, boundsH);
354
+ const bufStrideU32 = rowBytes / 4;
355
+
356
+ const scratch = device.createBuffer({
357
+ label: `atlas/mipGutter/scratch(${boundsW}x${boundsH})`,
358
+ size: bufSize,
359
+ usage: 0x80 /* STORAGE */ | 0x04 /* COPY_SRC */ | 0x08 /* COPY_DST */,
360
+ mappedAtCreation: true,
361
+ });
362
+
363
+ // CPU init: write mip-0 source pixels at the mip-0 interior offset
364
+ // inside the buffer. mips[0].origin is the interior offset.
365
+ const mip0 = mips[0]!;
366
+ {
367
+ const mapped = new Uint32Array(scratch.getMappedRange());
368
+ for (let y = 0; y < srcH; y++) {
369
+ for (let x = 0; x < srcW; x++) {
370
+ const si = (y * srcW + x) * 4;
371
+ const r = srcPixels[si]!;
372
+ const g = srcPixels[si + 1]!;
373
+ const b = srcPixels[si + 2]!;
374
+ const a = srcPixels[si + 3]!;
375
+ // little-endian rgba8: byte0=r, byte1=g, byte2=b, byte3=a.
376
+ const packed = (a << 24) | (b << 16) | (g << 8) | r;
377
+ mapped[(mip0.origin.y + y) * bufStrideU32 + (mip0.origin.x + x)] = packed >>> 0;
378
+ }
379
+ }
380
+ }
381
+ scratch.unmap();
382
+
383
+ runMipGutter(
384
+ device, page, subRectX, subRectY, boundsW, boundsH,
385
+ rowBytes, scratch, srcW, srcH, mips, { kind: "prefilled" },
386
+ );
387
+ }
388
+
389
+ /**
390
+ * Build the mip pyramid + gutters on GPU from a **staging texture**
391
+ * and upload the result to a sub-rect of `page`. `srcTex` holds the
392
+ * decoded mip-0 pixels (`srcW × srcH`) — typically just filled by
393
+ * `copyExternalImageToTexture`, so no main-thread pixel readback is
394
+ * involved. An `interiorFromTexture` compute pass copies `srcTex` into
395
+ * the scratch buffer; the rest is identical to `buildMipsAndGutterOnGpu`
396
+ * (downscale every further mip, fill every mip's 2-px gutter ring,
397
+ * single `copyBufferToTexture` into the page).
398
+ *
399
+ * Caller owns `srcTex` and is responsible for destroying it after the
400
+ * submission completes (`device.queue.onSubmittedWorkDone()`); this
401
+ * function only reads it. `srcTex` must have TEXTURE_BINDING usage.
402
+ */
403
+ export function buildMipsAndGutterFromTexture(
404
+ device: GPUDevice,
405
+ page: GPUTexture,
406
+ subRectX: number, subRectY: number,
407
+ boundsW: number, boundsH: number,
408
+ srcTex: GPUTexture,
409
+ srcW: number, srcH: number,
410
+ mips: readonly MipSlot[],
411
+ ): void {
412
+ if (mips.length === 0) return;
413
+ const { rowBytes, bufSize } = scratchGeom(boundsW, boundsH);
414
+ const scratch = device.createBuffer({
415
+ label: `atlas/mipGutter/scratch(${boundsW}x${boundsH})`,
416
+ size: bufSize,
417
+ usage: 0x80 /* STORAGE */ | 0x04 /* COPY_SRC */ | 0x08 /* COPY_DST */,
418
+ });
419
+ runMipGutter(
420
+ device, page, subRectX, subRectY, boundsW, boundsH,
421
+ rowBytes, scratch, srcW, srcH, mips, { kind: "texture", tex: srcTex },
422
+ );
423
+ }
@@ -33,7 +33,7 @@ import { V2f, V2i } from "@aardworx/wombat.base";
33
33
  import { type aval, HashTable } from "@aardworx/wombat.adaptive";
34
34
  import type { ITexture, HostTextureSource } from "../../core/texture.js";
35
35
  import { TexturePacking } from "./packer.js";
36
- import { buildMipsAndGutterOnGpu, type MipSlot } from "./atlasMipGutterKernel.js";
36
+ import { buildMipsAndGutterOnGpu, buildMipsAndGutterFromTexture, type MipSlot } from "./atlasMipGutterKernel.js";
37
37
 
38
38
  export const ATLAS_PAGE_SIZE = 4096;
39
39
  /** Tier S/M source-side dimension cap. */
@@ -319,13 +319,15 @@ export class AtlasPool {
319
319
  }
320
320
 
321
321
  private allocatePage(format: AtlasPageFormat, pageId: number): AtlasPage {
322
- // rgba8unorm pages gain STORAGE_BINDING for the compute mip+gutter
323
- // kernel. rgba8unorm-srgb isn't a storage-capable format in WebGPU
324
- // 1.0 (without an unportable feature), so srgb pages fall back to
325
- // the CPU buildExtendedWithGutter path. Mixed strategy: linear
326
- // textures get the fast compute path; srgb textures take the CPU
327
- // path. Both produce identical visible output only the kernel
328
- // path differs.
322
+ // The compute mip+gutter kernel works for *all* page formats: it
323
+ // builds the pyramid + gutters in a scratch storage *buffer* (rgba8
324
+ // u32s) and finishes with `copyBufferToTexture` into the page, so
325
+ // the page itself never needs to be a storage texture — srgb pages
326
+ // included (the copy reinterprets the bytes; no colour conversion).
327
+ // Pages therefore only need TEXTURE_BINDING | COPY_DST | COPY_SRC |
328
+ // RENDER_ATTACHMENT (no STORAGE_BINDING). The CPU `buildExtended-
329
+ // WithGutter` path remains only as a fallback for the node mock-GPU
330
+ // device and headless-without-canvas edge cases.
329
331
  const texture = this.device.createTexture({
330
332
  label: `atlas/${format}/${pageId}`,
331
333
  size: { width: ATLAS_PAGE_SIZE, height: ATLAS_PAGE_SIZE, depthOrArrayLayers: 1 },
@@ -713,83 +715,102 @@ export class AtlasPool {
713
715
  h: number,
714
716
  numMips: number,
715
717
  ): void {
716
- // Fast path `external` source, no mips: upload mip-0 straight to
717
- // the page with `copyExternalImageToTexture` (decode happens on the
718
- // GPU/compositor side, not the main thread) and fill the 2-px
719
- // gutter ring with tiny intra-page `copyTextureToTexture` ops. This
720
- // skips `extractPixels`, whose canvas-2d `drawImage`+`getImageData`
721
- // round-trip per texture was ~8% of cold-boot CPU on the main
722
- // thread (the heap-demo-sg profile). If the platform rejects
723
- // same-texture copies (the original reason this path didn't exist
724
- // older Dawn) the gutter cells just stay as-is; per the existing
725
- // tolerance that's "visually invisible" for typical atlas content,
726
- // and mip-0 itself is correct either way. `numMips > 1` and `raw`
727
- // sources keep the kernel / CPU path (mip downsampling genuinely
728
- // needs the pixels; for `raw` `extractPixels` is a free slice).
729
- if (host.kind === "external" && numMips <= 1) {
730
- try {
731
- this.device.queue.copyExternalImageToTexture(
732
- { source: host.source as GPUImageCopyExternalImageSource },
733
- { texture: page.texture, origin: { x, y, z: 0 } },
734
- { width: w, height: h, depthOrArrayLayers: 1 },
735
- );
736
- this.fillGutterRing(page.texture, x, y, w, h);
737
- return;
738
- } catch {
739
- // Source not in a copyable state (e.g. a not-ready video) —
740
- // fall through to the CPU/kernel path below.
741
- }
742
- }
743
- // For storage-capable pages (rgba8unorm): use the GPU compute
744
- // kernel to build mips + gutter in one pass. Mip 0 interior is
745
- // uploaded raw via writeTexture; the kernel reads from there to
746
- // both downsample mip 1..N-1 and fill every mip's 2-px gutter
747
- // ring. No CPU canvas-2d, no per-mip extended-buffer build.
718
+ // GPU mip+gutter kernel path (the normal, real-device path).
719
+ //
720
+ // Builds the full Iliffe pyramid for the sub-rect — every mip,
721
+ // each surrounded by the proper 2-px gutter ring (inner ring =
722
+ // clamp-replicate of the nearest edge texel, outer ring = wrap of
723
+ // the opposite edge) entirely on the GPU, in a scratch storage
724
+ // buffer, then a single `copyBufferToTexture` lands the real
725
+ // texture (interior + gutter, all mips) in the atlas page. No
726
+ // canvas-2d, no per-mip CPU buffer build. (`copyTextureToTexture`
727
+ // can't be used for the gutter — WebGPU forbids same-(sub)resource
728
+ // texture copies, §22.5.5 which is why this goes through a
729
+ // buffer.)
730
+ //
731
+ // Mip-0 source:
732
+ // · `external` host (ImageBitmap / <img> / <canvas> / …):
733
+ // `copyExternalImageToTexture` decodes it into a transient
734
+ // staging texture (decode happens GPU/compositor-side, off the
735
+ // main thread) and an `interiorFromTexture` compute pass copies
736
+ // that into the buffer no `getImageData` round-trip (that
737
+ // canvas-2d path was ~8% of cold-boot CPU in the heap-demo-sg
738
+ // profile). The staging texture is freed once the work submits.
739
+ // · `raw` host: the pixels are already in memory; `extractPixels`
740
+ // is a free slice — write them straight into the buffer.
748
741
  if (this.canUseGpuKernel(page)) {
749
- const px = this.extractPixels(host, w, h);
750
- if (px !== null) {
751
- // Bounding rect that the sub-rect occupies — caller passed
752
- // (x, y) as the interior position (sub-rect origin + 2/+2).
753
- // The scratch buffer covers this bounding rect.
754
- const boundsX = x - 2;
755
- const boundsY = y - 2;
756
- // Reserved size matches the packer's allocation.
757
- const reservedW = numMips > 1
758
- ? (w + 4) + Math.max(1, w >> 1) + 4
759
- : w + 4;
760
- let reservedH = h + 4;
761
- if (numMips > 1) {
762
- let stackedH = 0;
763
- for (let k = 1; k < numMips; k++) stackedH += Math.max(1, h >> k) + 4;
764
- reservedH = Math.max(reservedH, stackedH);
765
- }
766
- // Mip slot offsets, in bounding-rect-relative pixel coords.
767
- // mip-0 interior is at (+2, +2) inside the bounds.
768
- const slots: MipSlot[] = [];
769
- for (let k = 0; k < numMips; k++) {
770
- const off = mipOffsetInPyramid(w, h, k);
771
- slots.push({
772
- origin: { x: 2 + off.x, y: 2 + off.y },
773
- size: { w: Math.max(1, w >> k), h: Math.max(1, h >> k) },
742
+ // Bounding rect the sub-rect occupies: caller passed (x, y) as
743
+ // the mip-0 interior position (sub-rect origin + 2/+2). Reserved
744
+ // size matches the packer's allocation.
745
+ const boundsX = x - 2;
746
+ const boundsY = y - 2;
747
+ const reservedW = numMips > 1
748
+ ? (w + 4) + Math.max(1, w >> 1) + 4
749
+ : w + 4;
750
+ let reservedH = h + 4;
751
+ if (numMips > 1) {
752
+ let stackedH = 0;
753
+ for (let k = 1; k < numMips; k++) stackedH += Math.max(1, h >> k) + 4;
754
+ reservedH = Math.max(reservedH, stackedH);
755
+ }
756
+ // Mip slot offsets, in bounding-rect-relative pixel coords.
757
+ // mip-0 interior is at (+2, +2) inside the bounds.
758
+ const slots: MipSlot[] = [];
759
+ for (let k = 0; k < numMips; k++) {
760
+ const off = mipOffsetInPyramid(w, h, k);
761
+ slots.push({
762
+ origin: { x: 2 + off.x, y: 2 + off.y },
763
+ size: { w: Math.max(1, w >> k), h: Math.max(1, h >> k) },
764
+ });
765
+ }
766
+ if (host.kind === "external") {
767
+ try {
768
+ const staging = this.device.createTexture({
769
+ label: `atlas/staging(${w}x${h})`,
770
+ size: { width: w, height: h, depthOrArrayLayers: 1 },
771
+ format: "rgba8unorm",
772
+ // copyExternalImageToTexture requires COPY_DST | RENDER_ATTACHMENT
773
+ // on the destination; TEXTURE_BINDING for the kernel's read.
774
+ usage:
775
+ GPUTextureUsage.TEXTURE_BINDING |
776
+ GPUTextureUsage.COPY_DST |
777
+ GPUTextureUsage.RENDER_ATTACHMENT,
774
778
  });
779
+ this.device.queue.copyExternalImageToTexture(
780
+ { source: host.source as GPUImageCopyExternalImageSource },
781
+ { texture: staging },
782
+ { width: w, height: h, depthOrArrayLayers: 1 },
783
+ );
784
+ buildMipsAndGutterFromTexture(
785
+ this.device, page.texture,
786
+ boundsX, boundsY, reservedW, reservedH,
787
+ staging, w, h, slots,
788
+ );
789
+ void this.device.queue.onSubmittedWorkDone().then(() => staging.destroy());
790
+ return;
791
+ } catch {
792
+ // Source not in a copyable state (e.g. a not-ready video) —
793
+ // fall through to the CPU path below.
775
794
  }
776
- buildMipsAndGutterOnGpu(
777
- this.device, page.texture,
778
- boundsX, boundsY, reservedW, reservedH,
779
- px, w, h, slots,
780
- );
781
- return;
795
+ } else {
796
+ const px = this.extractPixels(host, w, h);
797
+ if (px !== null) {
798
+ buildMipsAndGutterOnGpu(
799
+ this.device, page.texture,
800
+ boundsX, boundsY, reservedW, reservedH,
801
+ px, w, h, slots,
802
+ );
803
+ return;
804
+ }
805
+ // `raw` extraction shouldn't fail; if it somehow does, fall
806
+ // through to the CPU path.
782
807
  }
783
- // Pixel extraction failed (headless without canvas for external
784
- // sources) — fall through to CPU path below.
785
808
  }
786
- // Otherwise (srgb pages): keep the CPU path.
787
- // Mip 0 lands at (x, y)already the interior position (caller
788
- // offset by +2/+2 for the gutter ring). Use the gutter-aware
789
- // upload path: builds (w+4)×(h+4) with both gutter rings
790
- // pre-filled CPU-side, then a single writeTexture covers the
791
- // whole rect. Falls back to plain uploadLevel if pixel
792
- // extraction fails (headless without canvas).
809
+ // Fallback: node mock-GPU device (no compute kernel) or the kernel
810
+ // path above threw. CPU gutter-extended uploadsame visible
811
+ // result. Mip 0 lands at (x, y) already the interior position
812
+ // (caller offset by +2/+2 for the gutter ring). Falls back to
813
+ // plain uploadLevel if pixel extraction fails (headless w/o canvas).
793
814
  if (!this.uploadLevelWithGutter(page, x, y, host, w, h)) {
794
815
  this.uploadLevel(page, x, y, host, w, h);
795
816
  }
@@ -927,69 +948,6 @@ export class AtlasPool {
927
948
  return true;
928
949
  }
929
950
 
930
- /**
931
- * Fill a sub-rect's 2-px gutter ring (inner clamp-replicate ring +
932
- * outer wrap ring) from the already-uploaded interior pixels, using
933
- * ~24 tiny intra-page `copyTextureToTexture` ops. `(ix, iy)` is the
934
- * interior origin (= sub-rect origin + 2,+2), `w×h` the interior
935
- * size. Used by the `copyExternalImageToTexture` fast path. If the
936
- * platform rejects same-texture copies these become no-ops (validation
937
- * errors don't throw synchronously) and the gutter cells stay as-is —
938
- * which is the documented "visually invisible" tolerance.
939
- */
940
- private fillGutterRing(
941
- t: GPUTexture,
942
- ix: number, iy: number, w: number, h: number,
943
- ): void {
944
- const enc = this.device.createCommandEncoder({ label: "atlas/gutter" });
945
- const cp = (sx: number, sy: number, sw: number, sh: number, dx: number, dy: number): void => {
946
- enc.copyTextureToTexture(
947
- { texture: t, origin: { x: sx, y: sy, z: 0 } },
948
- { texture: t, origin: { x: dx, y: dy, z: 0 } },
949
- { width: sw, height: sh, depthOrArrayLayers: 1 },
950
- );
951
- };
952
- // ─── Inner clamp-replicate ring ───────────────────────────────
953
- // Edge strips (clamp Y or clamp X = nearest interior row/col)
954
- cp(ix, iy, w, 1, ix, iy - 1); // top : row 0
955
- cp(ix, iy + h - 1, w, 1, ix, iy + h); // bottom : row h-1
956
- cp(ix, iy, 1, h, ix - 1, iy); // left : col 0
957
- cp(ix + w - 1, iy, 1, h, ix + w, iy); // right : col w-1
958
- // Inner corners (clamp X × clamp Y = the diagonal interior texel)
959
- cp(ix, iy, 1, 1, ix - 1, iy - 1); // TL T[0, 0]
960
- cp(ix + w - 1, iy, 1, 1, ix + w, iy - 1); // TR T[0, w-1]
961
- cp(ix, iy + h - 1, 1, 1, ix - 1, iy + h); // BL T[h-1, 0]
962
- cp(ix + w - 1, iy + h - 1, 1, 1, ix + w, iy + h); // BR T[h-1, w-1]
963
- // ─── Outer wrap ring ──────────────────────────────────────────
964
- // Edge strips (wrap Y or wrap X = opposite interior row/col)
965
- cp(ix, iy + h - 1, w, 1, ix, iy - 2); // top wrap-Y: row h-1
966
- cp(ix, iy, w, 1, ix, iy + h + 1); // bot wrap-Y: row 0
967
- cp(ix + w - 1, iy, 1, h, ix - 2, iy); // left wrap-X: col w-1
968
- cp(ix, iy, 1, h, ix + w + 1, iy); // right wrap-X: col 0
969
- // Outer corners — each of 4 outer 2×2 blocks contains 3 outer-ring
970
- // cells. Source rules per cell:
971
- // (-2, -2) = wrap-X wrap-Y → diagonal opposite corner = T[h-1, w-1]
972
- // (-1, -2) = clamp-X wrap-Y → T[h-1, 0]
973
- // (-2, -1) = wrap-X clamp-Y → T[0, w-1]
974
- // Top-left
975
- cp(ix + w - 1, iy + h - 1, 1, 1, ix - 2, iy - 2); // T[h-1, w-1]
976
- cp(ix, iy + h - 1, 1, 1, ix - 1, iy - 2); // T[h-1, 0]
977
- cp(ix + w - 1, iy, 1, 1, ix - 2, iy - 1); // T[0, w-1]
978
- // Top-right
979
- cp(ix, iy + h - 1, 1, 1, ix + w + 1, iy - 2); // T[h-1, 0]
980
- cp(ix + w - 1, iy + h - 1, 1, 1, ix + w, iy - 2); // T[h-1, w-1]
981
- cp(ix, iy, 1, 1, ix + w + 1, iy - 1); // T[0, 0]
982
- // Bottom-left
983
- cp(ix + w - 1, iy, 1, 1, ix - 2, iy + h + 1); // T[0, w-1]
984
- cp(ix, iy, 1, 1, ix - 1, iy + h + 1); // T[0, 0]
985
- cp(ix + w - 1, iy + h - 1, 1, 1, ix - 2, iy + h); // T[h-1, w-1]
986
- // Bottom-right
987
- cp(ix, iy, 1, 1, ix + w + 1, iy + h + 1); // T[0, 0]
988
- cp(ix + w - 1, iy, 1, 1, ix + w, iy + h + 1); // T[0, w-1]
989
- cp(ix, iy + h - 1, 1, 1, ix + w + 1, iy + h); // T[h-1, 0]
990
- this.device.queue.submit([enc.finish()]);
991
- }
992
-
993
951
  private uploadLevel(
994
952
  page: AtlasPage,
995
953
  x: number,