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