@godot-scene-web/canvas-effects 0.1.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.
@@ -0,0 +1,1349 @@
1
+ import { frameGridFor } from "@godot-scene-web/effects/particles";
2
+ import { INSTANCE_STRIDE as INSTANCE_STRIDE$1 } from "@godot-scene-web/effects";
3
+ //#region src/webgpu-pipeline.ts
4
+ /** Normative WebGPU flags, stated locally so this module needs no browser host. */
5
+ const BUFFER_USAGE = {
6
+ COPY_DST: 8,
7
+ UNIFORM: 64
8
+ };
9
+ const SHADER_STAGE = {
10
+ VERTEX: 1,
11
+ FRAGMENT: 2
12
+ };
13
+ /**
14
+ * Bytes per per-cell uniform slot. 256 is the maximum `minUniformBufferOffsetAlignment` any WebGPU
15
+ * implementation may report, so a buffer laid out at this pitch is bindable with a dynamic offset
16
+ * everywhere; the real limit is read off the device and only ever rounds DOWN from here.
17
+ */
18
+ const UNIFORM_SLOT_BYTES = 256;
19
+ let lastError = null;
20
+ /** The first error message from the most recent failed compile/validation (with WGSL line/col when
21
+ * the failure was a shader), for diagnostics. Null until something fails. */
22
+ function lastPipelineError() {
23
+ return lastError;
24
+ }
25
+ function fail(message, onError) {
26
+ onError?.();
27
+ lastError = message;
28
+ console.warn("[gsw webgpu]", message);
29
+ return null;
30
+ }
31
+ /**
32
+ * Compile a WGSL module, returning null when the source has an error-severity message.
33
+ *
34
+ * Reading `getCompilationInfo()` rather than waiting for pipeline creation is what makes the
35
+ * message available at all: a module built from bad WGSL is a valid object that only fails later,
36
+ * inside `createRenderPipeline`, with a generic validation error.
37
+ */
38
+ async function compileModule(device, code, label, onError) {
39
+ let module;
40
+ try {
41
+ module = device.createShaderModule({
42
+ code,
43
+ label
44
+ });
45
+ } catch (error) {
46
+ return fail(`${label} WGSL module creation failed — ${describe(error)}`, onError);
47
+ }
48
+ try {
49
+ const errors = (await module.getCompilationInfo()).messages.filter((message) => message.type === "error");
50
+ if (errors.length > 0) {
51
+ const first = errors[0];
52
+ return fail(`${label} WGSL failed to compile — ${first.message} (line ${first.lineNum}, col ${first.linePos}); ${errors.length} error(s) total`, onError);
53
+ }
54
+ } catch (error) {
55
+ return fail(`${label} WGSL compilation info failed — ${describe(error)}`, onError);
56
+ }
57
+ return module;
58
+ }
59
+ /**
60
+ * Create a render pipeline inside a validation error scope, so a layout/blend/vertex-buffer
61
+ * mismatch comes back as a message and a null instead of as a surface that draws nothing.
62
+ */
63
+ async function createPipeline(device, descriptor, label, onError) {
64
+ try {
65
+ device.pushErrorScope("validation");
66
+ const pipeline = device.createRenderPipeline(descriptor);
67
+ const error = await device.popErrorScope();
68
+ if (error) return fail(`${label} pipeline failed WebGPU validation — ${error.message}`, onError);
69
+ return pipeline;
70
+ } catch (error) {
71
+ return fail(`${label} pipeline creation threw — ${describe(error)}`, onError);
72
+ }
73
+ }
74
+ /** A uniform bind-group layout with binding 0 as a DYNAMIC-offset uniform buffer — the shape every
75
+ * ring in this package binds through. `minBindingSize` must not exceed the size the bind group
76
+ * actually binds (the slot, not the buffer), so callers that build their own bind group pass the
77
+ * same number to both. */
78
+ function uniformBindGroupLayout(device, visibility = SHADER_STAGE.VERTEX | SHADER_STAGE.FRAGMENT, minBindingSize, label = "gsw-uniform") {
79
+ return device.createBindGroupLayout({
80
+ label,
81
+ entries: [{
82
+ binding: 0,
83
+ visibility,
84
+ buffer: minBindingSize === void 0 ? {
85
+ type: "uniform",
86
+ hasDynamicOffset: true
87
+ } : {
88
+ type: "uniform",
89
+ hasDynamicOffset: true,
90
+ minBindingSize
91
+ }
92
+ }]
93
+ });
94
+ }
95
+ function createUniformRing(device, cells, slotFloats = 256 / 4, options = {}) {
96
+ const label = options.label ?? "gsw-uniform-ring";
97
+ const count = Math.max(1, Math.floor(cells) || 0);
98
+ const slotBytes = Math.max(4, Math.ceil(slotFloats) * 4);
99
+ const alignment = Math.max(256, device.limits.minUniformBufferOffsetAlignment || 256, slotBytes);
100
+ const pitch = Math.ceil(alignment / 256) * 256;
101
+ const buffer = device.createBuffer({
102
+ label,
103
+ size: pitch * count,
104
+ usage: BUFFER_USAGE.UNIFORM | BUFFER_USAGE.COPY_DST
105
+ });
106
+ const layout = options.layout ?? uniformBindGroupLayout(device, options.visibility, slotBytes, label);
107
+ return {
108
+ buffer,
109
+ layout,
110
+ bindGroup: device.createBindGroup({
111
+ label,
112
+ layout,
113
+ entries: [{
114
+ binding: 0,
115
+ resource: {
116
+ buffer,
117
+ offset: 0,
118
+ size: slotBytes
119
+ }
120
+ }]
121
+ }),
122
+ pitch,
123
+ slotBytes,
124
+ stride: pitch / 4,
125
+ staging: new Float32Array(pitch / 4 * count),
126
+ destroy() {
127
+ buffer.destroy();
128
+ }
129
+ };
130
+ }
131
+ function describe(error) {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+ /** TEST-ONLY: clear the last recorded pipeline/shader error. */
135
+ function __resetPipelineErrorForTest() {
136
+ lastError = null;
137
+ }
138
+ //#endregion
139
+ //#region src/webgpu-readback.ts
140
+ const BUFFER_USAGE$1 = {
141
+ MAP_READ: 1,
142
+ COPY_DST: 8
143
+ };
144
+ const MAP_MODE = { READ: 1 };
145
+ /** `copyTextureToBuffer` requires every row to start on a 256-byte boundary, which is why the read
146
+ * is padded and then re-packed rather than mapped straight into the caller's hands. */
147
+ const BYTES_PER_ROW_ALIGNMENT = 256;
148
+ /**
149
+ * Read `width`×`height` RGBA bytes out of `texture`, tightly packed (`width * 4` bytes per row,
150
+ * top-down, PREMULTIPLIED alpha as stored — every fragment on this backend emits `vec4f(rgb*a, a)`
151
+ * under `PREMULTIPLIED_BLEND`, so the bytes in the texture are already multiplied through. A
152
+ * consumer that needs straight alpha (an encode into a 2D canvas) converts: see
153
+ * `./still-capture`).
154
+ *
155
+ * `texture` must have been created with COPY_SRC usage — a texture that will be read back has to
156
+ * declare it at creation, and there is no way to add it afterwards.
157
+ */
158
+ async function readTexturePixels(shared, texture, width, height) {
159
+ const { device } = shared;
160
+ const w = Math.max(1, Math.floor(width));
161
+ const h = Math.max(1, Math.floor(height));
162
+ const bytesPerRow = Math.ceil(w * 4 / BYTES_PER_ROW_ALIGNMENT) * BYTES_PER_ROW_ALIGNMENT;
163
+ const buffer = device.createBuffer({
164
+ label: "gsw-readback",
165
+ size: bytesPerRow * h,
166
+ usage: BUFFER_USAGE$1.COPY_DST | BUFFER_USAGE$1.MAP_READ
167
+ });
168
+ try {
169
+ const encoder = device.createCommandEncoder({ label: "gsw-readback" });
170
+ encoder.copyTextureToBuffer({ texture }, {
171
+ buffer,
172
+ bytesPerRow,
173
+ rowsPerImage: h
174
+ }, [
175
+ w,
176
+ h,
177
+ 1
178
+ ]);
179
+ device.queue.submit([encoder.finish()]);
180
+ await buffer.mapAsync(MAP_MODE.READ);
181
+ const padded = new Uint8Array(buffer.getMappedRange());
182
+ const packed = new Uint8Array(w * h * 4);
183
+ for (let row = 0; row < h; row++) packed.set(padded.subarray(row * bytesPerRow, row * bytesPerRow + w * 4), row * w * 4);
184
+ buffer.unmap();
185
+ return packed;
186
+ } finally {
187
+ buffer.destroy();
188
+ }
189
+ }
190
+ //#endregion
191
+ //#region src/particles-webgpu.ts
192
+ const PARTICLE_BUFFER_USAGE = {
193
+ VERTEX: 32,
194
+ COPY_DST: 8
195
+ };
196
+ const PARTICLE_TEXTURE_USAGE = {
197
+ COPY_SRC: 1,
198
+ COPY_DST: 2,
199
+ TEXTURE_BINDING: 4,
200
+ RENDER_ATTACHMENT: 16
201
+ };
202
+ /** Bytes per packed instance record: the shipped `INSTANCE_STRIDE` (10 floats) × 4. Stated as a
203
+ * literal because it is what the vertex-buffer layout declares as its `arrayStride` and what every
204
+ * attribute offset below is measured in. */
205
+ const INSTANCE_STRIDE_BYTES = 40;
206
+ if (INSTANCE_STRIDE$1 * 4 !== 40) throw new Error(`gsw particle instance stride drift: INSTANCE_STRIDE (${INSTANCE_STRIDE$1}) * 4 !== 40. PARTICLE_VERTEX_BUFFERS in particles/render-webgpu.ts declares byte offsets into the packed record (center@0, scale@8, rotation@16, color@20, frame@36) and must be updated with it.`);
207
+ /** Vertex entry point of `PARTICLE_WGSL`. Referenced by the pipeline descriptors, never spelled twice. */
208
+ const PARTICLE_VS_ENTRY = "vs_particles";
209
+ /** Fragment entry point for MIX (and every non-additive) blend: premultiplied colour out. */
210
+ const PARTICLE_FS_ENTRY = "fs_particles";
211
+ /** Fragment entry point for the ADDITIVE accumulate pass: raw light out, alpha 0. */
212
+ const PARTICLE_FS_ADDITIVE_ENTRY = "fs_particles_additive";
213
+ /** Vertex entry point of `ADDITIVE_RESOLVE_WGSL` (a full-target strip). */
214
+ const RESOLVE_VS_ENTRY = "vs_resolve";
215
+ /** Fragment entry point of `ADDITIVE_RESOLVE_WGSL`. */
216
+ const RESOLVE_FS_ENTRY = "fs_resolve";
217
+ /**
218
+ * The blend state every non-additive draw uses, and the other half of the premultiply contract in
219
+ * the module header.
220
+ *
221
+ * `one / one-minus-src-alpha` on colour AND alpha: the fragment already carries `rgb * a`, so the
222
+ * source contributes its light unscaled and the destination is attenuated by the coverage the source
223
+ * claims — `dst' = src.rgb*src.a + dst*(1-src.a)`, which is what `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`
224
+ * computes over a straight-alpha source (the GL path's blend). The alpha channel gets the same pair
225
+ * so the canvas's own alpha accumulates the same way.
226
+ */
227
+ const PREMULTIPLIED_BLEND = {
228
+ color: {
229
+ operation: "add",
230
+ srcFactor: "one",
231
+ dstFactor: "one-minus-src-alpha"
232
+ },
233
+ alpha: {
234
+ operation: "add",
235
+ srcFactor: "one",
236
+ dstFactor: "one-minus-src-alpha"
237
+ }
238
+ };
239
+ /** The ACCUMULATE pass's blend: `ONE, ONE` on both channels, i.e. `render-webgl`'s
240
+ * `blendFactorsFor(1)` — overlapping additive particles SUM their raw light (Godot
241
+ * `BLEND_MODE_ADD`) instead of compositing over one another. */
242
+ const ADDITIVE_BLEND = {
243
+ color: {
244
+ operation: "add",
245
+ srcFactor: "one",
246
+ dstFactor: "one"
247
+ },
248
+ alpha: {
249
+ operation: "add",
250
+ srcFactor: "one",
251
+ dstFactor: "one"
252
+ }
253
+ };
254
+ /**
255
+ * The vertex buffer layout: slot 0 is the static unit-quad corner (stepped per VERTEX), slot 1 is the
256
+ * packed instance record (stepped per INSTANCE).
257
+ *
258
+ * The attribute offsets are the shipped float offsets × 4 — center@0, scale@8, rotation@16,
259
+ * color@20, frame@36 — i.e. exactly `INSTANCE_ATTRS` in `render-webgl.ts`, in bytes. Exported so a
260
+ * test can read the stride back without building a device.
261
+ */
262
+ const PARTICLE_VERTEX_BUFFERS = [{
263
+ arrayStride: 8,
264
+ stepMode: "vertex",
265
+ attributes: [{
266
+ shaderLocation: 0,
267
+ offset: 0,
268
+ format: "float32x2"
269
+ }]
270
+ }, {
271
+ arrayStride: 40,
272
+ stepMode: "instance",
273
+ attributes: [
274
+ {
275
+ shaderLocation: 1,
276
+ offset: 0,
277
+ format: "float32x2"
278
+ },
279
+ {
280
+ shaderLocation: 2,
281
+ offset: 8,
282
+ format: "float32x2"
283
+ },
284
+ {
285
+ shaderLocation: 3,
286
+ offset: 16,
287
+ format: "float32"
288
+ },
289
+ {
290
+ shaderLocation: 4,
291
+ offset: 20,
292
+ format: "float32x4"
293
+ },
294
+ {
295
+ shaderLocation: 5,
296
+ offset: 36,
297
+ format: "float32"
298
+ }
299
+ ]
300
+ }];
301
+ /** Floats in the per-surface uniform slot (`Params` below): 6 f32 then 6 u32, all 4 bytes. */
302
+ const PARAMS_FLOATS = 12;
303
+ const PARAMS_BYTES = PARAMS_FLOATS * 4;
304
+ /**
305
+ * The instanced particle module — `render-webgl.ts`'s two shader stages, transcribed.
306
+ *
307
+ * PER-SYSTEM VALUES ARE UNIFORMS, NOT PIPELINE VARIANTS. `textured`/`lut`/`mask`/`uvPolar` etc. select
308
+ * branches at runtime out of ONE pipeline, exactly as the GL program's `u_textured`/`u_lut` do — the
309
+ * alternative (a pipeline per feature combination) is 64 pipelines for a fragment whose branches are
310
+ * uniform-valued and therefore free of divergence. Sampling inside those branches is legal for two
311
+ * independent reasons: a branch on a uniform value is UNIFORM CONTROL FLOW (so even
312
+ * derivative-taking `textureSample` would be allowed), and the samples below use
313
+ * `textureSampleLevel(..., 0.0)`, which needs no derivatives at all. Level 0 is not an approximation
314
+ * here: every texture this backend binds is created with a single mip level (see `../webgpu/textures`).
315
+ */
316
+ const PARTICLE_WGSL = `struct Params {
317
+ viewport: vec2f, // canvas backing size, device px — REWRITTEN EVERY DRAW (canvases resize)
318
+ grid: vec2f, // hframes, vframes
319
+ erodeFactors: vec2f, // threshold, softness
320
+ textured: u32,
321
+ lut: u32,
322
+ alphaFromRed: u32,
323
+ erode: u32,
324
+ mask: u32,
325
+ uvPolar: u32,
326
+ };
327
+
328
+ @group(0) @binding(0) var<uniform> params: Params;
329
+ @group(1) @binding(0) var sprite_tex: texture_2d<f32>;
330
+ @group(1) @binding(1) var sprite_smp: sampler;
331
+ @group(1) @binding(2) var lut_tex: texture_2d<f32>;
332
+ @group(1) @binding(3) var lut_smp: sampler;
333
+ @group(1) @binding(4) var mask_tex: texture_2d<f32>;
334
+ @group(1) @binding(5) var mask_smp: sampler;
335
+
336
+ // GLSL's \`mod\` is FLOOR-signed; WGSL's \`%\` is TRUNC-signed. The flipbook cell index and the polar
337
+ // wrap are both written against GLSL semantics in the shader this ports, so re-derive them rather
338
+ // than swap in an operator that agrees only for positive operands.
339
+ fn godot_mod(x: f32, y: f32) -> f32 {
340
+ return x - y * floor(x / y);
341
+ }
342
+
343
+ struct VsOut {
344
+ @builtin(position) pos: vec4f,
345
+ @location(0) uv: vec2f,
346
+ @location(1) quad: vec2f,
347
+ @location(2) cell: vec2f,
348
+ @location(3) color: vec4f,
349
+ };
350
+
351
+ @vertex
352
+ fn ${PARTICLE_VS_ENTRY}(
353
+ @location(0) corner: vec2f,
354
+ @location(1) center: vec2f,
355
+ @location(2) scale: vec2f,
356
+ @location(3) rotation: f32,
357
+ @location(4) color: vec4f,
358
+ @location(5) frame: f32,
359
+ ) -> VsOut {
360
+ let c = cos(rotation);
361
+ let s = sin(rotation);
362
+ let rotated = vec2f(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
363
+ let px = center + rotated * scale;
364
+ var clip = (px / params.viewport) * 2.0 - 1.0;
365
+ clip.y = -clip.y; // canvas Y-down -> clip Y-up
366
+ var out: VsOut;
367
+ out.pos = vec4f(clip, 0.0, 1.0);
368
+ let uv01 = corner + 0.5; // 0..1 across the SPRITE quad
369
+ let cell = vec2f(godot_mod(frame, params.grid.x), floor(frame / params.grid.x));
370
+ out.uv = (cell + uv01) / params.grid;
371
+ // The flipbook cell INDEX, so a fragment that re-derives its own sprite-local UV (the polar remap)
372
+ // can map it back into the same cell instead of over the whole sheet.
373
+ out.cell = cell;
374
+ // Quad-local 0..1, INDEPENDENT of the flipbook grid: the untextured dot and the coverage mask are
375
+ // both measured from this. Deriving them from the atlas-mapped uv put the dot's centre at the
376
+ // SHEET's centre, so any grid > 1x1 left it off-centre and clipped to a sliver of one cell.
377
+ out.quad = uv01;
378
+ out.color = color;
379
+ return out;
380
+ }
381
+
382
+ struct Shaded {
383
+ col: vec4f,
384
+ coverage: f32,
385
+ };
386
+
387
+ // The whole fragment feature set, shared by both entry points below so the mix and additive paths
388
+ // can never disagree about it (in GL they are one shader and a branch).
389
+ fn shade(in: VsOut) -> Shaded {
390
+ var uv = in.uv;
391
+ if (params.uvPolar == 1u) {
392
+ // Godot polar_coordinates(UV, vec2(0.5), 1, 1) (shaders/vfx/_util/polar_coordinates.gdshaderinc):
393
+ // x = radius from the sprite centre (0..1.41 at the corners), y = the angle mapped to 0..1, both
394
+ // wrapped — then RE-WRAPPED INTO THE SAME CELL through in.cell, so a flipbook stays a flipbook.
395
+ // A radial sheet (common_ring_polar_a) is a RING only through this; sampled flat it is a
396
+ // vertical BAR.
397
+ let dir = in.quad - vec2f(0.5);
398
+ let radius = length(dir) * 2.0;
399
+ let angle = atan2(dir.y, dir.x) * (1.0 / (3.1416 * 2.0));
400
+ uv = (in.cell + vec2f(godot_mod(radius, 1.0), godot_mod(angle, 1.0))) / params.grid;
401
+ }
402
+ var tex: vec4f;
403
+ if (params.textured == 1u) {
404
+ tex = textureSampleLevel(sprite_tex, sprite_smp, uv, 0.0);
405
+ } else {
406
+ // Soft round dot when the system has no texture — measured across the QUAD, not the atlas-mapped
407
+ // uv, so it stays centred whatever the (meaningless, textureless) grid is.
408
+ let r = length(in.quad - vec2f(0.5)) * 2.0;
409
+ tex = vec4f(1.0, 1.0, 1.0, 1.0 - smoothstep(0.7, 1.0, r));
410
+ }
411
+ // COVERAGE — taken PRE-LUT, because the LUT is a colour lookup INDEXED by that same red channel:
412
+ // reading it afterwards would sample the LUT's own (usually white) output instead of the sheet's
413
+ // shape. Grayscale VFX sheets are alpha-less PNGs, so tex.a is 1.0 everywhere and the alpha branch
414
+ // draws a SQUARE.
415
+ var coverage = select(tex.a, tex.r, params.alphaFromRed == 1u);
416
+ if (params.lut == 1u) {
417
+ // Godot's VFX particle-shader family: COLOR = vec4(texture(lut, texture_color.rr).rgb, alpha) *
418
+ // vertex_color. The sheet is a single-channel MASK, so its own RGB is meaningless; the LUT holds
419
+ // the real colours. Sampled AFTER the texture/dot resolve so both branches are recoloured, and
420
+ // the source ALPHA is preserved untouched — only RGB comes from the LUT.
421
+ tex = vec4f(textureSampleLevel(lut_tex, lut_smp, vec2f(tex.r, 0.5), 0.0).rgb, tex.a);
422
+ }
423
+ if (params.erode == 1u) {
424
+ // Godot erosion_from_factors(vec2(threshold, softness), coverage) — a CONSTANT erosion curve,
425
+ // i.e. the threshold does not sweep over the particle's life. AFTER the LUT, BEFORE the mask.
426
+ coverage = smoothstep(params.erodeFactors.x, params.erodeFactors.x + params.erodeFactors.y, coverage);
427
+ }
428
+ if (params.mask == 1u) {
429
+ // Godot's mask sampler reads the sprite's own UV, NOT the flipbook cell — it shapes the whole quad.
430
+ coverage = coverage * textureSampleLevel(mask_tex, mask_smp, in.quad, 0.0).r;
431
+ }
432
+ tex.a = coverage;
433
+ var out: Shaded;
434
+ out.col = tex * in.color;
435
+ out.coverage = coverage;
436
+ return out;
437
+ }
438
+
439
+ @fragment
440
+ fn ${PARTICLE_FS_ENTRY}(in: VsOut) -> @location(0) vec4f {
441
+ let col = shade(in).col;
442
+ // PREMULTIPLIED — see the module header. Only correct under PREMULTIPLIED_BLEND.
443
+ return vec4f(col.rgb * col.a, col.a);
444
+ }
445
+
446
+ @fragment
447
+ fn ${PARTICLE_FS_ADDITIVE_ENTRY}(in: VsOut) -> @location(0) vec4f {
448
+ // Additive sprites contribute light = colour x alpha (Godot BLEND_MODE_ADD adds src.rgb * src.a to
449
+ // the framebuffer, so an opaque-black glow background or an alpha-shaped sprite's transparent area
450
+ // both add nothing). Emit that light RAW and SUM it across particles (ADDITIVE_BLEND, into the
451
+ // accumulator); the resolve pass converts the per-pixel TOTAL to coverage ONCE. Normalizing per
452
+ // PARTICLE amplified every faint texel to full brightness and let overlaps clamp to white while
453
+ // stacking coverage — a subtle 5-particle fog rendered as an opaque white haze wall.
454
+ // ALPHA IS 0: the accumulator holds light, not coverage.
455
+ let shaded = shade(in);
456
+ return vec4f(shaded.col.rgb * shaded.coverage * in.color.a, 0.0);
457
+ }
458
+ `;
459
+ /**
460
+ * The additive RESOLVE pass: read the summed light out of the accumulator and present it.
461
+ *
462
+ * THE ALGEBRA. A premultiplied canvas wants the accumulated light itself: `(light, cov)` composites
463
+ * to `light + dst*(1-cov)`, with no division at all — and therefore no `cov > 0` guard either, since
464
+ * the division that would have needed one is gone (at cov = 0 the fragment is (0,0,0,0), which
465
+ * composites to `dst` exactly). `RESOLVE_FRAGMENT_SRC` in `./render-webgl.ts` is now the same
466
+ * expression, its canvas being premultiplied too; it used to divide by `cov` for a straight-alpha
467
+ * canvas and rely on the blit into the node canvas to multiply it back.
468
+ *
469
+ * `textureLoad` at integer pixel coordinates, like GL's `texelFetch`: the accumulate pass renders
470
+ * into the top-left w×h rect of a grow-only accumulator, and WebGPU framebuffer coordinates are
471
+ * Y-DOWN in both passes, so texel (x, y) is fragment (x, y) with no flip arithmetic anywhere.
472
+ */
473
+ const ADDITIVE_RESOLVE_WGSL = `@group(0) @binding(0) var accum_tex: texture_2d<f32>;
474
+
475
+ @vertex
476
+ fn ${RESOLVE_VS_ENTRY}(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
477
+ // TRIANGLE_STRIP corner order, matching the particle quad's: (-1,-1) (1,-1) (-1,1) (1,1).
478
+ var corners = array<vec2f, 4>(
479
+ vec2f(-1.0, -1.0),
480
+ vec2f(1.0, -1.0),
481
+ vec2f(-1.0, 1.0),
482
+ vec2f(1.0, 1.0)
483
+ );
484
+ return vec4f(corners[index], 0.0, 1.0);
485
+ }
486
+
487
+ @fragment
488
+ fn ${RESOLVE_FS_ENTRY}(@builtin(position) pos: vec4f) -> @location(0) vec4f {
489
+ let light = textureLoad(accum_tex, vec2i(pos.xy), 0).rgb;
490
+ let cov = max(light.r, max(light.g, light.b));
491
+ return vec4f(light, cov);
492
+ }
493
+ `;
494
+ /** The accumulator's format. `rgba8unorm` rather than a float target ON PURPOSE: the GL path
495
+ * accumulates into an RGBA/UNSIGNED_BYTE FBO, so its sums CLAMP at 1.0 per channel, and matching
496
+ * that byte-clamping beats being more faithful than the renderer this must look identical to. */
497
+ const ACCUM_FORMAT = "rgba8unorm";
498
+ const TRANSPARENT$1 = {
499
+ r: 0,
500
+ g: 0,
501
+ b: 0,
502
+ a: 0
503
+ };
504
+ let programMemo;
505
+ let programSettled;
506
+ let programDevice = null;
507
+ function acquireParticleProgram(options) {
508
+ if (programDevice !== options.device) {
509
+ programMemo = void 0;
510
+ programSettled = void 0;
511
+ programDevice = options.device;
512
+ }
513
+ if (programMemo) return programMemo;
514
+ programMemo = buildParticleProgram(options).then((program) => {
515
+ programSettled = program;
516
+ return program;
517
+ });
518
+ return programMemo;
519
+ }
520
+ function peekParticleProgram(options) {
521
+ return programDevice === options.device ? programSettled : void 0;
522
+ }
523
+ async function buildParticleProgram(options) {
524
+ const { device, format } = options;
525
+ const module = await compileModule(device, PARTICLE_WGSL, "gsw-particles");
526
+ if (!module) return null;
527
+ const resolveModule = await compileModule(device, ADDITIVE_RESOLVE_WGSL, "gsw-particle-resolve");
528
+ if (!resolveModule) return null;
529
+ const uniformLayout = uniformBindGroupLayout(device, SHADER_STAGE.VERTEX | SHADER_STAGE.FRAGMENT, PARAMS_BYTES, "gsw-particle-params");
530
+ const textureLayout = device.createBindGroupLayout({
531
+ label: "gsw-particle-textures",
532
+ entries: [
533
+ {
534
+ binding: 0,
535
+ visibility: SHADER_STAGE.FRAGMENT,
536
+ texture: {}
537
+ },
538
+ {
539
+ binding: 1,
540
+ visibility: SHADER_STAGE.FRAGMENT,
541
+ sampler: {}
542
+ },
543
+ {
544
+ binding: 2,
545
+ visibility: SHADER_STAGE.FRAGMENT,
546
+ texture: {}
547
+ },
548
+ {
549
+ binding: 3,
550
+ visibility: SHADER_STAGE.FRAGMENT,
551
+ sampler: {}
552
+ },
553
+ {
554
+ binding: 4,
555
+ visibility: SHADER_STAGE.FRAGMENT,
556
+ texture: {}
557
+ },
558
+ {
559
+ binding: 5,
560
+ visibility: SHADER_STAGE.FRAGMENT,
561
+ sampler: {}
562
+ }
563
+ ]
564
+ });
565
+ const accumLayout = device.createBindGroupLayout({
566
+ label: "gsw-particle-accum",
567
+ entries: [{
568
+ binding: 0,
569
+ visibility: SHADER_STAGE.FRAGMENT,
570
+ texture: {}
571
+ }]
572
+ });
573
+ const particleLayout = device.createPipelineLayout({
574
+ label: "gsw-particles",
575
+ bindGroupLayouts: [uniformLayout, textureLayout]
576
+ });
577
+ const resolvePipelineLayout = device.createPipelineLayout({
578
+ label: "gsw-particle-resolve",
579
+ bindGroupLayouts: [accumLayout]
580
+ });
581
+ const normal = await createPipeline(device, particlePipelineDescriptor(module, particleLayout, format, false), "gsw-particles");
582
+ if (!normal) return null;
583
+ const accumulate = await createPipeline(device, particlePipelineDescriptor(module, particleLayout, ACCUM_FORMAT, true), "gsw-particles-additive");
584
+ if (!accumulate) return null;
585
+ const resolve = await createPipeline(device, resolvePipelineDescriptor(resolveModule, resolvePipelineLayout, format), "gsw-particle-resolve");
586
+ if (!resolve) return null;
587
+ const corners = device.createBuffer({
588
+ label: "gsw-particle-corners",
589
+ size: 32,
590
+ usage: PARTICLE_BUFFER_USAGE.VERTEX | PARTICLE_BUFFER_USAGE.COPY_DST
591
+ });
592
+ device.queue.writeBuffer(corners, 0, new Float32Array([
593
+ -.5,
594
+ -.5,
595
+ .5,
596
+ -.5,
597
+ -.5,
598
+ .5,
599
+ .5,
600
+ .5
601
+ ]));
602
+ const placeholder = device.createTexture({
603
+ label: "gsw-particle-placeholder",
604
+ size: [
605
+ 1,
606
+ 1,
607
+ 1
608
+ ],
609
+ format: "rgba8unorm",
610
+ usage: PARTICLE_TEXTURE_USAGE.TEXTURE_BINDING | PARTICLE_TEXTURE_USAGE.COPY_DST
611
+ });
612
+ device.queue.writeTexture({ texture: placeholder }, new Uint8Array([
613
+ 255,
614
+ 255,
615
+ 255,
616
+ 255
617
+ ]), {
618
+ bytesPerRow: 4,
619
+ rowsPerImage: 1
620
+ }, [
621
+ 1,
622
+ 1,
623
+ 1
624
+ ]);
625
+ return {
626
+ device,
627
+ format,
628
+ module,
629
+ resolveModule,
630
+ uniformLayout,
631
+ textureLayout,
632
+ accumLayout,
633
+ particleLayout,
634
+ resolvePipelineLayout,
635
+ normal,
636
+ accumulate,
637
+ resolve,
638
+ corners,
639
+ placeholderView: placeholder.createView(),
640
+ placeholderSampler: device.createSampler({
641
+ label: "gsw-particle-placeholder",
642
+ magFilter: "linear",
643
+ minFilter: "linear"
644
+ }),
645
+ accum: null,
646
+ capture: null
647
+ };
648
+ }
649
+ function particlePipelineDescriptor(module, layout, format, additive) {
650
+ return {
651
+ label: additive ? "gsw-particles-additive" : "gsw-particles",
652
+ layout,
653
+ vertex: {
654
+ module,
655
+ entryPoint: PARTICLE_VS_ENTRY,
656
+ buffers: PARTICLE_VERTEX_BUFFERS
657
+ },
658
+ fragment: {
659
+ module,
660
+ entryPoint: additive ? PARTICLE_FS_ADDITIVE_ENTRY : PARTICLE_FS_ENTRY,
661
+ targets: [{
662
+ format,
663
+ blend: additive ? ADDITIVE_BLEND : PREMULTIPLIED_BLEND
664
+ }]
665
+ },
666
+ primitive: { topology: "triangle-strip" }
667
+ };
668
+ }
669
+ function resolvePipelineDescriptor(module, layout, format) {
670
+ return {
671
+ label: "gsw-particle-resolve",
672
+ layout,
673
+ vertex: {
674
+ module,
675
+ entryPoint: RESOLVE_VS_ENTRY
676
+ },
677
+ fragment: {
678
+ module,
679
+ entryPoint: RESOLVE_FS_ENTRY,
680
+ targets: [{ format }]
681
+ },
682
+ primitive: { topology: "triangle-strip" }
683
+ };
684
+ }
685
+ /** Construct a renderer after its device program is compiled. Presentation contexts and texture handles stay host-owned. */
686
+ async function createWebgpuParticleRenderer(options) {
687
+ const program = await acquireParticleProgram(options);
688
+ return program ? rendererOver(options, program) : null;
689
+ }
690
+ function peekWebgpuParticleRenderer(options) {
691
+ const program = peekParticleProgram(options);
692
+ return program === void 0 ? void 0 : program === null ? null : rendererOver(options, program);
693
+ }
694
+ function rendererOver(options, program) {
695
+ const { device } = options;
696
+ let encoder = null;
697
+ let recorded = false;
698
+ let implicit = false;
699
+ let submitCount = 0;
700
+ const ensureEncoder = () => {
701
+ if (!encoder) {
702
+ encoder = device.createCommandEncoder({ label: "gsw-particles" });
703
+ implicit = true;
704
+ }
705
+ return encoder;
706
+ };
707
+ const flush = () => {
708
+ if (encoder && recorded) {
709
+ device.queue.submit([encoder.finish()]);
710
+ submitCount++;
711
+ }
712
+ encoder = null;
713
+ recorded = false;
714
+ implicit = false;
715
+ };
716
+ const flushImplicit = () => {
717
+ if (implicit) flush();
718
+ };
719
+ return {
720
+ createSurface(surface) {
721
+ const ring = createUniformRing(device, 1, PARAMS_FLOATS, {
722
+ label: "gsw-particle-params",
723
+ layout: program.uniformLayout
724
+ });
725
+ const state = Object.assign(surface, {
726
+ ring,
727
+ words: new Uint32Array(ring.staging.buffer),
728
+ instances: null,
729
+ instanceBytes: 0,
730
+ bindGroup: null,
731
+ boundViews: [
732
+ null,
733
+ null,
734
+ null
735
+ ],
736
+ bindGroupDirty: true,
737
+ disposeTextureListener: null
738
+ });
739
+ state.disposeTextureListener = surface.onTexturesChanged?.(() => {
740
+ state.bindGroupDirty = true;
741
+ }) ?? null;
742
+ return state;
743
+ },
744
+ disposeSurface(state) {
745
+ state.disposeTextureListener?.();
746
+ state.instances?.destroy();
747
+ state.ring.destroy();
748
+ state.bindGroup = null;
749
+ },
750
+ beginFrame() {
751
+ if (!encoder) {
752
+ encoder = device.createCommandEncoder({ label: "gsw-particles" });
753
+ recorded = false;
754
+ implicit = false;
755
+ }
756
+ },
757
+ endFrame: flush,
758
+ clear(state) {
759
+ const view = currentView(state);
760
+ if (view) {
761
+ ensureEncoder().beginRenderPass({
762
+ label: "gsw-particle-clear",
763
+ colorAttachments: [{
764
+ view,
765
+ clearValue: TRANSPARENT$1,
766
+ loadOp: "clear",
767
+ storeOp: "store"
768
+ }]
769
+ }).end();
770
+ recorded = true;
771
+ }
772
+ flushImplicit();
773
+ },
774
+ draw(state, buffer, opts) {
775
+ const view = currentView(state);
776
+ if (view) {
777
+ encodeSurface(program, ensureEncoder(), state, view, buffer, opts);
778
+ recorded = true;
779
+ }
780
+ flushImplicit();
781
+ },
782
+ submits: () => submitCount,
783
+ captureSurface: (state, buffer, opts) => captureSurfacePixels(program, options, state, buffer, opts)
784
+ };
785
+ }
786
+ /** The canvas's current swap-chain view, or null when it cannot be had (an unconfigured context, a
787
+ * zero-sized canvas, a lost device). A frame that cannot acquire its target is SKIPPED, not thrown
788
+ * out of: the runtime's next tick asks again. */
789
+ function currentView(state) {
790
+ try {
791
+ return state.context.getCurrentTexture().createView();
792
+ } catch {
793
+ return null;
794
+ }
795
+ }
796
+ /** Record one system's draw into `encoder`, targeting `view` (the canvas's swap-chain image, or a
797
+ * capture texture). Additive systems record TWO passes; passes execute in the order they were
798
+ * recorded, so accumulate→resolve is safe inside the shared one-submit frame. */
799
+ function encodeSurface(program, encoder, state, view, buffer, opts, capture = false) {
800
+ const w = Math.max(1, Math.floor(opts.width));
801
+ const h = Math.max(1, Math.floor(opts.height));
802
+ writeParams(program, state, w, h, opts);
803
+ uploadInstances(program, state, buffer);
804
+ const textures = ensureBindGroup(program, state);
805
+ const additive = opts.blendMode === 1;
806
+ const pipelines = capture ? captureOrThrow(program) : program;
807
+ if (additive) {
808
+ const accum = ensureAccum(program, w, h);
809
+ const accumPass = encoder.beginRenderPass({
810
+ label: "gsw-particle-accumulate",
811
+ colorAttachments: [{
812
+ view: accum.view,
813
+ clearValue: TRANSPARENT$1,
814
+ loadOp: "clear",
815
+ storeOp: "store"
816
+ }]
817
+ });
818
+ accumPass.setViewport(0, 0, w, h, 0, 1);
819
+ accumPass.setScissorRect(0, 0, w, h);
820
+ accumPass.setPipeline(program.accumulate);
821
+ accumPass.setBindGroup(0, state.ring.bindGroup, [0]);
822
+ accumPass.setBindGroup(1, textures);
823
+ accumPass.setVertexBuffer(0, program.corners);
824
+ accumPass.setVertexBuffer(1, state.instances);
825
+ accumPass.draw(4, buffer.count);
826
+ accumPass.end();
827
+ const resolvePass = encoder.beginRenderPass({
828
+ label: "gsw-particle-resolve",
829
+ colorAttachments: [{
830
+ view,
831
+ clearValue: TRANSPARENT$1,
832
+ loadOp: "clear",
833
+ storeOp: "store"
834
+ }]
835
+ });
836
+ resolvePass.setPipeline(pipelines.resolve);
837
+ resolvePass.setBindGroup(0, accum.bindGroup);
838
+ resolvePass.draw(4, 1);
839
+ resolvePass.end();
840
+ return;
841
+ }
842
+ const pass = encoder.beginRenderPass({
843
+ label: "gsw-particle-draw",
844
+ colorAttachments: [{
845
+ view,
846
+ clearValue: TRANSPARENT$1,
847
+ loadOp: "clear",
848
+ storeOp: "store"
849
+ }]
850
+ });
851
+ pass.setPipeline(pipelines.normal);
852
+ pass.setBindGroup(0, state.ring.bindGroup, [0]);
853
+ pass.setBindGroup(1, textures);
854
+ pass.setVertexBuffer(0, program.corners);
855
+ pass.setVertexBuffer(1, state.instances);
856
+ pass.draw(4, buffer.count);
857
+ pass.end();
858
+ }
859
+ /** The per-surface uniform slot. REWRITTEN EVERY DRAW rather than latched at create: a node canvas
860
+ * resizes (renderScale, a pin change, a rotation, a laid-out box moving), and a stale viewport maps
861
+ * every particle to the wrong clip position — silently, since nothing about it is an error. */
862
+ function writeParams(program, state, w, h, opts) {
863
+ const textured = Boolean(opts.textured && state.textures.sprite);
864
+ const [hframes, vframes] = frameGridFor(textured, opts.hframes, opts.vframes);
865
+ const erode = opts.erode ?? null;
866
+ const floats = state.ring.staging;
867
+ const words = state.words;
868
+ floats[0] = w;
869
+ floats[1] = h;
870
+ floats[2] = hframes;
871
+ floats[3] = vframes;
872
+ floats[4] = erode ? erode.threshold : 0;
873
+ floats[5] = erode ? erode.softness : 0;
874
+ words[6] = textured ? 1 : 0;
875
+ words[7] = opts.lutTexture && state.textures.lut ? 1 : 0;
876
+ words[8] = opts.alphaFromRed ? 1 : 0;
877
+ words[9] = erode ? 1 : 0;
878
+ words[10] = opts.maskTexture && state.textures.mask ? 1 : 0;
879
+ words[11] = opts.uvPolar ? 1 : 0;
880
+ program.device.queue.writeBuffer(state.ring.buffer, 0, floats, 0, PARAMS_FLOATS);
881
+ }
882
+ /** The per-surface instance buffer: GROW-ONLY, like everything else in this pipeline, so a steady
883
+ * fleet allocates nothing per frame. */
884
+ function uploadInstances(program, state, buffer) {
885
+ const bytes = Math.max(40, buffer.count * 40);
886
+ if (!state.instances || bytes > state.instanceBytes) {
887
+ state.instances?.destroy();
888
+ state.instances = program.device.createBuffer({
889
+ label: "gsw-particle-instances",
890
+ size: bytes,
891
+ usage: PARTICLE_BUFFER_USAGE.VERTEX | PARTICLE_BUFFER_USAGE.COPY_DST
892
+ });
893
+ state.instanceBytes = bytes;
894
+ }
895
+ if (buffer.count <= 0) return;
896
+ program.device.queue.writeBuffer(state.instances, 0, buffer.data.buffer, buffer.data.byteOffset, buffer.count * 40);
897
+ }
898
+ /** The surface's texture bind group, rebuilt when a decode replaced one of its views (see
899
+ * `WebgpuParticleSurfaceState.boundViews`) and otherwise reused. */
900
+ function ensureBindGroup(program, state) {
901
+ const spriteView = state.textures.sprite?.view ?? program.placeholderView;
902
+ const lutView = state.textures.lut?.view ?? program.placeholderView;
903
+ const maskView = state.textures.mask?.view ?? program.placeholderView;
904
+ const stale = state.boundViews[0] !== spriteView || state.boundViews[1] !== lutView || state.boundViews[2] !== maskView;
905
+ if (state.bindGroup && !state.bindGroupDirty && !stale) return state.bindGroup;
906
+ state.bindGroup = program.device.createBindGroup({
907
+ label: "gsw-particle-textures",
908
+ layout: program.textureLayout,
909
+ entries: [
910
+ {
911
+ binding: 0,
912
+ resource: spriteView
913
+ },
914
+ {
915
+ binding: 1,
916
+ resource: state.textures.sprite?.sampler ?? program.placeholderSampler
917
+ },
918
+ {
919
+ binding: 2,
920
+ resource: lutView
921
+ },
922
+ {
923
+ binding: 3,
924
+ resource: state.textures.lut?.sampler ?? program.placeholderSampler
925
+ },
926
+ {
927
+ binding: 4,
928
+ resource: maskView
929
+ },
930
+ {
931
+ binding: 5,
932
+ resource: state.textures.mask?.sampler ?? program.placeholderSampler
933
+ }
934
+ ]
935
+ });
936
+ state.boundViews = [
937
+ spriteView,
938
+ lutView,
939
+ maskView
940
+ ];
941
+ state.bindGroupDirty = false;
942
+ return state.bindGroup;
943
+ }
944
+ /** Grow (never shrink) the device-scope light accumulator to cover w×h — the same policy, for the
945
+ * same realloc-cost reason, as the GL path's `ensureAccumTarget`. */
946
+ function ensureAccum(program, w, h) {
947
+ const current = program.accum;
948
+ if (current && current.width >= w && current.height >= h) return current;
949
+ const width = Math.max(current?.width ?? 0, w);
950
+ const height = Math.max(current?.height ?? 0, h);
951
+ current?.texture.destroy();
952
+ const texture = program.device.createTexture({
953
+ label: "gsw-particle-accum",
954
+ size: [
955
+ width,
956
+ height,
957
+ 1
958
+ ],
959
+ format: ACCUM_FORMAT,
960
+ usage: PARTICLE_TEXTURE_USAGE.RENDER_ATTACHMENT | PARTICLE_TEXTURE_USAGE.TEXTURE_BINDING
961
+ });
962
+ const view = texture.createView();
963
+ const accum = {
964
+ texture,
965
+ view,
966
+ bindGroup: program.device.createBindGroup({
967
+ label: "gsw-particle-accum",
968
+ layout: program.accumLayout,
969
+ entries: [{
970
+ binding: 0,
971
+ resource: view
972
+ }]
973
+ }),
974
+ width,
975
+ height
976
+ };
977
+ program.accum = accum;
978
+ return accum;
979
+ }
980
+ function captureOrThrow(program) {
981
+ const capture = program.capture;
982
+ if (!capture) throw new Error("gsw: capture pipelines were not compiled");
983
+ return capture;
984
+ }
985
+ /**
986
+ * Re-render this surface's CURRENT frame into an offscreen texture and read it back.
987
+ *
988
+ * NEVER `drawImage`/`toDataURL` FROM THE CANVAS. Both read a WebGPU canvas through its presentation
989
+ * path, which is blank under headless SwiftShader and pathological on Android Chrome (S7 measured
990
+ * 23 Hz against 87 for direct presentation). `copyTextureToBuffer` + `mapAsync` — what
991
+ * `../webgpu/readback` does — is the one path verified to work fully headless, which is why this
992
+ * hook exists at all rather than the parity harness simply reading the canvas.
993
+ *
994
+ * The pipelines are the live ones re-created against `rgba8unorm`: a pipeline's fragment target
995
+ * format must match its attachment, and the canvas format is usually `bgra8unorm`. Everything else —
996
+ * module, entry points, blend states, uniforms, bind groups — is shared with the live draw, so what
997
+ * comes back is the frame the canvas is showing, not a second interpretation of it.
998
+ */
999
+ async function captureSurfacePixels(program, options, state, buffer, opts) {
1000
+ const w = Math.max(1, Math.floor(opts.width));
1001
+ const h = Math.max(1, Math.floor(opts.height));
1002
+ if (!program.capture) {
1003
+ const normal = await createPipeline(program.device, particlePipelineDescriptor(program.module, program.particleLayout, ACCUM_FORMAT, false), "gsw-particles-capture");
1004
+ const resolve = await createPipeline(program.device, resolvePipelineDescriptor(program.resolveModule, program.resolvePipelineLayout, ACCUM_FORMAT), "gsw-particle-resolve-capture");
1005
+ if (!normal || !resolve) return null;
1006
+ program.capture = {
1007
+ normal,
1008
+ resolve
1009
+ };
1010
+ }
1011
+ const target = program.device.createTexture({
1012
+ label: "gsw-particle-capture",
1013
+ size: [
1014
+ w,
1015
+ h,
1016
+ 1
1017
+ ],
1018
+ format: ACCUM_FORMAT,
1019
+ usage: PARTICLE_TEXTURE_USAGE.RENDER_ATTACHMENT | PARTICLE_TEXTURE_USAGE.COPY_SRC
1020
+ });
1021
+ try {
1022
+ const encoder = program.device.createCommandEncoder({ label: "gsw-particle-capture" });
1023
+ encodeSurface(program, encoder, state, target.createView(), buffer, {
1024
+ ...opts,
1025
+ width: w,
1026
+ height: h
1027
+ }, true);
1028
+ program.device.queue.submit([encoder.finish()]);
1029
+ return await (options.readTexturePixels ?? ((texture, width, height) => readTexturePixels({ device: options.device }, texture, width, height)))(target, w, h);
1030
+ } catch {
1031
+ options.onPipelineError?.();
1032
+ return null;
1033
+ } finally {
1034
+ target.destroy();
1035
+ }
1036
+ }
1037
+ /** TEST-ONLY: drop the device-scope pipelines so a suite can re-probe with a fresh stub device. */
1038
+ function __resetWebgpuParticleProgramForTest() {
1039
+ programMemo = void 0;
1040
+ programSettled = void 0;
1041
+ programDevice = null;
1042
+ }
1043
+ //#endregion
1044
+ //#region src/shader-webgpu.ts
1045
+ /** Adapter-neutral WebGPU command executor for canvas shaders. Hosts own DOM, texture resolution and bind groups. */
1046
+ const TEXTURE_USAGE = {
1047
+ COPY_SRC: 1,
1048
+ RENDER_ATTACHMENT: 16
1049
+ };
1050
+ const TRANSPARENT = {
1051
+ r: 0,
1052
+ g: 0,
1053
+ b: 0,
1054
+ a: 0
1055
+ };
1056
+ function createWebgpuShaderBindGroupLayout(device, label, entries) {
1057
+ return device.createBindGroupLayout({
1058
+ label,
1059
+ entries: Array.from(entries)
1060
+ });
1061
+ }
1062
+ function createWebgpuShaderUniformBuffer(device, label, size) {
1063
+ return device.createBuffer({
1064
+ label,
1065
+ size,
1066
+ usage: 72
1067
+ });
1068
+ }
1069
+ function createWebgpuShaderBindGroup(device, label, layout, entries) {
1070
+ return device.createBindGroup({
1071
+ label,
1072
+ layout,
1073
+ entries: Array.from(entries)
1074
+ });
1075
+ }
1076
+ /** Owns one command encoder per host frame. It never creates contexts or texture handles. */
1077
+ var WebgpuShaderExecutor = class {
1078
+ device;
1079
+ encoder = null;
1080
+ recorded = false;
1081
+ implicit = false;
1082
+ submitCount = 0;
1083
+ constructor(device) {
1084
+ this.device = device;
1085
+ }
1086
+ beginFrame() {
1087
+ if (this.encoder) return;
1088
+ this.encoder = this.device.createCommandEncoder({ label: "gsw-shaders" });
1089
+ this.recorded = false;
1090
+ this.implicit = false;
1091
+ }
1092
+ endFrame() {
1093
+ this.flush();
1094
+ }
1095
+ submits() {
1096
+ return this.submitCount;
1097
+ }
1098
+ draw(draw) {
1099
+ const target = draw.target ?? this.currentView(draw.context);
1100
+ if (!target) return false;
1101
+ const encoder = this.ensureEncoder();
1102
+ this.device.queue.writeBuffer(draw.uniformBuffer, 0, draw.uniformBytes, 0, draw.uniformBytes.byteLength);
1103
+ const pass = encoder.beginRenderPass({
1104
+ label: "gsw-shader-draw",
1105
+ colorAttachments: [{
1106
+ view: target,
1107
+ clearValue: TRANSPARENT,
1108
+ loadOp: "clear",
1109
+ storeOp: "store"
1110
+ }]
1111
+ });
1112
+ pass.setViewport(0, 0, draw.width, draw.height, 0, 1);
1113
+ pass.setPipeline(draw.pipeline);
1114
+ pass.setBindGroup(0, draw.bindGroup);
1115
+ pass.draw(4, 1);
1116
+ pass.end();
1117
+ this.recorded = true;
1118
+ if (this.implicit) this.flush();
1119
+ return true;
1120
+ }
1121
+ async capture(draw) {
1122
+ const target = this.device.createTexture({
1123
+ label: "gsw-shader-capture",
1124
+ size: [
1125
+ draw.width,
1126
+ draw.height,
1127
+ 1
1128
+ ],
1129
+ format: "rgba8unorm",
1130
+ usage: TEXTURE_USAGE.RENDER_ATTACHMENT | TEXTURE_USAGE.COPY_SRC
1131
+ });
1132
+ try {
1133
+ const encoder = this.device.createCommandEncoder({ label: "gsw-shader-capture" });
1134
+ this.device.queue.writeBuffer(draw.uniformBuffer, 0, draw.uniformBytes, 0, draw.uniformBytes.byteLength);
1135
+ const pass = encoder.beginRenderPass({
1136
+ label: "gsw-shader-draw",
1137
+ colorAttachments: [{
1138
+ view: target.createView(),
1139
+ clearValue: TRANSPARENT,
1140
+ loadOp: "clear",
1141
+ storeOp: "store"
1142
+ }]
1143
+ });
1144
+ pass.setViewport(0, 0, draw.width, draw.height, 0, 1);
1145
+ pass.setPipeline(draw.pipeline);
1146
+ pass.setBindGroup(0, draw.bindGroup);
1147
+ pass.draw(4, 1);
1148
+ pass.end();
1149
+ this.device.queue.submit([encoder.finish()]);
1150
+ return await draw.read(target, draw.width, draw.height);
1151
+ } catch {
1152
+ return null;
1153
+ } finally {
1154
+ target.destroy();
1155
+ }
1156
+ }
1157
+ ensureEncoder() {
1158
+ if (!this.encoder) {
1159
+ this.encoder = this.device.createCommandEncoder({ label: "gsw-shaders" });
1160
+ this.implicit = true;
1161
+ }
1162
+ return this.encoder;
1163
+ }
1164
+ currentView(context) {
1165
+ try {
1166
+ return context?.getCurrentTexture().createView() ?? null;
1167
+ } catch {
1168
+ return null;
1169
+ }
1170
+ }
1171
+ flush() {
1172
+ if (this.encoder && this.recorded) {
1173
+ this.device.queue.submit([this.encoder.finish()]);
1174
+ this.submitCount++;
1175
+ }
1176
+ this.encoder = null;
1177
+ this.recorded = false;
1178
+ this.implicit = false;
1179
+ }
1180
+ };
1181
+ //#endregion
1182
+ //#region src/webgpu-pack-uniforms.ts
1183
+ /** A `PackedColorArray` arrives as RGBA quads even when the uniform is `vec3[]` — the same
1184
+ * correction the WebGL path makes in `normalizeVec3ArrayUniformValues`. */
1185
+ const PACKED_COLOR_ARRAY = "PackedColorArray";
1186
+ /** Uniform-address-space arrays are strided to 16 bytes (4 floats) per element. */
1187
+ const ARRAY_STRIDE_FLOATS = 4;
1188
+ /** How many scalar components each WGSL member type holds. */
1189
+ const COMPONENTS = {
1190
+ f32: 1,
1191
+ i32: 1,
1192
+ u32: 1,
1193
+ vec2f: 2,
1194
+ vec2i: 2,
1195
+ vec3f: 3,
1196
+ vec3i: 3,
1197
+ vec4f: 4,
1198
+ vec4i: 4
1199
+ };
1200
+ /** WGSL types written as signed INTEGERS rather than floats (an `int` uniform, and the `ivecN`s). */
1201
+ const INTEGER_TYPES = new Set([
1202
+ "i32",
1203
+ "vec2i",
1204
+ "vec3i",
1205
+ "vec4i"
1206
+ ]);
1207
+ /** Allocate a staging block big enough for `sizeBytes` (rounded up to a whole float). */
1208
+ function createUniformStaging(sizeBytes) {
1209
+ const size = Math.max(4, Math.ceil(sizeBytes / 4) * 4);
1210
+ const bytes = new ArrayBuffer(size);
1211
+ return {
1212
+ bytes,
1213
+ floats: new Float32Array(bytes),
1214
+ ints: new Int32Array(bytes)
1215
+ };
1216
+ }
1217
+ /**
1218
+ * Write `values` into `staging` at the byte offsets `layout` declares, and return it.
1219
+ *
1220
+ * ZEROED FIRST, deliberately: a uniform whose value disappeared between frames (a param attribute
1221
+ * dropped, a `MODULATE` that stopped applying) must read as 0, not as whatever the previous frame
1222
+ * left in that lane. The struct is small (tens of bytes) and this happens once per binding per
1223
+ * frame, so the clear is not worth optimising away for the class of bug it removes.
1224
+ */
1225
+ function packShaderUniforms(layout, values, staging = createUniformStaging(layout.uniformStructSizeBytes)) {
1226
+ staging.floats.fill(0);
1227
+ const { builtinOffsets } = layout;
1228
+ writeFloats(staging, builtinOffsets.uvFit, values.uvFit, 2);
1229
+ writeFloats(staging, builtinOffsets.uvWindow, values.uvWindow, 4);
1230
+ if (builtinOffsets.time !== void 0) staging.floats[builtinOffsets.time / 4] = values.time ?? 0;
1231
+ writeFloats(staging, builtinOffsets.texturePixelSize, values.texturePixelSize, 2);
1232
+ writeFloats(staging, builtinOffsets.modulate, values.modulate, 4);
1233
+ writeFloats(staging, builtinOffsets.screenOrigin, values.screenOrigin, 2);
1234
+ writeFloats(staging, builtinOffsets.screenSize, values.screenSize, 2);
1235
+ const params = values.params ?? {};
1236
+ for (const field of layout.uniforms) writeUniformField(staging, field, params[field.name] ?? field.default, values.paramKinds?.[field.name]);
1237
+ return staging;
1238
+ }
1239
+ function writeUniformField(staging, field, raw, paramKind) {
1240
+ const components = COMPONENTS[field.type];
1241
+ if (components === void 0) return;
1242
+ const target = INTEGER_TYPES.has(field.type) ? staging.ints : staging.floats;
1243
+ const base = field.offsetBytes / 4;
1244
+ if (field.arrayLength === void 0) {
1245
+ if (components === 1) {
1246
+ target[base] = scalarOf(raw, field.godotType);
1247
+ return;
1248
+ }
1249
+ const list = Array.isArray(raw) ? raw : [];
1250
+ for (let i = 0; i < components; i++) target[base + i] = list[i] ?? 0;
1251
+ return;
1252
+ }
1253
+ const list = normalizeArrayValues(raw, field, paramKind);
1254
+ for (let element = 0; element < field.arrayLength; element++) {
1255
+ const slot = base + element * ARRAY_STRIDE_FLOATS;
1256
+ for (let i = 0; i < components; i++) target[slot + i] = list[element * components + i] ?? 0;
1257
+ }
1258
+ }
1259
+ function scalarOf(raw, godotType) {
1260
+ const value = typeof raw === "number" ? raw : Array.isArray(raw) ? raw[0] ?? 0 : 0;
1261
+ if (godotType === "bool") return value ? 1 : 0;
1262
+ if (godotType === "int") return Math.round(value);
1263
+ return value;
1264
+ }
1265
+ function normalizeArrayValues(raw, field, paramKind) {
1266
+ const list = Array.isArray(raw) ? raw : typeof raw === "number" ? [raw] : [];
1267
+ if (field.type === "vec3f" && paramKind === PACKED_COLOR_ARRAY && list.length % 4 === 0) {
1268
+ const out = [];
1269
+ for (let i = 0; i < list.length; i += 4) out.push(list[i] ?? 0, list[i + 1] ?? 0, list[i + 2] ?? 0);
1270
+ return out;
1271
+ }
1272
+ return list;
1273
+ }
1274
+ function writeFloats(staging, offsetBytes, values, count) {
1275
+ if (offsetBytes === void 0) return;
1276
+ const base = offsetBytes / 4;
1277
+ for (let i = 0; i < count; i++) staging.floats[base + i] = values?.[i] ?? 0;
1278
+ }
1279
+ //#endregion
1280
+ //#region src/webgpu-textures.ts
1281
+ /** Device-scoped texture allocation and upload primitives. Hosts own decoding and cache keys. */
1282
+ const WEBGPU_UPLOAD_TEXTURE_USAGE = 23;
1283
+ function createWebgpuRgbaTexture(device, width, height, label) {
1284
+ const w = Math.max(1, Math.round(width));
1285
+ const h = Math.max(1, Math.round(height));
1286
+ return {
1287
+ texture: device.createTexture({
1288
+ label,
1289
+ size: [
1290
+ w,
1291
+ h,
1292
+ 1
1293
+ ],
1294
+ format: "rgba8unorm",
1295
+ usage: 23
1296
+ }),
1297
+ width: w,
1298
+ height: h
1299
+ };
1300
+ }
1301
+ function uploadWebgpuRgba(device, target, pixels) {
1302
+ device.queue.writeTexture({ texture: target.texture }, pixels, {
1303
+ bytesPerRow: target.width * 4,
1304
+ rowsPerImage: target.height
1305
+ }, [target.width, target.height]);
1306
+ }
1307
+ /** Upload a host-decoded external image. The host decides its source and crop. */
1308
+ function uploadWebgpuExternalImage(device, target, source) {
1309
+ device.queue.copyExternalImageToTexture(source, {
1310
+ texture: target.texture,
1311
+ premultipliedAlpha: false
1312
+ }, [target.width, target.height]);
1313
+ }
1314
+ function createWebgpuSampler(device, opts) {
1315
+ const filter = opts.nearest ? "nearest" : "linear";
1316
+ const address = opts.repeat ? "repeat" : "clamp-to-edge";
1317
+ return device.createSampler({
1318
+ magFilter: filter,
1319
+ minFilter: filter,
1320
+ addressModeU: address,
1321
+ addressModeV: address
1322
+ });
1323
+ }
1324
+ function destroyWebgpuTexture(texture) {
1325
+ try {
1326
+ texture.destroy();
1327
+ } catch {}
1328
+ }
1329
+ //#endregion
1330
+ //#region src/webgpu.ts
1331
+ /**
1332
+ * Explicit WebGPU execution entry point.
1333
+ *
1334
+ * Callers supply their device and presentation surface; this
1335
+ * package never creates a canvas, queries the DOM, fetches images, or schedules
1336
+ * animation frames.
1337
+ */
1338
+ /** Configure a caller-supplied WebGPU presentation context for premultiplied output. */
1339
+ function configureWebgpuSurface(context, device, format) {
1340
+ context.configure({
1341
+ device,
1342
+ format,
1343
+ alphaMode: "premultiplied"
1344
+ });
1345
+ }
1346
+ //#endregion
1347
+ export { ADDITIVE_BLEND, ADDITIVE_RESOLVE_WGSL, BUFFER_USAGE, INSTANCE_STRIDE_BYTES, PARTICLE_FS_ADDITIVE_ENTRY, PARTICLE_FS_ENTRY, PARTICLE_VERTEX_BUFFERS, PARTICLE_VS_ENTRY, PARTICLE_WGSL, PREMULTIPLIED_BLEND, RESOLVE_FS_ENTRY, RESOLVE_VS_ENTRY, SHADER_STAGE, UNIFORM_SLOT_BYTES, WEBGPU_UPLOAD_TEXTURE_USAGE, WebgpuShaderExecutor, __resetPipelineErrorForTest, __resetWebgpuParticleProgramForTest, compileModule, configureWebgpuSurface, createPipeline, createUniformRing, createUniformStaging, createWebgpuParticleRenderer, createWebgpuRgbaTexture, createWebgpuSampler, createWebgpuShaderBindGroup, createWebgpuShaderBindGroupLayout, createWebgpuShaderUniformBuffer, destroyWebgpuTexture, lastPipelineError, packShaderUniforms, peekWebgpuParticleRenderer, readTexturePixels, uniformBindGroupLayout, uploadWebgpuExternalImage, uploadWebgpuRgba };
1348
+
1349
+ //# sourceMappingURL=webgpu.mjs.map