wgsl-play 0.0.39 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +59 -2
  2. package/dist/{WgslPlay-CSjRo-5Z.d.ts → WgslPlay-ouKx64GN.d.ts} +52 -3
  3. package/dist/WgslPlay-zK9mE5rV.js +1544 -0
  4. package/dist/WgslPlay.d.ts +2 -2
  5. package/dist/WgslPlay.js +2 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/jsx-preact.d.ts +12 -0
  9. package/dist/jsx-preact.js +0 -0
  10. package/dist/wgsl-play.js +1834 -754
  11. package/package.json +12 -8
  12. package/src/CanvasSize.ts +27 -0
  13. package/src/ComputeBuild.ts +151 -0
  14. package/src/FragmentRender.ts +147 -0
  15. package/src/PlaybackControls.ts +36 -13
  16. package/src/RenderResources.ts +132 -0
  17. package/src/Renderer.ts +112 -138
  18. package/src/ResultsPanel.ts +261 -0
  19. package/src/WgslPlay.css +80 -8
  20. package/src/WgslPlay.ts +346 -103
  21. package/src/icons/refresh.svg +1 -0
  22. package/src/jsx-preact.ts +15 -0
  23. package/src/test/BundleHydrator.test.ts +8 -11
  24. package/src/test/CanvasSize.test.ts +48 -0
  25. package/src/test/E2eHelpers.ts +117 -0
  26. package/src/test/RenderFrame.e2e.ts +33 -0
  27. package/src/test/ResultsPanel.test.ts +164 -0
  28. package/src/test/WgslPlay.basics.e2e.ts +87 -0
  29. package/src/test/WgslPlay.compute.e2e.ts +147 -0
  30. package/src/test/WgslPlay.connect.e2e.ts +101 -0
  31. package/src/test/WgslPlay.loading.e2e.ts +68 -0
  32. package/src/test/WgslPlay.resize.e2e.ts +199 -0
  33. package/src/test/WgslPlay.resources.e2e.ts +67 -0
  34. package/src/test/WgslPlay.resources.e2e.ts-snapshots/buffer-zero-init-chromium-darwin.png +0 -0
  35. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-grid-chromium-darwin.png +0 -0
  36. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-green-chromium-darwin.png +0 -0
  37. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-magenta-chromium-darwin.png +0 -0
  38. package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-multi-chromium-darwin.png +0 -0
  39. package/src/test/WgslPlay.uniforms.e2e.ts +57 -0
  40. package/dist/WgslPlay-LsU6XE09.js +0 -933
  41. package/src/test/WgslPlay.e2e.ts +0 -327
  42. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/basic-shader-chromium-darwin.png +0 -0
  43. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-after-red-chromium-darwin.png +0 -0
  44. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-initial-green-chromium-darwin.png +0 -0
  45. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-green-chromium-darwin.png +0 -0
  46. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-red-chromium-darwin.png +0 -0
  47. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-dynamic-npm-chromium-darwin.png +0 -0
  48. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-external-chromium-darwin.png +0 -0
  49. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-multifile-chromium-darwin.png +0 -0
  50. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/link-import-chromium-darwin.png +0 -0
  51. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/npm-cdn-chromium-darwin.png +0 -0
  52. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-internal-chromium-darwin.png +0 -0
  53. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-src-chromium-darwin.png +0 -0
  54. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/static-import-chromium-darwin.png +0 -0
  55. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-initial-chromium-darwin.png +0 -0
  56. /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-slider-changed-chromium-darwin.png +0 -0
package/src/Renderer.ts CHANGED
@@ -1,48 +1,75 @@
1
- import { requestWeslDevice } from "wesl";
1
+ import { parseSrcModule, requestWeslDevice } from "wesl";
2
2
  import {
3
- type AutoValues,
4
3
  createUniformBuffer,
5
- linkAndCreatePipeline,
6
- renderFrame,
7
- scanUniforms,
4
+ type ResolveUserTexture,
8
5
  type UniformBufferState,
9
6
  type WeslOptions,
10
- writeUniforms,
11
7
  } from "wesl-gpu";
