wgsl-play 0.0.39 → 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.
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
package/src/WgslPlay.ts CHANGED
@@ -1,23 +1,31 @@
1
1
  import type { Conditions, WeslBundle, WeslProject } from "wesl";
2
2
  import { fileToModulePath, WeslParseError } from "wesl";
3
3
  import { fetchDependencies, loadShaderFromUrl } from "wesl-fetch";
4
- import type { AnnotatedLayout } from "wesl-reflect";
4
+ import { type ResolveUserTexture, ResourceLoadError } from "wesl-gpu";
5
+ import { clampCanvas, entrySize } from "./CanvasSize.ts";
6
+ import { rerunCompute } from "./ComputeBuild.ts";
5
7
  import type { WgslPlayConfig } from "./Config.ts";
6
8
  import { ErrorOverlay } from "./ErrorOverlay.ts";
7
- import { PlaybackControls } from "./PlaybackControls.ts";
8
9
  import {
9
10
  calculateTime,
11
+ type PlaybackState,
12
+ renderOnce,
13
+ startRenderLoop,
14
+ } from "./FragmentRender.ts";
15
+ import { PlaybackControls } from "./PlaybackControls.ts";
16
+ import {
17
+ type BuildResult,
10
18
  createPipeline,
11
19
  initWebGPU,
12
20
  type LinkOptions,
13
- type PlaybackState,
14
21
  type RenderState,
15
- renderOnce,
16
- startRenderLoop,
17
22
  } from "./Renderer.ts";
23
+ import { disposeResources } from "./RenderResources.ts";
24
+ import { renderResultsPanel } from "./ResultsPanel.ts";
18
25
  import { UniformControls } from "./UniformControls.ts";
19
26
  import cssText from "./WgslPlay.css?inline";
20
27
 
28
+ export { ResourceLoadError } from "wesl-gpu";
21
29
  export { defaults, getConfig, resetConfig } from "./Config.ts";
22
30
 
23
31
  /** One source location within a compile error. */
@@ -26,6 +34,8 @@ export interface CompileErrorLocation {
26
34
  line: number;
27
35
  column: number; // 0-indexed
28
36
  length?: number;
37
+ /** byte offset into the source file */
38
+ offset: number;
29
39
  severity: "error" | "warning" | "info";
30
40
  message: string;
31
41
  }
