@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.
- package/LICENSE +201 -0
- package/esm/OverlayCanvas.d.ts +29 -0
- package/esm/OverlayCanvas.js +43 -0
- package/esm/RenderCanvas.d.ts +76 -0
- package/esm/RenderCanvas.js +38 -0
- package/esm/RenderLifecycleMixin.d.ts +180 -0
- package/esm/RenderLifecycleMixin.js +279 -0
- package/esm/ScrollLockedOverlay.d.ts +28 -0
- package/esm/ScrollLockedOverlay.js +42 -0
- package/esm/blockClipUtils.d.ts +27 -0
- package/esm/blockClipUtils.js +101 -0
- package/esm/canvas2dUtils.d.ts +372 -0
- package/esm/canvas2dUtils.js +564 -0
- package/esm/canvasContext.d.ts +68 -0
- package/esm/canvasContext.js +74 -0
- package/esm/colorRampLut.d.ts +17 -0
- package/esm/colorRampLut.js +25 -0
- package/esm/coverageBand.d.ts +107 -0
- package/esm/coverageBand.js +109 -0
- package/esm/coverageBandBuffers.d.ts +30 -0
- package/esm/coverageBandBuffers.js +13 -0
- package/esm/createRenderingBackend.d.ts +52 -0
- package/esm/createRenderingBackend.js +58 -0
- package/esm/displayPhase.d.ts +155 -0
- package/esm/displayPhase.js +97 -0
- package/esm/globalRenderingBackend.d.ts +90 -0
- package/esm/globalRenderingBackend.js +53 -0
- package/esm/gpuDevice.d.ts +91 -0
- package/esm/gpuDevice.js +277 -0
- package/esm/graphicsCapabilities.d.ts +98 -0
- package/esm/graphicsCapabilities.js +165 -0
- package/esm/hal/createHal.d.ts +22 -0
- package/esm/hal/createHal.js +91 -0
- package/esm/hal/deviceGpuCache.d.ts +36 -0
- package/esm/hal/deviceGpuCache.js +120 -0
- package/esm/hal/gpuHalBase.d.ts +55 -0
- package/esm/hal/gpuHalBase.js +81 -0
- package/esm/hal/index.d.ts +7 -0
- package/esm/hal/index.js +5 -0
- package/esm/hal/mockHal.d.ts +153 -0
- package/esm/hal/mockHal.js +297 -0
- package/esm/hal/oomReporter.d.ts +15 -0
- package/esm/hal/oomReporter.js +22 -0
- package/esm/hal/passIds.d.ts +22 -0
- package/esm/hal/passIds.js +36 -0
- package/esm/hal/regionRegistry.d.ts +11 -0
- package/esm/hal/regionRegistry.js +66 -0
- package/esm/hal/types.d.ts +116 -0
- package/esm/hal/types.js +1 -0
- package/esm/hal/webgl2Hal.d.ts +47 -0
- package/esm/hal/webgl2Hal.js +490 -0
- package/esm/hal/webgpuHal.d.ts +169 -0
- package/esm/hal/webgpuHal.js +722 -0
- package/esm/installUpload.d.ts +91 -0
- package/esm/installUpload.js +94 -0
- package/esm/instanceCache.d.ts +43 -0
- package/esm/instanceCache.js +53 -0
- package/esm/instancePass.d.ts +50 -0
- package/esm/instancePass.js +34 -0
- package/esm/keyedRenderingBackend.d.ts +41 -0
- package/esm/keyedRenderingBackend.js +19 -0
- package/esm/mapUploadSync.d.ts +31 -0
- package/esm/mapUploadSync.js +45 -0
- package/esm/namedReactions.d.ts +29 -0
- package/esm/namedReactions.js +62 -0
- package/esm/perRegionRenderingBackend.d.ts +114 -0
- package/esm/perRegionRenderingBackend.js +93 -0
- package/esm/recoveryBudget.d.ts +40 -0
- package/esm/recoveryBudget.js +56 -0
- package/esm/regionDataMap.d.ts +60 -0
- package/esm/regionDataMap.js +98 -0
- package/esm/renderBlock.d.ts +25 -0
- package/esm/renderBlock.js +10 -0
- package/esm/renderingBackendBase.d.ts +71 -0
- package/esm/renderingBackendBase.js +50 -0
- package/esm/shaders/antialias.js.generated.d.ts +4 -0
- package/esm/shaders/antialias.js.generated.js +25 -0
- package/esm/shaders/capsule.generated.d.ts +1 -0
- package/esm/shaders/capsule.generated.js +3 -0
- package/esm/shaders/colorRampLut.generated.d.ts +1 -0
- package/esm/shaders/colorRampLut.generated.js +3 -0
- package/esm/shaders/coverageBand.generated.d.ts +2 -0
- package/esm/shaders/coverageBand.generated.js +4 -0
- package/esm/shaders/coverageBar.generated.d.ts +4 -0
- package/esm/shaders/coverageBar.generated.js +6 -0
- package/esm/shaders/coverageBar.iface.generated.d.ts +85 -0
- package/esm/shaders/coverageBar.iface.generated.js +114 -0
- package/esm/shaders/coverageIndicator.generated.d.ts +4 -0
- package/esm/shaders/coverageIndicator.generated.js +6 -0
- package/esm/shaders/coverageIndicator.iface.generated.d.ts +85 -0
- package/esm/shaders/coverageIndicator.iface.generated.js +114 -0
- package/esm/shaders/coverageInterbase.generated.d.ts +4 -0
- package/esm/shaders/coverageInterbase.generated.js +6 -0
- package/esm/shaders/coverageInterbase.iface.generated.d.ts +93 -0
- package/esm/shaders/coverageInterbase.iface.generated.js +134 -0
- package/esm/shaders/coverageMod.generated.d.ts +4 -0
- package/esm/shaders/coverageMod.generated.js +6 -0
- package/esm/shaders/coverageMod.iface.generated.d.ts +97 -0
- package/esm/shaders/coverageMod.iface.generated.js +144 -0
- package/esm/shaders/coverageSnp.generated.d.ts +4 -0
- package/esm/shaders/coverageSnp.generated.js +6 -0
- package/esm/shaders/coverageSnp.iface.generated.d.ts +97 -0
- package/esm/shaders/coverageSnp.iface.generated.js +144 -0
- package/esm/shaders/hpmath.js.generated.d.ts +3 -0
- package/esm/shaders/hpmath.js.generated.js +48 -0
- package/esm/shaders/pointGlyph.generated.d.ts +1 -0
- package/esm/shaders/pointGlyph.generated.js +3 -0
- package/esm/shaders/pointGlyph.js.generated.d.ts +1 -0
- package/esm/shaders/pointGlyph.js.generated.js +7 -0
- package/esm/shaders/rowRect.generated.d.ts +3 -0
- package/esm/shaders/rowRect.generated.js +5 -0
- package/esm/shaders/rowRect.js.generated.d.ts +2 -0
- package/esm/shaders/rowRect.js.generated.js +13 -0
- package/esm/shaders/scoreScale.js.generated.d.ts +1 -0
- package/esm/shaders/scoreScale.js.generated.js +46 -0
- package/esm/slangPass.d.ts +49 -0
- package/esm/slangPass.js +22 -0
- package/esm/useEventCallback.d.ts +1 -0
- package/esm/useEventCallback.js +19 -0
- package/esm/useGraphicsCapabilities.d.ts +17 -0
- package/esm/useGraphicsCapabilities.js +38 -0
- package/esm/useRenderingBackend.d.ts +83 -0
- package/esm/useRenderingBackend.js +422 -0
- package/esm/useTabVisibilityRerender.d.ts +13 -0
- package/esm/useTabVisibilityRerender.js +39 -0
- package/esm/webgpuUtils.d.ts +23 -0
- package/esm/webgpuUtils.js +76 -0
- package/package.json +91 -0
- package/src/shaders/antialias.js.generated.ts +33 -0
- package/src/shaders/antialias.slang +112 -0
- package/src/shaders/capsule.generated.ts +4 -0
- package/src/shaders/capsule.slang +62 -0
- package/src/shaders/colorPack.slang +26 -0
- package/src/shaders/colorRampLut.generated.ts +4 -0
- package/src/shaders/colorRampLut.slang +51 -0
- package/src/shaders/coverageBand.generated.ts +6 -0
- package/src/shaders/coverageBand.slang +276 -0
- package/src/shaders/coverageBar.generated.ts +10 -0
- package/src/shaders/coverageBar.iface.generated.ts +172 -0
- package/src/shaders/coverageBar.slang +37 -0
- package/src/shaders/coverageIndicator.generated.ts +10 -0
- package/src/shaders/coverageIndicator.iface.generated.ts +172 -0
- package/src/shaders/coverageIndicator.slang +61 -0
- package/src/shaders/coverageInterbase.generated.ts +10 -0
- package/src/shaders/coverageInterbase.iface.generated.ts +198 -0
- package/src/shaders/coverageInterbase.slang +63 -0
- package/src/shaders/coverageMod.generated.ts +10 -0
- package/src/shaders/coverageMod.iface.generated.ts +211 -0
- package/src/shaders/coverageMod.slang +36 -0
- package/src/shaders/coverageSnp.generated.ts +10 -0
- package/src/shaders/coverageSnp.iface.generated.ts +211 -0
- package/src/shaders/coverageSnp.slang +52 -0
- package/src/shaders/diagonalGrid.slang +25 -0
- package/src/shaders/glyphEdgeAlpha.test.ts +241 -0
- package/src/shaders/hpmath.js.generated.ts +47 -0
- package/src/shaders/hpmath.slang +226 -0
- package/src/shaders/pointGlyph.generated.ts +4 -0
- package/src/shaders/pointGlyph.js.generated.ts +9 -0
- package/src/shaders/pointGlyph.slang +93 -0
- package/src/shaders/rowRect.generated.ts +8 -0
- package/src/shaders/rowRect.js.generated.ts +17 -0
- package/src/shaders/rowRect.slang +160 -0
- package/src/shaders/scoreScale.js.generated.ts +51 -0
- 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
|
+
}
|