12
- import type { AnnotatedLayout } from "wesl-reflect";
8
+ import {
9
+ type AnnotatedLayout,
10
+ classifyEntryPoints,
11
+ type DiscoveredResource,
12
+ type EntryPoint,
13
+ findAnnotatedResources,
14
+ type VarReflection,
15
+ } from "wesl-reflect";
16
+ import { buildCompute } from "./ComputeBuild.ts";
17
+ import { buildFragment } from "./FragmentRender.ts";
18
+ import { prepareResources } from "./RenderResources.ts";
19
+ import type { BufferEntry } from "./ResultsPanel.ts";
13
20
 
14
21
  /** Mutable mouse state, updated by pointer event listeners. */
15
22
  export interface MouseState {
16
23
  pos: [number, number];
17
24
  delta: [number, number];
18
- button: number; // 0=none, 1=left, 2=middle, 3=right
25
+ /** 0=none, 1=left, 2=middle, 3=right */
26
+ button: number;
27
+ }
28
+
29
+ export type RendererMode = "fragment" | "compute";
30
+
31
+ /** Compute-mode runtime state, retained for re-dispatch on slider/refresh. */
32
+ export interface ComputeState {
33
+ pipelineLayout: GPUPipelineLayout;
34
+ bindGroup: GPUBindGroup;
35
+ module: GPUShaderModule;
36
+ entryPoint: string;
37
+ /** Storage buffers to read back, keyed by var name. */
38
+ readBuffers: Map<string, GPUBuffer>;
39
+ /** Buffer type info, keyed by var name. */
40
+ reflections: Map<string, VarReflection>;
19
41
  }
20
42
 
21
- /** WebGPU state */
43
+ /** WebGPU state retained across frames. */
22
44
  export interface RenderState {
23
45
  device: GPUDevice;
24
46
  canvas: HTMLCanvasElement;
25
47
  context: GPUCanvasContext;
26
48
  presentationFormat: GPUTextureFormat;
27
- bindGroupLayout: GPUBindGroupLayout;
28
- pipelineLayout: GPUPipelineLayout;
29
49
  uniformState: UniformBufferState;
30
50
  bindGroup: GPUBindGroup;
31
51
  mouse: MouseState;
32
52
  pipeline?: GPURenderPipeline;
33
53
  frameCount: number;
54
+ /** Textures owned by the last successful compile, disposed on next compile. */
55
+ resourceTextures: GPUTexture[];
56
+ /** Storage buffers owned by the last successful compile, disposed on next compile. */
57
+ resourceBuffers: GPUBuffer[];
58
+ /** Compute-mode runtime info, present iff mode === "compute". */
59
+ compute?: ComputeState;
34
60
  }
35
61
 
36
- /** Animation state */
37
- export interface PlaybackState {
38
- isPlaying: boolean;
39
- startTime: number;
40
- pausedDuration: number;
41
- }
42
-
43
- /** Options for linking shaders - re-exports wesl-gpu's WeslOptions */
62
+ /** Options for linking shaders (re-exported from wesl-gpu). */
44
63
  export type LinkOptions = WeslOptions;
45
64
 
65
+ /** Result of a successful build, returned to the WgslPlay element. */
66
+ export interface BuildResult {
67
+ layout: AnnotatedLayout | null;
68
+ mode: RendererMode;
69
+ /** Present iff mode === "compute"; initial readback after dispatch. */
70
+ computeReadback?: BufferEntry[];
71
+ }
72
+
46
73
  /** Initialize WebGPU for a canvas element. */
47
74
  export async function initWebGPU(
48
75
  canvas: HTMLCanvasElement,
@@ -58,16 +85,9 @@ export async function initWebGPU(
58
85
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
59
86
  context.configure({ device, format: presentationFormat, alphaMode });
60
87
 
61
- const bindGroupLayout = device.createBindGroupLayout({
62
- entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: {} }],
63
- });
64
- const pipelineLayout = device.createPipelineLayout({
65
- bindGroupLayouts: [bindGroupLayout],
66
- });
67
-
68
- // Initial buffer with default layout (will be recreated per-compile)
69
88
  const uniformState = createUniformBuffer(device, null);
70
- const bindGroup = createBindGroup(device, bindGroupLayout, uniformState);
89
+ const layout = uniformOnlyLayout(device);
90
+ const bindGroup = createUniformBindGroup(device, layout, uniformState);
71
91
  const mouse: MouseState = { pos: [0, 0], delta: [0, 0], button: 0 };
72
92
 
