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.
- package/README.md +59 -2
- package/dist/{WgslPlay-CSjRo-5Z.d.ts → WgslPlay-ouKx64GN.d.ts} +52 -3
- package/dist/WgslPlay-zK9mE5rV.js +1544 -0
- package/dist/WgslPlay.d.ts +2 -2
- package/dist/WgslPlay.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/jsx-preact.d.ts +12 -0
- package/dist/jsx-preact.js +0 -0
- package/dist/wgsl-play.js +1834 -754
- package/package.json +12 -8
- package/src/CanvasSize.ts +27 -0
- package/src/ComputeBuild.ts +151 -0
- package/src/FragmentRender.ts +147 -0
- package/src/PlaybackControls.ts +36 -13
- package/src/RenderResources.ts +132 -0
- package/src/Renderer.ts +112 -138
- package/src/ResultsPanel.ts +261 -0
- package/src/WgslPlay.css +80 -8
- package/src/WgslPlay.ts +346 -103
- package/src/icons/refresh.svg +1 -0
- package/src/jsx-preact.ts +15 -0
- package/src/test/BundleHydrator.test.ts +8 -11
- package/src/test/CanvasSize.test.ts +48 -0
- package/src/test/E2eHelpers.ts +117 -0
- package/src/test/RenderFrame.e2e.ts +33 -0
- package/src/test/ResultsPanel.test.ts +164 -0
- package/src/test/WgslPlay.basics.e2e.ts +87 -0
- package/src/test/WgslPlay.compute.e2e.ts +147 -0
- package/src/test/WgslPlay.connect.e2e.ts +101 -0
- package/src/test/WgslPlay.loading.e2e.ts +68 -0
- package/src/test/WgslPlay.resize.e2e.ts +199 -0
- package/src/test/WgslPlay.resources.e2e.ts +67 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/buffer-zero-init-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-grid-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-green-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-magenta-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-multi-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.uniforms.e2e.ts +57 -0
- package/dist/WgslPlay-LsU6XE09.js +0 -933
- package/src/test/WgslPlay.e2e.ts +0 -327
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/basic-shader-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-after-red-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-initial-green-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-green-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-red-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-dynamic-npm-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-external-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-multifile-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/link-import-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/npm-cdn-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-internal-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-src-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/static-import-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-initial-chromium-darwin.png +0 -0
- /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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
225
|
+
if (newValue !== null) this.controls.hide();
|
|
226
|
+
else this.controls.show();
|
|
175
227
|
return;
|
|
176
228
|
case "no-settings":
|
|
177
|
-
newValue !== null
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
374
|
-
if (!
|
|
375
|
-
for (const [
|
|
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
|
|
381
|
-
if (!
|
|
382
|
-
return Object.fromEntries(
|
|
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
|
-
|
|
432
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
568
|
-
|
|
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
|
-
|
|
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
|
|
605
|
-
if (!this._dirty) this.applyBuild(
|
|
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(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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(
|
|
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
|
-
|
|
789
|
+
this.applyMode(result);
|
|
641
790
|
this.dispatchEvent(new CustomEvent("compile-success"));
|
|
642
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
-
/**
|
|
728
|
-
function
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
}
|