@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
@@ -3,7 +3,7 @@
3
3
  // (eventually) `renderTo(...)`.
4
4
 
5
5
  import type { Command, CompiledEffect, Effect, FramebufferSignature, IRenderTask, RenderTree } from "../core/index.js";
6
- import type { alist } from "@aardworx/wombat.adaptive";
6
+ import type { alist, aval } from "@aardworx/wombat.adaptive";
7
7
  import { compileRenderTask, type RuntimeContext } from "./renderTask.js";
8
8
  import { renderTo, type RenderToOptions, type RenderToResult } from "./renderTo.js";
9
9
 
@@ -17,6 +17,30 @@ export interface RuntimeOptions {
17
17
  * …).
18
18
  */
19
19
  readonly compileEffect?: (effect: Effect, signature: FramebufferSignature) => CompiledEffect;
20
+ /**
21
+ * Global on/off toggle for the heap fast path. When `false`,
22
+ * every RO routes through the legacy per-RO renderer. Reactive:
23
+ * ROs migrate between heap and legacy subsets when the cval
24
+ * flips. Useful for A/B perf comparisons. Default: heap on.
25
+ */
26
+ readonly heapEnabled?: aval<boolean>;
27
+ /**
28
+ * §6 family-merge opt-in. When `true`, all heap-eligible effects
29
+ * collapse into one bucket per pipelineState via layoutId-switch
30
+ * dispatch. Default `false`: each effect lands in its own bucket /
31
+ * shader / pipeline. Per-effect bucketing is at-or-better than
32
+ * merged on tested workloads; merge stays opt-in until the v2
33
+ * trace-based auto-trigger lands.
34
+ */
35
+ readonly enableFamilyMerge?: boolean;
36
+ /**
37
+ * §7 derived-uniforms opt-in. When true, ModelView / ViewProj /
38
+ * ModelViewProj / NormalMatrix / *TrafoInv / etc. are produced by a
39
+ * df32 GPU compute pre-pass instead of being supplied per-RO by the
40
+ * uniform provider. ROs MUST supply ModelTrafo / ViewTrafo /
41
+ * ProjTrafo as `aval<Trafo3d>` for any derived they consume.
42
+ */
43
+ readonly enableDerivedUniforms?: boolean;
20
44
  }
21
45
 
