wgsl-play 0.0.39 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +59 -2
  2. package/dist/{WgslPlay-CSjRo-5Z.d.ts → WgslPlay-ouKx64GN.d.ts} +52 -3
  3. package/dist/WgslPlay-zK9mE5rV.js +1544 -0
  4. package/dist/WgslPlay.d.ts +2 -2
  5. package/dist/WgslPlay.js +2 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/jsx-preact.d.ts +12 -0
  9. package/dist/jsx-preact.js +0 -0
  10. package/dist/wgsl-play.js +1834 -754
  11. package/package.json +12 -8
  12. package/src/CanvasSize.ts +27 -0
  13. package/src/ComputeBuild.ts +151 -0
  14. package/src/FragmentRender.ts +147 -0
  15. package/src/PlaybackControls.ts +36 -13
  16. package/src/RenderResources.ts +132 -0
  17. package/src/Renderer.ts +112 -138
  18. package/src/ResultsPanel.ts +261 -0
  19. package/src/WgslPlay.css +80 -8
  20. package/src/WgslPlay.ts +346 -103
  21. package/src/icons/refresh.svg +1 -0
  22. package/src/jsx-preact.ts +15 -0
  23. package/src/test/BundleHydrator.test.ts +8 -11
  24. package/src/test/CanvasSize.test.ts +48 -0
  25. package/src/test/E2eHelpers.ts +117 -0
  26. package/src/test/RenderFrame.e2e.ts +33 -0
  27. package/src/test/ResultsPanel.test.ts +164 -0
  28. package/src/test/WgslPlay.basics.e2e.ts +87 -0
  29. package/src/test/WgslPlay.compute.e2e.ts +147 -0
  30. package/src/test/WgslPlay.connect.e2e.ts +101 -0
  31. package/src/test/WgslPlay.loading.e2e.ts +68 -0
  32. package/src/test/WgslPlay.resize.e2e.ts +199 -0
  33. package/src/test/WgslPlay.resources.e2e.ts +67 -0
  34. package/src/test/WgslPlay.resources.e2e.ts-snapshots/buffer-zero-init-chromium-darwin.png +0 -0
  35. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-grid-chromium-darwin.png +0 -0
  36. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-green-chromium-darwin.png +0 -0
  37. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-magenta-chromium-darwin.png +0 -0
  38. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-multi-chromium-darwin.png +0 -0
  39. package/src/test/WgslPlay.uniforms.e2e.ts +57 -0
  40. package/dist/WgslPlay-LsU6XE09.js +0 -933
  41. package/src/test/WgslPlay.e2e.ts +0 -327
  42. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/basic-shader-chromium-darwin.png +0 -0
  43. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-after-red-chromium-darwin.png +0 -0
  44. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-initial-green-chromium-darwin.png +0 -0
  45. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-green-chromium-darwin.png +0 -0
  46. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-red-chromium-darwin.png +0 -0
  47. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-dynamic-npm-chromium-darwin.png +0 -0
  48. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-external-chromium-darwin.png +0 -0
  49. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-multifile-chromium-darwin.png +0 -0
  50. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/link-import-chromium-darwin.png +0 -0
  51. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/npm-cdn-chromium-darwin.png +0 -0
  52. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-internal-chromium-darwin.png +0 -0
  53. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-src-chromium-darwin.png +0 -0
  54. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/static-import-chromium-darwin.png +0 -0
  55. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-initial-chromium-darwin.png +0 -0
  56. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-slider-changed-chromium-darwin.png +0 -0
