@jbrowse/render-core 4.3.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 (164) hide show
  1. package/LICENSE +201 -0
  2. package/esm/OverlayCanvas.d.ts +29 -0
  3. package/esm/OverlayCanvas.js +43 -0
  4. package/esm/RenderCanvas.d.ts +76 -0
  5. package/esm/RenderCanvas.js +38 -0
  6. package/esm/RenderLifecycleMixin.d.ts +180 -0
  7. package/esm/RenderLifecycleMixin.js +279 -0
  8. package/esm/ScrollLockedOverlay.d.ts +28 -0
  9. package/esm/ScrollLockedOverlay.js +42 -0
  10. package/esm/blockClipUtils.d.ts +27 -0
  11. package/esm/blockClipUtils.js +101 -0
  12. package/esm/canvas2dUtils.d.ts +372 -0
  13. package/esm/canvas2dUtils.js +564 -0
  14. package/esm/canvasContext.d.ts +68 -0
  15. package/esm/canvasContext.js +74 -0
  16. package/esm/colorRampLut.d.ts +17 -0
  17. package/esm/colorRampLut.js +25 -0
  18. package/esm/coverageBand.d.ts +107 -0
  19. package/esm/coverageBand.js +109 -0
  20. package/esm/coverageBandBuffers.d.ts +30 -0
  21. package/esm/coverageBandBuffers.js +13 -0
  22. package/esm/createRenderingBackend.d.ts +52 -0
  23. package/esm/createRenderingBackend.js +58 -0
  24. package/esm/displayPhase.d.ts +155 -0
  25. package/esm/displayPhase.js +97 -0
  26. package/esm/globalRenderingBackend.d.ts +90 -0
  27. package/esm/globalRenderingBackend.js +53 -0
  28. package/esm/gpuDevice.d.ts +91 -0
  29. package/esm/gpuDevice.js +277 -0
  30. package/esm/graphicsCapabilities.d.ts +98 -0
  31. package/esm/graphicsCapabilities.js +165 -0
  32. package/esm/hal/createHal.d.ts +22 -0
  33. package/esm/hal/createHal.js +91 -0
  34. package/esm/hal/deviceGpuCache.d.ts +36 -0
  35. package/esm/hal/deviceGpuCache.js +120 -0
  36. package/esm/hal/gpuHalBase.d.ts +55 -0
  37. package/esm/hal/gpuHalBase.js +81 -0
  38. package/esm/hal/index.d.ts +7 -0
  39. package/esm/hal/index.js +5 -0
  40. package/esm/hal/mockHal.d.ts +153 -0
  41. package/esm/hal/mockHal.js +297 -0
  42. package/esm/hal/oomReporter.d.ts +15 -0
  43. package/esm/hal/oomReporter.js +22 -0
  44. package/esm/hal/passIds.d.ts +22 -0
  45. package/esm/hal/passIds.js +36 -0
  46. package/esm/hal/regionRegistry.d.ts +11 -0
  47. package/esm/hal/regionRegistry.js +66 -0
  48. package/esm/hal/types.d.ts +116 -0
  49. package/esm/hal/types.js +1 -0
  50. package/esm/hal/webgl2Hal.d.ts +47 -0
  51. package/esm/hal/webgl2Hal.js +490 -0
  52. package/esm/hal/webgpuHal.d.ts +169 -0
  53. package/esm/hal/webgpuHal.js +722 -0
  54. package/esm/installUpload.d.ts +91 -0
  55. package/esm/installUpload.js +94 -0
  56. package/esm/instanceCache.d.ts +43 -0
  57. package/esm/instanceCache.js +53 -0
  58. package/esm/instancePass.d.ts +50 -0
  59. package/esm/instancePass.js +34 -0
  60. package/esm/keyedRenderingBackend.d.ts +41 -0
  61. package/esm/keyedRenderingBackend.js +19 -0
  62. package/esm/mapUploadSync.d.ts +31 -0
  63. package/esm/mapUploadSync.js +45 -0
  64. package/esm/namedReactions.d.ts +29 -0
  65. package/esm/namedReactions.js +62 -0
  66. package/esm/perRegionRenderingBackend.d.ts +114 -0
  67. package/esm/perRegionRenderingBackend.js +93 -0
  68. package/esm/recoveryBudget.d.ts +40 -0
  69. package/esm/recoveryBudget.js +56 -0
  70. package/esm/regionDataMap.d.ts +60 -0
  71. package/esm/regionDataMap.js +98 -0
  72. package/esm/renderBlock.d.ts +25 -0
  73. package/esm/renderBlock.js +10 -0
  74. package/esm/renderingBackendBase.d.ts +71 -0
  75. package/esm/renderingBackendBase.js +50 -0
  76. package/esm/shaders/antialias.js.generated.d.ts +4 -0
  77. package/esm/shaders/antialias.js.generated.js +25 -0
  78. package/esm/shaders/capsule.generated.d.ts +1 -0
  79. package/esm/shaders/capsule.generated.js +3 -0
  80. package/esm/shaders/colorRampLut.generated.d.ts +1 -0
  81. package/esm/shaders/colorRampLut.generated.js +3 -0
  82. package/esm/shaders/coverageBand.generated.d.ts +2 -0
  83. package/esm/shaders/coverageBand.generated.js +4 -0
  84. package/esm/shaders/coverageBar.generated.d.ts +4 -0
  85. package/esm/shaders/coverageBar.generated.js +6 -0
  86. package/esm/shaders/coverageBar.iface.generated.d.ts +85 -0
  87. package/esm/shaders/coverageBar.iface.generated.js +114 -0
  88. package/esm/shaders/coverageIndicator.generated.d.ts +4 -0
  89. package/esm/shaders/coverageIndicator.generated.js +6 -0
  90. package/esm/shaders/coverageIndicator.iface.generated.d.ts +85 -0
  91. package/esm/shaders/coverageIndicator.iface.generated.js +114 -0
  92. package/esm/shaders/coverageInterbase.generated.d.ts +4 -0
  93. package/esm/shaders/coverageInterbase.generated.js +6 -0
  94. package/esm/shaders/coverageInterbase.iface.generated.d.ts +93 -0
  95. package/esm/shaders/coverageInterbase.iface.generated.js +134 -0
  96. package/esm/shaders/coverageMod.generated.d.ts +4 -0
  97. package/esm/shaders/coverageMod.generated.js +6 -0
  98. package/esm/shaders/coverageMod.iface.generated.d.ts +97 -0
  99. package/esm/shaders/coverageMod.iface.generated.js +144 -0
  100. package/esm/shaders/coverageSnp.generated.d.ts +4 -0
  101. package/esm/shaders/coverageSnp.generated.js +6 -0
  102. package/esm/shaders/coverageSnp.iface.generated.d.ts +97 -0
  103. package/esm/shaders/coverageSnp.iface.generated.js +144 -0
  104. package/esm/shaders/hpmath.js.generated.d.ts +3 -0
  105. package/esm/shaders/hpmath.js.generated.js +48 -0
  106. package/esm/shaders/pointGlyph.generated.d.ts +1 -0
  107. package/esm/shaders/pointGlyph.generated.js +3 -0
  108. package/esm/shaders/pointGlyph.js.generated.d.ts +1 -0
  109. package/esm/shaders/pointGlyph.js.generated.js +7 -0
  110. package/esm/shaders/rowRect.generated.d.ts +3 -0
  111. package/esm/shaders/rowRect.generated.js +5 -0
  112. package/esm/shaders/rowRect.js.generated.d.ts +2 -0
  113. package/esm/shaders/rowRect.js.generated.js +13 -0
  114. package/esm/shaders/scoreScale.js.generated.d.ts +1 -0
  115. package/esm/shaders/scoreScale.js.generated.js +46 -0
  116. package/esm/slangPass.d.ts +49 -0
  117. package/esm/slangPass.js +22 -0
  118. package/esm/useEventCallback.d.ts +1 -0
  119. package/esm/useEventCallback.js +19 -0
  120. package/esm/useGraphicsCapabilities.d.ts +17 -0
  121. package/esm/useGraphicsCapabilities.js +38 -0
  122. package/esm/useRenderingBackend.d.ts +83 -0
  123. package/esm/useRenderingBackend.js +422 -0
  124. package/esm/useTabVisibilityRerender.d.ts +13 -0
  125. package/esm/useTabVisibilityRerender.js +39 -0
  126. package/esm/webgpuUtils.d.ts +23 -0
  127. package/esm/webgpuUtils.js +76 -0
  128. package/package.json +91 -0
  129. package/src/shaders/antialias.js.generated.ts +33 -0
  130. package/src/shaders/antialias.slang +112 -0
  131. package/src/shaders/capsule.generated.ts +4 -0
  132. package/src/shaders/capsule.slang +62 -0
  133. package/src/shaders/colorPack.slang +26 -0
  134. package/src/shaders/colorRampLut.generated.ts +4 -0
  135. package/src/shaders/colorRampLut.slang +51 -0
  136. package/src/shaders/coverageBand.generated.ts +6 -0
  137. package/src/shaders/coverageBand.slang +276 -0
  138. package/src/shaders/coverageBar.generated.ts +10 -0
  139. package/src/shaders/coverageBar.iface.generated.ts +172 -0
  140. package/src/shaders/coverageBar.slang +37 -0
  141. package/src/shaders/coverageIndicator.generated.ts +10 -0
  142. package/src/shaders/coverageIndicator.iface.generated.ts +172 -0
  143. package/src/shaders/coverageIndicator.slang +61 -0
  144. package/src/shaders/coverageInterbase.generated.ts +10 -0
  145. package/src/shaders/coverageInterbase.iface.generated.ts +198 -0
  146. package/src/shaders/coverageInterbase.slang +63 -0
  147. package/src/shaders/coverageMod.generated.ts +10 -0
  148. package/src/shaders/coverageMod.iface.generated.ts +211 -0
  149. package/src/shaders/coverageMod.slang +36 -0
  150. package/src/shaders/coverageSnp.generated.ts +10 -0
  151. package/src/shaders/coverageSnp.iface.generated.ts +211 -0
  152. package/src/shaders/coverageSnp.slang +52 -0
  153. package/src/shaders/diagonalGrid.slang +25 -0
  154. package/src/shaders/glyphEdgeAlpha.test.ts +241 -0
  155. package/src/shaders/hpmath.js.generated.ts +47 -0
  156. package/src/shaders/hpmath.slang +226 -0
  157. package/src/shaders/pointGlyph.generated.ts +4 -0
  158. package/src/shaders/pointGlyph.js.generated.ts +9 -0
  159. package/src/shaders/pointGlyph.slang +93 -0
  160. package/src/shaders/rowRect.generated.ts +8 -0
  161. package/src/shaders/rowRect.js.generated.ts +17 -0
  162. package/src/shaders/rowRect.slang +160 -0
  163. package/src/shaders/scoreScale.js.generated.ts +51 -0
  164. package/src/shaders/scoreScale.slang +68 -0
