@aardworx/wombat.rendering 0.9.5 → 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.
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +24 -14
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +160 -68
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.d.ts +0 -11
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.js +90 -128
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +206 -74
- package/src/runtime/textureAtlas/atlasPool.ts +101 -143
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
153
|
-
* sub-rect
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
-
*
|
|
165
|
-
*
|
|
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
|
-
|
|
209
|
+
function runMipGutter(
|
|
168
210
|
device: GPUDevice,
|
|
169
211
|
page: GPUTexture,
|
|
170
212
|
subRectX: number, subRectY: number,
|
|
171
213
|
boundsW: number, boundsH: number,
|
|
172
|
-
|
|
214
|
+
rowBytes: number,
|
|
215
|
+
scratch: GPUBuffer,
|
|
173
216
|
srcW: number, srcH: number,
|
|
174
217
|
mips: readonly MipSlot[],
|
|
218
|
+
mip0Src: Mip0Source,
|
|
175
219
|
): void {
|
|
176
|
-
|
|
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
|
|
247
|
+
const makeBufBg = (ubo: GPUBuffer): GPUBindGroup =>
|
|
237
248
|
device.createBindGroup({
|
|
238
|
-
layout:
|
|
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
|
|
249
|
-
const src = mips[
|
|
250
|
-
const dst = mips[
|
|
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/${
|
|
253
|
-
pass.setPipeline(interiorPipeline);
|
|
254
|
-
pass.setBindGroup(0,
|
|
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-
|
|
260
|
-
//
|
|
261
|
-
// pass for
|
|
262
|
-
for (let
|
|
263
|
-
const dst = mips[
|
|
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/${
|
|
266
|
-
pass.setPipeline(gutterPipeline);
|
|
267
|
-
pass.setBindGroup(0,
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// the
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
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
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
let
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
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 upload — same 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,
|