@@ -0,0 +1,1544 @@
1
+ import { fetchDependencies, loadShaderFromUrl } from "wesl-fetch";
2
+ import { WeslParseError, fileToModulePath, parseSrcModule, requestWeslDevice } from "wesl";
3
+ import { ResourceLoadError, ResourceLoadError as ResourceLoadError$1, clearBuffers, createPlayResources, createUniformBuffer, linkAndCreatePipeline, linkComputeShader, renderFrame, runCompute, scanUniforms, withErrorScopes, writeUniforms } from "wesl-gpu";
4
+ import { TypeShapeError, annotatedResourcesPlugin, classifyEntryPoints, findAnnotatedResources, varReflection } from "wesl-reflect";
5
+ //#region src/Config.ts
6
+ const defaultConfig = { shaderRoot: "/shaders" };
7
+ let globalConfig = { ...defaultConfig };
8
+ /** Set global defaults for all wgsl-play instances. */
9
+ function defaults(config) {
10
+ globalConfig = {
11
+ ...globalConfig,
12
+ ...config
13
+ };
14
+ }
15
+ /** Get resolved config, merging element overrides with global defaults. */
16
+ function getConfig(overrides) {
17
+ return {
18
+ ...globalConfig,
19
+ ...overrides
20
+ };
21
+ }
22
+ /** Reset config to defaults (useful for testing). */
23
+ function resetConfig() {
24
+ globalConfig = { ...defaultConfig };
25
+ }
26
+ //#endregion
27
+ //#region src/CanvasSize.ts
28
+ /** Device-pixel size of an observed element.
29
+ * Prefers `devicePixelContentBoxSize` (Chromium/Firefox) — integer, pixel-exact.
30
+ * Falls back to `contentBoxSize × ratio` on Safari and when a `pixel-ratio`
31
+ * override is set (the override is CSS-pixel-based by intent). */
32
+ function entrySize(entry, pixelRatioOverride) {
33
+ if (pixelRatioOverride === null) {
34
+ const dp = entry.devicePixelContentBoxSize?.[0];
35
+ if (dp) return [dp.inlineSize, dp.blockSize];
36
+ }
37
+ const ratio = pixelRatioOverride !== null ? Number(pixelRatioOverride) : devicePixelRatio;
38
+ const css = entry.contentBoxSize?.[0];
39
+ if (css) return [css.inlineSize * ratio, css.blockSize * ratio];
40
+ const { width, height } = entry.contentRect;
41
+ return [width * ratio, height * ratio];
42
+ }
43
+ /** Floor and clamp to `[1, max]` so the canvas never exceeds the GPU's 2D limit.
44
+ * Pass `max = undefined` to skip the upper clamp (e.g. before the GPU device
45
+ * is available; device init will surface limit errors itself). */
46
+ function clampCanvas(size, max) {
47
+ const floored = Math.max(1, Math.floor(size));
48
+ return max !== void 0 ? Math.min(floored, max) : floored;
49
+ }
50
+ //#endregion
51
+ //#region src/RenderResources.ts
52
+ /** Default allocation size (bytes) for runtime-sized `@buffer` arrays. The
53
+ * WGSL gives no element count, so the playground picks one. 1024 bytes covers
54
+ * 256 f32s or 64 vec4f, enough for casual playground use. */
55
+ const defaultRuntimeArrayBytes = 1024;
56
+ /** Allocate user-declared resources, rebuild the uniform buffer to match the
57
+ * shader's uniform layout, and assemble the pipeline layout + bind group. */
58
+ async function prepareResources(p) {
59
+ const { state, resources, resolveTexture } = p;
60
+ const { device } = state;
61
+ const scan = scanUniforms(p.shaderSource, `${p.pkg}::${p.root}`);
62
+ const playResources = await createPlayResources({
63
+ device,
64
+ resources,
65
+ startBinding: 1,
66
+ resolveTexture,
67
+ minBufferBytes: defaultRuntimeArrayBytes
68
+ });
69
+ state.uniformState.buffer.destroy();
70
+ state.uniformState = createUniformBuffer(device, scan.layout);
71
+ const bindGroupLayout = buildBindGroupLayout(device, playResources);
72
+ return {
73
+ playResources,
74
+ pipelineLayout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
75
+ bindGroup: buildBindGroup(device, bindGroupLayout, state.uniformState, playResources),
76
+ layout: scan.layout
77
+ };
78
+ }
79
+ /** Destroy all textures and buffers owned by a PlayResources bundle. */
80
+ function destroyPlayResources(r) {
81
+ for (const t of r.textures) t.destroy();
82
+ for (const b of r.buffers) b.destroy();
83
+ }
84
+ /** Dispose GPU resources owned by the current compile (textures + storage buffers). */
85
+ function disposeResources(state) {
86
+ for (const t of state.resourceTextures) t.destroy();
87
+ for (const b of state.resourceBuffers) b.destroy();
88
+ state.resourceTextures = [];
89
+ state.resourceBuffers = [];
90
+ }
91
+ /** Add the @buffer/@texture/@sampler plugin to the user-supplied config. */
92
+ function mergeResourcePlugins(userConfig, resources) {
93
+ if (resources.length === 0) return userConfig;
94
+ const plugins = [...userConfig?.plugins ?? [], annotatedResourcesPlugin(resources, 1)];
95
+ return {
96
+ ...userConfig,
97
+ plugins
98
+ };
99
+ }
100
+ function buildBindGroupLayout(device, resources) {
101
+ const visibility = GPUShaderStage.COMPUTE | GPUShaderStage.FRAGMENT;
102
+ return device.createBindGroupLayout({ entries: [{
103
+ binding: 0,
104
+ visibility,
105
+ buffer: {}
106
+ }, ...resources.layoutEntries] });
107
+ }
108
+ function buildBindGroup(device, layout, uniformState, resources) {
109
+ return device.createBindGroup({
110
+ layout,
111
+ entries: [{
112
+ binding: 0,
113
+ resource: { buffer: uniformState.buffer }
114
+ }, ...resources.entries]
115
+ });
116
+ }
117
+ //#endregion
118
+ //#region src/ComputeBuild.ts
119
+ /** Build the compute pipeline, dispatch once, and return initial readback. */
120
+ async function buildCompute(p) {
121
+ const { state, resources, playResources, ast, shaderSource } = p;
122
+ const { device } = state;
123
+ const compute = await linkComputeFor({
124
+ device,
125
+ shaderSource,
126
+ resources,
127
+ options: p.options
128
+ }).catch((err) => {
129
+ destroyPlayResources(playResources);
130
+ throw err;
131
+ });
132
+ const readBuffers = mapBuffersByName(resources, playResources);
133
+ const reflections = mapBufferReflections(ast, resources);
134
+ disposeResources(state);
135
+ state.pipeline = void 0;
136
+ state.bindGroup = p.bindGroup;
137
+ state.resourceTextures = playResources.textures;
138
+ state.resourceBuffers = playResources.buffers;
139
+ state.compute = {
140
+ pipelineLayout: p.pipelineLayout,
141
+ bindGroup: p.bindGroup,
142
+ module: compute.module,
143
+ entryPoint: p.entryPoint,
144
+ readBuffers,
145
+ reflections
146
+ };
147
+ const computeReadback = await dispatchComputeAndReadback(state);
148
+ return {
149
+ layout: p.layout,
150
+ mode: "compute",
151
+ computeReadback
152
+ };
153
+ }
154
+ /** Re-dispatch the compute pipeline against the current uniform state and
155
+ * return fresh BufferEntry[] for the panel. */
156
+ async function rerunCompute(state) {
157
+ return dispatchComputeAndReadback(state);
158
+ }
159
+ /** Re-zero each readback buffer, dispatch, and read back. Used for both initial
160
+ * build and slider/refresh-driven re-runs in compute mode. */
161
+ async function dispatchComputeAndReadback(state) {
162
+ const compute = state.compute;
163
+ if (!compute) return [];
164
+ writeUniforms(state.device, state.uniformState, computeAutoValues(state));
165
+ clearBuffers(state.device, compute.readBuffers.values());
166
+ const { readbacks } = await runCompute({
167
+ device: state.device,
168
+ module: compute.module,
169
+ entryPoint: compute.entryPoint,
170
+ bindGroup: compute.bindGroup,
171
+ pipelineLayout: compute.pipelineLayout,
172
+ readBuffers: compute.readBuffers
173
+ });
174
+ return [...readbacks].map(([varName, data]) => ({
175
+ reflection: compute.reflections.get(varName),
176
+ data
177
+ }));
178
+ }
179
+ async function linkComputeFor(p) {
180
+ const config = mergeResourcePlugins(p.options?.config, p.resources);
181
+ return withErrorScopes(p.device, () => linkComputeShader({
182
+ device: p.device,
183
+ computeSource: p.shaderSource,
184
+ ...p.options,
185
+ config
186
+ }));
187
+ }
188
+ /** Map @buffer var name to its allocated GPU buffer (positional alignment). */
189
+ function mapBuffersByName(resources, playResources) {
190
+ const bufferVars = resources.filter((r) => r.kind === "buffer");
191
+ return new Map(bufferVars.map((r, i) => [r.varName, playResources.buffers[i]]));
192
+ }
193
+ /** Map @buffer var name to its VarReflection (type tree, address space, etc.). */
194
+ function mapBufferReflections(ast, resources) {
195
+ return new Map(resources.filter((r) => r.kind === "buffer").map((r) => [r.varName, varReflection(ast, r.varName)]));
196
+ }
197
+ function computeAutoValues(state) {
198
+ return {
199
+ resolution: [state.canvas.width, state.canvas.height],
200
+ time: 0,
201
+ delta_time: 0,
202
+ frame: 0,
203
+ mouse_pos: [0, 0],
204
+ mouse_delta: [0, 0],
205
+ mouse_button: 0
206
+ };
207
+ }
208
+ //#endregion
209
+ //#region src/ErrorOverlay.ts
210
+ /** Manages an error overlay element within a shadow DOM. */
211
+ var ErrorOverlay = class {
212
+ el;
213
+ _message = null;
214
+ constructor(container) {
215
+ this.el = document.createElement("div");
216
+ this.el.className = "error-overlay";
217
+ container.appendChild(this.el);
218
+ }
219
+ show(message) {
220
+ this._message = message;
221
+ this.el.textContent = message;
222
+ this.el.classList.add("visible");
223
+ console.error("[wgsl-play]", message);
224
+ }
225
+ hide() {
226
+ this._message = null;
227
+ this.el.classList.remove("visible");
228
+ }
229
+ get visible() {
230
+ return this.el.classList.contains("visible");
231
+ }
232
+ get message() {
233
+ return this._message;
234
+ }
235
+ };
236
+ //#endregion
237
+ //#region src/FragmentRender.ts
238
+ /** Build the render pipeline and install it on state. */
239
+ async function buildFragment(p) {
240
+ const newPipeline = await buildRenderPipeline({
241
+ device: p.state.device,
242
+ fragmentSource: p.shaderSource,
243
+ presentationFormat: p.state.presentationFormat,
244
+ pipelineLayout: p.pipelineLayout,
245
+ resources: p.resources,
246
+ options: p.options
247
+ }).catch((err) => {
248
+ destroyPlayResources(p.playResources);
249
+ p.state.pipeline = void 0;
250
+ throw err;
251
+ });
252
+ disposeResources(p.state);
253
+ p.state.pipeline = newPipeline;
254
+ p.state.bindGroup = p.bindGroup;
255
+ p.state.compute = void 0;
256
+ p.state.resourceTextures = p.playResources.textures;
257
+ p.state.resourceBuffers = p.playResources.buffers;
258
+ return {
259
+ layout: p.layout,
260
+ mode: "fragment"
261
+ };
262
+ }
263
+ /** Render a single frame (used when paused). */
264
+ function renderOnce(state, playback) {
265
+ if (!state.pipeline) return;
266
+ const time = calculateTime(playback);
267
+ const { mouse, canvas } = state;
268
+ submitFrame(state, {
269
+ resolution: [canvas.width, canvas.height],
270
+ time,
271
+ delta_time: 0,
272
+ frame: state.frameCount,
273
+ mouse_pos: mouse.pos,
274
+ mouse_delta: [0, 0],
275
+ mouse_button: mouse.button
276
+ });
277
+ }
278
+ /** Start the render loop. Returns a stop function. */
279
+ function startRenderLoop(state, playback) {
280
+ let animationId;
281
+ let lastTime = 0;
282
+ function render() {
283
+ if (!state.pipeline) {
284
+ animationId = requestAnimationFrame(render);
285
+ return;
286
+ }
287
+ const time = calculateTime(playback);
288
+ const delta_time = time - lastTime;
289
+ lastTime = time;
290
+ const { mouse, canvas } = state;
291
+ const auto = {
292
+ resolution: [canvas.width, canvas.height],
293
+ time,
294
+ delta_time,
295
+ frame: state.frameCount,
296
+ mouse_pos: mouse.pos,
297
+ mouse_delta: mouse.delta,
298
+ mouse_button: mouse.button
299
+ };
300
+ mouse.delta = [0, 0];
301
+ submitFrame(state, auto);
302
+ animationId = requestAnimationFrame(render);
303
+ }
304
+ animationId = requestAnimationFrame(render);
305
+ return () => cancelAnimationFrame(animationId);
306
+ }
307
+ /** Seconds elapsed since playback start, frozen while paused. */
308
+ function calculateTime(playback) {
309
+ return ((playback.isPlaying ? performance.now() : playback.startTime + playback.pausedDuration) - playback.startTime) / 1e3;
310
+ }
311
+ /** Link the shader and create the render pipeline, surfacing both JS and GPU
312
+ * validation errors as a thrown rejection. */
313
+ async function buildRenderPipeline(p) {
314
+ const config = mergeResourcePlugins(p.options?.config, p.resources);
315
+ return withErrorScopes(p.device, () => linkAndCreatePipeline({
316
+ device: p.device,
317
+ fragmentSource: p.fragmentSource,
318
+ format: p.presentationFormat,
319
+ layout: p.pipelineLayout,
320
+ ...p.options,
321
+ config
322
+ }));
323
+ }
324
+ /** Write uniforms, render to the current swap-chain texture, and tick frameCount. */
325
+ function submitFrame(state, auto) {
326
+ const { device, context, bindGroup } = state;
327
+ writeUniforms(device, state.uniformState, auto);
328
+ const targetView = context.getCurrentTexture().createView();
329
+ renderFrame({
330
+ device,
331
+ pipeline: state.pipeline,
332
+ bindGroup,
333
+ targetView
334
+ });
335
+ state.frameCount++;
336
+ }
337
+ //#endregion
338
+ //#region src/icons/backToStart.svg?raw
339
+ var backToStart_default = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\"><rect x=\"4\" y=\"4\" width=\"3\" height=\"16\" fill=\"currentColor\"/><polygon points=\"20,4 9,12 20,20\" fill=\"currentColor\"/></svg>\n";
340
+ //#endregion
341
+ //#region src/icons/expand.svg?raw
342
+ var expand_default = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\"><path d=\"M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z\" fill=\"currentColor\"/></svg>\n";
343
+ //#endregion
344
+ //#region src/icons/pause.svg?raw
345
+ var pause_default = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\"><rect x=\"5\" y=\"4\" width=\"4\" height=\"16\" fill=\"currentColor\"/><rect x=\"15\" y=\"4\" width=\"4\" height=\"16\" fill=\"currentColor\"/></svg>\n";
346
+ //#endregion
347
+ //#region src/icons/play.svg?raw
348
+ var play_default = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\"><polygon points=\"6,4 20,12 6,20\" fill=\"currentColor\"/></svg>\n";
349
+ //#endregion
350
+ //#region src/icons/refresh.svg?raw
351
+ var refresh_default = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\"><path fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M17.66 17.66A8 8 0 1 1 19.45 9M19.95 4v5h-5\"/></svg>\n";
352
+ //#endregion
353
+ //#region src/icons/shrink.svg?raw
354
+ var shrink_default = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\"><path d=\"M9 3v6H3v-2h4V3h2zm6 0h2v4h4v2h-6V3zM3 15h6v6H7v-4H3v-2zm12 0h6v2h-4v4h-2v-6z\" fill=\"currentColor\"/></svg>\n";
355
+ //#endregion
356
+ //#region src/PlaybackControls.ts
357
+ /** Playback controls overlay for wgsl-play.
358
+ *
359
+ * In "render" mode (default): play/pause + rewind + fullscreen.
360
+ * In "compute" mode: refresh (re-dispatch) + fullscreen — play/pause and
361
+ * rewind are hidden because compute mode is not animation-driven. */
362
+ var PlaybackControls = class {
363
+ container;
364
+ playPauseBtn;
365
+ rewindBtn;
366
+ refreshBtn;
367
+ fullscreenBtn;
368
+ playing = true;
369
+ constructor(shadow, onPlay, onPause, onRewind, onFullscreen, onRefresh) {
370
+ this.container = document.createElement("div");
371
+ this.container.className = "controls";
372
+ this.fullscreenBtn = makeBtn(expand_default, onFullscreen);
373
+ this.rewindBtn = makeBtn(backToStart_default, onRewind);
374
+ this.playPauseBtn = makeBtn(pause_default, () => this.playing ? onPause() : onPlay());
375
+ this.refreshBtn = makeBtn(refresh_default, onRefresh);
376
+ this.refreshBtn.hidden = true;
377
+ this.container.append(this.fullscreenBtn, this.rewindBtn, this.playPauseBtn, this.refreshBtn);
378
+ shadow.appendChild(this.container);
379
+ }
380
+ setPlaying(playing) {
381
+ this.playing = playing;
382
+ this.playPauseBtn.innerHTML = playing ? pause_default : play_default;
383
+ }
384
+ setFullscreen(isFullscreen) {
385
+ this.fullscreenBtn.innerHTML = isFullscreen ? shrink_default : expand_default;
386
+ }
387
+ setMode(mode) {
388
+ const isCompute = mode === "compute";
389
+ this.rewindBtn.hidden = isCompute;
390
+ this.playPauseBtn.hidden = isCompute;
391
+ this.refreshBtn.hidden = !isCompute;
392
+ }
393
+ show() {
394
+ this.container.style.display = "";
395
+ }
396
+ hide() {
397
+ this.container.style.display = "none";
398
+ }
399
+ };
400
+ function makeBtn(html, handler) {
401
+ const btn = document.createElement("button");
402
+ btn.innerHTML = html;
403
+ btn.addEventListener("click", handler);
404
+ return btn;
405
+ }
406
+ //#endregion
407
+ //#region src/Renderer.ts
408
+ /** Initialize WebGPU for a canvas element. */
409
+ async function initWebGPU(canvas, alphaMode = "opaque") {
410
+ const adapter = await navigator.gpu.requestAdapter();
411
+ if (!adapter) throw new Error("WebGPU adapter not available");
412
+ const device = await requestWeslDevice(adapter);
413
+ const context = canvas.getContext("webgpu");
414
+ if (!context) throw new Error("WebGPU context not available");
415
+ const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
416
+ context.configure({
417
+ device,
418
+ format: presentationFormat,
419
+ alphaMode
420
+ });
421
+ const uniformState = createUniformBuffer(device, null);
422
+ return {
423
+ device,
424
+ canvas,
425
+ context,
426
+ presentationFormat,
427
+ uniformState,
428
+ bindGroup: createUniformBindGroup(device, uniformOnlyLayout(device), uniformState),
429
+ mouse: {
430
+ pos: [0, 0],
431
+ delta: [0, 0],
432
+ button: 0
433
+ },
434
+ pipeline: void 0,
435
+ frameCount: 0,
436
+ resourceTextures: [],
437
+ resourceBuffers: []
438
+ };
439
+ }
440
+ /** Compile a WESL shader and configure pipelines + bindings.
441
+ * Auto-detects fragment vs compute mode based on entry-point attributes. */
442
+ async function createPipeline(state, shaderSource, resolveTexture, options) {
443
+ const pkg = options?.packageName ?? "package";
444
+ const root = options?.rootModuleName ?? "main";
445
+ const ast = parseSrcModule({
446
+ modulePath: `${pkg}::${root}`,
447
+ debugFilePath: `./${root}.wesl`,
448
+ src: shaderSource
449
+ });
450
+ const resources = findAnnotatedResources(ast);
451
+ const entryPoints = classifyEntryPoints(ast);
452
+ const mode = detectMode(entryPoints);
453
+ if (mode === "compute") rejectComputeUnsupported(resources);
454
+ const branch = {
455
+ state,
456
+ resources,
457
+ shaderSource,
458
+ options,
459
+ ...await prepareResources({
460
+ state,
461
+ shaderSource,
462
+ pkg,
463
+ root,
464
+ resources,
465
+ resolveTexture
466
+ })
467
+ };
468
+ if (mode === "compute") return buildCompute({
469
+ ...branch,
470
+ ast,
471
+ entryPoint: entryPoints[0].fnName
472
+ });
473
+ return buildFragment(branch);
474
+ }
475
+ function uniformOnlyLayout(device) {
476
+ const visibility = GPUShaderStage.COMPUTE | GPUShaderStage.FRAGMENT;
477
+ return device.createBindGroupLayout({ entries: [{
478
+ binding: 0,
479
+ visibility,
480
+ buffer: {}
481
+ }] });
482
+ }
483
+ function createUniformBindGroup(device, layout, uniformState) {
484
+ return device.createBindGroup({
485
+ layout,
486
+ entries: [{
487
+ binding: 0,
488
+ resource: { buffer: uniformState.buffer }
489
+ }]
490
+ });
491
+ }
492
+ /** Pick fragment vs compute mode from entry points; throws on mixed/multi-compute. */
493
+ function detectMode(entryPoints) {
494
+ const compute = entryPoints.filter((e) => e.stage === "compute");
495
+ const fragment = entryPoints.filter((e) => e.stage === "fragment");
496
+ if (compute.length > 0 && fragment.length > 0) throw new Error("mixed compute and fragment entry points are not supported (use either, not both)");
497
+ if (compute.length > 1) throw new Error(`compute mode requires exactly one @compute entry point; found ${compute.length}`);
498
+ return compute.length === 1 ? "compute" : "fragment";
499
+ }
500
+ /** @sampler/@texture in compute mode are not yet implemented; throw early. */
501
+ function rejectComputeUnsupported(resources) {
502
+ for (const r of resources) {
503
+ if (r.kind === "texture" || r.kind === "test_texture") throw new Error("@texture in compute mode is not yet supported");
504
+ if (r.kind === "sampler") throw new Error("@sampler in compute mode is not yet supported");
505
+ }
506
+ }
507
+ //#endregion
508
+ //#region src/ResultsPanel.ts
509
+ const truncationLimit = 256;
510
+ /** Render compute-mode @buffer readbacks as one HTML table per buffer. */
511
+ function renderResultsPanel(p) {
512
+ p.panel.replaceChildren(...p.entries.map(renderTable));
513
+ }
514
+ /** Build the structured table data for a single @buffer entry. Pure (no DOM). */
515
+ function tableData(entry) {
516
+ const { reflection, data } = entry;
517
+ const { row, rowCount, stride } = rowShape(reflection, data.byteLength);
518
+ const visibleRows = Math.min(rowCount, truncationLimit);
519
+ const view = new DataView(data);
520
+ const td = {
521
+ caption: `${reflection.varName}: ${shapeLabel(reflection.type)}`,
522
+ headers: row.kind === "struct" ? ["", ...row.fields.map((f) => f.name)] : ["", "value"],
523
+ rows: buildRows(view, row, visibleRows, stride)
524
+ };
525
+ if (rowCount > truncationLimit) td.truncated = { totalRows: rowCount };
526
+ return td;
527
+ }
528
+ function renderTable(entry) {
529
+ const td = tableData(entry);
530
+ const section = document.createElement("section");
531
+ section.className = "result";
532
+ section.appendChild(makeCaption(td.caption));
533
+ const table = document.createElement("table");
534
+ table.appendChild(makeHeader(td.headers));
535
+ table.appendChild(makeBody(td.rows));
536
+ if (td.truncated) table.appendChild(makeShowAllFooter(table, entry, td.truncated.totalRows));
537
+ section.appendChild(table);
538
+ return section;
539
+ }
540
+ /** Resolve the per-row shape and stride for tabular display. Runtime-sized
541
+ * arrays derive rowCount from the readback's byteLength. Throws
542
+ * TypeShapeError if the element type isn't supported as a table cell. */
543
+ function rowShape(reflection, byteLength) {
544
+ const t = reflection.type;
545
+ if (t.kind === "array") {
546
+ assertRenderable(t.elem, reflection.varName);
547
+ const rowCount = t.length === "runtime" ? Math.floor(byteLength / t.stride) : t.length;
548
+ return {
549
+ row: t.elem,
550
+ rowCount,
551
+ stride: t.stride
552
+ };
553
+ }
554
+ assertRenderable(t, reflection.varName);
555
+ return {
556
+ row: t,
557
+ rowCount: 1,
558
+ stride: t.size
559
+ };
560
+ }
561
+ function shapeLabel(t) {
562
+ if (t.kind === "array") {
563
+ const len = t.length === "runtime" ? "" : `, ${t.length}`;
564
+ return `array<${typeLabel(t.elem)}${len}>`;
565
+ }
566
+ return typeLabel(t);
567
+ }
568
+ /** Materialize `count` row-cell arrays at successive `stride` offsets. */
569
+ function buildRows(view, row, count, stride) {
570
+ return Array.from({ length: count }, (_, i) => rowCells(view, row, i, i * stride));
571
+ }
572
+ function makeCaption(text) {
573
+ const caption = document.createElement("div");
574
+ caption.className = "result-caption";
575
+ caption.textContent = text;
576
+ return caption;
577
+ }
578
+ function makeHeader(headers) {
579
+ const thead = document.createElement("thead");
580
+ const tr = document.createElement("tr");
581
+ for (const h of headers) {
582
+ const th = document.createElement("th");
583
+ th.textContent = h;
584
+ tr.appendChild(th);
585
+ }
586
+ thead.appendChild(tr);
587
+ return thead;
588
+ }
589
+ function makeBody(rows) {
590
+ const tbody = document.createElement("tbody");
591
+ for (const row of rows) {
592
+ const tr = document.createElement("tr");
593
+ for (const cell of row) {
594
+ const td = document.createElement("td");
595
+ td.textContent = cell;
596
+ tr.appendChild(td);
597
+ }
598
+ tbody.appendChild(tr);
599
+ }
600
+ return tbody;
601
+ }
602
+ function makeShowAllFooter(table, entry, totalRows) {
603
+ const tfoot = document.createElement("tfoot");
604
+ const tr = document.createElement("tr");
605
+ const td = document.createElement("td");
606
+ const { row, stride } = rowShape(entry.reflection, entry.data.byteLength);
607
+ td.colSpan = row.kind === "struct" ? row.fields.length + 1 : 2;
608
+ const button = document.createElement("button");
609
+ button.textContent = `Show all ${totalRows} rows`;
610
+ button.addEventListener("click", () => {
611
+ const allRows = buildRows(new DataView(entry.data), row, totalRows, stride);
612
+ table.replaceChild(makeBody(allRows), table.tBodies[0]);
613
+ tfoot.remove();
614
+ });
615
+ td.appendChild(document.createTextNode(`Showing first ${truncationLimit} of ${totalRows} rows. `));
616
+ td.appendChild(button);
617
+ tr.appendChild(td);
618
+ tfoot.appendChild(tr);
619
+ return tfoot;
620
+ }
621
+ /** WgslPlay's compute-results table only renders f32/i32/u32 scalars, vectors of those, or structs of those. */
622
+ function assertRenderable(t, varName) {
623
+ if (t.kind === "scalar") {
624
+ if (t.type === "f16") throw notRenderable("f16", varName);
625
+ return;
626
+ }
627
+ if (t.kind === "vec") {
628
+ if (t.component === "f16") throw notRenderable("vec<f16>", varName);
629
+ return;
630
+ }
631
+ if (t.kind === "struct") {
632
+ for (const f of t.fields) assertRenderable(f.type, varName);
633
+ return;
634
+ }
635
+ if (t.kind === "mat") throw new TypeShapeError(`matrices are not supported in the compute results table; use vecN<f32> instead`, varName);
636
+ if (t.kind === "atomic") throw new TypeShapeError(`atomic types are not supported in the compute results table`, varName);
637
+ throw new TypeShapeError(`unsupported element type for table`, varName);
638
+ }
639
+ function typeLabel(t) {
640
+ if (t.kind === "scalar") return t.type;
641
+ if (t.kind === "vec") return `vec${t.n}<${t.component}>`;
642
+ if (t.kind === "mat") return `mat${t.cols}x${t.rows}<${t.component}>`;
643
+ if (t.kind === "atomic") return `atomic<${t.component}>`;
644
+ if (t.kind === "struct") return t.name;
645
+ const len = t.length === "runtime" ? "" : `, ${t.length}`;
646
+ return `array<${typeLabel(t.elem)}${len}>`;
647
+ }
648
+ function rowCells(view, row, rowIndex, baseOffset) {
649
+ if (row.kind === "struct") return [String(rowIndex), ...row.fields.map((f) => formatElem(view, f.type, baseOffset + f.offset))];
650
+ return [String(rowIndex), formatElem(view, row, baseOffset)];
651
+ }
652
+ function notRenderable(typeName, varName) {
653
+ return new TypeShapeError(`${typeName} is not supported in the compute results table`, varName);
654
+ }
655
+ function formatElem(view, elem, offset) {
656
+ if (elem.kind === "scalar") return formatScalar(view, elem.type, offset);
657
+ if (elem.kind === "vec") return `(${Array.from({ length: elem.n }, (_, i) => formatScalar(view, elem.component, offset + i * 4)).join(", ")})`;
658
+ return `<${elem.kind}>`;
659
+ }
660
+ function formatScalar(view, type, offset) {
661
+ if (type === "f32") return formatF32(view.getFloat32(offset, true));
662
+ if (type === "i32") return String(view.getInt32(offset, true));
663
+ if (type === "u32") return String(view.getUint32(offset, true));
664
+ if (type === "bool") return view.getUint32(offset, true) !== 0 ? "true" : "false";
665
+ return "<f16>";
666
+ }
667
+ function formatF32(v) {
668
+ if (!Number.isFinite(v)) return String(v);
669
+ if (Number.isInteger(v)) return v.toFixed(1);
670
+ return Number.parseFloat(v.toPrecision(4)).toString();
671
+ }
672
+ //#endregion
673
+ //#region src/UniformControls.ts
674
+ /** Uniform controls panel for wgsl-play. Collapsed by default, toggle on hover. */
675
+ var UniformControls = class {
676
+ container;
677
+ toggleBtn;
678
+ panel;
679
+ panelInner;
680
+ expanded = false;
681
+ onChange;
682
+ constructor(shadow, onChange) {
683
+ this.onChange = onChange;
684
+ this.container = document.createElement("div");
685
+ this.container.className = "settings";
686
+ this.toggleBtn = document.createElement("button");
687
+ this.toggleBtn.className = "uniform-toggle";
688
+ this.toggleBtn.innerHTML = chevronSvg;
689
+ this.toggleBtn.addEventListener("click", () => this.toggle());
690
+ this.panel = document.createElement("div");
691
+ this.panel.className = "uniform-panel";
692
+ this.panelInner = document.createElement("div");
693
+ this.panelInner.className = "uniform-panel-inner";
694
+ this.panel.appendChild(this.panelInner);
695
+ this.container.append(this.toggleBtn, this.panel);
696
+ shadow.appendChild(this.container);
697
+ }
698
+ /** Update controls from a new set of UniformControl descriptors. */
699
+ setControls(controls) {
700
+ this.panelInner.innerHTML = "";
701
+ if (controls.length === 0) {
702
+ this.container.classList.add("empty");
703
+ return;
704
+ }
705
+ this.container.classList.remove("empty");
706
+ for (const c of controls) this.panelInner.appendChild(this.buildControl(c));
707
+ }
708
+ /** Show/hide the controls container. */
709
+ show() {
710
+ this.container.style.display = "";
711
+ }
712
+ hide() {
713
+ this.container.style.display = "none";
714
+ }
715
+ toggle() {
716
+ this.expanded = !this.expanded;
717
+ this.container.classList.toggle("expanded", this.expanded);
718
+ }
719
+ /** Create a DOM input element (range slider, color picker, or checkbox) for a uniform. */
720
+ buildControl(c) {
721
+ const row = document.createElement("div");
722
+ row.className = "uniform-row";
723
+ const label = document.createElement("label");
724
+ label.textContent = c.name;
725
+ if (c.kind === "range") {
726
+ const input = document.createElement("input");
727
+ input.type = "range";
728
+ input.min = String(c.min);
729
+ input.max = String(c.max);
730
+ input.value = String(c.initial);
731
+ if (c.step) input.step = String(c.step);
732
+ else input.step = c.type === "i32" ? "1" : "0.01";
733
+ const valueSpan = document.createElement("span");
734
+ valueSpan.className = "uniform-value";
735
+ valueSpan.textContent = String(c.initial);
736
+ input.addEventListener("input", () => {
737
+ const v = Number(input.value);
738
+ valueSpan.textContent = input.value;
739
+ this.onChange(c.name, v);
740
+ });
741
+ row.append(label, input, valueSpan);
742
+ } else if (c.kind === "color") {
743
+ const input = document.createElement("input");
744
+ input.type = "color";
745
+ input.value = rgbToHex(c.initial);
746
+ input.addEventListener("input", () => {
747
+ this.onChange(c.name, hexToRgb(input.value));
748
+ });
749
+ row.append(label, input);
750
+ } else if (c.kind === "toggle") {
751
+ const input = document.createElement("input");
752
+ input.type = "checkbox";
753
+ input.checked = c.initial === 1;
754
+ input.addEventListener("change", () => {
755
+ this.onChange(c.name, input.checked ? 1 : 0);
756
+ });
757
+ row.append(label, input);
758
+ }
759
+ return row;
760
+ }
761
+ };
762
+ const chevronSvg = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
763
+ <path d="M4.7 5.3L8 8.6l3.3-3.3.7.7L8 10 4 6l.7-.7z"/>
764
+ </svg>`;
765
+ function rgbToHex([r, g, b]) {
766
+ const toHex = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
767
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
768
+ }
769
+ function hexToRgb(hex) {
770
+ const n = Number.parseInt(hex.slice(1), 16);
771
+ return [
772
+ (n >> 16) / 255,
773
+ (n >> 8 & 255) / 255,
774
+ (n & 255) / 255
775
+ ];
776
+ }
777
+ //#endregion
778
+ //#region src/WgslPlay.css?inline
779
+ var WgslPlay_default = ":host {\n --error-bg: rgba(220, 102, 18, 0.8);\n --error-color: white;\n --controls-bg: rgba(0, 0, 0, 0.55);\n --controls-color: rgba(255, 255, 255, 0.8);\n --controls-hover-color: white;\n --controls-hover-bg: rgba(255, 255, 255, 0.15);\n\n display: block;\n position: relative;\n overflow: hidden;\n}\n\n.resize-handle {\n display: none;\n position: absolute;\n width: 22px;\n height: 22px;\n z-index: 2;\n touch-action: none;\n opacity: 0;\n transition: opacity 0.15s;\n}\n\n:host([resizable]) .resize-handle {\n display: block;\n}\n\n.resize-handle.br {\n bottom: 0;\n right: 0;\n cursor: nwse-resize;\n}\n\n.resize-handle.bl {\n bottom: 0;\n left: 0;\n cursor: nesw-resize;\n}\n\n.resize-handle:hover,\n.resize-handle.dragging {\n opacity: 1;\n}\n\n.resize-handle::before {\n content: \"\";\n position: absolute;\n bottom: 3px;\n width: 8px;\n height: 8px;\n border-bottom: 2px solid var(--controls-bg);\n}\n\n.resize-handle.br::before {\n right: 3px;\n border-right: 2px solid var(--controls-bg);\n}\n\n.resize-handle.bl::before {\n left: 3px;\n border-left: 2px solid var(--controls-bg);\n}\n\n:host(.dark) {\n --error-bg: rgba(180, 60, 10, 0.85);\n --controls-bg: rgba(0, 0, 0, 0.5);\n}\n\ncanvas {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.controls {\n background: var(--controls-bg);\n position: absolute;\n bottom: 8px;\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n gap: 20px;\n border-radius: 6px;\n padding: 2px;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 1;\n}\n\n:host(:hover) .controls {\n opacity: 1;\n}\n\n.controls button {\n border: none;\n background: none;\n color: var(--controls-color);\n cursor: pointer;\n padding: 4px;\n border-radius: 4px;\n display: flex;\n align-items: center;\n}\n\n.controls button:hover {\n color: var(--controls-hover-color);\n background: var(--controls-hover-bg);\n}\n\n.controls button[hidden] {\n display: none;\n}\n\n/* Compute-mode results panel */\n.results-panel {\n font-family: var(--code-font, monospace);\n font-size: 0.85rem;\n padding: 8px;\n box-sizing: border-box;\n color: var(--results-fg, currentColor);\n background: var(--results-bg, transparent);\n}\n\n.results-panel[hidden] {\n display: none;\n}\n\n.results-panel .result {\n margin-bottom: 24px;\n}\n\n.results-panel .result-caption {\n font-weight: bold;\n padding-bottom: 8px;\n}\n\n.results-panel table {\n border-collapse: collapse;\n}\n\n.results-panel th,\n.results-panel td {\n padding: 2px 8px;\n border: 1px solid var(--results-border, rgba(128, 128, 128, 0.3));\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n.results-panel tfoot td {\n text-align: left;\n}\n\n.results-panel tfoot button {\n margin-left: 6px;\n cursor: pointer;\n}\n\n/* Settings panel */\n.settings {\n position: absolute;\n top: 8px;\n left: 8px;\n z-index: 1;\n opacity: 0;\n transition: opacity 0.2s;\n}\n\n:host(:hover) .settings {\n opacity: 1;\n}\n\n.settings.empty {\n display: none;\n}\n\n.uniform-toggle {\n border: none;\n background: var(--controls-bg);\n color: var(--controls-color);\n cursor: pointer;\n padding: 4px 6px;\n border-radius: 4px;\n display: flex;\n align-items: center;\n transition: background 0.15s;\n}\n\n.uniform-toggle:hover {\n color: var(--controls-hover-color);\n background: var(--controls-bg);\n}\n\n.uniform-toggle svg {\n transform: rotate(-90deg);\n transition: transform 0.25s linear;\n}\n\n.settings.expanded .uniform-toggle svg {\n transform: rotate(0deg);\n}\n\n.uniform-panel {\n display: grid;\n grid-template-rows: 0fr;\n margin-top: 4px;\n min-width: 180px;\n transition: grid-template-rows 0.25s linear;\n}\n\n.settings.expanded .uniform-panel {\n grid-template-rows: 1fr;\n}\n\n.uniform-panel-inner {\n overflow: hidden;\n background: var(--controls-bg);\n border-radius: 6px;\n padding: 0 10px;\n}\n\n.uniform-panel-inner > :first-child {\n margin-top: 8px;\n}\n\n.uniform-panel-inner > :last-child {\n margin-bottom: 8px;\n}\n\n.uniform-row {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 4px;\n color: var(--controls-color);\n font-family: monospace;\n font-size: 0.75rem;\n}\n\n.uniform-row label {\n min-width: 60px;\n flex-shrink: 0;\n}\n\n.uniform-row input[type=\"range\"] {\n flex: 1;\n min-width: 80px;\n}\n\n.uniform-row input[type=\"color\"] {\n width: 28px;\n height: 20px;\n border: none;\n padding: 0;\n background: none;\n cursor: pointer;\n}\n\n.uniform-row input[type=\"checkbox\"] {\n cursor: pointer;\n}\n\n.uniform-value {\n min-width: 36px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n.error-overlay {\n position: absolute;\n inset: 0;\n background: var(--error-bg);\n color: var(--error-color);\n padding: 1rem;\n font-family: monospace;\n font-size: 0.875rem;\n white-space: pre-wrap;\n overflow: auto;\n display: none;\n}\n\n.error-overlay.visible {\n display: block;\n}\n";
780
+ //#endregion
781
+ //#region src/WgslPlay.ts
782
+ let styles = null;
783
+ let template = null;
784
+ /** <wgsl-play> web component for rendering WESL/WGSL fragment shaders. */
785
+ var WgslPlay = class extends HTMLElement {
786
+ static observedAttributes = [
787
+ "src",
788
+ "shader-root",
789
+ "from",
790
+ "no-controls",
791
+ "no-settings",
792
+ "theme",
793
+ "autoplay",
794
+ "transparent",
795
+ "fetch-libs",
796
+ "fetch-sources",
797
+ "width",
798
+ "height",
799
+ "pixel-ratio"
800
+ ];
801
+ canvas;
802
+ resultsPanel;
803
+ errorOverlay;
804
+ controls;
805
+ settings;
806
+ resizeObserver;
807
+ stopRenderLoop;
808
+ _currentMode = "fragment";
809
+ _rerunPending = false;
810
+ renderState;
811
+ pendingUniforms = /* @__PURE__ */ new Map();
812
+ playback = {
813
+ isPlaying: true,
814
+ startTime: performance.now(),
815
+ pausedDuration: 0
816
+ };
817
+ _weslSrc = {};
818
+ _rootModuleName = "package::main";
819
+ _libs;
820
+ _linkOptions = {};
821
+ _fetchSources = true;
822
+ _initPromise;
823
+ _sourceEl = null;
824
+ _sourceListener = null;
825
+ _fetchLibs = true;
826
+ _dirty = false;
827
+ _building = false;
828
+ _theme = "auto";
829
+ _mediaQuery = null;
830
+ _onFullscreenChange = () => this.controls.setFullscreen(!!document.fullscreenElement);
831
+ _pointerCleanup;
832
+ _resizeCleanups = [];
833
+ _childObserver;
834
+ /** Get config overrides from element attributes. */
835
+ getConfigOverrides() {
836
+ const shaderRoot = this.getAttribute("shader-root");
837
+ if (!shaderRoot) return void 0;
838
+ return { shaderRoot };
839
+ }
840
+ constructor() {
841
+ super();
842
+ const shadow = this.attachShadow({ mode: "open" });
843
+ shadow.adoptedStyleSheets = [getStyles()];
844
+ shadow.appendChild(getTemplate().content.cloneNode(true));
845
+ this.canvas = shadow.querySelector("canvas");
846
+ this.resultsPanel = shadow.querySelector(".results-panel");
847
+ this.errorOverlay = new ErrorOverlay(shadow);
848
+ this.settings = new UniformControls(shadow, (name, value) => {
849
+ this.setUniform(name, value);
850
+ if (this._currentMode === "compute") this.scheduleComputeRerun();
851
+ });
852
+ this.controls = new PlaybackControls(shadow, () => this.play(), () => this.pause(), () => this.rewind(), () => this.toggleFullscreen(), () => this.scheduleComputeRerun());
853
+ this.resizeObserver = new ResizeObserver((entries) => {
854
+ if (!this.renderState) return;
855
+ if (this.hasAttribute("width") && this.hasAttribute("height")) return;
856
+ const entry = entries.at(-1);
857
+ if (!entry) return;
858
+ const [width, height] = entrySize(entry, this.getAttribute("pixel-ratio"));
859
+ if (width > 0 && height > 0) {
860
+ const maxDim = this.renderState.device.limits.maxTextureDimension2D;
861
+ this.canvas.width = clampCanvas(width, maxDim);
862
+ this.canvas.height = clampCanvas(height, maxDim);
863
+ }
864
+ });
865
+ const handles = shadow.querySelectorAll(".resize-handle");
866
+ for (const h of handles) this.setupResizeHandle(h, h.classList.contains("bl") ? "bl" : "br");
867
+ }
868
+ connectedCallback() {
869
+ const themeAttr = this.getAttribute("theme");
870
+ if (themeAttr) this._theme = themeAttr;
871
+ this._mediaQuery = matchMedia("(prefers-color-scheme: dark)");
872
+ this._mediaQuery.addEventListener("change", () => this.updateTheme());
873
+ this.updateTheme();
874
+ document.addEventListener("fullscreenchange", this._onFullscreenChange);
875
+ if (!this.autoplay) {
876
+ this.playback.isPlaying = false;
877
+ this.controls.setPlaying(false);
878
+ }
879
+ this.initialize();
880
+ this.observeLightDomChildren();
881
+ upgradeProperty(this, "conditions");
882
+ upgradeProperty(this, "shader");
883
+ upgradeProperty(this, "project");
884
+ }
885
+ /** Start watching element size. Deferred until after `initWebGPU` so the
886
+ * observer has a real `maxTextureDimension2D` to clamp against. */
887
+ observeCanvasSize() {
888
+ try {
889
+ this.resizeObserver.observe(this, { box: "device-pixel-content-box" });
890
+ } catch {
891
+ this.resizeObserver.observe(this);
892
+ }
893
+ }
894
+ disconnectedCallback() {
895
+ this.resizeObserver.disconnect();
896
+ this._childObserver?.disconnect();
897
+ this._childObserver = void 0;
898
+ this.stopRenderLoop?.();
899
+ this._pointerCleanup?.();
900
+ for (const c of this._resizeCleanups) c();
901
+ this._resizeCleanups = [];
902
+ document.removeEventListener("fullscreenchange", this._onFullscreenChange);
903
+ if (this._sourceEl && this._sourceListener) this._sourceEl.removeEventListener("change", this._sourceListener);
904
+ if (this.renderState) disposeResources(this.renderState);
905
+ }
906
+ attributeChangedCallback(name, oldValue, newValue) {
907
+ if (oldValue === newValue) return;
908
+ switch (name) {
909
+ case "no-controls":
910
+ if (newValue !== null) this.controls.hide();
911
+ else this.controls.show();
912
+ return;
913
+ case "no-settings":
914
+ if (newValue !== null) this.settings.hide();
915
+ else this.settings.show();
916
+ return;
917
+ case "theme":
918
+ this._theme = newValue || "auto";
919
+ this.updateTheme();
920
+ return;
921
+ case "autoplay":
922
+ if (newValue === "false") this.pause();
923
+ else this.play();
924
+ return;
925
+ case "fetch-libs":
926
+ this._fetchLibs = newValue !== "false";
927
+ return;
928
+ case "fetch-sources":
929
+ this._fetchSources = newValue !== "false";
930
+ return;
931
+ case "src":
932
+ if (newValue && this._initPromise) this.loadFromUrl(newValue);
933
+ return;
934
+ case "width":
935
+ case "height":
936
+ case "pixel-ratio":
937
+ this.updateCanvasSize();
938
+ return;
939
+ }
940
+ }
941
+ /** Current shader source code (main module). */
942
+ get shader() {
943
+ return this._weslSrc[this._rootModuleName] ?? "";
944
+ }
945
+ /** Set shader source directly (single-file convenience). */
946
+ set shader(value) {
947
+ this._weslSrc = { [this._rootModuleName]: value };
948
+ this._libs = void 0;
949
+ this.requestBuild();
950
+ }
951
+ /** Conditions for conditional compilation (@if/@elif/@else). */
952
+ get conditions() {
953
+ return this._linkOptions.conditions ?? {};
954
+ }
955
+ set conditions(value) {
956
+ this._linkOptions = {
957
+ ...this._linkOptions,
958
+ conditions: value
959
+ };
960
+ if (Object.keys(this._weslSrc).length === 0) return;
961
+ this.requestBuild();
962
+ }
963
+ /** Set project configuration (mirrors wesl link() API). */
964
+ set project(value) {
965
+ const { weslSrc, rootModuleName, libs } = value;
966
+ const { packageName, conditions, constants } = value;
967
+ if (packageName !== void 0) this._linkOptions.packageName = packageName;
968
+ if (conditions !== void 0) this._linkOptions.conditions = conditions;
969
+ if (constants !== void 0) this._linkOptions.constants = constants;
970
+ if (libs) this._libs = libs;
971
+ if (weslSrc) {
972
+ const pkg = this._linkOptions.packageName || "package";
973
+ const root = rootModuleName ?? "main";
974
+ this._weslSrc = toModulePaths(weslSrc, pkg);
975
+ this._rootModuleName = fileToModulePath(root, pkg, false);
976
+ this.requestBuild();
977
+ return;
978
+ }
979
+ if (Object.keys(this._weslSrc).length === 0) return;
980
+ this.requestBuild();
981
+ }
982
+ /** Whether to auto-fetch missing library packages from npm (default: true). */
983
+ get fetchLibs() {
984
+ return this._fetchLibs;
985
+ }
986
+ set fetchLibs(value) {
987
+ this._fetchLibs = value;
988
+ this.setBoolAttr("fetch-libs", value);
989
+ }
990
+ /** Whether to fetch local .wesl source files via HTTP (default: true). */
991
+ get fetchSources() {
992
+ return this._fetchSources;
993
+ }
994
+ set fetchSources(value) {
995
+ this._fetchSources = value;
996
+ this.setBoolAttr("fetch-sources", value);
997
+ }
998
+ /** Whether autoplay is enabled (default: true). Set autoplay="false" to start paused. */
999
+ get autoplay() {
1000
+ return this.getAttribute("autoplay") !== "false";
1001
+ }
1002
+ set autoplay(value) {
1003
+ const enabled = value !== false && value !== "false";
1004
+ this.setBoolAttr("autoplay", enabled);
1005
+ }
1006
+ /** Scale factor from CSS pixels to canvas pixels (default: devicePixelRatio). */
1007
+ get pixelRatio() {
1008
+ const attr = this.getAttribute("pixel-ratio");
1009
+ return attr !== null ? Number(attr) : devicePixelRatio;
1010
+ }
1011
+ set pixelRatio(value) {
1012
+ this.setAttribute("pixel-ratio", String(value));
1013
+ }
1014
+ /** Whether the shader is currently playing. */
1015
+ get isPlaying() {
1016
+ return this.playback.isPlaying;
1017
+ }
1018
+ /** Current animation time in seconds. */
1019
+ get time() {
1020
+ return calculateTime(this.playback);
1021
+ }
1022
+ /** Number of frames rendered (for testing/debugging). */
1023
+ get frameCount() {
1024
+ return this.renderState?.frameCount ?? 0;
1025
+ }
1026
+ /** Whether there's a compilation error. */
1027
+ get hasError() {
1028
+ return this.errorOverlay.visible;
1029
+ }
1030
+ /** Current error message, or null if no error. */
1031
+ get errorMessage() {
1032
+ return this.errorOverlay.message;
1033
+ }
1034
+ /** Start playback. */
1035
+ play() {
1036
+ const { isPlaying, pausedDuration } = this.playback;
1037
+ if (isPlaying) return;
1038
+ this.playback.startTime = performance.now() - pausedDuration;
1039
+ this.setPlaying(true);
1040
+ if (this.renderState) this.stopRenderLoop = startRenderLoop(this.renderState, this.playback);
1041
+ }
1042
+ /** Pause playback. */
1043
+ pause() {
1044
+ if (!this.playback.isPlaying) return;
1045
+ this.playback.pausedDuration = performance.now() - this.playback.startTime;
1046
+ this.stopAndPresent();
1047
+ }
1048
+ setPlaying(playing) {
1049
+ this.playback.isPlaying = playing;
1050
+ this.controls.setPlaying(playing);
1051
+ const detail = { isPlaying: playing };
1052
+ this.dispatchEvent(new CustomEvent("playback-change", { detail }));
1053
+ }
1054
+ /** Mirror a default-true setter onto its attribute: absent = on, "false" = off. */
1055
+ setBoolAttr(name, on) {
1056
+ if (on) this.removeAttribute(name);
1057
+ else this.setAttribute(name, "false");
1058
+ }
1059
+ /** Wait for any in-flight build and render one frame to the canvas, resolving
1060
+ * once it has been presented. Read the canvas via
1061
+ * `element.shadowRoot.querySelector('canvas')`. */
1062
+ async renderFrame() {
1063
+ await this.awaitIdleBuild();
1064
+ if (!this.renderState) throw new Error("renderFrame: not initialized");
1065
+ if (this.errorOverlay.visible) throw new Error("renderFrame: shader has compile error");
1066
+ if (this._currentMode === "compute") throw new Error("renderFrame: compute mode has no canvas output");
1067
+ renderOnce(this.renderState, this.playback);
1068
+ await new Promise((r) => requestAnimationFrame(() => r()));
1069
+ }
1070
+ /** Resolve once initialization and any pending build have settled. Yields via
1071
+ * setTimeout so async build steps (network fetch, GPU validation) get a
1072
+ * chance to advance between polls. */
1073
+ async awaitIdleBuild() {
1074
+ await this._initPromise;
1075
+ while (this._building || this._dirty) await new Promise((r) => setTimeout(r, 0));
1076
+ }
1077
+ /** Reset animation to time 0 and pause. */
1078
+ rewind() {
1079
+ this.playback.startTime = performance.now();
1080
+ this.playback.pausedDuration = 0;
1081
+ this.stopAndPresent();
1082
+ }
1083
+ /** Stop the RAF loop, mark paused, and re-present the current frame. */
1084
+ stopAndPresent() {
1085
+ this.stopRenderLoop?.();
1086
+ this.setPlaying(false);
1087
+ if (this.renderState) renderOnce(this.renderState, this.playback);
1088
+ }
1089
+ /** Display error message in overlay. Pass empty string to clear. */
1090
+ showError(message) {
1091
+ if (!message) {
1092
+ this.errorOverlay.hide();
1093
+ return;
1094
+ }
1095
+ this.errorOverlay.show(message);
1096
+ this.pause();
1097
+ }
1098
+ /** Set a uniform value by name. Works before or after compilation. */
1099
+ setUniform(name, value) {
1100
+ this.pendingUniforms.set(name, value);
1101
+ this.renderState?.uniformState.controlValues.set(name, value);
1102
+ if (this.renderState && !this.playback.isPlaying) renderOnce(this.renderState, this.playback);
1103
+ }
1104
+ /** Apply uniforms set before the pipeline existed. */
1105
+ flushPendingUniforms() {
1106
+ const controlValues = this.renderState?.uniformState.controlValues;
1107
+ if (!controlValues) return;
1108
+ for (const [name, value] of this.pendingUniforms) controlValues.set(name, value);
1109
+ }
1110
+ /** Current uniform control values (readable). */
1111
+ get uniforms() {
1112
+ const controlValues = this.renderState?.uniformState.controlValues;
1113
+ if (!controlValues) return {};
1114
+ return Object.fromEntries(controlValues);
1115
+ }
1116
+ /** Toggle fullscreen on this element. */
1117
+ toggleFullscreen() {
1118
+ if (document.fullscreenElement) document.exitFullscreen();
1119
+ else this.requestFullscreen();
1120
+ }
1121
+ /** Track pointer events on canvas for mouse_pos @auto fields. */
1122
+ setupMouseTracking() {
1123
+ const mouse = this.renderState.mouse;
1124
+ const canvas = this.canvas;
1125
+ const onMove = (e) => {
1126
+ const rect = canvas.getBoundingClientRect();
1127
+ const x = (e.clientX - rect.left) * (canvas.width / rect.width);
1128
+ const y = (e.clientY - rect.top) * (canvas.height / rect.height);
1129
+ mouse.delta = [x - mouse.pos[0], y - mouse.pos[1]];
1130
+ mouse.pos = [x, y];
1131
+ };
1132
+ const onDown = (e) => {
1133
+ mouse.button = e.button + 1;
1134
+ };
1135
+ const onUp = () => {
1136
+ mouse.button = 0;
1137
+ };
1138
+ canvas.addEventListener("pointermove", onMove);
1139
+ canvas.addEventListener("pointerdown", onDown);
1140
+ canvas.addEventListener("pointerup", onUp);
1141
+ canvas.addEventListener("pointerleave", onUp);
1142
+ this._pointerCleanup = () => {
1143
+ canvas.removeEventListener("pointermove", onMove);
1144
+ canvas.removeEventListener("pointerdown", onDown);
1145
+ canvas.removeEventListener("pointerup", onUp);
1146
+ canvas.removeEventListener("pointerleave", onUp);
1147
+ };
1148
+ }
1149
+ /** Drag-to-resize via a custom handle (works on touch + mouse). */
1150
+ setupResizeHandle(handle, corner) {
1151
+ const xSign = corner === "bl" ? -1 : 1;
1152
+ let startX = 0;
1153
+ let startY = 0;
1154
+ let startW = 0;
1155
+ let startH = 0;
1156
+ /** Numeric aspect ratio (w/h) sampled at drag start, or null if `auto`. */
1157
+ let ratio = null;
1158
+ const onMove = (e) => {
1159
+ const dx = (e.clientX - startX) * xSign;
1160
+ const dy = e.clientY - startY;
1161
+ let w = startW + dx;
1162
+ let h = startH + dy;
1163
+ if (ratio !== null) if (Math.abs(dx) >= Math.abs(dy)) h = w / ratio;
1164
+ else w = h * ratio;
1165
+ this.style.width = `${Math.max(16, w)}px`;
1166
+ this.style.height = `${Math.max(16, h)}px`;
1167
+ };
1168
+ const onUp = () => {
1169
+ handle.classList.remove("dragging");
1170
+ document.removeEventListener("pointermove", onMove);
1171
+ document.removeEventListener("pointerup", onUp);
1172
+ };
1173
+ const onDown = (e) => {
1174
+ e.preventDefault();
1175
+ handle.setPointerCapture(e.pointerId);
1176
+ handle.classList.add("dragging");
1177
+ const rect = this.getBoundingClientRect();
1178
+ startX = e.clientX;
1179
+ startY = e.clientY;
1180
+ startW = rect.width;
1181
+ startH = rect.height;
1182
+ ratio = parseAspectRatio(getComputedStyle(this).aspectRatio);
1183
+ document.addEventListener("pointermove", onMove);
1184
+ document.addEventListener("pointerup", onUp);
1185
+ };
1186
+ handle.addEventListener("pointerdown", onDown);
1187
+ this._resizeCleanups.push(() => {
1188
+ handle.removeEventListener("pointerdown", onDown);
1189
+ onUp();
1190
+ });
1191
+ }
1192
+ /** Recompute canvas resolution from attributes or CSS size. */
1193
+ updateCanvasSize() {
1194
+ const w = this.getAttribute("width");
1195
+ const h = this.getAttribute("height");
1196
+ const maxDim = this.renderState?.device.limits.maxTextureDimension2D;
1197
+ if (w !== null && h !== null) {
1198
+ this.canvas.width = clampCanvas(Number(w), maxDim);
1199
+ this.canvas.height = clampCanvas(Number(h), maxDim);
1200
+ return;
1201
+ }
1202
+ const rect = this.getBoundingClientRect();
1203
+ if (rect.width <= 0 || rect.height <= 0) return;
1204
+ const ratio = this.pixelRatio;
1205
+ this.canvas.width = clampCanvas(rect.width * ratio, maxDim);
1206
+ this.canvas.height = clampCanvas(rect.height * ratio, maxDim);
1207
+ }
1208
+ /** Toggle the `dark` class to match `theme` attr or system preference. */
1209
+ updateTheme() {
1210
+ const isDark = this._theme === "dark" || this._theme === "auto" && matchMedia("(prefers-color-scheme: dark)").matches;
1211
+ this.classList.toggle("dark", isDark);
1212
+ }
1213
+ /** Set up WebGPU and load initial shader. Returns true if successful. */
1214
+ initialize() {
1215
+ if (this.renderState) return Promise.resolve(true);
1216
+ if (!this._initPromise) this._initPromise = this.doInitialize();
1217
+ return this._initPromise;
1218
+ }
1219
+ /** Init WebGPU, wire observers/listeners, and dispatch ready/init-error. */
1220
+ async doInitialize() {
1221
+ try {
1222
+ const alphaMode = this.hasAttribute("transparent") ? "premultiplied" : "opaque";
1223
+ this.renderState = await initWebGPU(this.canvas, alphaMode);
1224
+ this.observeCanvasSize();
1225
+ this.setupMouseTracking();
1226
+ this.loadInitialContent();
1227
+ if (this.playback.isPlaying) this.stopRenderLoop = startRenderLoop(this.renderState, this.playback);
1228
+ this.dispatchEvent(new CustomEvent("ready"));
1229
+ return true;
1230
+ } catch (error) {
1231
+ const message = !navigator.gpu ? "WebGPU is not supported in this browser.\nTry Chrome 113+, Edge 113+, or Safari 18+." : `WebGPU initialization failed: ${error}`;
1232
+ this.errorOverlay.show(message);
1233
+ this.pause();
1234
+ const detail = { message };
1235
+ this.dispatchEvent(new CustomEvent("init-error", { detail }));
1236
+ return false;
1237
+ }
1238
+ }
1239
+ /** Load from source element, src URL, script child, or inline textContent. */
1240
+ loadInitialContent() {
1241
+ const fromId = this.getAttribute("from");
1242
+ if (fromId) {
1243
+ this.connectFrom(fromId);
1244
+ return;
1245
+ }
1246
+ const src = this.getAttribute("src");
1247
+ if (src) {
1248
+ this.loadFromUrl(src);
1249
+ return;
1250
+ }
1251
+ const inlineSource = this.querySelector("script[type=\"text/wgsl\"], script[type=\"text/wesl\"]")?.textContent?.trim() ?? this.textContent?.trim();
1252
+ if (!inlineSource) return;
1253
+ this._weslSrc = { [this._rootModuleName]: inlineSource };
1254
+ this.requestBuild();
1255
+ }
1256
+ /** Connect to a source provider element (e.g., wgsl-edit). */
1257
+ connectFrom(id) {
1258
+ const el = document.getElementById(id);
1259
+ if (!el) {
1260
+ console.error(`wgsl-play: source element "${id}" not found`);
1261
+ return;
1262
+ }
1263
+ this._sourceEl = el;
1264
+ if (this.id && !el.getAttribute("lint-from")) el.setAttribute("lint-from", this.id);
1265
+ const p = el.project;
1266
+ if (p) this.project = p;
1267
+ this._sourceListener = (e) => {
1268
+ const detail = e.detail;
1269
+ if (detail) this.project = detail;
1270
+ };
1271
+ el.addEventListener("change", this._sourceListener);
1272
+ }
1273
+ /** Fetch shader from URL, then trigger a build. */
1274
+ async loadFromUrl(url) {
1275
+ try {
1276
+ const shaderRoot = this.getConfigOverrides()?.shaderRoot;
1277
+ const { weslSrc, libs, rootModuleName } = await loadShaderFromUrl(url, shaderRoot);
1278
+ this._weslSrc = weslSrc;
1279
+ this._libs = libs;
1280
+ if (rootModuleName) this._rootModuleName = rootModuleName;
1281
+ this.requestBuild();
1282
+ } catch (error) {
1283
+ this.handleCompileError(error);
1284
+ }
1285
+ }
1286
+ /** Mark build as needed. Coalesces rapid requests into a single build. */
1287
+ requestBuild() {
1288
+ this._dirty = true;
1289
+ if (!this._building) this.runBuild();
1290
+ }
1291
+ /** Run builds until no longer dirty. Only one instance runs at a time. */
1292
+ async runBuild() {
1293
+ this._building = true;
1294
+ while (this._dirty) {
1295
+ this._dirty = false;
1296
+ if (!await this.initialize()) break;
1297
+ const mainSource = this._weslSrc[this._rootModuleName];
1298
+ if (!mainSource) {
1299
+ console.warn(`wgsl-play: root module "${this._rootModuleName}" not found in sources:`, Object.keys(this._weslSrc));
1300
+ continue;
1301
+ }
1302
+ try {
1303
+ this.errorOverlay.hide();
1304
+ const result = await this.buildPipeline(mainSource);
1305
+ if (!this._dirty) this.applyBuild(result);
1306
+ } catch (error) {
1307
+ if (!this._dirty) this.handleCompileError(error);
1308
+ }
1309
+ }
1310
+ this._building = false;
1311
+ }
1312
+ /** Fetch deps if needed and create the render pipeline. */
1313
+ async buildPipeline(mainSource) {
1314
+ if (this._fetchSources || this._fetchLibs) {
1315
+ const { weslSrc, libs } = await fetchDependencies(mainSource, {
1316
+ shaderRoot: this.getConfigOverrides()?.shaderRoot,
1317
+ existingSources: this._weslSrc,
1318
+ fetchLibs: this._fetchLibs,
1319
+ fetchSources: this._fetchSources
1320
+ });
1321
+ this._weslSrc = {
1322
+ ...this._weslSrc,
1323
+ ...weslSrc
1324
+ };
1325
+ this._libs = dedupLibs(this._libs, libs);
1326
+ }
1327
+ return createPipeline(this.renderState, mainSource, (source) => this.resolveHostTexture(source), {
1328
+ ...this._linkOptions,
1329
+ weslSrc: this._weslSrc,
1330
+ libs: this._libs,
1331
+ rootModuleName: this._rootModuleName
1332
+ });
1333
+ }
1334
+ /** Resolve a @texture(name) to a decoded image from light-DOM children.
1335
+ * Prefers [data-texture="name"], falls back to #id. */
1336
+ resolveHostTexture = async (source) => {
1337
+ const selector = `[data-texture="${cssEscape(source)}"], #${cssEscape(source)}`;
1338
+ const el = this.querySelector(selector);
1339
+ if (!el) return null;
1340
+ if (el instanceof HTMLImageElement) return decodeImage(el, source);
1341
+ if (el instanceof HTMLCanvasElement) return el;
1342
+ throw new ResourceLoadError(`@texture(${source}): matched element is a <${el.tagName.toLowerCase()}>; expected <img> or <canvas>`, source);
1343
+ };
1344
+ /** Watch light-DOM for <img> add/remove/src changes and trigger a rebuild. */
1345
+ observeLightDomChildren() {
1346
+ if (this._childObserver) return;
1347
+ this._childObserver = new MutationObserver((records) => {
1348
+ for (const r of records) {
1349
+ if (r.type === "attributes") {
1350
+ this.requestBuild();
1351
+ return;
1352
+ }
1353
+ for (const node of [...r.addedNodes, ...r.removedNodes]) if (node instanceof HTMLImageElement) {
1354
+ this.requestBuild();
1355
+ return;
1356
+ }
1357
+ }
1358
+ });
1359
+ this._childObserver.observe(this, {
1360
+ childList: true,
1361
+ subtree: true,
1362
+ attributes: true,
1363
+ attributeFilter: [
1364
+ "src",
1365
+ "id",
1366
+ "data-texture"
1367
+ ]
1368
+ });
1369
+ }
1370
+ /** Apply a successful build: flush uniforms, update controls, render. */
1371
+ applyBuild(result) {
1372
+ this.flushPendingUniforms();
1373
+ const controls = this.renderState.uniformState.layout.controls;
1374
+ this.settings.setControls(controls);
1375
+ this.applyMode(result);
1376
+ this.dispatchEvent(new CustomEvent("compile-success"));
1377
+ const detail = result.layout;
1378
+ this.dispatchEvent(new CustomEvent("uniforms-layout", { detail }));
1379
+ }
1380
+ /** Show canvas vs results panel and (re-)render based on build mode. */
1381
+ applyMode(result) {
1382
+ this._currentMode = result.mode;
1383
+ if (result.mode === "compute") {
1384
+ this.canvas.hidden = true;
1385
+ this.resultsPanel.hidden = false;
1386
+ this.controls.setMode("compute");
1387
+ const entries = result.computeReadback ?? [];
1388
+ renderResultsPanel({
1389
+ panel: this.resultsPanel,
1390
+ entries
1391
+ });
1392
+ return;
1393
+ }
1394
+ this.canvas.hidden = false;
1395
+ this.resultsPanel.hidden = true;
1396
+ this.controls.setMode("render");
1397
+ if (!this.playback.isPlaying) renderOnce(this.renderState, this.playback);
1398
+ }
1399
+ /** Coalesce rapid uniform/refresh events into a single re-dispatch. */
1400
+ scheduleComputeRerun() {
1401
+ if (this._rerunPending) return;
1402
+ this._rerunPending = true;
1403
+ queueMicrotask(async () => {
1404
+ this._rerunPending = false;
1405
+ if (this._currentMode !== "compute" || !this.renderState) return;
1406
+ try {
1407
+ this.flushPendingUniforms();
1408
+ const entries = await rerunCompute(this.renderState);
1409
+ renderResultsPanel({
1410
+ panel: this.resultsPanel,
1411
+ entries
1412
+ });
1413
+ } catch (error) {
1414
+ this.handleCompileError(error);
1415
+ }
1416
+ });
1417
+ }
1418
+ /** Show the error overlay and dispatch a `compile-error` event. */
1419
+ handleCompileError(error) {
1420
+ const message = error?.message ?? String(error);
1421
+ this.errorOverlay.show(message);
1422
+ const detail = {
1423
+ message,
1424
+ source: error instanceof WeslParseError ? "wesl" : "webgpu",
1425
+ kind: error instanceof ResourceLoadError ? "resource" : "shader",
1426
+ locations: this.extractLocations(error)
1427
+ };
1428
+ if (error instanceof ResourceLoadError) detail.resourceSource = error.resourceSource;
1429
+ this.dispatchEvent(new CustomEvent("compile-error", { detail }));
1430
+ }
1431
+ /** Extract source locations from a WESL parse error or GPU compilation error. */
1432
+ extractLocations(error) {
1433
+ const loc = error?.weslLocation;
1434
+ if (loc) {
1435
+ const message = error?.message ?? "";
1436
+ const { file, line, column, length, offset } = loc;
1437
+ return [{
1438
+ file,
1439
+ line,
1440
+ column: column - 1,
1441
+ length,
1442
+ offset,
1443
+ severity: "error",
1444
+ message
1445
+ }];
1446
+ }
1447
+ const msgs = error?.compilationInfo?.messages;
1448
+ if (msgs) return msgs.map((m) => ({
1449
+ file: m.module?.url,
1450
+ line: m.lineNum,
1451
+ column: m.linePos - 1,
1452
+ length: m.length,
1453
+ offset: m.offset,
1454
+ severity: toSeverity(m.type),
1455
+ message: m.message
1456
+ }));
1457
+ return [];
1458
+ }
1459
+ };
1460
+ function getStyles() {
1461
+ if (!styles) {
1462
+ styles = new CSSStyleSheet();
1463
+ styles.replaceSync(WgslPlay_default);
1464
+ }
1465
+ return styles;
1466
+ }
1467
+ function getTemplate() {
1468
+ if (!template) {
1469
+ template = document.createElement("template");
1470
+ template.innerHTML = `<canvas part="canvas"></canvas><div class="results-panel" part="results-panel" hidden></div><div class="resize-handle br"></div><div class="resize-handle bl"></div>`;
1471
+ }
1472
+ return template;
1473
+ }
1474
+ /** Absorb instance properties set before custom element upgrade.
1475
+ * Duplicated in WgslEdit.ts; extract to a shared package later. */
1476
+ function upgradeProperty(el, prop) {
1477
+ if (Object.hasOwn(el, prop)) {
1478
+ const value = el[prop];
1479
+ delete el[prop];
1480
+ el[prop] = value;
1481
+ }
1482
+ }
1483
+ /** Normalize all keys in a weslSrc record to module paths. */
1484
+ function toModulePaths(weslSrc, pkg) {
1485
+ const entries = Object.entries(weslSrc).map(([key, value]) => [fileToModulePath(key, pkg, false), value]);
1486
+ return Object.fromEntries(entries);
1487
+ }
1488
+ /** Parse `getComputedStyle().aspectRatio` (`"auto"`, `"1"`, `"16 / 9"`) to w/h. */
1489
+ function parseAspectRatio(value) {
1490
+ if (!value || value === "auto") return null;
1491
+ const [w, h = "1"] = value.split("/").map((s) => s.trim());
1492
+ const wNum = Number(w);
1493
+ const hNum = Number(h);
1494
+ if (!wNum || !hNum) return null;
1495
+ return wNum / hNum;
1496
+ }
1497
+ /** Merge new libs, deduplicating by bundle name. */
1498
+ function dedupLibs(existing, newLibs) {
1499
+ if (!existing || newLibs.length === 0) return [...existing ?? [], ...newLibs];
1500
+ const names = new Set(newLibs.map((b) => b.name));
1501
+ return [...existing.filter((b) => !names.has(b.name)), ...newLibs];
1502
+ }
1503
+ /** Escape a value for safe use as an attribute-selector literal or id selector. */
1504
+ function cssEscape(value) {
1505
+ if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(value);
1506
+ return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
1507
+ }
1508
+ /** Decode an <img> to an ImageBitmap with deterministic upload flags. */
1509
+ async function decodeImage(el, source) {
1510
+ try {
1511
+ if (!el.complete) await waitForImageLoad(el);
1512
+ if (el.decode) await el.decode();
1513
+ return await createImageBitmap(el, {
1514
+ imageOrientation: "from-image",
1515
+ premultiplyAlpha: "none",
1516
+ colorSpaceConversion: "none"
1517
+ });
1518
+ } catch (err) {
1519
+ const detail = err instanceof Error ? err.message : String(err);
1520
+ throw new ResourceLoadError(`@texture(${source}): failed to decode <img src="${el.src}"> — ${detail}`, source);
1521
+ }
1522
+ }
1523
+ /** Map a GPU compilation message `type` field to our severity enum. */
1524
+ function toSeverity(type) {
1525
+ if (type === "warning") return "warning";
1526
+ if (type === "info") return "info";
1527
+ return "error";
1528
+ }
1529
+ function waitForImageLoad(el) {
1530
+ return new Promise((resolve, reject) => {
1531
+ const onLoad = () => {
1532
+ el.removeEventListener("error", onError);
1533
+ resolve();
1534
+ };
1535
+ const onError = () => {
1536
+ el.removeEventListener("load", onLoad);
1537
+ reject(/* @__PURE__ */ new Error("image load failed"));
1538
+ };
1539
+ el.addEventListener("load", onLoad, { once: true });
1540
+ el.addEventListener("error", onError, { once: true });
1541
+ });
1542
+ }
1543
+ //#endregion
1544
+ export { resetConfig as a, getConfig as i, WgslPlay as n, defaults as r, ResourceLoadError$1 as t };