@@ -0,0 +1,722 @@
1
+ /// <reference types="@webgpu/types" />
2
+ import { syncCanvasSize } from '../canvas2dUtils.js';
3
+ import { canvasConfiguredBy, canvasContextError, noteCanvasConfigured, noteCanvasContext, } from '../canvasContext.js';
4
+ import { getGpuDevice } from '../gpuDevice.js';
5
+ import { STANDARD_BLEND_STATE, createUniformOnlyBindGroup, createVertexBuffer, toGpuVertexFormat, } from '../webgpuUtils.js';
6
+ import { getDeviceLayouts, getOrBuildPipeline } from './deviceGpuCache.js';
7
+ import { GpuHalBase } from './gpuHalBase.js';
8
+ class ShaderCompileError extends Error {
9
+ constructor(passId, details) {
10
+ super(`WGSL compile error in pass "${passId}": ${details}`);
11
+ this.name = 'ShaderCompileError';
12
+ }
13
+ }
14
+ // Maximum number of writeUniforms() calls per frame. Each call occupies one
15
+ // aligned slot in the uniform ring buffer.
16
+ //
17
+ // The ring is allocated eagerly at this many slots, as a GPU buffer AND a CPU
18
+ // staging array, whether a renderer writes 4 slots or 1900 — so the cost is
19
+ // per display and is the aligned uniform size that decides it, not the slot
20
+ // count. A display whose uniform fits the 256-byte minimum alignment pays
21
+ // 512 KB each side; alignments' 864-byte uniform aligns to 1024, so 2 MiB each
22
+ // side. Trivial once, per-track at ten open alignments tracks.
23
+ //
24
+ // Exhausting it does not throw: the write is dropped and its draws render
25
+ // against the previous batch's uniforms, which is wrong data rather than stale
26
+ // data. If we ever hit the cap, switch to a dynamic-growth buffer (recreate
27
+ // buffer + every region's bind group) rather than just bumping the constant
28
+ // again.
29
+ const MAX_UNIFORM_SLOTS = 2048;
30
+ // Warn while there is still headroom, because the cap itself is not a place to
31
+ // find out. A renderer's per-frame write count is rarely one number: alignments
32
+ // writes once per stacked section per block, plus one per section with an arc
33
+ // band, so a grouped view multiplies it by up to MAX_GROUPS (40) and a
34
+ // multi-region view by the block count again. The headroom is real but it is not
35
+ // the "~50 writes/frame" this file used to claim, and nothing reported the
36
+ // difference between 50 and 1900.
37
+ const UNIFORM_SLOT_WARN_AT = MAX_UNIFORM_SLOTS / 2;
38
+ function gpuBlendState(bs) {
39
+ // Max ignores its factors, but WebGPU still validates them and rejects
40
+ // anything but 'one' on both channels ("Destination blend factor ... is
41
+ // defined and not BlendFactor::One when blend operation is
42
+ // BlendOperation::Max"). Same blend as webgl2Hal's bare glBlendEquation(MAX).
43
+ const max = { srcFactor: 'one', dstFactor: 'one', operation: 'max' };
44
+ // Otherwise RGB uses the caller-supplied factors and alpha always accumulates
45
+ // through ONE / ONE_MINUS_SRC_ALPHA (matches webgl2Hal.applyBlendState).
46
+ return bs.op === 'max'
47
+ ? { color: max, alpha: max }
48
+ : {
49
+ color: { srcFactor: bs.srcFactor, dstFactor: bs.dstFactor },
50
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
51
+ };
52
+ }
53
+ // Every pass reads per-instance data from a vertex buffer (no storage binding
54
+ // at 0) because Slang-generated shaders cross-compile to GLSL ES, which has no
55
+ // SSBOs. The two @group(0) layouts a pass is built against — uniform-only, and
56
+ // uniform + texture + sampler — belong to the device, not to this HAL, and live
57
+ // in `deviceGpuCache.ts` with the pipelines.
58
+ async function buildPipeline(device, desc, layouts, sampleCount) {
59
+ const module = device.createShaderModule({ code: desc.wgslSource });
60
+ const info = await module.getCompilationInfo();
61
+ const errors = info.messages.filter(m => m.type === 'error');
62
+ if (errors.length > 0) {
63
+ const details = errors
64
+ .map(m => `line ${m.lineNum}: ${m.message}`)
65
+ .join('; ');
66
+ throw new ShaderCompileError(desc.id, details);
67
+ }
68
+ const blend = desc.blend
69
+ ? desc.blendState
70
+ ? gpuBlendState(desc.blendState)
71
+ : STANDARD_BLEND_STATE
72
+ : undefined;
73
+ // Every pass feeds @location(N) inputs from a bound vertex buffer.
74
+ const vertexBuffers = [
75
+ {
76
+ arrayStride: desc.instanceStride,
77
+ stepMode: 'instance',
78
+ attributes: desc.vertexAttributes.map((attr, i) => ({
79
+ shaderLocation: i,
80
+ offset: attr.offsetBytes,
81
+ format: toGpuVertexFormat(attr),
82
+ })),
83
+ },
84
+ ];
85
+ return device.createRenderPipelineAsync({
86
+ layout: desc.textures?.length
87
+ ? layouts.texturedPipelineLayout
88
+ : layouts.uniformOnlyPipelineLayout,
89
+ vertex: { module, entryPoint: 'vs_main', buffers: vertexBuffers },
90
+ fragment: {
91
+ module,
92
+ entryPoint: 'fs_main',
93
+ targets: [
94
+ {
95
+ format: navigator.gpu.getPreferredCanvasFormat(),
96
+ ...(blend && { blend }),
97
+ },
98
+ ],
99
+ },
100
+ primitive: { topology: desc.topology ?? 'triangle-list' },
101
+ multisample: sampleCount === 1 ? undefined : { count: sampleCount },
102
+ });
103
+ }
104
+ // Resolve every declared pass to its pipeline, taking the device-wide cache's
105
+ // answer wherever another display of this type already built one. Still every
106
+ // pass up front rather than on first draw — the WebGL2 side compiles lazily
107
+ // (`getPass`) and this side does not; see ARCHITECTURAL_LIMITS.md §"Every
108
+ // WebGPU display resolves its whole pass list before it can paint".
109
+ async function resolvePipelines(device, descriptors, sampleCount) {
110
+ const built = await Promise.all(descriptors.map(desc => getOrBuildPipeline(device, desc, sampleCount, (layouts, count) => buildPipeline(device, desc, layouts, count))));
111
+ return new Map(descriptors.map((desc, i) => [desc.id, built[i]]));
112
+ }
113
+ // Behavioral parity with WebGL2Hal is enforced by tests, not by this file:
114
+ // products/jbrowse-web/browser-tests/compare-backends.ts pixel-diffs webgl vs
115
+ // webgpu vs canvas2d output, and shared buffer bookkeeping is covered by
116
+ // hal/regionRegistry.test.ts. Neither HAL is where attribute layout is checked —
117
+ // `assertVertexInputsMatch` does that at `pnpm gen:shaders` time, per shader and
118
+ // per target. Mirror any behavior change in webgl2Hal.ts — and note that
119
+ // `GpuHalBase` already holds the parts that were only ever mirrored: the
120
+ // descriptor map, the buffer registry, both upload shells and their over-limit
121
+ // wording, and the dispose guard.
122
+ export class WebGPUHal extends GpuHalBase {
123
+ device;
124
+ canvas;
125
+ context;
126
+ pipelines;
127
+ passTextures = new Map();
128
+ // One bind group per textured pass, built lazily by `getBindGroup` and
129
+ // dropped when `uploadTexture` swaps that pass's texture. Uniform-only passes
130
+ // are not stored here — they all share `uniformOnlyBindGroup`.
131
+ passBindGroups = new Map();
132
+ // The device's layouts, not this HAL's — shared with every other display on
133
+ // the same device. The bind GROUPS above stay per-HAL: they reference this
134
+ // HAL's uniform ring buffer.
135
+ layouts;
136
+ // Uniform ring buffer: holds up to MAX_UNIFORM_SLOTS sets of uniforms so
137
+ // that all draw calls in a frame can reference different uniform data via
138
+ // dynamic offsets, enabling a single command encoder + submit per frame.
139
+ alignedUniformSize;
140
+ uniformRingBuffer;
141
+ uniformStaging;
142
+ uniformSlot = 0;
143
+ // Once per HAL, not once per frame: at 60fps a per-frame warning is a
144
+ // console the developer stops reading, and the fact is about the renderer
145
+ // rather than about this frame.
146
+ warnedUniformSlots = false;
147
+ // Shared bind group for every uniform-only pass — only references
148
+ // `uniformRingBuffer` (via dynamic offset at draw time), so one instance
149
+ // serves all passes/regions instead of allocating a fresh one per upload.
150
+ uniformOnlyBindGroup;
151
+ // Samples per pixel this display renders at, stated by whoever built it (see
152
+ // `RenderingBackendOptions.sampleCount`). Every render-pass, texture and
153
+ // pipeline decision below reads it, so the two shapes cannot mismatch — and
154
+ // at 1 there is no MSAA texture at all rather than a smaller one, which is
155
+ // the whole point of the knob.
156
+ sampleCount;
157
+ // The multisampled colour attachment, resolved into the canvas texture at the
158
+ // end of the frame. Null for the life of the HAL when `sampleCount` is 1.
159
+ msaaTexture = null;
160
+ msaaView = null;
161
+ // Frame state — single render pass batches all draws per frame so MSAA
162
+ // resolves only once, eliminating artifacts from intermediate resolves.
163
+ currentTextureView = null;
164
+ currentEncoder = null;
165
+ currentPass = null;
166
+ /**
167
+ * Buffers and textures whose destroy landed while the frame's render pass was
168
+ * open, held until the frame has been submitted.
169
+ *
170
+ * WebGPU validates `destroy()` against the submitted command buffer, not
171
+ * against when the draw was encoded: destroying a vertex buffer an already
172
+ * encoded draw references makes `queue.submit` reject the whole frame, so one
173
+ * region re-uploading a pass it drew earlier in the same frame blanks
174
+ * everything else drawn with it. Alignments does exactly that — a chain
175
+ * selection spanning two sections re-uploads `OVERLAY_REGION` once per
176
+ * section, inside the block loop.
177
+ *
178
+ * Deferring is what makes mid-frame replacement legal instead of merely
179
+ * warned about; synteny's `ensureUploaded` has always deleted a pass mid-frame
180
+ * that the open pass never referenced, and that case was never the bug.
181
+ */
182
+ pendingDestroy = [];
183
+ // Scissor/viewport state (physical pixels, top-left origin). `null` means the
184
+ // full attachment, which is both the caller's "cleared" state and a render
185
+ // pass's own initial one.
186
+ scissorRect = null;
187
+ viewportRect = null;
188
+ // What the open render pass was last told, so `drawPass` re-issues only on a
189
+ // change. Seeded in `beginFrame` with the full rect the pass already starts
190
+ // at, which keeps the common path (one scissor per block, no clears) at
191
+ // exactly the calls it made before. Mutated rather than replaced — these are
192
+ // touched once per draw call, and a draw call is not a place to allocate.
193
+ appliedScissor = { x: 0, y: 0, w: 0, h: 0 };
194
+ appliedViewport = { x: 0, y: 0, w: 0, h: 0 };
195
+ // One-shot flags for `acquireTextureView`: the warn fires once per HAL rather
196
+ // than once per frame, and a reconfigure that failed is not attempted again.
197
+ warnedSwapChainLoss = false;
198
+ swapChainUnrecoverable = false;
199
+ constructor(device, canvas, context, descriptors, uniformByteSize, sampleCount, pipelines, layouts) {
200
+ super(descriptors, 'WebGPUHal');
201
+ this.device = device;
202
+ this.canvas = canvas;
203
+ this.context = context;
204
+ this.pipelines = pipelines;
205
+ this.layouts = layouts;
206
+ this.sampleCount = sampleCount;
207
+ // Align uniform slots to device requirements for dynamic offsets
208
+ const alignment = device.limits.minUniformBufferOffsetAlignment;
209
+ this.alignedUniformSize = Math.ceil(uniformByteSize / alignment) * alignment;
210
+ const ringSize = MAX_UNIFORM_SLOTS * this.alignedUniformSize;
211
+ this.uniformRingBuffer = device.createBuffer({
212
+ size: ringSize,
213
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
214
+ });
215
+ this.uniformStaging = new Uint8Array(ringSize);
216
+ this.uniformOnlyBindGroup = createUniformOnlyBindGroup(device, layouts.uniformOnlyBindGroupLayout, this.uniformRingBuffer, this.alignedUniformSize);
217
+ this.configureContext();
218
+ }
219
+ limits() {
220
+ const { maxBufferSize, maxTextureDimension2D } = this.device.limits;
221
+ return {
222
+ maxBufferBytes: maxBufferSize,
223
+ maxTextureDimensionPx: maxTextureDimension2D,
224
+ };
225
+ }
226
+ createBuffer(data, count) {
227
+ return { dataBuffer: createVertexBuffer(this.device, data), count };
228
+ }
229
+ destroyBuffer(buf) {
230
+ this.destroyWhenIdle(buf.dataBuffer);
231
+ }
232
+ /**
233
+ * Claim the canvas's swap chain, and record that this HAL is the one holding
234
+ * it — see `noteCanvasConfigured` for why that ownership has to be tracked.
235
+ *
236
+ * Called from the constructor rather than from `create`, so that the object
237
+ * doing the claiming exists, and so a throwing constructor leaves no
238
+ * configured context behind with nothing to release it.
239
+ */
240
+ configureContext() {
241
+ this.context.configure({
242
+ device: this.device,
243
+ format: navigator.gpu.getPreferredCanvasFormat(),
244
+ alphaMode: 'premultiplied',
245
+ });
246
+ noteCanvasConfigured(this.canvas, this);
247
+ }
248
+ static async create(canvas, descriptors, uniformByteSize, sampleCount) {
249
+ const device = await getGpuDevice();
250
+ if (!device) {
251
+ return null;
252
+ }
253
+ // Resolve pipelines BEFORE acquiring the canvas's webgpu context. A canvas's
254
+ // context type is permanent once acquired, so if shader compilation throws
255
+ // here the canvas stays pristine and createGpuHal's WebGL2 fallback can
256
+ // still claim it — otherwise a partial WebGPU init would drop us all the way
257
+ // to Canvas2D on a WebGL2-capable machine.
258
+ const layouts = getDeviceLayouts(device);
259
+ const pipelines = await resolvePipelines(device, descriptors, sampleCount);
260
+ const context = canvas.getContext('webgpu');
261
+ if (!context) {
262
+ // Returning null (rather than throwing) keeps the ladder running, so this
263
+ // is the one rung whose reason would otherwise be console-only. It is
264
+ // recorded as the rung's failure so `createGpuHal` can attach it to
265
+ // whatever the ladder eventually fails with.
266
+ throw canvasContextError(canvas, 'webgpu');
267
+ }
268
+ noteCanvasContext(canvas, 'webgpu');
269
+ return new WebGPUHal(device, canvas, context, descriptors, uniformByteSize, sampleCount, pipelines, layouts);
270
+ }
271
+ resize(width, height) {
272
+ const { changed, scale } = syncCanvasSize(this.canvas, width, height);
273
+ // `!this.msaaTexture` covers the first frame and a rebuild that bailed. At
274
+ // sampleCount 1 there is never a texture, so without the count in the
275
+ // condition that clause would be true on every frame and re-run the
276
+ // over-limit check for a texture nobody is building.
277
+ if (changed || (this.sampleCount > 1 && !this.msaaTexture)) {
278
+ this.recreateMsaaTexture(this.canvas.width, this.canvas.height);
279
+ }
280
+ return scale;
281
+ }
282
+ recreateMsaaTexture(width, height) {
283
+ this.msaaTexture?.destroy();
284
+ this.msaaTexture = null;
285
+ this.msaaView = null;
286
+ const maxDim = this.device.limits.maxTextureDimension2D;
287
+ if (width > maxDim || height > maxDim) {
288
+ this.oom.report(`This view is too large for this GPU — zoom in or reduce the track height. (canvas ${width}×${height} exceeds max texture size ${maxDim})`);
289
+ return;
290
+ }
291
+ if (this.sampleCount > 1 && width > 0 && height > 0) {
292
+ this.msaaTexture = this.device.createTexture({
293
+ size: [width, height],
294
+ format: navigator.gpu.getPreferredCanvasFormat(),
295
+ sampleCount: this.sampleCount,
296
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
297
+ });
298
+ this.msaaView = this.msaaTexture.createView();
299
+ }
300
+ }
301
+ /**
302
+ * Bind group matching the pipeline layout of `passId`.
303
+ *
304
+ * Keyed on the pass being DRAWN, never on the buffer it draws from:
305
+ * `drawPass(a, key, b)` runs pass `a`'s pipeline over pass `b`'s vertex
306
+ * buffer, so the bind group has to match `a`'s layout. Caching it on the
307
+ * (region, `b`) buffer entry instead bound `b`'s layout to `a`'s pipeline —
308
+ * fine while every such pair happened to be uniform-only, a validation error
309
+ * (and a dropped draw) the moment one side samples a texture.
310
+ *
311
+ * Nothing in a bind group varies per region either: it references the
312
+ * HAL-wide uniform ring buffer plus the pass's own texture/sampler. So
313
+ * uniform-only passes all share `uniformOnlyBindGroup`, and a textured pass
314
+ * gets exactly one, cached until `uploadTexture` replaces its texture.
315
+ * Returns undefined when a pass needs a texture that hasn't arrived yet;
316
+ * drawPass skips those.
317
+ */
318
+ getBindGroup(passId) {
319
+ const tb = this.descriptors.get(passId)?.textures?.[0];
320
+ if (tb) {
321
+ let bindGroup = this.passBindGroups.get(passId);
322
+ if (!bindGroup) {
323
+ const texState = this.passTextures.get(passId);
324
+ if (texState) {
325
+ bindGroup = this.device.createBindGroup({
326
+ layout: this.layouts.texturedBindGroupLayout,
327
+ entries: [
328
+ {
329
+ binding: 1,
330
+ resource: {
331
+ buffer: this.uniformRingBuffer,
332
+ offset: 0,
333
+ size: this.alignedUniformSize,
334
+ },
335
+ },
336
+ {
337
+ binding: tb.textureBinding,
338
+ resource: texState.texture.createView(),
339
+ },
340
+ { binding: tb.samplerBinding, resource: texState.sampler },
341
+ ],
342
+ });
343
+ this.passBindGroups.set(passId, bindGroup);
344
+ }
345
+ }
346
+ return bindGroup;
347
+ }
348
+ return this.uniformOnlyBindGroup;
349
+ }
350
+ /**
351
+ * The one place a GPU resource this HAL owns is released — see
352
+ * {@link pendingDestroy} for why the frame decides when.
353
+ *
354
+ * Every buffer release routes here through the `RegionRegistry` destroy hook,
355
+ * so `uploadBuffer`, `deleteBuffer`, `deleteRegion`, `pruneRegions` and
356
+ * `dispose` are all covered without any of them knowing a frame is open.
357
+ */
358
+ destroyWhenIdle(resource) {
359
+ if (this.currentEncoder) {
360
+ this.pendingDestroy.push(resource);
361
+ }
362
+ else {
363
+ resource.destroy();
364
+ }
365
+ }
366
+ drainPendingDestroy() {
367
+ for (const resource of this.pendingDestroy) {
368
+ resource.destroy();
369
+ }
370
+ this.pendingDestroy.length = 0;
371
+ }
372
+ createTexture(passId, binding, data, width, height) {
373
+ const existing = this.passTextures.get(passId);
374
+ if (existing) {
375
+ // Same hazard as a buffer, one step further from a live caller: every
376
+ // `uploadTexture` in tree is a colour ramp written from an upload autorun
377
+ // (hic's `LinearHicDisplay/model.ts`, variants' `LDDisplay/shared.ts`),
378
+ // which never runs inside a frame. Routing it through the same deferral
379
+ // costs nothing and keeps "replaced mid-frame" from being a hazard again
380
+ // the first time a renderer builds a texture from render state.
381
+ this.destroyWhenIdle(existing.texture);
382
+ }
383
+ const texture = this.device.createTexture({
384
+ size: [width, height],
385
+ format: 'rgba8unorm',
386
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
387
+ });
388
+ this.device.queue.writeTexture({ texture }, data, { bytesPerRow: width * 4 }, { width, height });
389
+ const sampler = this.device.createSampler({
390
+ magFilter: binding.filter,
391
+ minFilter: binding.filter,
392
+ addressModeU: 'clamp-to-edge',
393
+ addressModeV: 'clamp-to-edge',
394
+ });
395
+ this.passTextures.set(passId, { texture, sampler });
396
+ // Drop the cached bind group so the next draw rebuilds it against the new
397
+ // texture (and so a pass drawn before its first texture arrived stops
398
+ // being skipped).
399
+ this.passBindGroups.delete(passId);
400
+ }
401
+ writeUniforms(data) {
402
+ if (this.currentEncoder) {
403
+ // Inside a frame: stage data at the current slot for batched upload
404
+ if (this.uniformSlot >= MAX_UNIFORM_SLOTS) {
405
+ console.error(`[WebGPUHal] uniform ring buffer exhausted at ${MAX_UNIFORM_SLOTS} ` +
406
+ `writeUniforms calls in one frame — this write is dropped, so the ` +
407
+ `paired draw renders with the previous batch's uniforms (wrong ` +
408
+ `data, not last-frame-stale). This indicates a renderer doing far ` +
409
+ `more per-frame uniform writes than expected; investigate the call ` +
410
+ `site before raising the cap (and consider switching to a ` +
411
+ `dynamic-growth ring buffer).`);
412
+ return;
413
+ }
414
+ const offset = this.uniformSlot * this.alignedUniformSize;
415
+ this.uniformStaging.set(new Uint8Array(data), offset);
416
+ this.uniformSlot++;
417
+ }
418
+ else {
419
+ // No active frame (currentEncoder is null). Every renderer writes
420
+ // uniforms strictly between beginFrame and endFrame, so the only way to
421
+ // land here is when beginFrame early-returned on a zero-size canvas
422
+ // (it skips creating the encoder and resetting uniformSlot). The paired
423
+ // drawPass/endFrame also no-op on that path, so this write never reaches
424
+ // the screen — but writing to slot 0 and marking it used keeps the ring
425
+ // state coherent rather than appending to a stale uniformSlot. Defensive,
426
+ // not a real render path.
427
+ this.device.queue.writeBuffer(this.uniformRingBuffer, 0, data);
428
+ this.uniformSlot = 1;
429
+ }
430
+ }
431
+ /**
432
+ * The canvas texture to draw this frame into, or null when the swap chain is
433
+ * gone and could not be rebuilt.
434
+ *
435
+ * **A configuration can disappear under a live HAL.** The case we have seen is
436
+ * a sibling HAL on a reused canvas element releasing a context it turned out
437
+ * not to own (`noteCanvasConfigured` has the shape), and a browser is free to
438
+ * drop one for its own reasons too. Firefox reports it as `InvalidStateError:
439
+ * GPUCanvasContext.getCurrentTexture: Canvas not configured` — on this call
440
+ * and, measured, on no other. Nothing else in the stack hears about it: there
441
+ * is no context-lost event to fire the recovery in `useRenderingBackend`, so
442
+ * unguarded the throw leaves `RenderLifecycleMixin`'s render autorun, lands in
443
+ * `renderError`, and the display banners a raw DOMException until the tab is
444
+ * reloaded.
445
+ *
446
+ * Reconfiguring restores it in full, so this frame goes on to paint. A
447
+ * reconfigure that does *not* restore it is reported and not retried — every
448
+ * later frame would rebuild a swap chain to no effect, and the display's own
449
+ * Retry is what builds a fresh HAL.
450
+ */
451
+ acquireTextureView() {
452
+ try {
453
+ return this.context.getCurrentTexture().createView();
454
+ }
455
+ catch (e) {
456
+ if (this.swapChainUnrecoverable) {
457
+ return null;
458
+ }
459
+ if (!this.warnedSwapChainLoss) {
460
+ this.warnedSwapChainLoss = true;
461
+ console.warn(`[WebGPUHal] canvas lost its swap chain, rebuilding: ${e}`);
462
+ }
463
+ try {
464
+ this.configureContext();
465
+ return this.context.getCurrentTexture().createView();
466
+ }
467
+ catch (retryError) {
468
+ this.swapChainUnrecoverable = true;
469
+ this.oom.report(`This canvas lost its GPU swap chain and could not reclaim one, so it cannot draw. Retry to rebuild the renderer. (${retryError})`);
470
+ return null;
471
+ }
472
+ }
473
+ }
474
+ beginFrame(clearR, clearG, clearB, clearA = 1) {
475
+ // Skip the frame entirely rather than encode one that cannot be valid.
476
+ // Zero-size canvas: nothing to draw. Missing MSAA target while MSAA is
477
+ // configured: every pipeline was built with `multisample.count =
478
+ // this.sampleCount`, so the single-sample fallback attachment below would
479
+ // mismatch and every draw in the frame would be rejected. That happens after
480
+ // `recreateMsaaTexture` bails on an over-`maxTextureDimension2D` canvas — it
481
+ // has already reported through `oom`, so the user has the real message and
482
+ // there is nothing to gain from also spraying validation errors each frame.
483
+ if (this.disposed ||
484
+ this.canvas.width === 0 ||
485
+ this.canvas.height === 0 ||
486
+ (this.sampleCount > 1 && !this.msaaView)) {
487
+ return;
488
+ }
489
+ // Before the error scopes, so a failed acquisition needs no unwinding: the
490
+ // scopes stay pushed iff an encoder is created, which is what endFrame's
491
+ // early-return on !currentEncoder is paired with.
492
+ const textureView = this.acquireTextureView();
493
+ if (!textureView) {
494
+ return;
495
+ }
496
+ this.device.pushErrorScope('validation');
497
+ this.device.pushErrorScope('out-of-memory');
498
+ this.scissorRect = null;
499
+ this.viewportRect = null;
500
+ this.seedApplied(this.appliedScissor);
501
+ this.seedApplied(this.appliedViewport);
502
+ this.currentTextureView = textureView;
503
+ this.currentEncoder = this.device.createCommandEncoder();
504
+ this.uniformSlot = 0;
505
+ // With MSAA: render to the multisampled texture, then resolve to the canvas
506
+ // texture. Without MSAA (`sampleCount === 1`, so `msaaView` is never built):
507
+ // render directly to the canvas texture. The guard above is what keeps those
508
+ // the only two cases — a null `msaaView` while MSAA is on never reaches here.
509
+ const clearValue = { r: clearR, g: clearG, b: clearB, a: clearA };
510
+ this.currentPass = this.currentEncoder.beginRenderPass({
511
+ colorAttachments: [
512
+ this.msaaView
513
+ ? {
514
+ view: this.msaaView,
515
+ resolveTarget: this.currentTextureView,
516
+ loadOp: 'clear',
517
+ storeOp: 'discard',
518
+ clearValue,
519
+ }
520
+ : {
521
+ view: this.currentTextureView,
522
+ loadOp: 'clear',
523
+ storeOp: 'store',
524
+ clearValue,
525
+ },
526
+ ],
527
+ });
528
+ }
529
+ drawPass(passId, regionKey, bufferPassId) {
530
+ if (!this.currentPass) {
531
+ return;
532
+ }
533
+ const pipeline = this.pipelines.get(passId);
534
+ if (!pipeline) {
535
+ return;
536
+ }
537
+ const regionBuf = this.regions.get(regionKey, bufferPassId ?? passId);
538
+ if (!regionBuf || regionBuf.count === 0) {
539
+ return;
540
+ }
541
+ const desc = this.descriptors.get(passId);
542
+ if (!desc) {
543
+ return;
544
+ }
545
+ const bindGroup = this.getBindGroup(passId);
546
+ if (!bindGroup) {
547
+ return;
548
+ }
549
+ // uniformSlot is post-incremented in writeUniforms, so slot (uniformSlot-1)
550
+ // holds the uniforms written for THIS draw call. Multiple drawPass calls
551
+ // between writeUniforms calls intentionally share the same slot.
552
+ //
553
+ // Edge case: if drawPass is called before any writeUniforms in this frame
554
+ // (uniformSlot === 0), Math.max clamps the offset to 0 so we never index
555
+ // slot -1. Every renderer pairs writeUniforms with its draws, so this
556
+ // clamp is purely defensive — the clamped draw reads whatever slot 0 last
557
+ // held, which is acceptable only because no renderer relies on it.
558
+ const dynamicOffset = Math.max(0, this.uniformSlot - 1) * this.alignedUniformSize;
559
+ this.applyViewport(this.currentPass);
560
+ this.applyScissor(this.currentPass);
561
+ this.currentPass.setPipeline(pipeline);
562
+ this.currentPass.setBindGroup(0, bindGroup, [dynamicOffset]);
563
+ this.currentPass.setVertexBuffer(0, regionBuf.dataBuffer);
564
+ this.currentPass.draw(desc.verticesPerInstance, regionBuf.count);
565
+ }
566
+ /**
567
+ * Close the frame's state whether the submit landed or threw.
568
+ *
569
+ * The scopes pushed in `beginFrame` have to be popped either way — leaving a
570
+ * pair pushed makes every later frame's pop read an older frame's errors —
571
+ * and the deferred destroys have to be drained either way, since a frame that
572
+ * threw is a frame no later `endFrame` will run for.
573
+ */
574
+ closeFrame(slotAtSubmit) {
575
+ void this.device
576
+ .popErrorScope()
577
+ .then(err => {
578
+ if (err) {
579
+ // Genuine VRAM exhaustion during the frame (distinct from the proactive
580
+ // over-limit checks in uploadBuffer/uploadTexture). Surface to the
581
+ // display, not just the console — the view is too large for this GPU.
582
+ this.oom.report(`This view exhausted GPU memory — zoom in or reduce the track height. (out-of-memory after submit, slot ${slotAtSubmit}: ${err.message})`);
583
+ }
584
+ })
585
+ .catch(() => { });
586
+ void this.device
587
+ .popErrorScope()
588
+ .then(err => {
589
+ if (err) {
590
+ console.error('[WebGPUHal] endFrame: VALIDATION error after submit, slot=', slotAtSubmit, err.message);
591
+ }
592
+ })
593
+ .catch(() => { });
594
+ this.currentPass = null;
595
+ this.currentEncoder = null;
596
+ this.currentTextureView = null;
597
+ this.drainPendingDestroy();
598
+ }
599
+ endFrame() {
600
+ const encoder = this.currentEncoder;
601
+ if (!encoder) {
602
+ return;
603
+ }
604
+ const slotAtSubmit = this.uniformSlot;
605
+ try {
606
+ if (this.currentPass) {
607
+ this.currentPass.end();
608
+ }
609
+ if (slotAtSubmit > 0) {
610
+ const uploadSize = slotAtSubmit * this.alignedUniformSize;
611
+ this.device.queue.writeBuffer(this.uniformRingBuffer, 0, this.uniformStaging, 0, uploadSize);
612
+ }
613
+ if (slotAtSubmit >= UNIFORM_SLOT_WARN_AT && !this.warnedUniformSlots) {
614
+ this.warnedUniformSlots = true;
615
+ console.warn(`[WebGPUHal] this frame used ${slotAtSubmit} of ${MAX_UNIFORM_SLOTS} uniform ring slots. At the cap, writes are dropped and their draws render against another batch's uniforms — wrong pixels, no error. A count this high usually means a per-frame write inside a loop that has grown a dimension (stacked sections, displayed regions), or a write nothing draws with. Check the renderer before raising the cap.`);
616
+ }
617
+ this.device.queue.submit([encoder.finish()]);
618
+ }
619
+ finally {
620
+ this.closeFrame(slotAtSubmit);
621
+ }
622
+ }
623
+ /**
624
+ * Mark `at` as holding the whole attachment — the state a freshly-begun
625
+ * render pass is already in, so seeding with it means the first draw of an
626
+ * unclipped frame issues nothing.
627
+ */
628
+ seedApplied(at) {
629
+ at.x = 0;
630
+ at.y = 0;
631
+ at.w = this.canvas.width;
632
+ at.h = this.canvas.height;
633
+ }
634
+ /**
635
+ * Re-issue the scissor whenever it differs from what this pass was last told,
636
+ * **including when it has been cleared**.
637
+ *
638
+ * WebGL2 clears by turning the state off — `disable(SCISSOR_TEST)`, and a
639
+ * `viewport` back to the full canvas — and that lands immediately.
640
+ * `setScissorRect` / `setViewport` have no off switch and no reset: they are
641
+ * render-pass state that persists to the end of the pass. So dropping the
642
+ * stored rect on `clearScissor` is not a clear at all — it left the
643
+ * *previous* rect clipping every later draw of the frame, while WebGL2 drew
644
+ * those same calls unclipped.
645
+ *
646
+ * Nothing in tree clears mid-frame today — the two callers
647
+ * (`GpuPerRegionRenderingBackend.renderBlocks` and alignments' own) clear
648
+ * after their last draw — so this is parity kept ahead of the renderer that
649
+ * needs it rather than a fix for a live bug. The bug it would have been is
650
+ * the kind this package exists to refuse: right on Canvas2D, right on WebGL2,
651
+ * wrong on WebGPU alone, and silent everywhere.
652
+ */
653
+ applyScissor(pass) {
654
+ const r = this.scissorRect;
655
+ const x = r ? r.x : 0;
656
+ const y = r ? r.y : 0;
657
+ const w = r ? r.w : this.canvas.width;
658
+ const h = r ? r.h : this.canvas.height;
659
+ const at = this.appliedScissor;
660
+ if (at.x !== x || at.y !== y || at.w !== w || at.h !== h) {
661
+ pass.setScissorRect(x, y, w, h);
662
+ at.x = x;
663
+ at.y = y;
664
+ at.w = w;
665
+ at.h = h;
666
+ }
667
+ }
668
+ /** The viewport half of {@link applyScissor}; same reasoning throughout. */
669
+ applyViewport(pass) {
670
+ const r = this.viewportRect;
671
+ const x = r ? r.x : 0;
672
+ const y = r ? r.y : 0;
673
+ const w = r ? r.w : this.canvas.width;
674
+ const h = r ? r.h : this.canvas.height;
675
+ const at = this.appliedViewport;
676
+ if (at.x !== x || at.y !== y || at.w !== w || at.h !== h) {
677
+ pass.setViewport(x, y, w, h, 0, 1);
678
+ at.x = x;
679
+ at.y = y;
680
+ at.w = w;
681
+ at.h = h;
682
+ }
683
+ }
684
+ setScissor(x, y, w, h) {
685
+ this.scissorRect = { x, y, w, h };
686
+ }
687
+ clearScissor() {
688
+ this.scissorRect = null;
689
+ }
690
+ setViewport(x, y, w, h) {
691
+ this.viewportRect = { x, y, w, h };
692
+ }
693
+ clearViewport() {
694
+ this.viewportRect = null;
695
+ }
696
+ releaseResources() {
697
+ // Abandon an open frame before releasing anything, so the releases below
698
+ // are immediate: its encoded draws never reach the queue, and a deferral
699
+ // here would have no `endFrame` left to drain it. The error scopes it
700
+ // pushed are popped by `closeFrame` on the way out — the device outlives
701
+ // this HAL, so a pair left pushed would report into a sibling's frame.
702
+ if (this.currentEncoder) {
703
+ this.closeFrame(this.uniformSlot);
704
+ }
705
+ this.regions.deleteAll();
706
+ this.uniformRingBuffer.destroy();
707
+ for (const ts of this.passTextures.values()) {
708
+ ts.texture.destroy();
709
+ }
710
+ this.passTextures.clear();
711
+ this.passBindGroups.clear();
712
+ this.msaaTexture?.destroy();
713
+ // Release the swapchain so the browser can reclaim GPU memory immediately
714
+ // rather than waiting for the canvas to be GC'd — but only while it is still
715
+ // ours to release. A `GPUCanvasContext` belongs to the element, not to the
716
+ // HAL that configured it, so on a reused canvas this call can take a live
717
+ // sibling's swap chain instead; `noteCanvasConfigured` has the full shape.
718
+ if (canvasConfiguredBy(this.canvas, this)) {
719
+ this.context.unconfigure();
720
+ }
721
+ }
722
+ }