wgsl-play 0.0.2
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 +80 -0
- package/package.json +25 -0
- package/playwright-report/index.html +81 -0
- package/playwright.config.ts +33 -0
- package/playwright.port.ts +2 -0
- package/src/BundleHydrator.ts +187 -0
- package/src/BundleLoader.ts +119 -0
- package/src/ErrorOverlay.ts +32 -0
- package/src/PackageLoader.ts +83 -0
- package/src/Renderer.ts +144 -0
- package/src/WgslPlay.css +28 -0
- package/src/WgslPlay.ts +308 -0
- package/src/index.ts +11 -0
- package/src/test/BundleHydrator.test.ts +122 -0
- package/src/test/WgslPlay.e2e.ts +38 -0
- package/src/test/WgslPlay.e2e.ts-snapshots/gradient-t0-chromium-darwin.png +0 -0
- package/src/vite-env.d.ts +1 -0
- package/test-page/index.html +28 -0
- package/test-page/main.ts +12 -0
- package/test-results/.last-run.json +4 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +8 -0
package/src/WgslPlay.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { LinkParams, WeslBundle } from "wesl";
|
|
2
|
+
import { ErrorOverlay } from "./ErrorOverlay.ts";
|
|
3
|
+
import {
|
|
4
|
+
fetchDependenciesForSource,
|
|
5
|
+
loadShaderFromUrl,
|
|
6
|
+
} from "./PackageLoader.ts";
|
|
7
|
+
import {
|
|
8
|
+
createPipeline,
|
|
9
|
+
initWebGPU,
|
|
10
|
+
type LinkOptions,
|
|
11
|
+
type PlaybackState,
|
|
12
|
+
type RenderState,
|
|
13
|
+
startRenderLoop,
|
|
14
|
+
} from "./Renderer.ts";
|
|
15
|
+
import cssText from "./WgslPlay.css?inline";
|
|
16
|
+
|
|
17
|
+
/** Project configuration for multi-file shaders (subset of wesl link() API). */
|
|
18
|
+
export type WeslProject = Pick<
|
|
19
|
+
LinkParams,
|
|
20
|
+
| "weslSrc"
|
|
21
|
+
| "rootModuleName"
|
|
22
|
+
| "conditions"
|
|
23
|
+
| "constants"
|
|
24
|
+
| "libs"
|
|
25
|
+
| "packageName"
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
/** Compile error detail for events. */
|
|
29
|
+
export interface CompileErrorDetail {
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Lazy-init for SSR/Node.js compatibility (avoid browser APIs at module load)
|
|
34
|
+
let styles: CSSStyleSheet | null = null;
|
|
35
|
+
let template: HTMLTemplateElement | null = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* <wgsl-play> - Web component for rendering WESL/WGSL fragment shaders.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* <!-- From URL -->
|
|
42
|
+
* <wgsl-play src="./shader.wesl"></wgsl-play>
|
|
43
|
+
*
|
|
44
|
+
* <!-- Inline source -->
|
|
45
|
+
* <wgsl-play>
|
|
46
|
+
* @fragment fn fs_main() -> @location(0) vec4f {
|
|
47
|
+
* return vec4f(1.0, 0.0, 0.0, 1.0);
|
|
48
|
+
* }
|
|
49
|
+
* </wgsl-play>
|
|
50
|
+
*/
|
|
51
|
+
export class WgslPlay extends HTMLElement {
|
|
52
|
+
static observedAttributes = ["src"];
|
|
53
|
+
|
|
54
|
+
private canvas: HTMLCanvasElement;
|
|
55
|
+
private errorOverlay: ErrorOverlay;
|
|
56
|
+
private resizeObserver: ResizeObserver;
|
|
57
|
+
private stopRenderLoop?: () => void;
|
|
58
|
+
|
|
59
|
+
private renderState?: RenderState;
|
|
60
|
+
private playback: PlaybackState = {
|
|
61
|
+
isPlaying: true,
|
|
62
|
+
startTime: performance.now(),
|
|
63
|
+
pausedDuration: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
private _source = "";
|
|
67
|
+
private _linkOptions: LinkOptions = {};
|
|
68
|
+
private _initialized = false;
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
73
|
+
shadow.adoptedStyleSheets = [getStyles()];
|
|
74
|
+
shadow.appendChild(getTemplate().content.cloneNode(true));
|
|
75
|
+
|
|
76
|
+
this.canvas = shadow.querySelector("canvas")!;
|
|
77
|
+
this.errorOverlay = new ErrorOverlay(shadow, () => this.pause());
|
|
78
|
+
|
|
79
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const { width, height } = entry.contentRect;
|
|
82
|
+
if (width > 0 && height > 0) {
|
|
83
|
+
this.canvas.width = Math.floor(width * devicePixelRatio);
|
|
84
|
+
this.canvas.height = Math.floor(height * devicePixelRatio);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
connectedCallback(): void {
|
|
91
|
+
this.resizeObserver.observe(this);
|
|
92
|
+
this.initialize();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
disconnectedCallback(): void {
|
|
96
|
+
this.resizeObserver.disconnect();
|
|
97
|
+
this.stopRenderLoop?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
attributeChangedCallback(
|
|
101
|
+
name: string,
|
|
102
|
+
oldValue: string | null,
|
|
103
|
+
newValue: string | null,
|
|
104
|
+
): void {
|
|
105
|
+
if (oldValue === newValue) return;
|
|
106
|
+
|
|
107
|
+
// Initial src is handled by initialize(); this handles later changes
|
|
108
|
+
if (name === "src" && newValue && this._initialized) {
|
|
109
|
+
this.loadFromUrl(newValue);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Current shader source code. */
|
|
114
|
+
get source(): string {
|
|
115
|
+
return this._source;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Set shader source directly. */
|
|
119
|
+
set source(value: string) {
|
|
120
|
+
this._source = value;
|
|
121
|
+
this.compileSource(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Set project configuration (mirrors wesl link() API). */
|
|
125
|
+
set project(value: WeslProject) {
|
|
126
|
+
const { weslSrc, rootModuleName, libs } = value;
|
|
127
|
+
const { packageName, conditions, constants } = value;
|
|
128
|
+
if (!weslSrc || !rootModuleName) return;
|
|
129
|
+
const mainSource = weslSrc[rootModuleName];
|
|
130
|
+
if (!mainSource) return;
|
|
131
|
+
|
|
132
|
+
this._source = mainSource;
|
|
133
|
+
this._linkOptions = { packageName, conditions, constants };
|
|
134
|
+
|
|
135
|
+
if (libs?.length) {
|
|
136
|
+
this.compileWithLibs(mainSource, libs);
|
|
137
|
+
} else {
|
|
138
|
+
this.compileSource(mainSource);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether the shader is currently playing. */
|
|
143
|
+
get isPlaying(): boolean {
|
|
144
|
+
return this.playback.isPlaying;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Current animation time in seconds. */
|
|
148
|
+
get time(): number {
|
|
149
|
+
const currentTime = this.playback.isPlaying
|
|
150
|
+
? performance.now()
|
|
151
|
+
: this.playback.startTime + this.playback.pausedDuration;
|
|
152
|
+
return (currentTime - this.playback.startTime) / 1000;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether there's a compilation error. */
|
|
156
|
+
get hasError(): boolean {
|
|
157
|
+
return this.errorOverlay.visible;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Current error message, or null if no error. */
|
|
161
|
+
get errorMessage(): string | null {
|
|
162
|
+
return this.errorOverlay.message;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Start playback. */
|
|
166
|
+
play(): void {
|
|
167
|
+
if (this.playback.isPlaying) return;
|
|
168
|
+
const pauseTime = this.playback.startTime + this.playback.pausedDuration;
|
|
169
|
+
this.playback.startTime =
|
|
170
|
+
performance.now() - (pauseTime - this.playback.startTime);
|
|
171
|
+
this.setPlaying(true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Pause playback. */
|
|
175
|
+
pause(): void {
|
|
176
|
+
if (!this.playback.isPlaying) return;
|
|
177
|
+
this.playback.pausedDuration = performance.now() - this.playback.startTime;
|
|
178
|
+
this.setPlaying(false);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private setPlaying(playing: boolean): void {
|
|
182
|
+
this.playback.isPlaying = playing;
|
|
183
|
+
this.dispatchEvent(
|
|
184
|
+
new CustomEvent("playback-change", { detail: { isPlaying: playing } }),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Reset animation to time 0 and pause. */
|
|
189
|
+
rewind(): void {
|
|
190
|
+
this.playback.startTime = performance.now();
|
|
191
|
+
this.playback.pausedDuration = 0;
|
|
192
|
+
this.setPlaying(false);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Display error message in overlay. Pass empty string to clear. */
|
|
196
|
+
showError(message: string): void {
|
|
197
|
+
if (message) {
|
|
198
|
+
this.errorOverlay.show(message);
|
|
199
|
+
this.pause();
|
|
200
|
+
} else {
|
|
201
|
+
this.errorOverlay.hide();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Set up WebGPU and load initial shader from src attribute or inline content. */
|
|
206
|
+
private async initialize(): Promise<void> {
|
|
207
|
+
if (this._initialized) return;
|
|
208
|
+
this._initialized = true;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
this.renderState = await initWebGPU(this.canvas);
|
|
212
|
+
await this.loadInitialContent();
|
|
213
|
+
this.stopRenderLoop = startRenderLoop(this.renderState, this.playback);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const message = `WebGPU initialization failed: ${error}`;
|
|
216
|
+
this.errorOverlay.show(message);
|
|
217
|
+
this.pause();
|
|
218
|
+
this.dispatchEvent(
|
|
219
|
+
new CustomEvent("init-error", { detail: { message } }),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Load from src attribute or inline textContent. */
|
|
225
|
+
private async loadInitialContent(): Promise<void> {
|
|
226
|
+
const src = this.getAttribute("src");
|
|
227
|
+
if (src) {
|
|
228
|
+
await this.loadFromUrl(src);
|
|
229
|
+
} else {
|
|
230
|
+
const inlineSource = this.textContent?.trim();
|
|
231
|
+
if (inlineSource) await this.compileSource(inlineSource);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Fetch shader from URL, auto-fetching any imported dependencies. */
|
|
236
|
+
private async loadFromUrl(url: string): Promise<void> {
|
|
237
|
+
if (!this.renderState) return;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
this.errorOverlay.hide();
|
|
241
|
+
const { source, bundles } = await loadShaderFromUrl(url);
|
|
242
|
+
this._source = source;
|
|
243
|
+
await createPipeline(this.renderState, source, bundles);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
this.handleCompileError(error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Compile source string, auto-fetching any imported dependencies. */
|
|
250
|
+
private async compileSource(source: string): Promise<void> {
|
|
251
|
+
if (!this.renderState) return;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
this.errorOverlay.hide();
|
|
255
|
+
const bundles = await fetchDependenciesForSource(source);
|
|
256
|
+
await createPipeline(
|
|
257
|
+
this.renderState,
|
|
258
|
+
source,
|
|
259
|
+
bundles,
|
|
260
|
+
this._linkOptions,
|
|
261
|
+
);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
this.handleCompileError(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Compile with pre-loaded library bundles (no network fetch for libs). */
|
|
268
|
+
private async compileWithLibs(
|
|
269
|
+
source: string,
|
|
270
|
+
libs: WeslBundle[],
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
if (!this.renderState) {
|
|
273
|
+
await this.initialize();
|
|
274
|
+
}
|
|
275
|
+
if (!this.renderState) return;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
this.errorOverlay.hide();
|
|
279
|
+
await createPipeline(this.renderState, source, libs, this._linkOptions);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.handleCompileError(error);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private handleCompileError(error: unknown): void {
|
|
286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
287
|
+
this.errorOverlay.show(message);
|
|
288
|
+
this.pause();
|
|
289
|
+
const detail: CompileErrorDetail = { message };
|
|
290
|
+
this.dispatchEvent(new CustomEvent("compile-error", { detail }));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getTemplate(): HTMLTemplateElement {
|
|
295
|
+
if (!template) {
|
|
296
|
+
template = document.createElement("template");
|
|
297
|
+
template.innerHTML = `<canvas part="canvas"></canvas>`;
|
|
298
|
+
}
|
|
299
|
+
return template;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getStyles(): CSSStyleSheet {
|
|
303
|
+
if (!styles) {
|
|
304
|
+
styles = new CSSStyleSheet();
|
|
305
|
+
styles.replaceSync(cssText);
|
|
306
|
+
}
|
|
307
|
+
return styles;
|
|
308
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./BundleHydrator.ts";
|
|
2
|
+
export * from "./BundleLoader.ts";
|
|
3
|
+
export * from "./PackageLoader.ts";
|
|
4
|
+
export * from "./WgslPlay.ts";
|
|
5
|
+
|
|
6
|
+
import { WgslPlay } from "./WgslPlay.ts";
|
|
7
|
+
|
|
8
|
+
// Auto-register the custom element
|
|
9
|
+
if (typeof customElements !== "undefined" && !customElements.get("wgsl-play")) {
|
|
10
|
+
customElements.define("wgsl-play", WgslPlay);
|
|
11
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { gunzipSync } from "fflate";
|
|
5
|
+
import { parseTar } from "nanotar";
|
|
6
|
+
import { expect, test } from "vitest";
|
|
7
|
+
import type { WeslBundleFile } from "../BundleHydrator.ts";
|
|
8
|
+
import { loadBundlesFromFiles } from "../BundleHydrator.ts";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const testPkgDir = join(__dirname, "../../../../test_pkg");
|
|
13
|
+
|
|
14
|
+
function loadBundlesFromTgz(tgzPath: string, packageName: string) {
|
|
15
|
+
const gzipData = readFileSync(tgzPath);
|
|
16
|
+
const tarData = gunzipSync(new Uint8Array(gzipData));
|
|
17
|
+
const entries = parseTar(tarData);
|
|
18
|
+
const bundleFiles: WeslBundleFile[] = entries
|
|
19
|
+
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
20
|
+
.map(f => ({ packagePath: f.name, content: f.text, packageName }));
|
|
21
|
+
return loadBundlesFromFiles(bundleFiles);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("load single bundle from tgz", async () => {
|
|
25
|
+
const tgzPath = join(testPkgDir, "dependent_package-0.0.1.tgz");
|
|
26
|
+
const bundles = await loadBundlesFromTgz(tgzPath, "dependent_package");
|
|
27
|
+
|
|
28
|
+
expect(bundles.length).toBe(1);
|
|
29
|
+
const bundle = bundles[0];
|
|
30
|
+
expect(bundle.name).toBe("dependent_package");
|
|
31
|
+
expect(bundle.edition).toBe("unstable_2025_1");
|
|
32
|
+
expect(Object.keys(bundle.modules)).toEqual(["lib.wesl"]);
|
|
33
|
+
expect(bundle.modules["lib.wesl"]).toContain("fn dep()");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("load bundles with dependencies from tgz", async () => {
|
|
37
|
+
// Multi_pkg has a transitive bundle that imports from dependent_package
|
|
38
|
+
// Load both to verify dependency resolution across packages
|
|
39
|
+
const multiTgz = join(testPkgDir, "multi_pkg-0.0.1.tgz");
|
|
40
|
+
const depTgz = join(testPkgDir, "dependent_package-0.0.1.tgz");
|
|
41
|
+
|
|
42
|
+
const multiFiles = extractBundleFiles(multiTgz, "multi_pkg");
|
|
43
|
+
const depFiles = extractBundleFiles(depTgz, "dependent_package");
|
|
44
|
+
|
|
45
|
+
// Load all bundles together - each file knows its package name
|
|
46
|
+
const allFiles = [...depFiles, ...multiFiles];
|
|
47
|
+
const allBundles = await loadBundlesFromFiles(allFiles);
|
|
48
|
+
|
|
49
|
+
// Should get 4 bundles: 1 from dependent_package, 3 from multi_pkg
|
|
50
|
+
// All bundles can now resolve because each is registered under correct package
|
|
51
|
+
expect(allBundles.length).toBe(4);
|
|
52
|
+
|
|
53
|
+
// Verify the transitive bundle successfully resolved its dependency
|
|
54
|
+
const transitive = allBundles.find(b => b.modules["transitive.wesl"]);
|
|
55
|
+
if (!transitive) throw new Error("transitive bundle not found");
|
|
56
|
+
if (!transitive.dependencies)
|
|
57
|
+
throw new Error("transitive has no dependencies");
|
|
58
|
+
expect(transitive.dependencies.length).toBe(1);
|
|
59
|
+
expect(transitive.dependencies[0].name).toBe("dependent_package");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("circular dependencies", async () => {
|
|
63
|
+
// Create two bundles that import each other
|
|
64
|
+
const bundleA: WeslBundleFile = {
|
|
65
|
+
packagePath: "package/dist/weslBundle.js",
|
|
66
|
+
packageName: "circular_test",
|
|
67
|
+
content: `
|
|
68
|
+
import bundleB from "circular_test/b";
|
|
69
|
+
export const weslBundle = {
|
|
70
|
+
name: "circular_test",
|
|
71
|
+
edition: "unstable_2025_1",
|
|
72
|
+
modules: { "a.wesl": "fn a() {}" },
|
|
73
|
+
dependencies: [bundleB]
|
|
74
|
+
};
|
|
75
|
+
`,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const bundleB: WeslBundleFile = {
|
|
79
|
+
packagePath: "package/dist/b/weslBundle.js",
|
|
80
|
+
packageName: "circular_test",
|
|
81
|
+
content: `
|
|
82
|
+
import bundleA from "circular_test";
|
|
83
|
+
export const weslBundle = {
|
|
84
|
+
name: "circular_test",
|
|
85
|
+
edition: "unstable_2025_1",
|
|
86
|
+
modules: { "b.wesl": "fn b() {}" },
|
|
87
|
+
dependencies: [bundleA]
|
|
88
|
+
};
|
|
89
|
+
`,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const bundles = await loadBundlesFromFiles([bundleA, bundleB]);
|
|
93
|
+
|
|
94
|
+
// Both bundles should evaluate successfully
|
|
95
|
+
expect(bundles.length).toBe(2);
|
|
96
|
+
|
|
97
|
+
const a = bundles.find(b => b.modules["a.wesl"]);
|
|
98
|
+
const b = bundles.find(b => b.modules["b.wesl"]);
|
|
99
|
+
|
|
100
|
+
if (!a) throw new Error("bundle a not found");
|
|
101
|
+
if (!b) throw new Error("bundle b not found");
|
|
102
|
+
|
|
103
|
+
// Verify circular references
|
|
104
|
+
expect(a.dependencies?.length).toBe(1);
|
|
105
|
+
expect(b.dependencies?.length).toBe(1);
|
|
106
|
+
|
|
107
|
+
// Each bundle should reference the other
|
|
108
|
+
expect(a.dependencies?.[0]).toBe(b);
|
|
109
|
+
expect(b.dependencies?.[0]).toBe(a);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function extractBundleFiles(
|
|
113
|
+
tgzPath: string,
|
|
114
|
+
packageName: string,
|
|
115
|
+
): WeslBundleFile[] {
|
|
116
|
+
const gzipData = readFileSync(tgzPath);
|
|
117
|
+
const tarData = gunzipSync(new Uint8Array(gzipData));
|
|
118
|
+
const entries = parseTar(tarData);
|
|
119
|
+
return entries
|
|
120
|
+
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
121
|
+
.map(f => ({ packagePath: f.name, content: f.text, packageName }));
|
|
122
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** E2E tests for wgsl-play component using Playwright. */
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
|
|
4
|
+
test("canvas renders correctly (visual regression)", async ({ page }) => {
|
|
5
|
+
await page.goto("/");
|
|
6
|
+
|
|
7
|
+
// Wait for WebGPU to initialize and render
|
|
8
|
+
await page.waitForTimeout(1000);
|
|
9
|
+
|
|
10
|
+
// Rewind to t=0 for consistent snapshot
|
|
11
|
+
await page.getByRole("button", { name: "Rewind" }).click();
|
|
12
|
+
await page.waitForTimeout(100);
|
|
13
|
+
|
|
14
|
+
const player = page.locator("wgsl-play");
|
|
15
|
+
await expect(player).toHaveScreenshot("gradient-t0.png", {
|
|
16
|
+
maxDiffPixelRatio: 0.01,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("no critical console errors", async ({ page }) => {
|
|
21
|
+
const errors: string[] = [];
|
|
22
|
+
page.on("console", msg => {
|
|
23
|
+
if (msg.type() === "error") {
|
|
24
|
+
const text = msg.text();
|
|
25
|
+
if (!text.includes("favicon")) {
|
|
26
|
+
errors.push(text);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await page.goto("/");
|
|
32
|
+
await page.waitForTimeout(2000);
|
|
33
|
+
|
|
34
|
+
const criticalErrors = errors.filter(
|
|
35
|
+
e => !e.includes("favicon") && !e.includes("404"),
|
|
36
|
+
);
|
|
37
|
+
expect(criticalErrors).toEqual([]);
|
|
38
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>wgsl-play test</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin: 20px; background: #1a1a1a; color: #e0e0e0; font-family: sans-serif; }
|
|
9
|
+
wgsl-play { width: 512px; height: 512px; display: block; }
|
|
10
|
+
.controls { margin-bottom: 10px; }
|
|
11
|
+
button { padding: 6px 12px; margin-right: 8px; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div class="controls">
|
|
16
|
+
<button id="play">Play</button>
|
|
17
|
+
<button id="pause">Pause</button>
|
|
18
|
+
<button id="rewind">Rewind</button>
|
|
19
|
+
</div>
|
|
20
|
+
<wgsl-play id="player">
|
|
21
|
+
@fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
22
|
+
let uv = pos.xy / 512.0;
|
|
23
|
+
return vec4f(uv, 0.5, 1.0);
|
|
24
|
+
}
|
|
25
|
+
</wgsl-play>
|
|
26
|
+
<script type="module" src="./main.ts"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "../src/index.ts";
|
|
2
|
+
import type { WgslPlay } from "../src/WgslPlay.ts";
|
|
3
|
+
|
|
4
|
+
const player = document.querySelector<WgslPlay>("#player")!;
|
|
5
|
+
|
|
6
|
+
document.querySelector("#play")!.addEventListener("click", () => player.play());
|
|
7
|
+
document
|
|
8
|
+
.querySelector("#pause")!
|
|
9
|
+
.addEventListener("click", () => player.pause());
|
|
10
|
+
document
|
|
11
|
+
.querySelector("#rewind")!
|
|
12
|
+
.addEventListener("click", () => player.rewind());
|
package/tsconfig.json
ADDED