73
93
  return {
@@ -75,135 +95,60 @@ export async function initWebGPU(
75
95
  canvas,
76
96
  context,
77
97
  presentationFormat,
78
- bindGroupLayout,
79
- pipelineLayout,
80
98
  uniformState,
81
99
  bindGroup,
82
100
  mouse,
83
101
  pipeline: undefined,
84
102
  frameCount: 0,
103
+ resourceTextures: [],
104
+ resourceBuffers: [],
85
105
  };
86
106
  }
87
107
 
88
- /** Compile WESL fragment shader and create render pipeline.
89
- * Recreates the uniform buffer and bind group based on @uniforms metadata. */
108
+ /** Compile a WESL shader and configure pipelines + bindings.
109
+ * Auto-detects fragment vs compute mode based on entry-point attributes. */
90
110
  export async function createPipeline(
91
111
  state: RenderState,
92
- fragmentSource: string,
112
+ shaderSource: string,
113
+ resolveTexture: ResolveUserTexture,
93
114
  options?: LinkOptions,
94
- ): Promise<AnnotatedLayout | null> {
115
+ ): Promise<BuildResult> {
95
116
  const pkg = options?.packageName ?? "package";
96
117
  const root = options?.rootModuleName ?? "main";
97
- const scan = scanUniforms(fragmentSource, `${pkg}::${root}`);
98
-
99
- const { device, bindGroupLayout, presentationFormat, pipelineLayout } = state;
100
- state.uniformState.buffer.destroy();
101
- state.uniformState = createUniformBuffer(device, scan.layout);
102
- state.bindGroup = createBindGroup(
103
- device,
104
- bindGroupLayout,
105
- state.uniformState,
106
- );
107
-
108
- device.pushErrorScope("validation");
109
- let gpuError: unknown;
110
- let jsError: unknown;
111
- try {
112
- state.pipeline = await linkAndCreatePipeline({
113
- device,
114
- fragmentSource,
115
- format: presentationFormat,
116
- layout: pipelineLayout,
117
- ...options,
118
- });
119
- } catch (e) {
120
- jsError = e;
121
- } finally {
122
- gpuError = await device.popErrorScope();
123
- }
124
- if (jsError || gpuError) {
125
- state.pipeline = undefined;
126
- throw jsError ?? gpuError;
127
- }
128
-
129
- return scan.layout;
130
- }
131
-
132
- /** Render a single frame (used when paused). */
133
- export function renderOnce(state: RenderState, playback: PlaybackState): void {
134
- if (!state.pipeline) return;
135
- doRender(state, playback);
136
- }
137
-
138
- /** Start the render loop. Returns a stop function. */
139
- export function startRenderLoop(
140
- state: RenderState,
141
- playback: PlaybackState,
142
- ): () => void {
143
- let animationId: number;
144
- let lastTime = 0;
145
-
146
- function render(): void {
147
- if (!state.pipeline) {
148
- animationId = requestAnimationFrame(render);
149
- return;
150
- }
118
+ const ast = parseSrcModule({
119
+ modulePath: `${pkg}::${root}`,
120
+ debugFilePath: `./${root}.wesl`,
121
+ src: shaderSource,
122
+ });
123
+ const resources = findAnnotatedResources(ast);
124
+ const entryPoints = classifyEntryPoints(ast);
125
+ const mode = detectMode(entryPoints);
126
+ if (mode === "compute") rejectComputeUnsupported(resources);
127
+
128
+ const setup = await prepareResources({
129
+ state,
130
+ shaderSource,
131
+ pkg,
132
+ root,
133
+ resources,
134
+ resolveTexture,
135
+ });
151
136
 
152
- const time = calculateTime(playback);
153
- const delta_time = time - lastTime;
154
- lastTime = time;
155
-
156
- const { mouse, device, canvas, context, bindGroup } = state;
157
- const auto: AutoValues = {
158
- resolution: [canvas.width, canvas.height],
159
- time,
160
- delta_time,
161
- frame: state.frameCount,
162
- mouse_pos: mouse.pos,
163
- mouse_delta: mouse.delta,
164
- mouse_button: mouse.button,
165
- };
166
- // Reset per-frame deltas after reading
167
- mouse.delta = [0, 0];
168
-
169
- writeUniforms(device, state.uniformState, auto);
170
- const targetView = context.getCurrentTexture().createView();
171
- renderFrame({ device, pipeline: state.pipeline!, bindGroup, targetView });
172
- state.frameCount++;
173
- animationId = requestAnimationFrame(render);
137
+ const branch = { state, resources, shaderSource, options, ...setup };
138
+ if (mode === "compute") {
139
+ return buildCompute({ ...branch, ast, entryPoint: entryPoints[0].fnName });
174
140
  }
175
-
176
- animationId = requestAnimationFrame(render);
177
- return () => cancelAnimationFrame(animationId);
178
- }
179
-
180
- /** Update uniforms and submit one GPU frame (one-shot, e.g. when paused). */
181
- function doRender(state: RenderState, playback: PlaybackState): void {
182
- const time = calculateTime(playback);
183
- const { mouse, device, canvas, context, bindGroup } = state;
184
- const auto: AutoValues = {
185
- resolution: [canvas.width, canvas.height],
186
- time,
187
- delta_time: 0,
188
- frame: state.frameCount,
189
- mouse_pos: mouse.pos,
190
- mouse_delta: [0, 0],
191
- mouse_button: mouse.button,
192
- };
193
- writeUniforms(device, state.uniformState, auto);
194
- const targetView = context.getCurrentTexture().createView();
195
- renderFrame({ device, pipeline: state.pipeline!, bindGroup, targetView });
196
- state.frameCount++;
141
+ return buildFragment(branch);
197
142
  }
198
143
 
199
- export function calculateTime(playback: PlaybackState): number {
200
- const currentTime = playback.isPlaying
201
- ? performance.now()
202
- : playback.startTime + playback.pausedDuration;
203
- return (currentTime - playback.startTime) / 1000;
144
+ function uniformOnlyLayout(device: GPUDevice): GPUBindGroupLayout {
145
+ const visibility = GPUShaderStage.COMPUTE | GPUShaderStage.FRAGMENT;
146
+ return device.createBindGroupLayout({
147
+ entries: [{ binding: 0, visibility, buffer: {} }],
148
+ });
204
149
  }
205
150
 
206
- function createBindGroup(
151
+ function createUniformBindGroup(
207
152
  device: GPUDevice,
208
153
  layout: GPUBindGroupLayout,
209
154
  uniformState: UniformBufferState,
@@ -213,3 +158,32 @@ function createBindGroup(
213
158
  entries: [{ binding: 0, resource: { buffer: uniformState.buffer } }],
214
159
  });
215
160
  }
161
+
162
+ /** Pick fragment vs compute mode from entry points; throws on mixed/multi-compute. */
163
+ function detectMode(entryPoints: EntryPoint[]): RendererMode {
164
+ const compute = entryPoints.filter(e => e.stage === "compute");
165
+ const fragment = entryPoints.filter(e => e.stage === "fragment");
166
+ if (compute.length > 0 && fragment.length > 0) {
167
+ throw new Error(
168
+ "mixed compute and fragment entry points are not supported (use either, not both)",
169
+ );
170
+ }
171
+ if (compute.length > 1) {
172
+ throw new Error(
173
+ `compute mode requires exactly one @compute entry point; found ${compute.length}`,
174
+ );
175
+ }
176
+ return compute.length === 1 ? "compute" : "fragment";
177
+ }
178
+
179
+ /** @sampler/@texture in compute mode are not yet implemented; throw early. */
180
+ function rejectComputeUnsupported(resources: DiscoveredResource[]): void {
181
+ for (const r of resources) {
182
+ if (r.kind === "texture" || r.kind === "test_texture") {
183
+ throw new Error("@texture in compute mode is not yet supported");
184
+ }
185
+ if (r.kind === "sampler") {
186
+ throw new Error("@sampler in compute mode is not yet supported");
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,261 @@
1
+ import {
2
+ type ScalarKind,
3
+ type TypeShape,
4
+ TypeShapeError,
5
+ type VarReflection,
6
+ } from "wesl-reflect";
7
+
8
+ export interface BufferEntry {
9
+ reflection: VarReflection;
10
+ data: ArrayBuffer;
11
+ }
12
+
13
+ export interface RenderPanelParams {
14
+ panel: HTMLElement;
15
+ entries: BufferEntry[];
16
+ }
17
+
18
+ /** Pre-rendered table data, used by both the DOM renderer and unit tests. */
19
+ export interface TableData {
20
+ caption: string;
21
+ headers: string[];
22
+ rows: string[][];
23
+ /** Rows beyond `truncationLimit` (only the first `truncationLimit` are in `rows`). */
24
+ truncated?: { totalRows: number };
25
+ }
26
+
27
+ interface RowShape {
28
+ /** Per-row type: array element type, or the var's type when not an array. */
29
+ row: TypeShape;
30
+ rowCount: number;
31
+ stride: number;
32
+ }
33
+
34
+ const truncationLimit = 256;
35
+
36
+ /** Render compute-mode @buffer readbacks as one HTML table per buffer. */
37
+ export function renderResultsPanel(p: RenderPanelParams): void {
38
+ p.panel.replaceChildren(...p.entries.map(renderTable));
39
+ }
40
+
41
+ /** Build the structured table data for a single @buffer entry. Pure (no DOM). */
42
+ export function tableData(entry: BufferEntry): TableData {
43
+ const { reflection, data } = entry;
44
+ const { row, rowCount, stride } = rowShape(reflection, data.byteLength);
45
+ const visibleRows = Math.min(rowCount, truncationLimit);
46
+ const view = new DataView(data);
47
+
48
+ const caption = `${reflection.varName}: ${shapeLabel(reflection.type)}`;
49
+ const headers =
50
+ row.kind === "struct"
51
+ ? ["", ...row.fields.map(f => f.name)]
52
+ : ["", "value"];
53
+
54
+ const rows = buildRows(view, row, visibleRows, stride);
55
+ const td: TableData = { caption, headers, rows };
56
+ if (rowCount > truncationLimit) td.truncated = { totalRows: rowCount };
57
+ return td;
58
+ }
59
+
60
+ function renderTable(entry: BufferEntry): HTMLElement {
61
+ const td = tableData(entry);
62
+ const section = document.createElement("section");
63
+ section.className = "result";
64
+ section.appendChild(makeCaption(td.caption));
65
+ const table = document.createElement("table");
66
+ table.appendChild(makeHeader(td.headers));
67
+ table.appendChild(makeBody(td.rows));
68
+ if (td.truncated) {
69
+ table.appendChild(makeShowAllFooter(table, entry, td.truncated.totalRows));
70
+ }
71
+ section.appendChild(table);
72
+ return section;
73
+ }
74
+
75
+ /** Resolve the per-row shape and stride for tabular display. Runtime-sized
76
+ * arrays derive rowCount from the readback's byteLength. Throws
77
+ * TypeShapeError if the element type isn't supported as a table cell. */
78
+ function rowShape(reflection: VarReflection, byteLength: number): RowShape {
79
+ const t = reflection.type;
80
+ if (t.kind === "array") {
81
+ assertRenderable(t.elem, reflection.varName);
82
+ const rowCount =
83
+ t.length === "runtime" ? Math.floor(byteLength / t.stride) : t.length;
84
+ return { row: t.elem, rowCount, stride: t.stride };
85
+ }
86
+ assertRenderable(t, reflection.varName);
87
+ return { row: t, rowCount: 1, stride: t.size };
88
+ }
89
+
90
+ function shapeLabel(t: TypeShape): string {
91
+ if (t.kind === "array") {
92
+ const len = t.length === "runtime" ? "" : `, ${t.length}`;
93
+ return `array<${typeLabel(t.elem)}${len}>`;
94
+ }
95
+ return typeLabel(t);
96
+ }
97
+
98
+ /** Materialize `count` row-cell arrays at successive `stride` offsets. */
99
+ function buildRows(
100
+ view: DataView,
101
+ row: TypeShape,
102
+ count: number,
103
+ stride: number,
104
+ ): string[][] {
105
+ return Array.from({ length: count }, (_, i) =>
106
+ rowCells(view, row, i, i * stride),
107
+ );
108
+ }
109
+
110
+ function makeCaption(text: string): HTMLElement {
111
+ const caption = document.createElement("div");
112
+ caption.className = "result-caption";
113
+ caption.textContent = text;
114
+ return caption;
115
+ }
116
+
117
+ function makeHeader(headers: string[]): HTMLElement {
118
+ const thead = document.createElement("thead");
119
+ const tr = document.createElement("tr");
120
+ for (const h of headers) {
121
+ const th = document.createElement("th");
122
+ th.textContent = h;
123
+ tr.appendChild(th);
124
+ }
125
+ thead.appendChild(tr);
126
+ return thead;
127
+ }
128
+
129
+ function makeBody(rows: string[][]): HTMLElement {
130
+ const tbody = document.createElement("tbody");
131
+ for (const row of rows) {
132
+ const tr = document.createElement("tr");
133
+ for (const cell of row) {
134
+ const td = document.createElement("td");
135
+ td.textContent = cell;
136
+ tr.appendChild(td);
137
+ }
138
+ tbody.appendChild(tr);
139
+ }
140
+ return tbody;
141
+ }
142
+
143
+ function makeShowAllFooter(
144
+ table: HTMLTableElement,
145
+ entry: BufferEntry,
146
+ totalRows: number,
147
+ ): HTMLElement {
148
+ const tfoot = document.createElement("tfoot");
149
+ const tr = document.createElement("tr");
150
+ const td = document.createElement("td");
151
+ const { row, stride } = rowShape(entry.reflection, entry.data.byteLength);
152
+ td.colSpan = row.kind === "struct" ? row.fields.length + 1 : 2;
153
+ const button = document.createElement("button");
154
+ button.textContent = `Show all ${totalRows} rows`;
155
+ button.addEventListener("click", () => {
156
+ const view = new DataView(entry.data);
157
+ const allRows = buildRows(view, row, totalRows, stride);
158
+ table.replaceChild(makeBody(allRows), table.tBodies[0]);
159
+ tfoot.remove();
160
+ });
161
+ td.appendChild(
162
+ document.createTextNode(
163
+ `Showing first ${truncationLimit} of ${totalRows} rows. `,
164
+ ),
165
+ );
166
+ td.appendChild(button);
167
+ tr.appendChild(td);
168
+ tfoot.appendChild(tr);
169
+ return tfoot;
170
+ }
171
+
172
+ /** WgslPlay's compute-results table only renders f32/i32/u32 scalars, vectors of those, or structs of those. */
173
+ function assertRenderable(t: TypeShape, varName: string): void {
174
+ if (t.kind === "scalar") {
175
+ if (t.type === "f16") throw notRenderable("f16", varName);
176
+ return;
177
+ }
178
+ if (t.kind === "vec") {
179
+ if (t.component === "f16") throw notRenderable("vec<f16>", varName);
180
+ return;
181
+ }
182
+ if (t.kind === "struct") {
183
+ for (const f of t.fields) assertRenderable(f.type, varName);
184
+ return;
185
+ }
186
+ if (t.kind === "mat") {
187
+ throw new TypeShapeError(
188
+ `matrices are not supported in the compute results table; use vecN<f32> instead`,
189
+ varName,
190
+ );
191
+ }
192
+ if (t.kind === "atomic") {
193
+ throw new TypeShapeError(
194
+ `atomic types are not supported in the compute results table`,
195
+ varName,
196
+ );
197
+ }
198
+ throw new TypeShapeError(`unsupported element type for table`, varName);
199
+ }
200
+
201
+ function typeLabel(t: TypeShape): string {
202
+ if (t.kind === "scalar") return t.type;
203
+ if (t.kind === "vec") return `vec${t.n}<${t.component}>`;
204
+ if (t.kind === "mat") return `mat${t.cols}x${t.rows}<${t.component}>`;
205
+ if (t.kind === "atomic") return `atomic<${t.component}>`;
206
+ if (t.kind === "struct") return t.name;
207
+ const len = t.length === "runtime" ? "" : `, ${t.length}`;
208
+ return `array<${typeLabel(t.elem)}${len}>`;
209
+ }
210
+
211
+ function rowCells(
212
+ view: DataView,
213
+ row: TypeShape,
214
+ rowIndex: number,
215
+ baseOffset: number,
216
+ ): string[] {
217
+ if (row.kind === "struct") {
218
+ return [
219
+ String(rowIndex),
220
+ ...row.fields.map(f => formatElem(view, f.type, baseOffset + f.offset)),
221
+ ];
222
+ }
223
+ return [String(rowIndex), formatElem(view, row, baseOffset)];
224
+ }
225
+
226
+ function notRenderable(typeName: string, varName: string): TypeShapeError {
227
+ return new TypeShapeError(
228
+ `${typeName} is not supported in the compute results table`,
229
+ varName,
230
+ );
231
+ }
232
+
233
+ function formatElem(view: DataView, elem: TypeShape, offset: number): string {
234
+ if (elem.kind === "scalar") return formatScalar(view, elem.type, offset);
235
+ if (elem.kind === "vec") {
236
+ const joined = Array.from({ length: elem.n }, (_, i) =>
237
+ formatScalar(view, elem.component, offset + i * 4),
238
+ ).join(", ");
239
+ return `(${joined})`;
240
+ }
241
+ return `<${elem.kind}>`;
242
+ }
243
+
244
+ function formatScalar(
245
+ view: DataView,
246
+ type: ScalarKind,
247
+ offset: number,
248
+ ): string {
249
+ if (type === "f32") return formatF32(view.getFloat32(offset, true));
250
+ if (type === "i32") return String(view.getInt32(offset, true));
251
+ if (type === "u32") return String(view.getUint32(offset, true));
252
+ if (type === "bool")
253
+ return view.getUint32(offset, true) !== 0 ? "true" : "false";
254
+ return "<f16>";
255
+ }
256
+
257
+ function formatF32(v: number): string {
258
+ if (!Number.isFinite(v)) return String(v);
259
+ if (Number.isInteger(v)) return v.toFixed(1);
260
+ return Number.parseFloat(v.toPrecision(4)).toString();
261
+ }
package/src/WgslPlay.css CHANGED
@@ -14,28 +14,52 @@
14
14
  .resize-handle {
15
15
  display: none;
16
16
  position: absolute;
17
+ width: 22px;
18
+ height: 22px;
19
+ z-index: 2;
20
+ touch-action: none;
21
+ opacity: 0;
22
+ transition: opacity 0.15s;
23
+ }
24
+
25
+ :host([resizable]) .resize-handle {
26
+ display: block;
27
+ }
28
+
29
+ .resize-handle.br {
17
30
  bottom: 0;
18
31
  right: 0;
19
- width: 16px;
20
- height: 16px;
21
32
  cursor: nwse-resize;
22
- z-index: 2;
23
- touch-action: none;
33
+ }
34
+
35
+ .resize-handle.bl {
36
+ bottom: 0;
37
+ left: 0;
38
+ cursor: nesw-resize;
39
+ }
40
+
41
+ .resize-handle:hover,
42
+ .resize-handle.dragging {
43
+ opacity: 1;
24
44
  }
25
45
 
26
46
  .resize-handle::before {
27
47
  content: "";
28
48
  position: absolute;
29
49
  bottom: 3px;
30
- right: 3px;
31
50
  width: 8px;
32
51
  height: 8px;
33
- border-right: 2px solid var(--controls-bg);
34
52
  border-bottom: 2px solid var(--controls-bg);
35
53
  }
36
54
 
37
- :host([resizable]:hover) .resize-handle {
38
- display: block;
55
+ .resize-handle.br::before {
56
+ right: 3px;
57
+ border-right: 2px solid var(--controls-bg);
58
+ }
59
+
60
+ .resize-handle.bl::before {
61
+ left: 3px;
62
+ border-left: 2px solid var(--controls-bg);
39
63
  }
40
64
 
41
65
  :host(.dark) {
@@ -86,6 +110,54 @@ canvas {
86
110
  background: var(--controls-hover-bg);
87
111
  }
88
112
 
113
+ .controls button[hidden] {
114
+ display: none;
115
+ }
116
+
117
+ /* Compute-mode results panel */
118
+ .results-panel {
119
+ font-family: var(--code-font, monospace);
120
+ font-size: 0.85rem;
121
+ padding: 8px;
122
+ box-sizing: border-box;
123
+ color: var(--results-fg, currentColor);
124
+ background: var(--results-bg, transparent);
125
+ }
126
+
127
+ .results-panel[hidden] {
128
+ display: none;
129
+ }
130
+
131
+ .results-panel .result {
132
+ margin-bottom: 24px;
133
+ }
134
+
135
+ .results-panel .result-caption {
136
+ font-weight: bold;
137
+ padding-bottom: 8px;
138
+ }
139
+
140
+ .results-panel table {
141
+ border-collapse: collapse;
142
+ }
143
+
144
+ .results-panel th,
145
+ .results-panel td {
146
+ padding: 2px 8px;
147
+ border: 1px solid var(--results-border, rgba(128, 128, 128, 0.3));
148
+ text-align: right;
149
+ font-variant-numeric: tabular-nums;
150
+ }
151
+
152
+ .results-panel tfoot td {
153
+ text-align: left;
154
+ }
155
+
156
+ .results-panel tfoot button {
157
+ margin-left: 6px;
158
+ cursor: pointer;
159
+ }
160
+
89
161
  /* Settings panel */
90
162
  .settings {
91
163
  position: absolute;