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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
3
|
+
import { testPort } from "./playwright.port.ts";
|
|
4
|
+
|
|
5
|
+
const port = process.env.PLAYWRIGHT_PORT || testPort;
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
webServer: {
|
|
9
|
+
command: `pnpm run dev --port ${port}`,
|
|
10
|
+
url: `http://localhost:${port}`,
|
|
11
|
+
reuseExistingServer: !process.env.CI,
|
|
12
|
+
stdout: "pipe",
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
testDir: "./src/test",
|
|
16
|
+
testMatch: "**/*.e2e.ts",
|
|
17
|
+
fullyParallel: true,
|
|
18
|
+
forbidOnly: !!process.env.CI,
|
|
19
|
+
retries: process.env.CI ? 2 : 0,
|
|
20
|
+
workers: process.env.CI ? 1 : undefined,
|
|
21
|
+
reporter: "html",
|
|
22
|
+
use: {
|
|
23
|
+
baseURL: `http://localhost:${port}`,
|
|
24
|
+
trace: "on-first-retry",
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
projects: [
|
|
28
|
+
{
|
|
29
|
+
name: "chromium",
|
|
30
|
+
use: { ...devices["Desktop Chrome"] },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { init, parse } from "es-module-lexer";
|
|
2
|
+
import type { WeslBundle } from "wesl";
|
|
3
|
+
|
|
4
|
+
// Initialize es-module-lexer WASM once at module load
|
|
5
|
+
const initPromise = init;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bundle hydration for WESL packages.
|
|
9
|
+
*
|
|
10
|
+
* Takes bundle JS source files (strings) and returns WeslBundle objects with
|
|
11
|
+
* resolved cross-references. Each bundle file is an ES module that exports a
|
|
12
|
+
* `weslBundle` object containing shader source and metadata, and declares
|
|
13
|
+
* dependencies via imports:
|
|
14
|
+
*
|
|
15
|
+
* import dependency from "other-package";
|
|
16
|
+
* export const weslBundle = { ..., dependencies: [dependency] };
|
|
17
|
+
*
|
|
18
|
+
* We use es-module-lexer to parse imports without executing code, reconstruct
|
|
19
|
+
* the dependency graph, then hydrate bundles in dependency order using
|
|
20
|
+
* Function() constructor with dependency injection. Returns WeslBundle objects
|
|
21
|
+
* where dependency references are resolved to actual bundle objects.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// This relies the generated weslBundle.js using a conventional format. LATER generalize
|
|
25
|
+
|
|
26
|
+
/** Bundle file from filesystem, tgz, or network source. (i.e. a weslBundle.js file) */
|
|
27
|
+
export interface WeslBundleFile {
|
|
28
|
+
/** for debug, normally weslBundle.js */
|
|
29
|
+
packagePath: string;
|
|
30
|
+
|
|
31
|
+
/** JavaScript source code */
|
|
32
|
+
content: string;
|
|
33
|
+
|
|
34
|
+
/** name of the wesl package (the npm package name, sanitized to remove @ and -) */
|
|
35
|
+
packageName: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BundleInfo {
|
|
39
|
+
/** JavaScript source containing the weslBundle object literal */
|
|
40
|
+
bundleLiteral: string;
|
|
41
|
+
|
|
42
|
+
imports: Array<{ varName: string; path: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Registry of parsed bundle info, keyed by import path. */
|
|
46
|
+
export type BundleRegistry = Map<string, BundleInfo>;
|
|
47
|
+
|
|
48
|
+
/** Fetcher callback for loading missing packages on-demand. */
|
|
49
|
+
type PackageFetcher = (pkgName: string) => Promise<WeslBundleFile[]>;
|
|
50
|
+
|
|
51
|
+
// Matches: package/dist/foo/bar/weslBundle.js (captures "foo/bar")
|
|
52
|
+
const nestedBundlePattern = /package\/dist\/(.+)\/weslBundle\.js$/;
|
|
53
|
+
|
|
54
|
+
// Matches: package/dist/weslBundle.js
|
|
55
|
+
const rootBundlePattern = /package\/dist\/weslBundle\.js$/;
|
|
56
|
+
|
|
57
|
+
/** Load WeslBundle objects from BundleFile sources. */
|
|
58
|
+
export async function loadBundlesFromFiles(
|
|
59
|
+
bundleFiles: WeslBundleFile[],
|
|
60
|
+
): Promise<WeslBundle[]> {
|
|
61
|
+
const registry = await bundleRegistry(bundleFiles);
|
|
62
|
+
return hydrateBundleRegistry(registry);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse bundle files into a registry without hydrating. */
|
|
66
|
+
export async function bundleRegistry(
|
|
67
|
+
bundleFiles: WeslBundleFile[],
|
|
68
|
+
registry: BundleRegistry = new Map(),
|
|
69
|
+
): Promise<BundleRegistry> {
|
|
70
|
+
await initPromise;
|
|
71
|
+
for (const file of bundleFiles) {
|
|
72
|
+
const { content, packagePath, packageName } = file;
|
|
73
|
+
const bundleInfo = parseBundleImports(content);
|
|
74
|
+
const modulePath = filePathToModulePath(packagePath, packageName);
|
|
75
|
+
registry.set(modulePath, bundleInfo);
|
|
76
|
+
}
|
|
77
|
+
return registry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Hydrate bundles, optionally fetching missing packages on-demand. */
|
|
81
|
+
export async function hydrateBundleRegistry(
|
|
82
|
+
registry: BundleRegistry,
|
|
83
|
+
fetcher?: PackageFetcher,
|
|
84
|
+
): Promise<WeslBundle[]> {
|
|
85
|
+
const hydrated = new Map<string, WeslBundle>();
|
|
86
|
+
const bundles: WeslBundle[] = [];
|
|
87
|
+
for (const path of registry.keys()) {
|
|
88
|
+
bundles.push(await hydrateBundle(path, registry, hydrated, fetcher));
|
|
89
|
+
}
|
|
90
|
+
return bundles;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Matches: export const weslBundle = { ... };
|
|
94
|
+
const weslBundleExportPattern =
|
|
95
|
+
/export\s+const\s+weslBundle\s*=\s*({[\s\S]+});?\s*$/m;
|
|
96
|
+
|
|
97
|
+
// Matches: import foo from "package" (captures "foo")
|
|
98
|
+
const importVarPattern = /import\s+(\w+)\s+from/;
|
|
99
|
+
|
|
100
|
+
/** Parse ES module imports from bundle code using es-module-lexer. */
|
|
101
|
+
function parseBundleImports(code: string): BundleInfo {
|
|
102
|
+
const exportMatch = code.match(weslBundleExportPattern);
|
|
103
|
+
if (!exportMatch) {
|
|
104
|
+
throw new Error("Could not find weslBundle export in bundle");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const [imports] = parse(code);
|
|
108
|
+
const parsedImports = imports.map(imp => {
|
|
109
|
+
const statement = code.slice(imp.ss, imp.se);
|
|
110
|
+
const match = statement.match(importVarPattern);
|
|
111
|
+
if (!match) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Could not parse import variable name from: ${statement}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { varName: match[1], path: imp.n!.replace(/\//g, "::") };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { bundleLiteral: exportMatch[1], imports: parsedImports };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Recursively hydrate a bundle with dependency injection.
|
|
124
|
+
*
|
|
125
|
+
* Uses Function() constructor to evaluate the bundle object literal with
|
|
126
|
+
* dependencies injected as parameters. Supports circular dependencies via
|
|
127
|
+
* placeholder objects.
|
|
128
|
+
*
|
|
129
|
+
* If fetcher is provided and a dependency is missing, fetches the package
|
|
130
|
+
* on-demand, adds to registry, and continues. Otherwise throws on missing deps.
|
|
131
|
+
*/
|
|
132
|
+
async function hydrateBundle(
|
|
133
|
+
path: string,
|
|
134
|
+
registry: BundleRegistry,
|
|
135
|
+
hydrated: Map<string, WeslBundle>,
|
|
136
|
+
fetcher?: PackageFetcher,
|
|
137
|
+
): Promise<WeslBundle> {
|
|
138
|
+
const cached = hydrated.get(path);
|
|
139
|
+
if (cached) return cached;
|
|
140
|
+
|
|
141
|
+
let info = registry.get(path);
|
|
142
|
+
if (!info) {
|
|
143
|
+
if (!fetcher) throw new Error(`Bundle not found in registry: ${path}`);
|
|
144
|
+
|
|
145
|
+
const pkgName = path.split("::")[0];
|
|
146
|
+
const files = await fetcher(pkgName);
|
|
147
|
+
await bundleRegistry(files, registry);
|
|
148
|
+
info = registry.get(path);
|
|
149
|
+
if (!info) throw new Error(`Bundle not found after fetch: ${path}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Create placeholder before recursing to handle cycles
|
|
153
|
+
const placeholder = {} as WeslBundle;
|
|
154
|
+
hydrated.set(path, placeholder);
|
|
155
|
+
|
|
156
|
+
// Recursively hydrate dependencies (will auto-fetch if missing)
|
|
157
|
+
const paramNames = info.imports.map(imp => imp.varName);
|
|
158
|
+
const paramValues = await Promise.all(
|
|
159
|
+
info.imports.map(imp =>
|
|
160
|
+
hydrateBundle(imp.path, registry, hydrated, fetcher),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Hydrate and fill placeholder with actual bundle data
|
|
165
|
+
const fn = new Function(
|
|
166
|
+
...paramNames,
|
|
167
|
+
`'use strict'; return ${info.bundleLiteral}`,
|
|
168
|
+
);
|
|
169
|
+
const bundle = fn(...paramValues) as WeslBundle;
|
|
170
|
+
Object.assign(placeholder, bundle);
|
|
171
|
+
|
|
172
|
+
return placeholder;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Convert bundle file path to module path (e.g., "package/dist/math/consts/weslBundle.js" => "pkg::math::consts"). */
|
|
176
|
+
function filePathToModulePath(filePath: string, packageName: string): string {
|
|
177
|
+
const multiMatch = filePath.match(nestedBundlePattern);
|
|
178
|
+
if (multiMatch) {
|
|
179
|
+
const subpath = multiMatch[1].replace(/\//g, "::");
|
|
180
|
+
return `${packageName}::${subpath}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const singleMatch = filePath.match(rootBundlePattern);
|
|
184
|
+
if (singleMatch) return packageName;
|
|
185
|
+
|
|
186
|
+
throw new Error(`Invalid bundle file path: ${filePath}`);
|
|
187
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { gunzipSync } from "fflate";
|
|
2
|
+
import { type ParsedTarFileItem, parseTar } from "nanotar";
|
|
3
|
+
import type { WeslBundle } from "wesl";
|
|
4
|
+
import { loadBundlesFromFiles, type WeslBundleFile } from "./BundleHydrator.ts";
|
|
5
|
+
|
|
6
|
+
/** Load bundles from URL or npm package name, return bundles, package name, and resolved tgz URL. */
|
|
7
|
+
export async function loadBundlesWithPackageName(
|
|
8
|
+
input: string,
|
|
9
|
+
): Promise<{ bundles: WeslBundle[]; packageName: string; tgzUrl: string }> {
|
|
10
|
+
const tgzUrl = await resolvePackageInput(input);
|
|
11
|
+
const entries = await fetchAndExtractTgz(tgzUrl);
|
|
12
|
+
|
|
13
|
+
const bundleFilesWithoutPackage = entries
|
|
14
|
+
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
15
|
+
.map(f => ({ packagePath: f.name, content: f.text }));
|
|
16
|
+
|
|
17
|
+
if (bundleFilesWithoutPackage.length === 0) {
|
|
18
|
+
throw new Error("No bundle files found in package");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const packageName = extractPackageName(bundleFilesWithoutPackage[0].content);
|
|
22
|
+
const bundleFiles: WeslBundleFile[] = bundleFilesWithoutPackage.map(f => ({
|
|
23
|
+
...f,
|
|
24
|
+
packageName,
|
|
25
|
+
}));
|
|
26
|
+
const bundles = await loadBundlesFromFiles(bundleFiles);
|
|
27
|
+
|
|
28
|
+
return { bundles, packageName, tgzUrl };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Load WESL bundles from a tgz URL. */
|
|
32
|
+
export async function loadBundlesFromTgz(
|
|
33
|
+
tgzUrl: string,
|
|
34
|
+
packageName: string,
|
|
35
|
+
): Promise<WeslBundle[]> {
|
|
36
|
+
const entries = await fetchAndExtractTgz(tgzUrl);
|
|
37
|
+
const bundleFiles: WeslBundleFile[] = entries
|
|
38
|
+
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
39
|
+
.map(f => ({ packagePath: f.name, content: f.text, packageName }));
|
|
40
|
+
|
|
41
|
+
return loadBundlesFromFiles(bundleFiles);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Custom tgz URL for lygia (npm package is outdated). */
|
|
45
|
+
export const lygiaTgzUrl =
|
|
46
|
+
"https://raw.githubusercontent.com/mighdoll/big-files/refs/heads/main/lygia-1.3.5-rc.2.tgz";
|
|
47
|
+
|
|
48
|
+
/** Resolve input to a tgz URL (converts npm package names to registry URLs). */
|
|
49
|
+
async function resolvePackageInput(input: string): Promise<string> {
|
|
50
|
+
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
if (input === "lygia") return lygiaTgzUrl;
|
|
54
|
+
return npmPackageToUrl(input);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract package name from bundle file content. */
|
|
58
|
+
function extractPackageName(bundleContent: string): string {
|
|
59
|
+
const nameMatch = bundleContent.match(/name:\s*"([^"]+)"/);
|
|
60
|
+
if (!nameMatch) {
|
|
61
|
+
throw new Error("Could not extract package name from bundle");
|
|
62
|
+
}
|
|
63
|
+
return nameMatch[1];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fetch and extract tgz file, returning tar entries. */
|
|
67
|
+
async function fetchAndExtractTgz(
|
|
68
|
+
tgzUrl: string,
|
|
69
|
+
): Promise<ParsedTarFileItem[]> {
|
|
70
|
+
const response = await fetch(tgzUrl);
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new Error(`Failed to fetch package: HTTP ${response.status}`);
|
|
73
|
+
}
|
|
74
|
+
const gzipData = new Uint8Array(await response.arrayBuffer());
|
|
75
|
+
const tarData = gunzipSync(gzipData);
|
|
76
|
+
return parseTar(tarData);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fetch bundle files from a tgz URL (without evaluating). */
|
|
80
|
+
export async function fetchBundleFilesFromUrl(
|
|
81
|
+
tgzUrl: string,
|
|
82
|
+
): Promise<WeslBundleFile[]> {
|
|
83
|
+
const entries = await fetchAndExtractTgz(tgzUrl);
|
|
84
|
+
const bundleFilesWithoutPkg = entries
|
|
85
|
+
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
86
|
+
.map(f => ({ packagePath: f.name, content: f.text }));
|
|
87
|
+
|
|
88
|
+
if (bundleFilesWithoutPkg.length === 0) return [];
|
|
89
|
+
|
|
90
|
+
const pkgName = extractPackageName(bundleFilesWithoutPkg[0].content);
|
|
91
|
+
return bundleFilesWithoutPkg.map(f => ({ ...f, packageName: pkgName }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetch bundle files from an npm package (without evaluating). */
|
|
95
|
+
export async function fetchBundleFilesFromNpm(
|
|
96
|
+
packageName: string,
|
|
97
|
+
): Promise<WeslBundleFile[]> {
|
|
98
|
+
const tgzUrl = await npmPackageToUrl(packageName);
|
|
99
|
+
return fetchBundleFilesFromUrl(tgzUrl);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Fetch npm package tarball URL from registry metadata. */
|
|
103
|
+
export async function npmPackageToUrl(packageName: string): Promise<string> {
|
|
104
|
+
const metadataUrl = `https://registry.npmjs.org/${packageName}`;
|
|
105
|
+
const response = await fetch(metadataUrl);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Package not found in npm registry: ${packageName}`);
|
|
108
|
+
}
|
|
109
|
+
const metadata = await response.json();
|
|
110
|
+
const latestVersion = metadata["dist-tags"]?.latest;
|
|
111
|
+
if (!latestVersion) {
|
|
112
|
+
throw new Error(`No latest version found for package: ${packageName}`);
|
|
113
|
+
}
|
|
114
|
+
const tarballUrl = metadata.versions?.[latestVersion]?.dist?.tarball;
|
|
115
|
+
if (!tarballUrl) {
|
|
116
|
+
throw new Error(`No tarball URL found for ${packageName}@${latestVersion}`);
|
|
117
|
+
}
|
|
118
|
+
return tarballUrl;
|
|
119
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Manages an error overlay element within a shadow DOM. */
|
|
2
|
+
export class ErrorOverlay {
|
|
3
|
+
private el: HTMLDivElement;
|
|
4
|
+
private _message: string | null = null;
|
|
5
|
+
|
|
6
|
+
constructor(container: ShadowRoot, onDismiss?: () => void) {
|
|
7
|
+
this.el = document.createElement("div");
|
|
8
|
+
this.el.className = "error-overlay";
|
|
9
|
+
if (onDismiss) this.el.addEventListener("click", onDismiss);
|
|
10
|
+
container.appendChild(this.el);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
show(message: string): void {
|
|
14
|
+
this._message = message;
|
|
15
|
+
this.el.textContent = message;
|
|
16
|
+
this.el.classList.add("visible");
|
|
17
|
+
console.error("[wgsl-play]", message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
hide(): void {
|
|
21
|
+
this._message = null;
|
|
22
|
+
this.el.classList.remove("visible");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get visible(): boolean {
|
|
26
|
+
return this.el.classList.contains("visible");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get message(): string | null {
|
|
30
|
+
return this._message;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { WeslBundle } from "wesl";
|
|
2
|
+
import { findUnboundIdents, npmNameVariations, RecordResolver } from "wesl";
|
|
3
|
+
import type { WeslBundleFile } from "./BundleHydrator.ts";
|
|
4
|
+
import { bundleRegistry, hydrateBundleRegistry } from "./BundleHydrator.ts";
|
|
5
|
+
import {
|
|
6
|
+
fetchBundleFilesFromNpm,
|
|
7
|
+
fetchBundleFilesFromUrl,
|
|
8
|
+
lygiaTgzUrl,
|
|
9
|
+
} from "./BundleLoader.ts";
|
|
10
|
+
|
|
11
|
+
/** Shader source with resolved dependency bundles. */
|
|
12
|
+
export interface ShaderWithDeps {
|
|
13
|
+
source: string;
|
|
14
|
+
bundles: WeslBundle[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Fetch bundles for external dependencies detected in shader source. */
|
|
18
|
+
export async function fetchDependenciesForSource(
|
|
19
|
+
source: string,
|
|
20
|
+
): Promise<WeslBundle[]> {
|
|
21
|
+
const packageNames = detectPackageDeps(source);
|
|
22
|
+
return fetchPackages(packageNames);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Load shader text from a url and recursively fetch imported bundles */
|
|
26
|
+
export async function loadShaderFromUrl(url: string): Promise<ShaderWithDeps> {
|
|
27
|
+
const source = await fetchShaderSource(url);
|
|
28
|
+
const packageNames = detectPackageDeps(source);
|
|
29
|
+
const bundles = await fetchPackages(packageNames);
|
|
30
|
+
return { source, bundles };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Detect external package dependencies from shader source. */
|
|
34
|
+
function detectPackageDeps(source: string): string[] {
|
|
35
|
+
const resolver = new RecordResolver({ "./main.wesl": source });
|
|
36
|
+
const unbound = findUnboundIdents(resolver);
|
|
37
|
+
const pkgRefs = unbound.filter(p => p[0] !== "constants" && p[0] !== "test"); // LATER make dynamic
|
|
38
|
+
const weslPackages = pkgRefs.map(p => p[0]);
|
|
39
|
+
return [...new Set(weslPackages)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fetch WESL bundles for packages, auto-fetching dependencies recursively. */
|
|
43
|
+
async function fetchPackages(pkgIds: string[]): Promise<WeslBundle[]> {
|
|
44
|
+
const loaded = new Set<string>();
|
|
45
|
+
|
|
46
|
+
const promisedBundles = pkgIds.map(id => fetchOnePackage(id, loaded));
|
|
47
|
+
const initialFiles = await Promise.all(promisedBundles);
|
|
48
|
+
const registry = await bundleRegistry(initialFiles.flat());
|
|
49
|
+
|
|
50
|
+
return hydrateBundleRegistry(registry, id => fetchOnePackage(id, loaded));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fetch bundle files for a single package. */
|
|
54
|
+
async function fetchOnePackage(
|
|
55
|
+
pkgId: string,
|
|
56
|
+
loaded: Set<string>,
|
|
57
|
+
): Promise<WeslBundleFile[]> {
|
|
58
|
+
if (loaded.has(pkgId)) return []; // already loaded
|
|
59
|
+
loaded.add(pkgId);
|
|
60
|
+
|
|
61
|
+
// Special case for lygia - use custom tgz URL (npm package is outdated)
|
|
62
|
+
if (pkgId === "lygia") {
|
|
63
|
+
return fetchBundleFilesFromUrl(lygiaTgzUrl);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const npmName of npmNameVariations(pkgId)) {
|
|
67
|
+
try {
|
|
68
|
+
return await fetchBundleFilesFromNpm(npmName);
|
|
69
|
+
} catch {
|
|
70
|
+
// Try next variation
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Package not found: ${pkgId}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fetch shader source from URL. */
|
|
77
|
+
async function fetchShaderSource(url: string): Promise<string> {
|
|
78
|
+
const response = await fetch(url);
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
81
|
+
}
|
|
82
|
+
return response.text();
|
|
83
|
+
}
|
package/src/Renderer.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { LinkParams, WeslBundle } from "wesl";
|
|
2
|
+
import { requestWeslDevice } from "wesl";
|
|
3
|
+
import {
|
|
4
|
+
linkAndCreatePipeline,
|
|
5
|
+
renderFrame,
|
|
6
|
+
updateRenderUniforms,
|
|
7
|
+
} from "wesl-gpu";
|
|
8
|
+
|
|
9
|
+
/** WebGPU state */
|
|
10
|
+
export interface RenderState {
|
|
11
|
+
device: GPUDevice;
|
|
12
|
+
canvas: HTMLCanvasElement;
|
|
13
|
+
context: GPUCanvasContext;
|
|
14
|
+
presentationFormat: GPUTextureFormat;
|
|
15
|
+
uniformBuffer: GPUBuffer;
|
|
16
|
+
pipelineLayout: GPUPipelineLayout;
|
|
17
|
+
bindGroup: GPUBindGroup;
|
|
18
|
+
pipeline?: GPURenderPipeline;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Animation state */
|
|
22
|
+
export interface PlaybackState {
|
|
23
|
+
isPlaying: boolean;
|
|
24
|
+
startTime: number;
|
|
25
|
+
pausedDuration: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Initialize WebGPU for a canvas element. */
|
|
29
|
+
export async function initWebGPU(
|
|
30
|
+
canvas: HTMLCanvasElement,
|
|
31
|
+
): Promise<RenderState> {
|
|
32
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
33
|
+
if (!adapter) throw new Error("WebGPU adapter not available");
|
|
34
|
+
|
|
35
|
+
const device = await requestWeslDevice(adapter);
|
|
36
|
+
const context = canvas.getContext("webgpu");
|
|
37
|
+
if (!context) throw new Error("WebGPU context not available");
|
|
38
|
+
|
|
39
|
+
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
|
|
40
|
+
context.configure({
|
|
41
|
+
device,
|
|
42
|
+
format: presentationFormat,
|
|
43
|
+
alphaMode: "opaque",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const uniformBuffer = device.createBuffer({
|
|
47
|
+
size: 32, // vec2f + f32 + padding + vec2f
|
|
48
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Explicit layout for now. LATER will construct layout based on reflection
|
|
52
|
+
const bindGroupLayout = device.createBindGroupLayout({
|
|
53
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: {} }],
|
|
54
|
+
});
|
|
55
|
+
const pipelineLayout = device.createPipelineLayout({
|
|
56
|
+
bindGroupLayouts: [bindGroupLayout],
|
|
57
|
+
});
|
|
58
|
+
const bindGroup = device.createBindGroup({
|
|
59
|
+
layout: bindGroupLayout,
|
|
60
|
+
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
device,
|
|
65
|
+
canvas,
|
|
66
|
+
context,
|
|
67
|
+
presentationFormat,
|
|
68
|
+
uniformBuffer,
|
|
69
|
+
pipelineLayout,
|
|
70
|
+
bindGroup,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type LinkOptions = Pick<
|
|
75
|
+
LinkParams,
|
|
76
|
+
"packageName" | "conditions" | "constants"
|
|
77
|
+
>;
|
|
78
|
+
|
|
79
|
+
/** Compile WESL fragment shader and create render pipeline. */
|
|
80
|
+
export async function createPipeline(
|
|
81
|
+
state: RenderState,
|
|
82
|
+
fragmentSource: string,
|
|
83
|
+
bundles: WeslBundle[],
|
|
84
|
+
options?: LinkOptions,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
state.pipeline = await linkAndCreatePipeline({
|
|
87
|
+
device: state.device,
|
|
88
|
+
fragmentSource,
|
|
89
|
+
bundles,
|
|
90
|
+
format: state.presentationFormat,
|
|
91
|
+
layout: state.pipelineLayout,
|
|
92
|
+
conditions: options?.conditions,
|
|
93
|
+
constants: options?.constants,
|
|
94
|
+
packageName: options?.packageName,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Start the render loop. Returns a stop function. */
|
|
99
|
+
export function startRenderLoop(
|
|
100
|
+
state: RenderState,
|
|
101
|
+
playback: PlaybackState,
|
|
102
|
+
): () => void {
|
|
103
|
+
let animationId: number;
|
|
104
|
+
|
|
105
|
+
function render(): void {
|
|
106
|
+
if (!state.pipeline) {
|
|
107
|
+
animationId = requestAnimationFrame(render);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const time = calculateTime(playback);
|
|
112
|
+
const resolution: [number, number] = [
|
|
113
|
+
state.canvas.width,
|
|
114
|
+
state.canvas.height,
|
|
115
|
+
];
|
|
116
|
+
const mouse: [number, number] = [0.0, 0.0];
|
|
117
|
+
|
|
118
|
+
updateRenderUniforms(
|
|
119
|
+
state.uniformBuffer,
|
|
120
|
+
state.device,
|
|
121
|
+
resolution,
|
|
122
|
+
time,
|
|
123
|
+
mouse,
|
|
124
|
+
);
|
|
125
|
+
renderFrame({
|
|
126
|
+
device: state.device,
|
|
127
|
+
pipeline: state.pipeline,
|
|
128
|
+
bindGroup: state.bindGroup,
|
|
129
|
+
targetView: state.context.getCurrentTexture().createView(),
|
|
130
|
+
});
|
|
131
|
+
animationId = requestAnimationFrame(render);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
animationId = requestAnimationFrame(render);
|
|
135
|
+
|
|
136
|
+
return () => cancelAnimationFrame(animationId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function calculateTime(playback: PlaybackState): number {
|
|
140
|
+
const currentTime = playback.isPlaying
|
|
141
|
+
? performance.now()
|
|
142
|
+
: playback.startTime + playback.pausedDuration;
|
|
143
|
+
return (currentTime - playback.startTime) / 1000;
|
|
144
|
+
}
|
package/src/WgslPlay.css
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
position: relative;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
canvas {
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
9
|
+
display: block;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.error-overlay {
|
|
13
|
+
position: absolute;
|
|
14
|
+
inset: 0;
|
|
15
|
+
background: rgba(200, 0, 0, 0.8);
|
|
16
|
+
color: white;
|
|
17
|
+
padding: 1rem;
|
|
18
|
+
font-family: monospace;
|
|
19
|
+
font-size: 0.875rem;
|
|
20
|
+
white-space: pre-wrap;
|
|
21
|
+
overflow: auto;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
display: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.error-overlay.visible {
|
|
27
|
+
display: block;
|
|
28
|
+
}
|