@@ -34,14 +44,29 @@ export interface CompileErrorLocation {
34
44
  export interface CompileErrorDetail {
35
45
  message: string;
36
46
  source: "wesl" | "webgpu";
47
+ /** What kind of failure: a shader compile/link problem or a host-side resource problem. */
48
+ kind: "shader" | "resource";
49
+ /** For resource errors, the `@texture(name)` or buffer var referenced. */
50
+ resourceSource?: string;
37
51
  locations: CompileErrorLocation[];
38
52
  }
39
53
 
40
- // Lazy-init for SSR/Node.js compatibility (avoid browser APIs at module load)
54
+ /** Attributes accepted by `<wgsl-play>` beyond standard HTML.
55
+ * Source of truth for framework-specific JSX augmentations (see `./jsx-preact.ts`). */
56
+ export interface WgslPlayAttrs {
57
+ /** ID of a `<wgsl-edit>` (or compatible) element to source shader content from. */
58
+ from?: string;
59
+ /** Color theme; `"auto"` follows `prefers-color-scheme`. */
60
+ theme?: "light" | "dark" | "auto";
61
+ /** Allow drag-resize of the player via the corner handle. */
62
+ resizable?: boolean;
63
+ }
64
+
65
+ // Lazy-init for SSR/Node.js compatibility (avoid browser APIs at module load).
41
66
  let styles: CSSStyleSheet | null = null;
42
67
  let template: HTMLTemplateElement | null = null;
43
68
 
44
- /** <wgsl-play> web component for rendering WESL/WGSL fragment shaders. */
69
+ /** <wgsl-play> web component for rendering WESL/WGSL fragment shaders. */
45
70
  export class WgslPlay extends HTMLElement {
46
71
  static observedAttributes = [
47
72
  "src",
@@ -60,11 +85,14 @@ export class WgslPlay extends HTMLElement {
60
85
  ];
61
86
 
62
87
  private canvas: HTMLCanvasElement;
88
+ private resultsPanel: HTMLElement;
63
89
  private errorOverlay: ErrorOverlay;
64
90
  private controls: PlaybackControls;
65
91
  private settings: UniformControls;
66
92
  private resizeObserver: ResizeObserver;
67
93
  private stopRenderLoop?: () => void;
94
+ private _currentMode: "fragment" | "compute" = "fragment";
95
+ private _rerunPending = false;
68
96
 
69
97
  private renderState?: RenderState;
70
98
  private pendingUniforms = new Map<string, number | number[]>();
@@ -90,7 +118,8 @@ export class WgslPlay extends HTMLElement {
90
118
  private _onFullscreenChange = () =>
91
119
  this.controls.setFullscreen(!!document.fullscreenElement);
92
120
  private _pointerCleanup?: () => void;
93
- private _resizeCleanup?: () => void;
121
+ private _resizeCleanups: Array<() => void> = [];
122
+ private _childObserver?: MutationObserver;
94
123
 
95
124
  /** Get config overrides from element attributes. */
96
125
  private getConfigOverrides(): Partial<WgslPlayConfig> | undefined {
@@ -106,35 +135,42 @@ export class WgslPlay extends HTMLElement {
106
135
  shadow.appendChild(getTemplate().content.cloneNode(true));
107
136
 
108
137
  this.canvas = shadow.querySelector("canvas")!;
138
+ this.resultsPanel = shadow.querySelector(".results-panel") as HTMLElement;
109
139
  this.errorOverlay = new ErrorOverlay(shadow);
110
- this.settings = new UniformControls(shadow, (name, value) =>
111
- this.setUniform(name, value),
112
- );
140
+ this.settings = new UniformControls(shadow, (name, value) => {
141
+ this.setUniform(name, value);
142
+ if (this._currentMode === "compute") this.scheduleComputeRerun();
143
+ });
113
144
  this.controls = new PlaybackControls(
114
145
  shadow,
115
146
  () => this.play(),
116
147
  () => this.pause(),
117
148
  () => this.rewind(),
118
149
  () => this.toggleFullscreen(),
150
+ () => this.scheduleComputeRerun(),
119
151
  );
120
152
 
121
153
  this.resizeObserver = new ResizeObserver(entries => {
154
+ if (!this.renderState) return;
122
155
  if (this.hasAttribute("width") && this.hasAttribute("height")) return;
123
- for (const entry of entries) {
124
- const { width, height } = entry.contentRect;
125
- if (width > 0 && height > 0) {
126
- const ratio = this.pixelRatio;
127
- this.canvas.width = Math.floor(width * ratio);
128
- this.canvas.height = Math.floor(height * ratio);
129
- }
156
+ const entry = entries.at(-1);
157
+ if (!entry) return;
158
+ const ratio = this.getAttribute("pixel-ratio");
159
+ const [width, height] = entrySize(entry, ratio);
160
+ if (width > 0 && height > 0) {
161
+ const maxDim = this.renderState.device.limits.maxTextureDimension2D;
162
+ this.canvas.width = clampCanvas(width, maxDim);
163
+ this.canvas.height = clampCanvas(height, maxDim);
130
164
  }
131
165
  });
132
166
 
133
- this.setupResizeHandle(shadow.querySelector(".resize-handle")!);
167
+ const handles = shadow.querySelectorAll<HTMLElement>(".resize-handle");
168
+ for (const h of handles) {
169
+ this.setupResizeHandle(h, h.classList.contains("bl") ? "bl" : "br");
170
+ }
134
171
  }
135
172
 
136
173
  connectedCallback(): void {
137
- this.resizeObserver.observe(this);
138
174
  const themeAttr = this.getAttribute("theme") as typeof this._theme | null;
139
175
  if (themeAttr) this._theme = themeAttr;
140
176
  this._mediaQuery = matchMedia("(prefers-color-scheme: dark)");
@@ -146,20 +182,35 @@ export class WgslPlay extends HTMLElement {
146
182
  this.controls.setPlaying(false);
147
183
  }
148
184
  this.initialize();
185
+ this.observeLightDomChildren();
149
186
  upgradeProperty(this, "conditions");
150
187
  upgradeProperty(this, "shader");
151
188
  upgradeProperty(this, "project");
152
189
  }
153
190
 
191
+ /** Start watching element size. Deferred until after `initWebGPU` so the
192
+ * observer has a real `maxTextureDimension2D` to clamp against. */
193
+ private observeCanvasSize(): void {
194
+ try {
195
+ this.resizeObserver.observe(this, { box: "device-pixel-content-box" });
196
+ } catch {
197
+ this.resizeObserver.observe(this);
198
+ }
199
+ }
200
+
154
201
  disconnectedCallback(): void {
155
202
  this.resizeObserver.disconnect();
203
+ this._childObserver?.disconnect();
204
+ this._childObserver = undefined;
156
205
  this.stopRenderLoop?.();
157
206
  this._pointerCleanup?.();
158
- this._resizeCleanup?.();
207
+ for (const c of this._resizeCleanups) c();
208
+ this._resizeCleanups = [];
159
209
  document.removeEventListener("fullscreenchange", this._onFullscreenChange);
160
210
  if (this._sourceEl && this._sourceListener) {
161
211
  this._sourceEl.removeEventListener("change", this._sourceListener);
162
212
  }
213
+ if (this.renderState) disposeResources(this.renderState);
163
214
  }
164
215
 
165
216
  attributeChangedCallback(
@@ -171,17 +222,20 @@ export class WgslPlay extends HTMLElement {
171
222
 
172
223
  switch (name) {
173
224
  case "no-controls":
174
- newValue !== null ? this.controls.hide() : this.controls.show();
225
+ if (newValue !== null) this.controls.hide();
226
+ else this.controls.show();
175
227
  return;
176
228
  case "no-settings":
177
- newValue !== null ? this.settings.hide() : this.settings.show();
229
+ if (newValue !== null) this.settings.hide();
230
+ else this.settings.show();
178
231
  return;
179
232
  case "theme":
180
233
  this._theme = (newValue as typeof this._theme) || "auto";
181
234
  this.updateTheme();
182
235
  return;
183
236
  case "autoplay":
184
- newValue === "false" ? this.pause() : this.play();
237
+ if (newValue === "false") this.pause();
238
+ else this.play();
185
239
  return;
186
240
  case "fetch-libs":
187
241
  this._fetchLibs = newValue !== "false";
@@ -194,8 +248,6 @@ export class WgslPlay extends HTMLElement {
194
248
  return;
195
249
  case "width":
196
250
  case "height":
197
- this.updateCanvasSize();
198
- return;
199
251
  case "pixel-ratio":
200
252
  this.updateCanvasSize();
201
253
  return;
@@ -254,8 +306,7 @@ export class WgslPlay extends HTMLElement {
254
306
 
255
307
  set fetchLibs(value: boolean) {
256
308
  this._fetchLibs = value;
257
- if (value) this.removeAttribute("fetch-libs");
258
- else this.setAttribute("fetch-libs", "false");
309
+ this.setBoolAttr("fetch-libs", value);
259
310
  }
260
311
 
261
312
  /** Whether to fetch local .wesl source files via HTTP (default: true). */
@@ -265,8 +316,7 @@ export class WgslPlay extends HTMLElement {
265
316
 
266
317
  set fetchSources(value: boolean) {
267
318
  this._fetchSources = value;
268
- if (value) this.removeAttribute("fetch-sources");
269
- else this.setAttribute("fetch-sources", "false");
319
+ this.setBoolAttr("fetch-sources", value);
270
320
  }
271
321
 
272
322
  /** Whether autoplay is enabled (default: true). Set autoplay="false" to start paused. */
@@ -276,8 +326,7 @@ export class WgslPlay extends HTMLElement {
276
326
 
277
327
  set autoplay(value: boolean | string) {
278
328
  const enabled = value !== false && value !== "false";
279
- if (enabled) this.removeAttribute("autoplay");
280
- else this.setAttribute("autoplay", "false");
329
+ this.setBoolAttr("autoplay", enabled);
281
330
  }
282
331
 
283
332
  /** Scale factor from CSS pixels to canvas pixels (default: devicePixelRatio). */
@@ -330,9 +379,7 @@ export class WgslPlay extends HTMLElement {
330
379
  pause(): void {
331
380
  if (!this.playback.isPlaying) return;
332
381
  this.playback.pausedDuration = performance.now() - this.playback.startTime;
333
- this.stopRenderLoop?.();
334
- this.setPlaying(false);
335
- if (this.renderState) renderOnce(this.renderState, this.playback);
382
+ this.stopAndPresent();
336
383
  }
337
384
 
338
385
  private setPlaying(playing: boolean): void {
@@ -342,10 +389,47 @@ export class WgslPlay extends HTMLElement {
342
389
  this.dispatchEvent(new CustomEvent("playback-change", { detail }));
343
390
  }
344
391
 
392
+ /** Mirror a default-true setter onto its attribute: absent = on, "false" = off. */
393
+ private setBoolAttr(name: string, on: boolean): void {
394
+ if (on) this.removeAttribute(name);
395
+ else this.setAttribute(name, "false");
396
+ }
397
+
398
+ /** Wait for any in-flight build and render one frame to the canvas, resolving
399
+ * once it has been presented. Read the canvas via
400
+ * `element.shadowRoot.querySelector('canvas')`. */
401
+ async renderFrame(): Promise<void> {
402
+ await this.awaitIdleBuild();
403
+ if (!this.renderState) throw new Error("renderFrame: not initialized");
404
+ if (this.errorOverlay.visible) {
405
+ throw new Error("renderFrame: shader has compile error");
406
+ }
407
+ if (this._currentMode === "compute") {
408
+ throw new Error("renderFrame: compute mode has no canvas output");
409
+ }
410
+ renderOnce(this.renderState, this.playback);
411
+ await new Promise<void>(r => requestAnimationFrame(() => r()));
412
+ }
413
+
414
+ /** Resolve once initialization and any pending build have settled. Yields via
415
+ * setTimeout so async build steps (network fetch, GPU validation) get a
416
+ * chance to advance between polls. */
417
+ private async awaitIdleBuild(): Promise<void> {
418
+ await this._initPromise;
419
+ while (this._building || this._dirty) {
420
+ await new Promise<void>(r => setTimeout(r, 0));
421
+ }
422
+ }
423
+
345
424
  /** Reset animation to time 0 and pause. */
346
425
  rewind(): void {
347
426
  this.playback.startTime = performance.now();
348
427
  this.playback.pausedDuration = 0;
428
+ this.stopAndPresent();
429
+ }
430
+
431
+ /** Stop the RAF loop, mark paused, and re-present the current frame. */
432
+ private stopAndPresent(): void {
349
433
  this.stopRenderLoop?.();
350
434
  this.setPlaying(false);
351
435
  if (this.renderState) renderOnce(this.renderState, this.playback);
@@ -369,17 +453,20 @@ export class WgslPlay extends HTMLElement {
369
453
  renderOnce(this.renderState, this.playback);
370
454
  }
371
455
 
456
+ /** Apply uniforms set before the pipeline existed. */
372
457
  private flushPendingUniforms(): void {
373
- const map = this.renderState?.uniformState.controlValues;
374
- if (!map) return;
375
- for (const [k, v] of this.pendingUniforms) map.set(k, v);
458
+ const controlValues = this.renderState?.uniformState.controlValues;
459
+ if (!controlValues) return;
460
+ for (const [name, value] of this.pendingUniforms) {
461
+ controlValues.set(name, value);
462
+ }
376
463
  }
377
464
 
378
465
  /** Current uniform control values (readable). */
379
466
  get uniforms(): Record<string, number | number[]> {
380
- const map = this.renderState?.uniformState.controlValues;
381
- if (!map) return {};
382
- return Object.fromEntries(map);
467
+ const controlValues = this.renderState?.uniformState.controlValues;
468
+ if (!controlValues) return {};
469
+ return Object.fromEntries(controlValues);
383
470
  }
384
471
 
385
472
  /** Toggle fullscreen on this element. */
@@ -421,56 +508,75 @@ export class WgslPlay extends HTMLElement {
421
508
  }
422
509
 
423
510
  /** Drag-to-resize via a custom handle (works on touch + mouse). */
424
- private setupResizeHandle(handle: HTMLElement): void {
511
+ private setupResizeHandle(handle: HTMLElement, corner: "br" | "bl"): void {
512
+ // Negate horizontal delta for the left handle so `w = startW + dx` and
513
+ // the aspect-ratio comparison both work unchanged for either corner.
514
+ const xSign = corner === "bl" ? -1 : 1;
425
515
  let startX = 0;
426
516
  let startY = 0;
427
517
  let startW = 0;
428
518
  let startH = 0;
519
+ /** Numeric aspect ratio (w/h) sampled at drag start, or null if `auto`. */
520
+ let ratio: number | null = null;
429
521
 
430
522
  const onMove = (e: PointerEvent): void => {
431
- this.style.width = `${startW + e.clientX - startX}px`;
432
- this.style.height = `${startH + e.clientY - startY}px`;
523
+ const dx = (e.clientX - startX) * xSign;
524
+ const dy = e.clientY - startY;
525
+ let w = startW + dx;
526
+ let h = startH + dy;
527
+ if (ratio !== null) {
528
+ // Honor consumer's CSS aspect-ratio: drive the size from whichever
529
+ // axis the user moved more, derive the other from the ratio.
530
+ if (Math.abs(dx) >= Math.abs(dy)) h = w / ratio;
531
+ else w = h * ratio;
532
+ }
533
+ this.style.width = `${Math.max(16, w)}px`;
534
+ this.style.height = `${Math.max(16, h)}px`;
433
535
  };
434
536
  const onUp = (): void => {
537
+ handle.classList.remove("dragging");
435
538
  document.removeEventListener("pointermove", onMove);
436
539
  document.removeEventListener("pointerup", onUp);
437
540
  };
438
541
  const onDown = (e: PointerEvent): void => {
439
542
  e.preventDefault();
440
543
  handle.setPointerCapture(e.pointerId);
544
+ handle.classList.add("dragging");
441
545
  const rect = this.getBoundingClientRect();
442
546
  startX = e.clientX;
443
547
  startY = e.clientY;
444
548
  startW = rect.width;
445
549
  startH = rect.height;
550
+ ratio = parseAspectRatio(getComputedStyle(this).aspectRatio);
446
551
  document.addEventListener("pointermove", onMove);
447
552
  document.addEventListener("pointerup", onUp);
448
553
  };
449
554
 
450
555
  handle.addEventListener("pointerdown", onDown);
451
- this._resizeCleanup = () => {
556
+ this._resizeCleanups.push(() => {
452
557
  handle.removeEventListener("pointerdown", onDown);
453
558
  onUp();
454
- };
559
+ });
455
560
  }
456
561
 
457
562
  /** Recompute canvas resolution from attributes or CSS size. */
458
563
  private updateCanvasSize(): void {
459
564
  const w = this.getAttribute("width");
460
565
  const h = this.getAttribute("height");
566
+ const maxDim = this.renderState?.device.limits.maxTextureDimension2D;
461
567
  if (w !== null && h !== null) {
462
- this.canvas.width = Number(w);
463
- this.canvas.height = Number(h);
464
- } else {
465
- const rect = this.getBoundingClientRect();
466
- if (rect.width > 0 && rect.height > 0) {
467
- const ratio = this.pixelRatio;
468
- this.canvas.width = Math.floor(rect.width * ratio);
469
- this.canvas.height = Math.floor(rect.height * ratio);
470
- }
568
+ this.canvas.width = clampCanvas(Number(w), maxDim);
569
+ this.canvas.height = clampCanvas(Number(h), maxDim);
570
+ return;
471
571
  }
572
+ const rect = this.getBoundingClientRect();
573
+ if (rect.width <= 0 || rect.height <= 0) return;
574
+ const ratio = this.pixelRatio;
575
+ this.canvas.width = clampCanvas(rect.width * ratio, maxDim);
576
+ this.canvas.height = clampCanvas(rect.height * ratio, maxDim);
472
577
  }
473
578
 
579
+ /** Toggle the `dark` class to match `theme` attr or system preference. */
474
580
  private updateTheme(): void {
475
581
  const isDark =
476
582
  this._theme === "dark" ||
@@ -486,11 +592,13 @@ export class WgslPlay extends HTMLElement {
486
592
  return this._initPromise;
487
593
  }
488
594
 
595
+ /** Init WebGPU, wire observers/listeners, and dispatch ready/init-error. */
489
596
  private async doInitialize(): Promise<boolean> {
490
597
  try {
491
598
  const transparent = this.hasAttribute("transparent");
492
599
  const alphaMode = transparent ? "premultiplied" : "opaque";
493
600
  this.renderState = await initWebGPU(this.canvas, alphaMode);
601
+ this.observeCanvasSize();
494
602
  this.setupMouseTracking();
495
603
  this.loadInitialContent();
496
604
  if (this.playback.isPlaying) {
@@ -564,10 +672,8 @@ export class WgslPlay extends HTMLElement {
564
672
  private async loadFromUrl(url: string): Promise<void> {
565
673
  try {
566
674
  const shaderRoot = this.getConfigOverrides()?.shaderRoot;
567
- const { weslSrc, libs, rootModuleName } = await loadShaderFromUrl(
568
- url,
569
- shaderRoot,
570
- );
675
+ const result = await loadShaderFromUrl(url, shaderRoot);
676
+ const { weslSrc, libs, rootModuleName } = result;
571
677
  this._weslSrc = weslSrc;
572
678
  this._libs = libs;
573
679
  if (rootModuleName) this._rootModuleName = rootModuleName;
@@ -588,7 +694,8 @@ export class WgslPlay extends HTMLElement {
588
694
  this._building = true;
589
695
  while (this._dirty) {
590
696
  this._dirty = false;
591
- if (!(await this.initialize())) break;
697
+ const initialized = await this.initialize();
698
+ if (!initialized) break;
592
699
 
593
700
  const mainSource = this._weslSrc[this._rootModuleName];
594
701
  if (!mainSource) {
@@ -601,8 +708,8 @@ export class WgslPlay extends HTMLElement {
601
708
 
602
709
  try {
603
710
  this.errorOverlay.hide();
604
- const layout = await this.buildPipeline(mainSource);
605
- if (!this._dirty) this.applyBuild(layout);
711
+ const result = await this.buildPipeline(mainSource);
712
+ if (!this._dirty) this.applyBuild(result);
606
713
  } catch (error) {
607
714
  if (!this._dirty) this.handleCompileError(error);
608
715
  }
@@ -611,9 +718,7 @@ export class WgslPlay extends HTMLElement {
611
718
  }
612
719
 
613
720
  /** Fetch deps if needed and create the render pipeline. */
614
- private async buildPipeline(
615
- mainSource: string,
616
- ): Promise<AnnotatedLayout | null> {
721
+ private async buildPipeline(mainSource: string): Promise<BuildResult> {
617
722
  if (this._fetchSources || this._fetchLibs) {
618
723
  const { weslSrc, libs } = await fetchDependencies(mainSource, {
619
724
  shaderRoot: this.getConfigOverrides()?.shaderRoot,
@@ -624,31 +729,115 @@ export class WgslPlay extends HTMLElement {
624
729
  this._weslSrc = { ...this._weslSrc, ...weslSrc };
625
730
  this._libs = dedupLibs(this._libs, libs);
626
731
  }
627
- return createPipeline(this.renderState!, mainSource, {
628
- ...this._linkOptions,
629
- weslSrc: this._weslSrc,
630
- libs: this._libs,
631
- rootModuleName: this._rootModuleName,
732
+ return createPipeline(
733
+ this.renderState!,
734
+ mainSource,
735
+ source => this.resolveHostTexture(source),
736
+ {
737
+ ...this._linkOptions,
738
+ weslSrc: this._weslSrc,
739
+ libs: this._libs,
740
+ rootModuleName: this._rootModuleName,
741
+ },
742
+ );
743
+ }
744
+
745
+ /** Resolve a @texture(name) to a decoded image from light-DOM children.
746
+ * Prefers [data-texture="name"], falls back to #id. */
747
+ private resolveHostTexture: ResolveUserTexture = async source => {
748
+ const selector = `[data-texture="${cssEscape(source)}"], #${cssEscape(source)}`;
749
+ const el = this.querySelector<HTMLElement>(selector);
750
+ if (!el) return null;
751
+ if (el instanceof HTMLImageElement) return decodeImage(el, source);
752
+ if (el instanceof HTMLCanvasElement) return el;
753
+ throw new ResourceLoadError(
754
+ `@texture(${source}): matched element is a <${el.tagName.toLowerCase()}>; expected <img> or <canvas>`,
755
+ source,
756
+ );
757
+ };
758
+
759
+ /** Watch light-DOM for <img> add/remove/src changes and trigger a rebuild. */
760
+ private observeLightDomChildren(): void {
761
+ if (this._childObserver) return;
762
+ this._childObserver = new MutationObserver(records => {
763
+ for (const r of records) {
764
+ if (r.type === "attributes") {
765
+ this.requestBuild();
766
+ return;
767
+ }
768
+ for (const node of [...r.addedNodes, ...r.removedNodes]) {
769
+ if (node instanceof HTMLImageElement) {
770
+ this.requestBuild();
771
+ return;
772
+ }
773
+ }
774
+ }
775
+ });
776
+ this._childObserver.observe(this, {
777
+ childList: true,
778
+ subtree: true,
779
+ attributes: true,
780
+ attributeFilter: ["src", "id", "data-texture"],
632
781
  });
633
782
  }
634
783
 
635
784
  /** Apply a successful build: flush uniforms, update controls, render. */
636
- private applyBuild(layout: AnnotatedLayout | null): void {
785
+ private applyBuild(result: BuildResult): void {
637
786
  this.flushPendingUniforms();
638
787
  const controls = this.renderState!.uniformState.layout.controls;
639
788
  this.settings.setControls(controls);
640
- if (!this.playback.isPlaying) renderOnce(this.renderState!, this.playback);
789
+ this.applyMode(result);
641
790
  this.dispatchEvent(new CustomEvent("compile-success"));
642
- this.dispatchEvent(new CustomEvent("uniforms-layout", { detail: layout }));
791
+ const detail = result.layout;
792
+ this.dispatchEvent(new CustomEvent("uniforms-layout", { detail }));
793
+ }
794
+
795
+ /** Show canvas vs results panel and (re-)render based on build mode. */
796
+ private applyMode(result: BuildResult): void {
797
+ this._currentMode = result.mode;
798
+ if (result.mode === "compute") {
799
+ this.canvas.hidden = true;
800
+ this.resultsPanel.hidden = false;
801
+ this.controls.setMode("compute");
802
+ const entries = result.computeReadback ?? [];
803
+ renderResultsPanel({ panel: this.resultsPanel, entries });
804
+ return;
805
+ }
806
+ this.canvas.hidden = false;
807
+ this.resultsPanel.hidden = true;
808
+ this.controls.setMode("render");
809
+ if (!this.playback.isPlaying) renderOnce(this.renderState!, this.playback);
810
+ }
811
+
812
+ /** Coalesce rapid uniform/refresh events into a single re-dispatch. */
813
+ private scheduleComputeRerun(): void {
814
+ if (this._rerunPending) return;
815
+ this._rerunPending = true;
816
+ queueMicrotask(async () => {
817
+ this._rerunPending = false;
818
+ if (this._currentMode !== "compute" || !this.renderState) return;
819
+ try {
820
+ this.flushPendingUniforms();
821
+ const entries = await rerunCompute(this.renderState);
822
+ renderResultsPanel({ panel: this.resultsPanel, entries });
823
+ } catch (error) {
824
+ this.handleCompileError(error);
825
+ }
826
+ });
643
827
  }
644
828
 
829
+ /** Show the error overlay and dispatch a `compile-error` event. */
645
830
  private handleCompileError(error: unknown): void {
646
831
  const message = (error as any)?.message ?? String(error);
647
832
  this.errorOverlay.show(message);
648
833
 
649
834
  const source = error instanceof WeslParseError ? "wesl" : "webgpu";
835
+ const kind = error instanceof ResourceLoadError ? "resource" : "shader";
650
836
  const locations = this.extractLocations(error);
651
- const detail: CompileErrorDetail = { message, source, locations };
837
+ const detail: CompileErrorDetail = { message, source, kind, locations };
838
+ if (error instanceof ResourceLoadError) {
839
+ detail.resourceSource = error.resourceSource;
840
+ }
652
841
  this.dispatchEvent(new CustomEvent("compile-error", { detail }));
653
842
  }
654
843
 
@@ -658,27 +847,21 @@ export class WgslPlay extends HTMLElement {
658
847
  const loc = (error as any)?.weslLocation;
659
848
  if (loc) {
660
849
  const message = (error as any)?.message ?? "";
850
+ const { file, line, column, length, offset } = loc;
851
+ const severity = "error" as const;
661
852
  return [
662
- {
663
- file: loc.file,
664
- line: loc.line,
665
- column: loc.column - 1,
666
- length: loc.length,
667
- severity: "error" as const,
668
- message,
669
- },
853
+ { file, line, column: column - 1, length, offset, severity, message },
670
854
  ];
671
855
  }
672
856
  // GPU compilation errors have multiple messages
673
857
  const msgs = (error as any)?.compilationInfo?.messages;
674
858
  if (msgs) {
675
- const toSeverity = (t: string) =>
676
- t === "warning" ? "warning" : t === "info" ? "info" : "error";
677
859
  return msgs.map((m: any) => ({
678
860
  file: m.module?.url,
679
861
  line: m.lineNum,
680
862
  column: m.linePos - 1,
681
863
  length: m.length,
864
+ offset: m.offset,
682
865
  severity: toSeverity(m.type),
683
866
  message: m.message,
684
867
  }));
@@ -687,14 +870,6 @@ export class WgslPlay extends HTMLElement {
687
870
  }
688
871
  }
689
872
 
690
- function getTemplate(): HTMLTemplateElement {
691
- if (!template) {
692
- template = document.createElement("template");
693
- template.innerHTML = `<canvas part="canvas"></canvas><div class="resize-handle"></div>`;
694
- }
695
- return template;
696
- }
697
-
698
873
  function getStyles(): CSSStyleSheet {
699
874
  if (!styles) {
700
875
  styles = new CSSStyleSheet();
@@ -703,8 +878,16 @@ function getStyles(): CSSStyleSheet {
703
878
  return styles;
704
879
  }
705
880
 
706
- // LATER: extract to shared web component utils if we add more helpers
707
- /** Absorb instance properties set before custom element upgrade. */
881
+ function getTemplate(): HTMLTemplateElement {
882
+ if (!template) {
883
+ template = document.createElement("template");
884
+ 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>`;
885
+ }
886
+ return template;
887
+ }
888
+
889
+ /** Absorb instance properties set before custom element upgrade.
890
+ * Duplicated in WgslEdit.ts; extract to a shared package later. */
708
891
  function upgradeProperty(el: HTMLElement, prop: string): void {
709
892
  if (Object.hasOwn(el, prop)) {
710
893
  const value = (el as any)[prop];
@@ -713,6 +896,27 @@ function upgradeProperty(el: HTMLElement, prop: string): void {
713
896
  }
714
897
  }
715
898
 
899
+ /** Normalize all keys in a weslSrc record to module paths. */
900
+ function toModulePaths(
901
+ weslSrc: Record<string, string>,
902
+ pkg: string,
903
+ ): Record<string, string> {
904
+ const entries = Object.entries(weslSrc).map(
905
+ ([key, value]) => [fileToModulePath(key, pkg, false), value] as const,
906
+ );
907
+ return Object.fromEntries(entries);
908
+ }
909
+
910
+ /** Parse `getComputedStyle().aspectRatio` (`"auto"`, `"1"`, `"16 / 9"`) to w/h. */
911
+ function parseAspectRatio(value: string): number | null {
912
+ if (!value || value === "auto") return null;
913
+ const [w, h = "1"] = value.split("/").map(s => s.trim());
914
+ const wNum = Number(w);
915
+ const hNum = Number(h);
916
+ if (!wNum || !hNum) return null;
917
+ return wNum / hNum;
918
+ }
919
+
716
920
  /** Merge new libs, deduplicating by bundle name. */
717
921
  function dedupLibs(
718
922
  existing: WeslBundle[] | undefined,
@@ -724,13 +928,52 @@ function dedupLibs(
724
928
  return [...existing.filter(b => !names.has(b.name)), ...newLibs];
725
929
  }
726
930
 
727
- /** Normalize all keys in a weslSrc record to module paths. */
728
- function toModulePaths(
729
- weslSrc: Record<string, string>,
730
- pkg: string,
731
- ): Record<string, string> {
732
- const entries = Object.entries(weslSrc).map(
733
- ([key, value]) => [fileToModulePath(key, pkg, false), value] as const,
734
- );
735
- return Object.fromEntries(entries);
931
+ /** Escape a value for safe use as an attribute-selector literal or id selector. */
932
+ function cssEscape(value: string): string {
933
+ if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(value);
934
+ return value.replace(/[^a-zA-Z0-9_-]/g, ch => `\\${ch}`);
935
+ }
936
+
937
+ /** Decode an <img> to an ImageBitmap with deterministic upload flags. */
938
+ async function decodeImage(
939
+ el: HTMLImageElement,
940
+ source: string,
941
+ ): Promise<ImageBitmap> {
942
+ try {
943
+ if (!el.complete) await waitForImageLoad(el);
944
+ if (el.decode) await el.decode();
945
+ return await createImageBitmap(el, {
946
+ imageOrientation: "from-image",
947
+ premultiplyAlpha: "none",
948
+ colorSpaceConversion: "none",
949
+ });
950
+ } catch (err) {
951
+ const detail = err instanceof Error ? err.message : String(err);
952
+ throw new ResourceLoadError(
953
+ `@texture(${source}): failed to decode <img src="${el.src}"> — ${detail}`,
954
+ source,
955
+ );
956
+ }
957
+ }
958
+
959
+ /** Map a GPU compilation message `type` field to our severity enum. */
960
+ function toSeverity(type: string): "error" | "warning" | "info" {
961
+ if (type === "warning") return "warning";
962
+ if (type === "info") return "info";
963
+ return "error";
964
+ }
965
+
966
+ function waitForImageLoad(el: HTMLImageElement): Promise<void> {
967
+ return new Promise((resolve, reject) => {
968
+ const onLoad = () => {
969
+ el.removeEventListener("error", onError);
970
+ resolve();
971
+ };
972
+ const onError = () => {
973
+ el.removeEventListener("load", onLoad);
974
+ reject(new Error("image load failed"));
975
+ };
976
+ el.addEventListener("load", onLoad, { once: true });
977
+ el.addEventListener("error", onError, { once: true });
978
+ });
736
979
  }