22
46
  /**
@@ -50,6 +74,9 @@ export class Runtime {
50
74
  target: "wgsl",
51
75
  fragmentOutputLayout: layoutFromSignature(sig),
52
76
  })),
77
+ ...(opts.heapEnabled !== undefined ? { heapEnabled: opts.heapEnabled } : {}),
78
+ ...(opts.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
79
+ ...(opts.enableDerivedUniforms === true ? { enableDerivedUniforms: true } : {}),
53
80
  };
54
81
  // `device.lost` is a real-WebGPU promise; mock devices may not
55
82
  // expose it. Treat as "never lost" in that case.
@@ -88,10 +115,16 @@ export class Runtime {
88
115
  // cleanest path is to discard the old Runtime and construct a new
89
116
  // one from the same source `alist<Command>`.
90
117
 
91
- /** Compile an `alist<Command>` into a runnable `IRenderTask`. */
92
- compile(commands: alist<Command>): IRenderTask {
118
+ /**
119
+ * Compile an `alist<Command>` into a runnable `IRenderTask`. The
120
+ * `signature` is constant across the task's lifetime — pipelines
121
+ * specialise against it at compile time. Framebuffer instances are
122
+ * supplied at `task.run(framebuffer, token)`; their signatures
123
+ * must match.
124
+ */
125
+ compile(signature: FramebufferSignature, commands: alist<Command>): IRenderTask {
93
126
  if (this._disposed) throw new Error("Runtime: compile after disposeAll");
94
- const task = compileRenderTask(this.ctx, commands);
127
+ const task = compileRenderTask(this.ctx, signature, commands);
95
128
  this._tasks.add(task);
96
129
  const origDispose = task.dispose.bind(task);
97
130
  task.dispose = () => { origDispose(); this._tasks.delete(task); };
@@ -261,17 +261,52 @@ export class ScenePass {
261
261
  }
262
262
 
263
263
  /**
264
- * Pull deltas from every dynamic subtree, splice the affected
265
- * walkers, and produce the current ordered list of leaves.
266
- * Idempotent on a clean adaptive graph (no readers fire).
264
+ * Pull deltas from every dynamic subtree and splice the affected
265
+ * walkers. CPU-only no GPU work. After this returns, the leaf
266
+ * set is current and any per-leaf prepared resources have been
267
+ * brought up to date. Idempotent on a clean adaptive graph.
268
+ *
269
+ * Hybrid composition: call this BEFORE opening the render pass,
270
+ * alongside any other backend's `update`. Then open the pass and
271
+ * call `encodeIntoPass`.
267
272
  */
268
- resolve(token: AdaptiveToken): PreparedRenderObject[] {
273
+ update(token: AdaptiveToken): void {
269
274
  this.root.update(token);
275
+ }
276
+
277
+ /**
278
+ * The current ordered list of leaves. Reads the walker tree's
279
+ * cached state — does not pull deltas. Call `update(token)` first
280
+ * if avals may have marked since last collect.
281
+ */
282
+ collect(): PreparedRenderObject[] {
270
283
  const out: PreparedRenderObject[] = [];
271
284
  this.root.emit(out);
272
285
  return out;
273
286
  }
274
287
 
288
+ /**
289
+ * Encode each leaf's draw into an existing render pass. No
290
+ * begin/end; caller owns the pass. Hybrid renderers call this on
291
+ * the legacy path's pass alongside the heap path's `encodeIntoPass`.
292
+ *
293
+ * Must be preceded by `update(token)` in the same frame.
294
+ */
295
+ encodeIntoPass(passEnc: GPURenderPassEncoder, token: AdaptiveToken): void {
296
+ const leaves = this.collect();
297
+ for (const leaf of leaves) leaf.record(passEnc, token);
298
+ }
299
+
300
+ /**
301
+ * Convenience: `update` + `collect`. The legacy entry point.
302
+ * Pre-existing callers keep working; new code paths prefer the
303
+ * split methods.
304
+ */
305
+ resolve(token: AdaptiveToken): PreparedRenderObject[] {
306
+ this.update(token);
307
+ return this.collect();
308
+ }
309
+
275
310
  dispose(): void {
276
311
  this.root.dispose();
277
312
  }
@@ -0,0 +1,291 @@
1
+ // Compute kernel that builds an atlas sub-rect's mip pyramid AND fills
2
+ // both gutter rings entirely on GPU, using only core WebGPU 1.0
3
+ // features (no `readonly-and-readwrite-storage-textures`).
4
+ //
5
+ // Architecture
6
+ // ============
7
+ // The whole operation runs inside a per-acquire **scratch buffer**
8
+ // laid out exactly like the destination region of the atlas page
9
+ // (bounding rect of the Iliffe pyramid + gutter, in atlas-pixel
10
+ // coordinates relative to the sub-rect origin). Each pixel is one
11
+ // packed `u32` (rgba8). Rows are padded to 256-byte alignment so the
12
+ // final `copyBufferToTexture` is straight-through.
13
+ //
14
+ // Per acquire:
15
+ // 1. CPU writes the raw source pixels into the scratch buffer at
16
+ // the mip-0 interior offset (via `mappedAtCreation`). One JS
17
+ // loop, no upload validation pitfalls.
18
+ // 2. For each mip k > 0: dispatch the `interior` kernel, which
19
+ // reads 2×2 from mip-(k-1) in the buffer and writes the average
20
+ // to mip-k interior, all in-buffer. Each mip lives in its own
21
+ // compute pass — the pass boundary acts as a barrier so
22
+ // mip-(k-1) is fully written before mip-k reads from it.
23
+ // 3. For each mip k: dispatch the `gutter` kernel, which copies
24
+ // the appropriate clamp / wrap interior cell into each gutter
25
+ // cell. Also one compute pass per mip.
26
+ // 4. `copyBufferToTexture` writes the full scratch buffer to the
27
+ // atlas page at the sub-rect origin.
28
+ //
29
+ // The kernel reads and writes ONE storage buffer through a
30
+ // `read_write` binding. WebGPU validates a single binding (even with
31
+ // read_write access) as one usage entry per resource — no hazard.
32
+ //
33
+ // No device features required. No same-texture binding pitfalls. The
34
+ // scratch buffer is per-acquire and freed after submission completes.
35
+
36
+ const WGSL = /* wgsl */`
37
+ struct Params {
38
+ buf_stride_u32: u32,
39
+ _pad0: u32,
40
+ src_origin_in_buf: vec2<u32>,
41
+ src_size: vec2<u32>,
42
+ dst_origin_in_buf: vec2<u32>,
43
+ dst_size: vec2<u32>,
44
+ }
45
+
46
+ @group(0) @binding(0) var<storage, read_write> buf: array<u32>;
47
+ @group(0) @binding(1) var<uniform> P: Params;
48
+
49
+ fn loadRgba(x: u32, y: u32) -> vec4<f32> {
50
+ let idx = y * P.buf_stride_u32 + x;
51
+ return unpack4x8unorm(buf[idx]);
52
+ }
53
+
54
+ fn storeRgba(x: u32, y: u32, v: vec4<f32>) {
55
+ let idx = y * P.buf_stride_u32 + x;
56
+ buf[idx] = pack4x8unorm(v);
57
+ }
58
+
59
+ @compute @workgroup_size(8, 8, 1)
60
+ fn interior(@builtin(global_invocation_id) gid: vec3<u32>) {
61
+ if (gid.x >= P.dst_size.x || gid.y >= P.dst_size.y) { return; }
62
+ let sx0 = P.src_origin_in_buf.x + gid.x * 2u;
63
+ let sy0 = P.src_origin_in_buf.y + gid.y * 2u;
64
+ let sxMax = P.src_origin_in_buf.x + P.src_size.x - 1u;
65
+ let syMax = P.src_origin_in_buf.y + P.src_size.y - 1u;
66
+ let sx1 = min(sx0 + 1u, sxMax);
67
+ let sy1 = min(sy0 + 1u, syMax);
68
+ let a = loadRgba(sx0, sy0);
69
+ let b = loadRgba(sx1, sy0);
70
+ let c = loadRgba(sx0, sy1);
71
+ let d = loadRgba(sx1, sy1);
72
+ let avg = (a + b + c + d) * 0.25;
73
+ storeRgba(P.dst_origin_in_buf.x + gid.x, P.dst_origin_in_buf.y + gid.y, avg);
74
+ }
75
+
76
+ @compute @workgroup_size(8, 8, 1)
77
+ fn gutter(@builtin(global_invocation_id) gid: vec3<u32>) {
78
+ let totW = P.dst_size.x + 4u;
79
+ let totH = P.dst_size.y + 4u;
80
+ if (gid.x >= totW || gid.y >= totH) { return; }
81
+ let dx = i32(gid.x) - 2;
82
+ let dy = i32(gid.y) - 2;
83
+ let dw = i32(P.dst_size.x);
84
+ let dh = i32(P.dst_size.y);
85
+ if (dx >= 0 && dx < dw && dy >= 0 && dy < dh) { return; }
86
+ var sx: i32;
87
+ var sy: i32;
88
+ if (dx == -2) { sx = dw - 1; }
89
+ else if (dx == -1) { sx = 0; }
90
+ else if (dx == dw) { sx = dw - 1; }
91
+ else if (dx == dw + 1) { sx = 0; }
92
+ else { sx = dx; }
93
+ if (dy == -2) { sy = dh - 1; }
94
+ else if (dy == -1) { sy = 0; }
95
+ else if (dy == dh) { sy = dh - 1; }
96
+ else if (dy == dh + 1) { sy = 0; }
97
+ else { sy = dy; }
98
+ let v = loadRgba(u32(i32(P.dst_origin_in_buf.x) + sx),
99
+ u32(i32(P.dst_origin_in_buf.y) + sy));
100
+ storeRgba(u32(i32(P.dst_origin_in_buf.x) + dx),
101
+ u32(i32(P.dst_origin_in_buf.y) + dy),
102
+ v);
103
+ }
104
+ `;
105
+
106
+ interface KernelCache {
107
+ bindGroupLayout: GPUBindGroupLayout;
108
+ interiorPipeline: GPUComputePipeline;
109
+ gutterPipeline: GPUComputePipeline;
110
+ }
111
+
112
+ const caches = new WeakMap<GPUDevice, KernelCache>();
113
+
114
+ function getKernel(device: GPUDevice): KernelCache {
115
+ const cached = caches.get(device);
116
+ if (cached !== undefined) return cached;
117
+ const module = device.createShaderModule({ code: WGSL, label: "atlas/mipGutterKernel" });
118
+ const bindGroupLayout = device.createBindGroupLayout({
119
+ label: "atlas/mipGutterKernel/bgl",
120
+ entries: [
121
+ { binding: 0, visibility: 0x4 /* COMPUTE */, buffer: { type: "storage" } },
122
+ { binding: 1, visibility: 0x4 /* COMPUTE */, buffer: { type: "uniform" } },
123
+ ],
124
+ });
125
+ const layout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
126
+ const interiorPipeline = device.createComputePipeline({
127
+ layout, compute: { module, entryPoint: "interior" },
128
+ label: "atlas/mipGutterKernel/interior",
129
+ });
130
+ const gutterPipeline = device.createComputePipeline({
131
+ layout, compute: { module, entryPoint: "gutter" },
132
+ label: "atlas/mipGutterKernel/gutter",
133
+ });
134
+ const entry: KernelCache = { bindGroupLayout, interiorPipeline, gutterPipeline };
135
+ caches.set(device, entry);
136
+ return entry;
137
+ }
138
+
139
+ /**
140
+ * Per-mip slot description. `origin`/`size` are in *bounding-rect-
141
+ * relative pixel coordinates* (not atlas-page coordinates) — the
142
+ * scratch buffer is laid out as the bounding rect with row stride
143
+ * `bufStrideU32` u32s per row. Mip 0's interior origin matches
144
+ * `acq.origin` − sub-rect origin.
145
+ */
146
+ export interface MipSlot {
147
+ readonly origin: { x: number; y: number };
148
+ readonly size: { w: number; h: number };
149
+ }
150
+
151
+ /**
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).
163
+ *
164
+ * Buffer is destroyed once submitted work completes (fire-and-forget
165
+ * via `onSubmittedWorkDone`).
166
+ */
167
+ export function buildMipsAndGutterOnGpu(
168
+ device: GPUDevice,
169
+ page: GPUTexture,
170
+ subRectX: number, subRectY: number,
171
+ boundsW: number, boundsH: number,
172
+ srcPixels: Uint8Array,
173
+ srcW: number, srcH: number,
174
+ mips: readonly MipSlot[],
175
+ ): 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);
183
+ 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
+ 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
+
213
+ const enc = device.createCommandEncoder({ label: "atlas/mipGutterKernel" });
214
+
215
+ const ubos: GPUBuffer[] = [];
216
+ const makeUbo = (
217
+ srcO: { x: number; y: number }, srcS: { w: number; h: number },
218
+ dstO: { x: number; y: number }, dstS: { w: number; h: number },
219
+ ): GPUBuffer => {
220
+ const buf = device.createBuffer({
221
+ size: 48, // 12 u32, padded
222
+ usage: 0x40 /* UNIFORM */ | 0x08 /* COPY_DST */,
223
+ label: "atlas/mipGutter/params",
224
+ });
225
+ const u = new Uint32Array(12);
226
+ u[0] = bufStrideU32;
227
+ u[1] = 0;
228
+ u[2] = srcO.x; u[3] = srcO.y;
229
+ u[4] = srcS.w; u[5] = srcS.h;
230
+ u[6] = dstO.x; u[7] = dstO.y;
231
+ u[8] = dstS.w; u[9] = dstS.h;
232
+ device.queue.writeBuffer(buf, 0, u);
233
+ ubos.push(buf);
234
+ return buf;
235
+ };
236
+ const makeBg = (ubo: GPUBuffer): GPUBindGroup =>
237
+ device.createBindGroup({
238
+ layout: bindGroupLayout,
239
+ entries: [
240
+ { binding: 0, resource: { buffer: scratch } },
241
+ { binding: 1, resource: { buffer: ubo } },
242
+ ],
243
+ });
244
+
245
+ // Interior passes: one compute pass per mip > 0 (pass boundary
246
+ // gives us the barrier ensuring mip-(k-1) is fully written before
247
+ // mip-k reads it).
248
+ for (let k = 1; k < mips.length; k++) {
249
+ const src = mips[k - 1]!;
250
+ const dst = mips[k]!;
251
+ 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));
255
+ pass.dispatchWorkgroups(Math.ceil(dst.size.w / 8), Math.ceil(dst.size.h / 8), 1);
256
+ pass.end();
257
+ }
258
+
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]!;
264
+ 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));
268
+ pass.dispatchWorkgroups(
269
+ Math.ceil((dst.size.w + 4) / 8),
270
+ Math.ceil((dst.size.h + 4) / 8),
271
+ 1,
272
+ );
273
+ pass.end();
274
+ }
275
+
276
+ // Final upload: buffer → page.
277
+ enc.copyBufferToTexture(
278
+ { buffer: scratch, bytesPerRow: rowBytes, rowsPerImage: boundsH },
279
+ { texture: page, origin: { x: subRectX, y: subRectY, z: 0 } },
280
+ { width: boundsW, height: boundsH, depthOrArrayLayers: 1 },
281
+ );
282
+
283
+ device.queue.submit([enc.finish()]);
284
+
285
+ // Lifetime: destroy scratch + per-pass UBOs once submitted work is
286
+ // done. The submit holds a ref until completion.
287
+ void device.queue.onSubmittedWorkDone().then(() => {
288
+ scratch.destroy();
289
+ for (const b of ubos) b.destroy();
290
+ });
291
+ }