wgsl-play 0.0.36 → 0.0.38
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 +7 -5
- package/dist/WgslPlay-BuIuE3cB.js +620 -0
- package/dist/WgslPlay-uTIOrTQq.d.ts +120 -0
- package/dist/WgslPlay.d.ts +2 -109
- package/dist/WgslPlay.js +2 -443
- package/dist/index.d.ts +2 -3
- package/dist/index.js +3 -7
- package/dist/wgsl-play.js +63 -175
- package/package.json +4 -4
- package/src/WgslPlay.ts +43 -72
- package/dist/Config-BV15Y2ZJ.d.ts +0 -14
- package/dist/WgslPlay-BRvURGA3.js +0 -209
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ You can include shader code inline if you'd prefer. Use a `<script type="text/wg
|
|
|
56
56
|
|
|
57
57
|
```typescript
|
|
58
58
|
const player = document.querySelector("wgsl-play");
|
|
59
|
-
player.
|
|
59
|
+
player.shader = shaderCode;
|
|
60
60
|
player.pause();
|
|
61
61
|
player.rewind();
|
|
62
62
|
player.play();
|
|
@@ -68,7 +68,7 @@ player.play();
|
|
|
68
68
|
import shader from './examples/noise.wesl?raw';
|
|
69
69
|
|
|
70
70
|
const player = document.querySelector("wgsl-play");
|
|
71
|
-
player.
|
|
71
|
+
player.shader = shader;
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
The `?raw` suffix imports the file as a string. This keeps shaders alongside your source files with HMR support.
|
|
@@ -80,12 +80,14 @@ The `?raw` suffix imports the file as a string. This keeps shaders alongside you
|
|
|
80
80
|
- `shader-root` - Root path for internal imports (default: `/shaders`)
|
|
81
81
|
- `autoplay` - Start animating on load (default: `true`). Set `autoplay="false"` to start paused
|
|
82
82
|
- `transparent` - Use premultiplied alpha for transparent backgrounds (default: opaque)
|
|
83
|
+
- `from` - Element ID of a source provider (e.g., wgsl-edit) to connect to
|
|
83
84
|
- `fetch-libs` - Auto-fetch missing libraries from npm (default: `true`). Set `fetch-libs="false"` to disable
|
|
85
|
+
- `fetch-sources` - Auto-fetch local .wesl source files via HTTP (default: `true`). Set `fetch-sources="false"` to disable
|
|
84
86
|
|
|
85
87
|
### Properties
|
|
86
|
-
- `
|
|
88
|
+
- `shader: string` - Get/set shader source (single-file convenience)
|
|
87
89
|
- `conditions: Record<string, boolean>` - Get/set conditions for conditional compilation (`@if`/`@elif`/`@else`)
|
|
88
|
-
- `project: WeslProject` -
|
|
90
|
+
- `project: WeslProject` - Get/set full project config (weslSrc, libs, conditions, constants)
|
|
89
91
|
- `isPlaying: boolean` - Playback state (readonly)
|
|
90
92
|
- `time: number` - Animation time in seconds (readonly)
|
|
91
93
|
- `hasError: boolean` - Compilation error state (readonly)
|
|
@@ -147,7 +149,7 @@ import super::common::tint;
|
|
|
147
149
|
|
|
148
150
|
## Using with wesl-plugin
|
|
149
151
|
|
|
150
|
-
For more control, use the [wesl-plugin](https://github.com/wgsl-tooling-wg/wesl-js/tree/main/
|
|
152
|
+
For more control, use the [wesl-plugin](https://github.com/wgsl-tooling-wg/wesl-js/tree/main/packages/wesl-plugin) to
|
|
151
153
|
assemble shaders and libraries at build time and provide
|
|
152
154
|
them wgsl-play in JavaScript or TypeScript.
|
|
153
155
|
- provides full support for Hot Module Reloading during development
|
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { fetchDependencies, loadShaderFromUrl } from "wesl-fetch";
|
|
2
|
+
import { WeslParseError, fileToModulePath, requestWeslDevice } from "wesl";
|
|
3
|
+
import { linkAndCreatePipeline, renderFrame, updateRenderUniforms } from "wesl-gpu";
|
|
4
|
+
//#region src/Config.ts
|
|
5
|
+
const defaultConfig = { shaderRoot: "/shaders" };
|
|
6
|
+
let globalConfig = { ...defaultConfig };
|
|
7
|
+
/** Set global defaults for all wgsl-play instances. */
|
|
8
|
+
function defaults(config) {
|
|
9
|
+
globalConfig = {
|
|
10
|
+
...globalConfig,
|
|
11
|
+
...config
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Get resolved config, merging element overrides with global defaults. */
|
|
15
|
+
function getConfig(overrides) {
|
|
16
|
+
return {
|
|
17
|
+
...globalConfig,
|
|
18
|
+
...overrides
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Reset config to defaults (useful for testing). */
|
|
22
|
+
function resetConfig() {
|
|
23
|
+
globalConfig = { ...defaultConfig };
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/ErrorOverlay.ts
|
|
27
|
+
/** Manages an error overlay element within a shadow DOM. */
|
|
28
|
+
var ErrorOverlay = class {
|
|
29
|
+
el;
|
|
30
|
+
_message = null;
|
|
31
|
+
constructor(container) {
|
|
32
|
+
this.el = document.createElement("div");
|
|
33
|
+
this.el.className = "error-overlay";
|
|
34
|
+
container.appendChild(this.el);
|
|
35
|
+
}
|
|
36
|
+
show(message) {
|
|
37
|
+
this._message = message;
|
|
38
|
+
this.el.textContent = message;
|
|
39
|
+
this.el.classList.add("visible");
|
|
40
|
+
console.error("[wgsl-play]", message);
|
|
41
|
+
}
|
|
42
|
+
hide() {
|
|
43
|
+
this._message = null;
|
|
44
|
+
this.el.classList.remove("visible");
|
|
45
|
+
}
|
|
46
|
+
get visible() {
|
|
47
|
+
return this.el.classList.contains("visible");
|
|
48
|
+
}
|
|
49
|
+
get message() {
|
|
50
|
+
return this._message;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/icons/backToStart.svg?raw
|
|
55
|
+
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";
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/icons/expand.svg?raw
|
|
58
|
+
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";
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/icons/pause.svg?raw
|
|
61
|
+
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";
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/icons/play.svg?raw
|
|
64
|
+
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";
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/icons/shrink.svg?raw
|
|
67
|
+
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";
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/PlaybackControls.ts
|
|
70
|
+
/** Playback controls overlay for wgsl-play. */
|
|
71
|
+
var PlaybackControls = class {
|
|
72
|
+
container;
|
|
73
|
+
playPauseBtn;
|
|
74
|
+
fullscreenBtn;
|
|
75
|
+
playing = true;
|
|
76
|
+
constructor(shadow, onPlay, onPause, onRewind, onFullscreen) {
|
|
77
|
+
this.container = document.createElement("div");
|
|
78
|
+
this.container.className = "controls";
|
|
79
|
+
this.fullscreenBtn = document.createElement("button");
|
|
80
|
+
this.fullscreenBtn.innerHTML = expand_default;
|
|
81
|
+
this.fullscreenBtn.addEventListener("click", onFullscreen);
|
|
82
|
+
const rewindBtn = document.createElement("button");
|
|
83
|
+
rewindBtn.innerHTML = backToStart_default;
|
|
84
|
+
rewindBtn.addEventListener("click", onRewind);
|
|
85
|
+
this.playPauseBtn = document.createElement("button");
|
|
86
|
+
this.playPauseBtn.innerHTML = pause_default;
|
|
87
|
+
this.playPauseBtn.addEventListener("click", () => this.playing ? onPause() : onPlay());
|
|
88
|
+
this.container.append(this.fullscreenBtn, rewindBtn, this.playPauseBtn);
|
|
89
|
+
shadow.appendChild(this.container);
|
|
90
|
+
}
|
|
91
|
+
setPlaying(playing) {
|
|
92
|
+
this.playing = playing;
|
|
93
|
+
this.playPauseBtn.innerHTML = playing ? pause_default : play_default;
|
|
94
|
+
}
|
|
95
|
+
setFullscreen(isFullscreen) {
|
|
96
|
+
this.fullscreenBtn.innerHTML = isFullscreen ? shrink_default : expand_default;
|
|
97
|
+
}
|
|
98
|
+
show() {
|
|
99
|
+
this.container.style.display = "";
|
|
100
|
+
}
|
|
101
|
+
hide() {
|
|
102
|
+
this.container.style.display = "none";
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/Renderer.ts
|
|
107
|
+
/** Initialize WebGPU for a canvas element. */
|
|
108
|
+
async function initWebGPU(canvas, alphaMode = "opaque") {
|
|
109
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
110
|
+
if (!adapter) throw new Error("WebGPU adapter not available");
|
|
111
|
+
const device = await requestWeslDevice(adapter);
|
|
112
|
+
const context = canvas.getContext("webgpu");
|
|
113
|
+
if (!context) throw new Error("WebGPU context not available");
|
|
114
|
+
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
|
|
115
|
+
context.configure({
|
|
116
|
+
device,
|
|
117
|
+
format: presentationFormat,
|
|
118
|
+
alphaMode
|
|
119
|
+
});
|
|
120
|
+
const uniformBuffer = device.createBuffer({
|
|
121
|
+
size: 32,
|
|
122
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
123
|
+
});
|
|
124
|
+
const bindGroupLayout = device.createBindGroupLayout({ entries: [{
|
|
125
|
+
binding: 0,
|
|
126
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
127
|
+
buffer: {}
|
|
128
|
+
}] });
|
|
129
|
+
return {
|
|
130
|
+
device,
|
|
131
|
+
canvas,
|
|
132
|
+
context,
|
|
133
|
+
presentationFormat,
|
|
134
|
+
uniformBuffer,
|
|
135
|
+
pipelineLayout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
|
|
136
|
+
bindGroup: device.createBindGroup({
|
|
137
|
+
layout: bindGroupLayout,
|
|
138
|
+
entries: [{
|
|
139
|
+
binding: 0,
|
|
140
|
+
resource: { buffer: uniformBuffer }
|
|
141
|
+
}]
|
|
142
|
+
}),
|
|
143
|
+
frameCount: 0
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Compile WESL fragment shader and create render pipeline. */
|
|
147
|
+
async function createPipeline(state, fragmentSource, options) {
|
|
148
|
+
state.device.pushErrorScope("validation");
|
|
149
|
+
let gpuError;
|
|
150
|
+
let jsError;
|
|
151
|
+
try {
|
|
152
|
+
state.pipeline = await linkAndCreatePipeline({
|
|
153
|
+
device: state.device,
|
|
154
|
+
fragmentSource,
|
|
155
|
+
format: state.presentationFormat,
|
|
156
|
+
layout: state.pipelineLayout,
|
|
157
|
+
...options
|
|
158
|
+
});
|
|
159
|
+
} catch (e) {
|
|
160
|
+
jsError = e;
|
|
161
|
+
} finally {
|
|
162
|
+
gpuError = await state.device.popErrorScope();
|
|
163
|
+
}
|
|
164
|
+
if (jsError || gpuError) {
|
|
165
|
+
state.pipeline = void 0;
|
|
166
|
+
throw jsError ?? gpuError;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Start the render loop. Returns a stop function. */
|
|
170
|
+
function startRenderLoop(state, playback) {
|
|
171
|
+
let animationId;
|
|
172
|
+
function render() {
|
|
173
|
+
if (!state.pipeline) {
|
|
174
|
+
animationId = requestAnimationFrame(render);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const time = calculateTime(playback);
|
|
178
|
+
const resolution = [state.canvas.width, state.canvas.height];
|
|
179
|
+
updateRenderUniforms(state.uniformBuffer, state.device, resolution, time, [0, 0]);
|
|
180
|
+
renderFrame({
|
|
181
|
+
device: state.device,
|
|
182
|
+
pipeline: state.pipeline,
|
|
183
|
+
bindGroup: state.bindGroup,
|
|
184
|
+
targetView: state.context.getCurrentTexture().createView()
|
|
185
|
+
});
|
|
186
|
+
state.frameCount++;
|
|
187
|
+
animationId = requestAnimationFrame(render);
|
|
188
|
+
}
|
|
189
|
+
animationId = requestAnimationFrame(render);
|
|
190
|
+
return () => cancelAnimationFrame(animationId);
|
|
191
|
+
}
|
|
192
|
+
function calculateTime(playback) {
|
|
193
|
+
return ((playback.isPlaying ? performance.now() : playback.startTime + playback.pausedDuration) - playback.startTime) / 1e3;
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/WgslPlay.css?inline
|
|
197
|
+
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.4);\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: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 right: 8px;\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.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";
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/WgslPlay.ts
|
|
200
|
+
let styles = null;
|
|
201
|
+
let template = null;
|
|
202
|
+
/** <wgsl-play> web component for rendering WESL/WGSL fragment shaders. */
|
|
203
|
+
var WgslPlay = class extends HTMLElement {
|
|
204
|
+
static observedAttributes = [
|
|
205
|
+
"src",
|
|
206
|
+
"shader-root",
|
|
207
|
+
"from",
|
|
208
|
+
"no-controls",
|
|
209
|
+
"theme",
|
|
210
|
+
"autoplay",
|
|
211
|
+
"transparent",
|
|
212
|
+
"fetch-libs",
|
|
213
|
+
"fetch-sources"
|
|
214
|
+
];
|
|
215
|
+
canvas;
|
|
216
|
+
errorOverlay;
|
|
217
|
+
controls;
|
|
218
|
+
resizeObserver;
|
|
219
|
+
stopRenderLoop;
|
|
220
|
+
renderState;
|
|
221
|
+
playback = {
|
|
222
|
+
isPlaying: true,
|
|
223
|
+
startTime: performance.now(),
|
|
224
|
+
pausedDuration: 0
|
|
225
|
+
};
|
|
226
|
+
_weslSrc = {};
|
|
227
|
+
_rootModuleName = "package::main";
|
|
228
|
+
_libs;
|
|
229
|
+
_linkOptions = {};
|
|
230
|
+
_fetchSources = true;
|
|
231
|
+
_initPromise;
|
|
232
|
+
_sourceEl = null;
|
|
233
|
+
_sourceListener = null;
|
|
234
|
+
_fetchLibs = true;
|
|
235
|
+
_dirty = false;
|
|
236
|
+
_building = false;
|
|
237
|
+
_theme = "auto";
|
|
238
|
+
_mediaQuery = null;
|
|
239
|
+
_onFullscreenChange = () => this.controls.setFullscreen(!!document.fullscreenElement);
|
|
240
|
+
/** Get config overrides from element attributes. */
|
|
241
|
+
getConfigOverrides() {
|
|
242
|
+
const shaderRoot = this.getAttribute("shader-root");
|
|
243
|
+
if (!shaderRoot) return void 0;
|
|
244
|
+
return { shaderRoot };
|
|
245
|
+
}
|
|
246
|
+
constructor() {
|
|
247
|
+
super();
|
|
248
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
249
|
+
shadow.adoptedStyleSheets = [getStyles()];
|
|
250
|
+
shadow.appendChild(getTemplate().content.cloneNode(true));
|
|
251
|
+
this.canvas = shadow.querySelector("canvas");
|
|
252
|
+
this.errorOverlay = new ErrorOverlay(shadow);
|
|
253
|
+
this.controls = new PlaybackControls(shadow, () => this.play(), () => this.pause(), () => this.rewind(), () => this.toggleFullscreen());
|
|
254
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
const { width, height } = entry.contentRect;
|
|
257
|
+
if (width > 0 && height > 0) {
|
|
258
|
+
this.canvas.width = Math.floor(width * devicePixelRatio);
|
|
259
|
+
this.canvas.height = Math.floor(height * devicePixelRatio);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
connectedCallback() {
|
|
265
|
+
this.resizeObserver.observe(this);
|
|
266
|
+
const themeAttr = this.getAttribute("theme");
|
|
267
|
+
if (themeAttr) this._theme = themeAttr;
|
|
268
|
+
this._mediaQuery = matchMedia("(prefers-color-scheme: dark)");
|
|
269
|
+
this._mediaQuery.addEventListener("change", () => this.updateTheme());
|
|
270
|
+
this.updateTheme();
|
|
271
|
+
document.addEventListener("fullscreenchange", this._onFullscreenChange);
|
|
272
|
+
if (!this.autoplay) {
|
|
273
|
+
this.playback.isPlaying = false;
|
|
274
|
+
this.controls.setPlaying(false);
|
|
275
|
+
}
|
|
276
|
+
this.initialize();
|
|
277
|
+
upgradeProperty(this, "conditions");
|
|
278
|
+
upgradeProperty(this, "shader");
|
|
279
|
+
upgradeProperty(this, "project");
|
|
280
|
+
}
|
|
281
|
+
disconnectedCallback() {
|
|
282
|
+
this.resizeObserver.disconnect();
|
|
283
|
+
this.stopRenderLoop?.();
|
|
284
|
+
document.removeEventListener("fullscreenchange", this._onFullscreenChange);
|
|
285
|
+
if (this._sourceEl && this._sourceListener) this._sourceEl.removeEventListener("change", this._sourceListener);
|
|
286
|
+
}
|
|
287
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
288
|
+
if (oldValue === newValue) return;
|
|
289
|
+
if (name === "no-controls") {
|
|
290
|
+
newValue !== null ? this.controls.hide() : this.controls.show();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (name === "theme") {
|
|
294
|
+
this._theme = newValue || "auto";
|
|
295
|
+
this.updateTheme();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (name === "autoplay") {
|
|
299
|
+
newValue === "false" ? this.pause() : this.play();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (name === "fetch-libs") {
|
|
303
|
+
this._fetchLibs = newValue !== "false";
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (name === "fetch-sources") {
|
|
307
|
+
this._fetchSources = newValue !== "false";
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (name === "src" && newValue && this._initPromise) this.loadFromUrl(newValue);
|
|
311
|
+
}
|
|
312
|
+
/** Current shader source code (main module). */
|
|
313
|
+
get shader() {
|
|
314
|
+
return this._weslSrc[this._rootModuleName] ?? "";
|
|
315
|
+
}
|
|
316
|
+
/** Set shader source directly (single-file convenience). */
|
|
317
|
+
set shader(value) {
|
|
318
|
+
this._weslSrc = { [this._rootModuleName]: value };
|
|
319
|
+
this._libs = void 0;
|
|
320
|
+
this.requestBuild();
|
|
321
|
+
}
|
|
322
|
+
/** Conditions for conditional compilation (@if/@elif/@else). */
|
|
323
|
+
get conditions() {
|
|
324
|
+
return this._linkOptions.conditions ?? {};
|
|
325
|
+
}
|
|
326
|
+
set conditions(value) {
|
|
327
|
+
this._linkOptions = {
|
|
328
|
+
...this._linkOptions,
|
|
329
|
+
conditions: value
|
|
330
|
+
};
|
|
331
|
+
if (Object.keys(this._weslSrc).length === 0) return;
|
|
332
|
+
this.requestBuild();
|
|
333
|
+
}
|
|
334
|
+
/** Set project configuration (mirrors wesl link() API). */
|
|
335
|
+
set project(value) {
|
|
336
|
+
const { weslSrc, rootModuleName, libs, packageName, conditions, constants } = value;
|
|
337
|
+
if (packageName !== void 0) this._linkOptions.packageName = packageName;
|
|
338
|
+
if (conditions !== void 0) this._linkOptions.conditions = conditions;
|
|
339
|
+
if (constants !== void 0) this._linkOptions.constants = constants;
|
|
340
|
+
if (libs) this._libs = libs;
|
|
341
|
+
if (weslSrc) {
|
|
342
|
+
const pkg = this._linkOptions.packageName || "package";
|
|
343
|
+
const root = rootModuleName ?? "main";
|
|
344
|
+
this._weslSrc = toModulePaths(weslSrc, pkg);
|
|
345
|
+
this._rootModuleName = fileToModulePath(root, pkg, false);
|
|
346
|
+
this.requestBuild();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (Object.keys(this._weslSrc).length === 0) return;
|
|
350
|
+
this.requestBuild();
|
|
351
|
+
}
|
|
352
|
+
/** Whether to auto-fetch missing library packages from npm (default: true). */
|
|
353
|
+
get fetchLibs() {
|
|
354
|
+
return this._fetchLibs;
|
|
355
|
+
}
|
|
356
|
+
set fetchLibs(value) {
|
|
357
|
+
this._fetchLibs = value;
|
|
358
|
+
if (value) this.removeAttribute("fetch-libs");
|
|
359
|
+
else this.setAttribute("fetch-libs", "false");
|
|
360
|
+
}
|
|
361
|
+
/** Whether to fetch local .wesl source files via HTTP (default: true). */
|
|
362
|
+
get fetchSources() {
|
|
363
|
+
return this._fetchSources;
|
|
364
|
+
}
|
|
365
|
+
set fetchSources(value) {
|
|
366
|
+
this._fetchSources = value;
|
|
367
|
+
if (value) this.removeAttribute("fetch-sources");
|
|
368
|
+
else this.setAttribute("fetch-sources", "false");
|
|
369
|
+
}
|
|
370
|
+
/** Whether autoplay is enabled (default: true). Set autoplay="false" to start paused. */
|
|
371
|
+
get autoplay() {
|
|
372
|
+
return this.getAttribute("autoplay") !== "false";
|
|
373
|
+
}
|
|
374
|
+
set autoplay(value) {
|
|
375
|
+
if (value !== false && value !== "false") this.removeAttribute("autoplay");
|
|
376
|
+
else this.setAttribute("autoplay", "false");
|
|
377
|
+
}
|
|
378
|
+
/** Whether the shader is currently playing. */
|
|
379
|
+
get isPlaying() {
|
|
380
|
+
return this.playback.isPlaying;
|
|
381
|
+
}
|
|
382
|
+
/** Current animation time in seconds. */
|
|
383
|
+
get time() {
|
|
384
|
+
const { isPlaying, startTime, pausedDuration } = this.playback;
|
|
385
|
+
return ((isPlaying ? performance.now() : startTime + pausedDuration) - startTime) / 1e3;
|
|
386
|
+
}
|
|
387
|
+
/** Number of frames rendered (for testing/debugging). */
|
|
388
|
+
get frameCount() {
|
|
389
|
+
return this.renderState?.frameCount ?? 0;
|
|
390
|
+
}
|
|
391
|
+
/** Whether there's a compilation error. */
|
|
392
|
+
get hasError() {
|
|
393
|
+
return this.errorOverlay.visible;
|
|
394
|
+
}
|
|
395
|
+
/** Current error message, or null if no error. */
|
|
396
|
+
get errorMessage() {
|
|
397
|
+
return this.errorOverlay.message;
|
|
398
|
+
}
|
|
399
|
+
/** Start playback. */
|
|
400
|
+
play() {
|
|
401
|
+
const { isPlaying, pausedDuration } = this.playback;
|
|
402
|
+
if (isPlaying) return;
|
|
403
|
+
this.playback.startTime = performance.now() - pausedDuration;
|
|
404
|
+
this.setPlaying(true);
|
|
405
|
+
}
|
|
406
|
+
/** Pause playback. */
|
|
407
|
+
pause() {
|
|
408
|
+
if (!this.playback.isPlaying) return;
|
|
409
|
+
this.playback.pausedDuration = performance.now() - this.playback.startTime;
|
|
410
|
+
this.setPlaying(false);
|
|
411
|
+
}
|
|
412
|
+
setPlaying(playing) {
|
|
413
|
+
this.playback.isPlaying = playing;
|
|
414
|
+
this.controls.setPlaying(playing);
|
|
415
|
+
this.dispatchEvent(new CustomEvent("playback-change", { detail: { isPlaying: playing } }));
|
|
416
|
+
}
|
|
417
|
+
/** Reset animation to time 0 and pause. */
|
|
418
|
+
rewind() {
|
|
419
|
+
this.playback.startTime = performance.now();
|
|
420
|
+
this.playback.pausedDuration = 0;
|
|
421
|
+
this.setPlaying(false);
|
|
422
|
+
}
|
|
423
|
+
/** Display error message in overlay. Pass empty string to clear. */
|
|
424
|
+
showError(message) {
|
|
425
|
+
if (!message) {
|
|
426
|
+
this.errorOverlay.hide();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
this.errorOverlay.show(message);
|
|
430
|
+
this.pause();
|
|
431
|
+
}
|
|
432
|
+
/** Toggle fullscreen on this element. */
|
|
433
|
+
toggleFullscreen() {
|
|
434
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
435
|
+
else this.requestFullscreen();
|
|
436
|
+
}
|
|
437
|
+
updateTheme() {
|
|
438
|
+
const isDark = this._theme === "dark" || this._theme === "auto" && matchMedia("(prefers-color-scheme: dark)").matches;
|
|
439
|
+
this.classList.toggle("dark", isDark);
|
|
440
|
+
}
|
|
441
|
+
/** Set up WebGPU and load initial shader. Returns true if successful. */
|
|
442
|
+
initialize() {
|
|
443
|
+
if (this.renderState) return Promise.resolve(true);
|
|
444
|
+
if (!this._initPromise) this._initPromise = this.doInitialize();
|
|
445
|
+
return this._initPromise;
|
|
446
|
+
}
|
|
447
|
+
async doInitialize() {
|
|
448
|
+
try {
|
|
449
|
+
const alphaMode = this.hasAttribute("transparent") ? "premultiplied" : "opaque";
|
|
450
|
+
this.renderState = await initWebGPU(this.canvas, alphaMode);
|
|
451
|
+
this.loadInitialContent();
|
|
452
|
+
this.stopRenderLoop = startRenderLoop(this.renderState, this.playback);
|
|
453
|
+
this.dispatchEvent(new CustomEvent("ready"));
|
|
454
|
+
return true;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
const message = !navigator.gpu ? "WebGPU is not supported in this browser.\nTry Chrome 113+, Edge 113+, or Safari 18+." : `WebGPU initialization failed: ${error}`;
|
|
457
|
+
this.errorOverlay.show(message);
|
|
458
|
+
this.pause();
|
|
459
|
+
this.dispatchEvent(new CustomEvent("init-error", { detail: { message } }));
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Load from source element, src URL, script child, or inline textContent. */
|
|
464
|
+
loadInitialContent() {
|
|
465
|
+
const fromId = this.getAttribute("from");
|
|
466
|
+
if (fromId) {
|
|
467
|
+
this.connectFrom(fromId);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const src = this.getAttribute("src");
|
|
471
|
+
if (src) {
|
|
472
|
+
this.loadFromUrl(src);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const inlineSource = this.querySelector("script[type=\"text/wgsl\"], script[type=\"text/wesl\"]")?.textContent?.trim() ?? this.textContent?.trim();
|
|
476
|
+
if (!inlineSource) return;
|
|
477
|
+
this._weslSrc = { [this._rootModuleName]: inlineSource };
|
|
478
|
+
this.requestBuild();
|
|
479
|
+
}
|
|
480
|
+
/** Connect to a source provider element (e.g., wgsl-edit). */
|
|
481
|
+
connectFrom(id) {
|
|
482
|
+
const el = document.getElementById(id);
|
|
483
|
+
if (!el) {
|
|
484
|
+
console.error(`wgsl-play: source element "${id}" not found`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this._sourceEl = el;
|
|
488
|
+
const p = el.project;
|
|
489
|
+
if (p) this.project = p;
|
|
490
|
+
this._sourceListener = (e) => {
|
|
491
|
+
const detail = e.detail;
|
|
492
|
+
if (detail) this.project = detail;
|
|
493
|
+
};
|
|
494
|
+
el.addEventListener("change", this._sourceListener);
|
|
495
|
+
}
|
|
496
|
+
/** Fetch shader from URL, then trigger a build. */
|
|
497
|
+
async loadFromUrl(url) {
|
|
498
|
+
try {
|
|
499
|
+
const { weslSrc, libs, rootModuleName } = await loadShaderFromUrl(url, this.getConfigOverrides()?.shaderRoot);
|
|
500
|
+
this._weslSrc = weslSrc;
|
|
501
|
+
this._libs = libs;
|
|
502
|
+
if (rootModuleName) this._rootModuleName = rootModuleName;
|
|
503
|
+
this.requestBuild();
|
|
504
|
+
} catch (error) {
|
|
505
|
+
this.handleCompileError(error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** Mark build as needed. Coalesces rapid requests into a single build. */
|
|
509
|
+
requestBuild() {
|
|
510
|
+
this._dirty = true;
|
|
511
|
+
if (!this._building) this.runBuild();
|
|
512
|
+
}
|
|
513
|
+
/** Run builds until no longer dirty. Only one instance runs at a time. */
|
|
514
|
+
async runBuild() {
|
|
515
|
+
this._building = true;
|
|
516
|
+
while (this._dirty) {
|
|
517
|
+
this._dirty = false;
|
|
518
|
+
if (!await this.initialize()) break;
|
|
519
|
+
const mainSource = this._weslSrc[this._rootModuleName];
|
|
520
|
+
if (!mainSource) {
|
|
521
|
+
console.warn(`wgsl-play: root module "${this._rootModuleName}" not found in sources:`, Object.keys(this._weslSrc));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
this.errorOverlay.hide();
|
|
526
|
+
if (this._fetchSources || this._fetchLibs) {
|
|
527
|
+
const { weslSrc, libs } = await fetchDependencies(mainSource, {
|
|
528
|
+
shaderRoot: this.getConfigOverrides()?.shaderRoot,
|
|
529
|
+
existingSources: this._weslSrc,
|
|
530
|
+
fetchLibs: this._fetchLibs,
|
|
531
|
+
fetchSources: this._fetchSources
|
|
532
|
+
});
|
|
533
|
+
this._weslSrc = {
|
|
534
|
+
...this._weslSrc,
|
|
535
|
+
...weslSrc
|
|
536
|
+
};
|
|
537
|
+
this._libs = dedupLibs(this._libs, libs);
|
|
538
|
+
}
|
|
539
|
+
await createPipeline(this.renderState, mainSource, {
|
|
540
|
+
...this._linkOptions,
|
|
541
|
+
weslSrc: this._weslSrc,
|
|
542
|
+
libs: this._libs,
|
|
543
|
+
rootModuleName: this._rootModuleName
|
|
544
|
+
});
|
|
545
|
+
if (!this._dirty) this.dispatchEvent(new CustomEvent("compile-success"));
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!this._dirty) this.handleCompileError(error);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
this._building = false;
|
|
551
|
+
}
|
|
552
|
+
handleCompileError(error) {
|
|
553
|
+
const message = error?.message ?? String(error);
|
|
554
|
+
this.errorOverlay.show(message);
|
|
555
|
+
const detail = {
|
|
556
|
+
message,
|
|
557
|
+
source: error instanceof WeslParseError ? "wesl" : "webgpu",
|
|
558
|
+
locations: this.extractLocations(error)
|
|
559
|
+
};
|
|
560
|
+
this.dispatchEvent(new CustomEvent("compile-error", { detail }));
|
|
561
|
+
}
|
|
562
|
+
/** Extract source locations from a WESL parse error or GPU compilation error. */
|
|
563
|
+
extractLocations(error) {
|
|
564
|
+
const loc = error?.weslLocation;
|
|
565
|
+
if (loc) return [{
|
|
566
|
+
file: loc.file,
|
|
567
|
+
line: loc.line,
|
|
568
|
+
column: loc.column - 1,
|
|
569
|
+
length: loc.length,
|
|
570
|
+
severity: "error",
|
|
571
|
+
message: error?.message ?? ""
|
|
572
|
+
}];
|
|
573
|
+
const msgs = error?.compilationInfo?.messages;
|
|
574
|
+
if (msgs) return msgs.map((m) => ({
|
|
575
|
+
file: m.module?.url,
|
|
576
|
+
line: m.lineNum,
|
|
577
|
+
column: m.linePos - 1,
|
|
578
|
+
length: m.length,
|
|
579
|
+
severity: m.type === "warning" ? "warning" : m.type === "info" ? "info" : "error",
|
|
580
|
+
message: m.message
|
|
581
|
+
}));
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
function getTemplate() {
|
|
586
|
+
if (!template) {
|
|
587
|
+
template = document.createElement("template");
|
|
588
|
+
template.innerHTML = `<canvas part="canvas"></canvas>`;
|
|
589
|
+
}
|
|
590
|
+
return template;
|
|
591
|
+
}
|
|
592
|
+
function getStyles() {
|
|
593
|
+
if (!styles) {
|
|
594
|
+
styles = new CSSStyleSheet();
|
|
595
|
+
styles.replaceSync(WgslPlay_default);
|
|
596
|
+
}
|
|
597
|
+
return styles;
|
|
598
|
+
}
|
|
599
|
+
/** Absorb instance properties set before custom element upgrade. */
|
|
600
|
+
function upgradeProperty(el, prop) {
|
|
601
|
+
if (Object.hasOwn(el, prop)) {
|
|
602
|
+
const value = el[prop];
|
|
603
|
+
delete el[prop];
|
|
604
|
+
el[prop] = value;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/** Merge new libs, deduplicating by bundle name. */
|
|
608
|
+
function dedupLibs(existing, newLibs) {
|
|
609
|
+
if (!existing || newLibs.length === 0) return [...existing ?? [], ...newLibs];
|
|
610
|
+
const names = new Set(newLibs.map((b) => b.name));
|
|
611
|
+
return [...existing.filter((b) => !names.has(b.name)), ...newLibs];
|
|
612
|
+
}
|
|
613
|
+
/** Normalize all keys in a weslSrc record to module paths. */
|
|
614
|
+
function toModulePaths(weslSrc, pkg) {
|
|
615
|
+
const result = {};
|
|
616
|
+
for (const [key, value] of Object.entries(weslSrc)) result[fileToModulePath(key, pkg, false)] = value;
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
export { resetConfig as i, defaults as n, getConfig as r, WgslPlay as t };
|