wesl-gpu 0.1.1
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 +4 -0
- package/dist/index.js +495 -0
- package/package.json +36 -0
- package/src/DeviceCache.ts +21 -0
- package/src/ErrorScopes.ts +30 -0
- package/src/ExampleTextures.ts +293 -0
- package/src/FragmentPipeline.ts +104 -0
- package/src/FragmentRender.ts +30 -0
- package/src/FullscreenVertex.ts +18 -0
- package/src/RenderUniforms.ts +106 -0
- package/src/SimpleRender.ts +139 -0
- package/src/index.ts +8 -0
- package/tsconfig.json +3 -0
package/README.md
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { CompositeResolver, RecordResolver, link } from "wesl";
|
|
2
|
+
import { withTextureCopy } from "thimbleberry";
|
|
3
|
+
|
|
4
|
+
//#region src/DeviceCache.ts
|
|
5
|
+
/**
|
|
6
|
+
* WeakMap cache for GPUDevice-based caching
|
|
7
|
+
*
|
|
8
|
+
* GPUDevices are weakly held to avoid memory leaks.
|
|
9
|
+
*/
|
|
10
|
+
var DeviceCache = class {
|
|
11
|
+
cache = /* @__PURE__ */ new WeakMap();
|
|
12
|
+
get(device, key) {
|
|
13
|
+
return this.cache.get(device)?.get(key);
|
|
14
|
+
}
|
|
15
|
+
set(device, key, value) {
|
|
16
|
+
let deviceCache = this.cache.get(device);
|
|
17
|
+
if (!deviceCache) {
|
|
18
|
+
deviceCache = /* @__PURE__ */ new Map();
|
|
19
|
+
this.cache.set(device, deviceCache);
|
|
20
|
+
}
|
|
21
|
+
deviceCache.set(key, value);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/ErrorScopes.ts
|
|
27
|
+
/**
|
|
28
|
+
* Runs a function with WebGPU error scopes, automatically handling push/pop/check.
|
|
29
|
+
* Throws if any validation, out-of-memory, or internal errors occur.
|
|
30
|
+
*/
|
|
31
|
+
async function withErrorScopes(device, fn) {
|
|
32
|
+
device.pushErrorScope("internal");
|
|
33
|
+
device.pushErrorScope("out-of-memory");
|
|
34
|
+
device.pushErrorScope("validation");
|
|
35
|
+
const result = await fn();
|
|
36
|
+
const validationError = await device.popErrorScope();
|
|
37
|
+
const oomError = await device.popErrorScope();
|
|
38
|
+
const internalError = await device.popErrorScope();
|
|
39
|
+
if (validationError) throw new Error(`WebGPU validation error: ${validationError.message}`);
|
|
40
|
+
if (oomError) throw new Error(`WebGPU out-of-memory error: ${oomError.message}`);
|
|
41
|
+
if (internalError) throw new Error(`WebGPU internal error: ${internalError.message}`);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/ExampleTextures.ts
|
|
47
|
+
const textureCache = new DeviceCache();
|
|
48
|
+
const samplerCache = new DeviceCache();
|
|
49
|
+
/** Create texture filled with solid color. Internally cached. */
|
|
50
|
+
function solidTexture(device, color, width, height) {
|
|
51
|
+
return cachedTexture(device, `solid:${color.join(",")}:${width}x${height}`, `test-texture-solid-${color.join(",")}`, width, height, (data) => {
|
|
52
|
+
for (let i = 0; i < width * height; i++) {
|
|
53
|
+
data[i * 4 + 0] = Math.round(color[0] * 255);
|
|
54
|
+
data[i * 4 + 1] = Math.round(color[1] * 255);
|
|
55
|
+
data[i * 4 + 2] = Math.round(color[2] * 255);
|
|
56
|
+
data[i * 4 + 3] = Math.round(color[3] * 255);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Create gradient texture. Direction: 'horizontal' (default) or 'vertical'. */
|
|
61
|
+
function gradientTexture(device, width, height, direction = "horizontal") {
|
|
62
|
+
return cachedTexture(device, `gradient:${direction}:${width}x${height}`, `test-texture-gradient-${direction}`, width, height, (data) => {
|
|
63
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
64
|
+
const idx = (y * width + x) * 4;
|
|
65
|
+
const gradient = direction === "horizontal" ? x / (width - 1) : y / (height - 1);
|
|
66
|
+
const value = Math.round(gradient * 255);
|
|
67
|
+
data[idx + 0] = value;
|
|
68
|
+
data[idx + 1] = value;
|
|
69
|
+
data[idx + 2] = value;
|
|
70
|
+
data[idx + 3] = 255;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Create checkerboard pattern. cellSize: pixels per cell (default: width/4). */
|
|
75
|
+
function checkerboardTexture(device, width, height, cellSize) {
|
|
76
|
+
const cell = cellSize ?? Math.floor(width / 4);
|
|
77
|
+
return cachedTexture(device, `checkerboard:${cell}:${width}x${height}`, `test-texture-checkerboard-${cell}`, width, height, (data) => {
|
|
78
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
79
|
+
const idx = (y * width + x) * 4;
|
|
80
|
+
const value = (Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0 ? 0 : 255;
|
|
81
|
+
data[idx + 0] = value;
|
|
82
|
+
data[idx + 1] = value;
|
|
83
|
+
data[idx + 2] = value;
|
|
84
|
+
data[idx + 3] = 255;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Create sampler. Default: linear filtering with clamp-to-edge. Internally cached. */
|
|
89
|
+
function createSampler(device, options) {
|
|
90
|
+
const { addressMode = "clamp-to-edge", filterMode = "linear" } = options ?? {};
|
|
91
|
+
const cacheKey = `${addressMode}:${filterMode}`;
|
|
92
|
+
const cached = samplerCache.get(device, cacheKey);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
const sampler = device.createSampler({
|
|
95
|
+
addressModeU: addressMode,
|
|
96
|
+
addressModeV: addressMode,
|
|
97
|
+
magFilter: filterMode,
|
|
98
|
+
minFilter: filterMode
|
|
99
|
+
});
|
|
100
|
+
samplerCache.set(device, cacheKey, sampler);
|
|
101
|
+
return sampler;
|
|
102
|
+
}
|
|
103
|
+
/** Create radial gradient texture (white center to black edge). */
|
|
104
|
+
function radialGradientTexture(device, size) {
|
|
105
|
+
const cacheKey = `radial:${size}`;
|
|
106
|
+
const centerX = size / 2;
|
|
107
|
+
const centerY = size / 2;
|
|
108
|
+
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
|
109
|
+
return cachedTexture(device, cacheKey, `test-texture-radial-${size}`, size, size, (data) => {
|
|
110
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
111
|
+
const idx = (y * size + x) * 4;
|
|
112
|
+
const dx = x - centerX;
|
|
113
|
+
const dy = y - centerY;
|
|
114
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
115
|
+
const gradient = 1 - Math.min(distance / maxRadius, 1);
|
|
116
|
+
const value = Math.round(gradient * 255);
|
|
117
|
+
data[idx + 0] = value;
|
|
118
|
+
data[idx + 1] = value;
|
|
119
|
+
data[idx + 2] = value;
|
|
120
|
+
data[idx + 3] = 255;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Create edge pattern texture with sharp vertical, horizontal, and diagonal lines. */
|
|
125
|
+
function edgePatternTexture(device, size) {
|
|
126
|
+
const cacheKey = `edges:${size}`;
|
|
127
|
+
const lineWidth = 2;
|
|
128
|
+
return cachedTexture(device, cacheKey, `test-texture-edges-${size}`, size, size, (data) => {
|
|
129
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
130
|
+
const idx = (y * size + x) * 4;
|
|
131
|
+
const value = Math.abs(x - size / 2) < lineWidth || Math.abs(y - size / 2) < lineWidth || Math.abs(x - y) < lineWidth || Math.abs(x - (size - 1 - y)) < lineWidth ? 255 : 0;
|
|
132
|
+
data[idx + 0] = value;
|
|
133
|
+
data[idx + 1] = value;
|
|
134
|
+
data[idx + 2] = value;
|
|
135
|
+
data[idx + 3] = 255;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Create color bars texture (RGB primaries and secondaries). */
|
|
140
|
+
function colorBarsTexture(device, size) {
|
|
141
|
+
const cacheKey = `colorbars:${size}`;
|
|
142
|
+
const colors = [
|
|
143
|
+
[
|
|
144
|
+
255,
|
|
145
|
+
0,
|
|
146
|
+
0
|
|
147
|
+
],
|
|
148
|
+
[
|
|
149
|
+
255,
|
|
150
|
+
255,
|
|
151
|
+
0
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
0,
|
|
155
|
+
255,
|
|
156
|
+
0
|
|
157
|
+
],
|
|
158
|
+
[
|
|
159
|
+
0,
|
|
160
|
+
255,
|
|
161
|
+
255
|
|
162
|
+
],
|
|
163
|
+
[
|
|
164
|
+
0,
|
|
165
|
+
0,
|
|
166
|
+
255
|
|
167
|
+
],
|
|
168
|
+
[
|
|
169
|
+
255,
|
|
170
|
+
0,
|
|
171
|
+
255
|
|
172
|
+
]
|
|
173
|
+
];
|
|
174
|
+
const barWidth = size / colors.length;
|
|
175
|
+
return cachedTexture(device, cacheKey, `test-texture-colorbars-${size}`, size, size, (data) => {
|
|
176
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
177
|
+
const idx = (y * size + x) * 4;
|
|
178
|
+
const color = colors[Math.min(Math.floor(x / barWidth), colors.length - 1)];
|
|
179
|
+
data[idx + 0] = color[0];
|
|
180
|
+
data[idx + 1] = color[1];
|
|
181
|
+
data[idx + 2] = color[2];
|
|
182
|
+
data[idx + 3] = 255;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** Create seeded noise pattern (deterministic). */
|
|
187
|
+
function noiseTexture(device, size, seed = 42) {
|
|
188
|
+
return cachedTexture(device, `noise:${size}:${seed}`, `test-texture-noise-${size}`, size, size, (data) => {
|
|
189
|
+
let rng = seed;
|
|
190
|
+
const random = () => {
|
|
191
|
+
rng |= 0;
|
|
192
|
+
rng = rng + 1831565813 | 0;
|
|
193
|
+
let t = Math.imul(rng ^ rng >>> 15, 1 | rng);
|
|
194
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
195
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
196
|
+
};
|
|
197
|
+
for (let i = 0; i < size * size * 4; i += 4) {
|
|
198
|
+
const value = Math.round(random() * 255);
|
|
199
|
+
data[i + 0] = value;
|
|
200
|
+
data[i + 1] = value;
|
|
201
|
+
data[i + 2] = value;
|
|
202
|
+
data[i + 3] = 255;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Common helper for creating cached textures with custom data generation. */
|
|
207
|
+
function cachedTexture(device, cacheKey, label, width, height, generateData) {
|
|
208
|
+
const cached = textureCache.get(device, cacheKey);
|
|
209
|
+
if (cached) return cached;
|
|
210
|
+
const texture = device.createTexture({
|
|
211
|
+
label,
|
|
212
|
+
size: {
|
|
213
|
+
width,
|
|
214
|
+
height,
|
|
215
|
+
depthOrArrayLayers: 1
|
|
216
|
+
},
|
|
217
|
+
format: "rgba8unorm",
|
|
218
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
|
|
219
|
+
});
|
|
220
|
+
const data = new Uint8Array(width * height * 4);
|
|
221
|
+
generateData(data, width, height);
|
|
222
|
+
device.queue.writeTexture({ texture }, data, { bytesPerRow: width * 4 }, {
|
|
223
|
+
width,
|
|
224
|
+
height
|
|
225
|
+
});
|
|
226
|
+
textureCache.set(device, cacheKey, texture);
|
|
227
|
+
return texture;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/FullscreenVertex.ts
|
|
232
|
+
/** Number of vertices drawn for fullscreen quad using triangle-strip topology */
|
|
233
|
+
const fullscreenVertexCount = 4;
|
|
234
|
+
/** Fullscreen triangle vertex shader that covers viewport with 3 vertices, no vertex buffer needed */
|
|
235
|
+
const fullscreenTriangleVertex = `
|
|
236
|
+
@vertex
|
|
237
|
+
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4f {
|
|
238
|
+
// Covers viewport with 3 vertices, no vertex buffer needed
|
|
239
|
+
var pos: vec2f;
|
|
240
|
+
if (idx == 0u) {
|
|
241
|
+
pos = vec2f(-1.0, -1.0);
|
|
242
|
+
} else if (idx == 1u) {
|
|
243
|
+
pos = vec2f(3.0, -1.0);
|
|
244
|
+
} else {
|
|
245
|
+
pos = vec2f(-1.0, 3.0);
|
|
246
|
+
}
|
|
247
|
+
return vec4f(pos, 0.0, 1.0);
|
|
248
|
+
}`;
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/RenderUniforms.ts
|
|
252
|
+
/**
|
|
253
|
+
* Creates a standard uniform buffer for running test fragment shaders.
|
|
254
|
+
*
|
|
255
|
+
* @param outputSize - Output texture dimensions (becomes uniforms.resolution)
|
|
256
|
+
* @param uniforms - User-provided uniform values (time, mouse)
|
|
257
|
+
* @returns GPUBuffer containing uniform data
|
|
258
|
+
*/
|
|
259
|
+
function renderUniformBuffer(device, outputSize, uniforms = {}) {
|
|
260
|
+
const resolution = outputSize;
|
|
261
|
+
const time = uniforms.time ?? 0;
|
|
262
|
+
const mouse = uniforms.mouse ?? [0, 0];
|
|
263
|
+
const buffer = device.createBuffer({
|
|
264
|
+
label: "standard-uniforms",
|
|
265
|
+
size: 32,
|
|
266
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
267
|
+
});
|
|
268
|
+
const data = new Float32Array([
|
|
269
|
+
resolution[0],
|
|
270
|
+
resolution[1],
|
|
271
|
+
time,
|
|
272
|
+
0,
|
|
273
|
+
mouse[0],
|
|
274
|
+
mouse[1],
|
|
275
|
+
0,
|
|
276
|
+
0
|
|
277
|
+
]);
|
|
278
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
279
|
+
return buffer;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Updates an existing uniform buffer with new values.
|
|
283
|
+
* Use this for per-frame updates in render loops (avoids buffer recreation).
|
|
284
|
+
*
|
|
285
|
+
* @param buffer - Existing uniform buffer to update
|
|
286
|
+
* @param device - GPU device
|
|
287
|
+
* @param resolution - Output texture dimensions
|
|
288
|
+
* @param time - Elapsed time in seconds
|
|
289
|
+
* @param mouse - Mouse position [0,1] normalized coords
|
|
290
|
+
*/
|
|
291
|
+
function updateRenderUniforms(buffer, device, resolution, time, mouse = [0, 0]) {
|
|
292
|
+
const data = new Float32Array([
|
|
293
|
+
resolution[0],
|
|
294
|
+
resolution[1],
|
|
295
|
+
time,
|
|
296
|
+
0,
|
|
297
|
+
mouse[0],
|
|
298
|
+
mouse[1],
|
|
299
|
+
0,
|
|
300
|
+
0
|
|
301
|
+
]);
|
|
302
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* return the WGSL struct for use in shaders as test::Uniforms.
|
|
306
|
+
*
|
|
307
|
+
* @returns virtual library object for passing to compileShader()
|
|
308
|
+
*/
|
|
309
|
+
function createUniformsVirtualLib() {
|
|
310
|
+
return { test: () => `
|
|
311
|
+
struct Uniforms {
|
|
312
|
+
resolution: vec2f, // Output viewport dimensions
|
|
313
|
+
time: f32, // Elapsed time in seconds
|
|
314
|
+
mouse: vec2f, // Mouse position [0,1] normalized coords
|
|
315
|
+
}
|
|
316
|
+
` };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/FragmentPipeline.ts
|
|
321
|
+
/** Combined: link WESL source and create pipeline in one step. */
|
|
322
|
+
async function linkAndCreatePipeline(params) {
|
|
323
|
+
const module = await linkFragmentShader(params);
|
|
324
|
+
return createFragmentPipeline({
|
|
325
|
+
device: params.device,
|
|
326
|
+
module,
|
|
327
|
+
format: params.format,
|
|
328
|
+
layout: params.layout
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Link a WESL/WGSL fragment shader to a shader module.
|
|
333
|
+
* Adds:
|
|
334
|
+
* - vertex shader that covers the viewport with a fullscreen triangle
|
|
335
|
+
* - a virtual module containing a std uniform buffer (for size, etc.)
|
|
336
|
+
*/
|
|
337
|
+
async function linkFragmentShader(params) {
|
|
338
|
+
const { device, fragmentSource, bundles = [], resolver } = params;
|
|
339
|
+
const { conditions, constants, packageName } = params;
|
|
340
|
+
const fullSource = `${fragmentSource}\n\n${fullscreenTriangleVertex}`;
|
|
341
|
+
let sourceParams;
|
|
342
|
+
if (resolver) sourceParams = { resolver: new CompositeResolver([new RecordResolver({ main: fullSource }, { packageName }), resolver]) };
|
|
343
|
+
else sourceParams = { weslSrc: { main: fullSource } };
|
|
344
|
+
return (await link({
|
|
345
|
+
...sourceParams,
|
|
346
|
+
rootModuleName: "main",
|
|
347
|
+
packageName,
|
|
348
|
+
libs: bundles,
|
|
349
|
+
virtualLibs: createUniformsVirtualLib(),
|
|
350
|
+
conditions,
|
|
351
|
+
constants
|
|
352
|
+
})).createShaderModule(device);
|
|
353
|
+
}
|
|
354
|
+
/** Create fullscreen fragment render pipeline from a shader module. */
|
|
355
|
+
function createFragmentPipeline(params) {
|
|
356
|
+
const { device, module, format, layout = "auto" } = params;
|
|
357
|
+
return device.createRenderPipeline({
|
|
358
|
+
layout,
|
|
359
|
+
vertex: { module },
|
|
360
|
+
fragment: {
|
|
361
|
+
module,
|
|
362
|
+
targets: [{ format }]
|
|
363
|
+
},
|
|
364
|
+
primitive: { topology: "triangle-list" }
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/FragmentRender.ts
|
|
370
|
+
/** Execute one fullscreen fragment render pass to target view. */
|
|
371
|
+
function renderFrame(params) {
|
|
372
|
+
const { device, pipeline, bindGroup, targetView } = params;
|
|
373
|
+
const encoder = device.createCommandEncoder();
|
|
374
|
+
const pass = encoder.beginRenderPass({ colorAttachments: [{
|
|
375
|
+
view: targetView,
|
|
376
|
+
clearValue: {
|
|
377
|
+
r: 0,
|
|
378
|
+
g: 0,
|
|
379
|
+
b: 0,
|
|
380
|
+
a: 1
|
|
381
|
+
},
|
|
382
|
+
loadOp: "clear",
|
|
383
|
+
storeOp: "store"
|
|
384
|
+
}] });
|
|
385
|
+
pass.setPipeline(pipeline);
|
|
386
|
+
if (bindGroup) pass.setBindGroup(0, bindGroup);
|
|
387
|
+
pass.draw(3);
|
|
388
|
+
pass.end();
|
|
389
|
+
device.queue.submit([encoder.finish()]);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/SimpleRender.ts
|
|
394
|
+
/**
|
|
395
|
+
* Executes a render pipeline with the given shader module.
|
|
396
|
+
* Creates a texture with the specified format, renders to it, and returns all pixel data.
|
|
397
|
+
* @returns output texture contents in a flattened array (rows flattened, color channels interleaved).
|
|
398
|
+
*/
|
|
399
|
+
async function simpleRender(params) {
|
|
400
|
+
const { device, module } = params;
|
|
401
|
+
const { outputFormat = "rgba32float", size = [1, 1] } = params;
|
|
402
|
+
const { textures = [], samplers = [], uniformBuffer } = params;
|
|
403
|
+
return await withErrorScopes(device, async () => {
|
|
404
|
+
const texture = device.createTexture({
|
|
405
|
+
label: "fragment-test-output",
|
|
406
|
+
size: {
|
|
407
|
+
width: size[0],
|
|
408
|
+
height: size[1],
|
|
409
|
+
depthOrArrayLayers: 1
|
|
410
|
+
},
|
|
411
|
+
format: outputFormat,
|
|
412
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
|
|
413
|
+
});
|
|
414
|
+
const bindings = uniformBuffer || textures.length > 0 ? createBindGroup(device, uniformBuffer, textures, samplers) : void 0;
|
|
415
|
+
const pipelineLayout = bindings ? device.createPipelineLayout({ bindGroupLayouts: [bindings.layout] }) : "auto";
|
|
416
|
+
renderFrame({
|
|
417
|
+
device,
|
|
418
|
+
pipeline: device.createRenderPipeline({
|
|
419
|
+
layout: pipelineLayout,
|
|
420
|
+
vertex: { module },
|
|
421
|
+
fragment: {
|
|
422
|
+
module,
|
|
423
|
+
targets: [{ format: outputFormat }]
|
|
424
|
+
},
|
|
425
|
+
primitive: { topology: "triangle-list" }
|
|
426
|
+
}),
|
|
427
|
+
bindGroup: bindings?.bindGroup,
|
|
428
|
+
targetView: texture.createView()
|
|
429
|
+
});
|
|
430
|
+
const data = await withTextureCopy(device, texture, (texData) => Array.from(texData));
|
|
431
|
+
texture.destroy();
|
|
432
|
+
uniformBuffer?.destroy();
|
|
433
|
+
return data;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Create bind group with optional uniforms, textures, and samplers.
|
|
438
|
+
* Binding layout: 0=uniform buffer (if provided), 1..n=textures, n+1..n+m=samplers.
|
|
439
|
+
* Samplers must be length 1 (reused for all textures) or match textures.length exactly.
|
|
440
|
+
*/
|
|
441
|
+
function createBindGroup(device, uniformBuffer, textures = [], samplers = []) {
|
|
442
|
+
if (textures.length > 0 && samplers.length > 0) {
|
|
443
|
+
if (samplers.length !== 1 && samplers.length !== textures.length) throw new Error(`Invalid sampler count: expected 1 or ${textures.length} samplers for ${textures.length} textures, got ${samplers.length}`);
|
|
444
|
+
}
|
|
445
|
+
const entries = [];
|
|
446
|
+
const bindGroupEntries = [];
|
|
447
|
+
if (uniformBuffer) {
|
|
448
|
+
entries.push({
|
|
449
|
+
binding: 0,
|
|
450
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
451
|
+
buffer: { type: "uniform" }
|
|
452
|
+
});
|
|
453
|
+
bindGroupEntries.push({
|
|
454
|
+
binding: 0,
|
|
455
|
+
resource: { buffer: uniformBuffer }
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
textures.forEach((texture, i) => {
|
|
459
|
+
const binding = i + 1;
|
|
460
|
+
entries.push({
|
|
461
|
+
binding,
|
|
462
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
463
|
+
texture: { sampleType: "float" }
|
|
464
|
+
});
|
|
465
|
+
bindGroupEntries.push({
|
|
466
|
+
binding,
|
|
467
|
+
resource: texture.createView()
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
const singleSampler = samplers.length === 1 ? samplers[0] : void 0;
|
|
471
|
+
for (let i = 0; i < textures.length; i++) {
|
|
472
|
+
const binding = textures.length + i + 1;
|
|
473
|
+
const sampler = singleSampler ?? samplers[i];
|
|
474
|
+
entries.push({
|
|
475
|
+
binding,
|
|
476
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
477
|
+
sampler: { type: "filtering" }
|
|
478
|
+
});
|
|
479
|
+
bindGroupEntries.push({
|
|
480
|
+
binding,
|
|
481
|
+
resource: sampler
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
const layout = device.createBindGroupLayout({ entries });
|
|
485
|
+
return {
|
|
486
|
+
layout,
|
|
487
|
+
bindGroup: device.createBindGroup({
|
|
488
|
+
layout,
|
|
489
|
+
entries: bindGroupEntries
|
|
490
|
+
})
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
//#endregion
|
|
495
|
+
export { DeviceCache, checkerboardTexture, colorBarsTexture, createBindGroup, createSampler, createUniformsVirtualLib, edgePatternTexture, fullscreenTriangleVertex, fullscreenVertexCount, gradientTexture, linkAndCreatePipeline, linkFragmentShader, noiseTexture, radialGradientTexture, renderFrame, renderUniformBuffer, simpleRender, solidTexture, updateRenderUniforms, withErrorScopes };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wesl-gpu",
|
|
3
|
+
"description": "Browser-compatible WebGPU utilities for shader testing and rendering",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": "github:wgsl-tooling-wg/wesl-js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"thimbleberry": "^0.2.10",
|
|
15
|
+
"wesl": "0.6.47"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@webgpu/types": "^0.1.65"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"rendering",
|
|
22
|
+
"shader",
|
|
23
|
+
"testing",
|
|
24
|
+
"WebGPU",
|
|
25
|
+
"WESL",
|
|
26
|
+
"WGSL"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsdown",
|
|
30
|
+
"dev": "tsdown --watch",
|
|
31
|
+
"test": "cross-env FORCE_COLOR=1 vitest",
|
|
32
|
+
"test:once": "vitest run",
|
|
33
|
+
"typecheck": "tsgo"
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.js"
|
|
36
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeakMap cache for GPUDevice-based caching
|
|
3
|
+
*
|
|
4
|
+
* GPUDevices are weakly held to avoid memory leaks.
|
|
5
|
+
*/
|
|
6
|
+
export class DeviceCache<T> {
|
|
7
|
+
private cache = new WeakMap<GPUDevice, Map<string, T>>();
|
|
8
|
+
|
|
9
|
+
get(device: GPUDevice, key: string): T | undefined {
|
|
10
|
+
return this.cache.get(device)?.get(key);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
set(device: GPUDevice, key: string, value: T): void {
|
|
14
|
+
let deviceCache = this.cache.get(device);
|
|
15
|
+
if (!deviceCache) {
|
|
16
|
+
deviceCache = new Map();
|
|
17
|
+
this.cache.set(device, deviceCache);
|
|
18
|
+
}
|
|
19
|
+
deviceCache.set(key, value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a function with WebGPU error scopes, automatically handling push/pop/check.
|
|
3
|
+
* Throws if any validation, out-of-memory, or internal errors occur.
|
|
4
|
+
*/
|
|
5
|
+
export async function withErrorScopes<T>(
|
|
6
|
+
device: GPUDevice,
|
|
7
|
+
fn: () => T | Promise<T>,
|
|
8
|
+
): Promise<T> {
|
|
9
|
+
device.pushErrorScope("internal");
|
|
10
|
+
device.pushErrorScope("out-of-memory");
|
|
11
|
+
device.pushErrorScope("validation");
|
|
12
|
+
|
|
13
|
+
const result = await fn();
|
|
14
|
+
|
|
15
|
+
const validationError = await device.popErrorScope();
|
|
16
|
+
const oomError = await device.popErrorScope();
|
|
17
|
+
const internalError = await device.popErrorScope();
|
|
18
|
+
|
|
19
|
+
if (validationError) {
|
|
20
|
+
throw new Error(`WebGPU validation error: ${validationError.message}`);
|
|
21
|
+
}
|
|
22
|
+
if (oomError) {
|
|
23
|
+
throw new Error(`WebGPU out-of-memory error: ${oomError.message}`);
|
|
24
|
+
}
|
|
25
|
+
if (internalError) {
|
|
26
|
+
throw new Error(`WebGPU internal error: ${internalError.message}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { DeviceCache } from "./DeviceCache.ts";
|
|
2
|
+
|
|
3
|
+
/* Texture and sampler creation helpers with internal caching for test performance */
|
|
4
|
+
|
|
5
|
+
export interface SamplerOptions {
|
|
6
|
+
addressMode?: "clamp-to-edge" | "repeat" | "mirror-repeat";
|
|
7
|
+
filterMode?: "nearest" | "linear";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const textureCache = new DeviceCache<GPUTexture>();
|
|
11
|
+
const samplerCache = new DeviceCache<GPUSampler>();
|
|
12
|
+
|
|
13
|
+
/** Create texture filled with solid color. Internally cached. */
|
|
14
|
+
export function solidTexture(
|
|
15
|
+
device: GPUDevice,
|
|
16
|
+
color: [r: number, g: number, b: number, a: number],
|
|
17
|
+
width: number,
|
|
18
|
+
height: number,
|
|
19
|
+
): GPUTexture {
|
|
20
|
+
const cacheKey = `solid:${color.join(",")}:${width}x${height}`;
|
|
21
|
+
return cachedTexture(
|
|
22
|
+
device,
|
|
23
|
+
cacheKey,
|
|
24
|
+
`test-texture-solid-${color.join(",")}`,
|
|
25
|
+
width,
|
|
26
|
+
height,
|
|
27
|
+
data => {
|
|
28
|
+
for (let i = 0; i < width * height; i++) {
|
|
29
|
+
data[i * 4 + 0] = Math.round(color[0] * 255);
|
|
30
|
+
data[i * 4 + 1] = Math.round(color[1] * 255);
|
|
31
|
+
data[i * 4 + 2] = Math.round(color[2] * 255);
|
|
32
|
+
data[i * 4 + 3] = Math.round(color[3] * 255);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Create gradient texture. Direction: 'horizontal' (default) or 'vertical'. */
|
|
39
|
+
export function gradientTexture(
|
|
40
|
+
device: GPUDevice,
|
|
41
|
+
width: number,
|
|
42
|
+
height: number,
|
|
43
|
+
direction: "horizontal" | "vertical" = "horizontal",
|
|
44
|
+
): GPUTexture {
|
|
45
|
+
const cacheKey = `gradient:${direction}:${width}x${height}`;
|
|
46
|
+
return cachedTexture(
|
|
47
|
+
device,
|
|
48
|
+
cacheKey,
|
|
49
|
+
`test-texture-gradient-${direction}`,
|
|
50
|
+
width,
|
|
51
|
+
height,
|
|
52
|
+
data => {
|
|
53
|
+
for (let y = 0; y < height; y++) {
|
|
54
|
+
for (let x = 0; x < width; x++) {
|
|
55
|
+
const idx = (y * width + x) * 4;
|
|
56
|
+
const gradient =
|
|
57
|
+
direction === "horizontal" ? x / (width - 1) : y / (height - 1);
|
|
58
|
+
const value = Math.round(gradient * 255);
|
|
59
|
+
data[idx + 0] = value;
|
|
60
|
+
data[idx + 1] = value;
|
|
61
|
+
data[idx + 2] = value;
|
|
62
|
+
data[idx + 3] = 255;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Create checkerboard pattern. cellSize: pixels per cell (default: width/4). */
|
|
70
|
+
export function checkerboardTexture(
|
|
71
|
+
device: GPUDevice,
|
|
72
|
+
width: number,
|
|
73
|
+
height: number,
|
|
74
|
+
cellSize?: number,
|
|
75
|
+
): GPUTexture {
|
|
76
|
+
const cell = cellSize ?? Math.floor(width / 4);
|
|
77
|
+
const cacheKey = `checkerboard:${cell}:${width}x${height}`;
|
|
78
|
+
return cachedTexture(
|
|
79
|
+
device,
|
|
80
|
+
cacheKey,
|
|
81
|
+
`test-texture-checkerboard-${cell}`,
|
|
82
|
+
width,
|
|
83
|
+
height,
|
|
84
|
+
data => {
|
|
85
|
+
for (let y = 0; y < height; y++) {
|
|
86
|
+
for (let x = 0; x < width; x++) {
|
|
87
|
+
const idx = (y * width + x) * 4;
|
|
88
|
+
const cellX = Math.floor(x / cell);
|
|
89
|
+
const cellY = Math.floor(y / cell);
|
|
90
|
+
const isBlack = (cellX + cellY) % 2 === 0;
|
|
91
|
+
const value = isBlack ? 0 : 255;
|
|
92
|
+
data[idx + 0] = value;
|
|
93
|
+
data[idx + 1] = value;
|
|
94
|
+
data[idx + 2] = value;
|
|
95
|
+
data[idx + 3] = 255;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Create sampler. Default: linear filtering with clamp-to-edge. Internally cached. */
|
|
103
|
+
export function createSampler(
|
|
104
|
+
device: GPUDevice,
|
|
105
|
+
options?: SamplerOptions,
|
|
106
|
+
): GPUSampler {
|
|
107
|
+
const { addressMode = "clamp-to-edge", filterMode = "linear" } =
|
|
108
|
+
options ?? {};
|
|
109
|
+
const cacheKey = `${addressMode}:${filterMode}`;
|
|
110
|
+
const cached = samplerCache.get(device, cacheKey);
|
|
111
|
+
if (cached) return cached;
|
|
112
|
+
|
|
113
|
+
const sampler = device.createSampler({
|
|
114
|
+
addressModeU: addressMode,
|
|
115
|
+
addressModeV: addressMode,
|
|
116
|
+
magFilter: filterMode,
|
|
117
|
+
minFilter: filterMode,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
samplerCache.set(device, cacheKey, sampler);
|
|
121
|
+
return sampler;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Create radial gradient texture (white center to black edge). */
|
|
125
|
+
export function radialGradientTexture(
|
|
126
|
+
device: GPUDevice,
|
|
127
|
+
size: number,
|
|
128
|
+
): GPUTexture {
|
|
129
|
+
const cacheKey = `radial:${size}`;
|
|
130
|
+
const centerX = size / 2;
|
|
131
|
+
const centerY = size / 2;
|
|
132
|
+
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
|
133
|
+
return cachedTexture(
|
|
134
|
+
device,
|
|
135
|
+
cacheKey,
|
|
136
|
+
`test-texture-radial-${size}`,
|
|
137
|
+
size,
|
|
138
|
+
size,
|
|
139
|
+
data => {
|
|
140
|
+
for (let y = 0; y < size; y++) {
|
|
141
|
+
for (let x = 0; x < size; x++) {
|
|
142
|
+
const idx = (y * size + x) * 4;
|
|
143
|
+
const dx = x - centerX;
|
|
144
|
+
const dy = y - centerY;
|
|
145
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
146
|
+
const gradient = 1.0 - Math.min(distance / maxRadius, 1.0);
|
|
147
|
+
const value = Math.round(gradient * 255);
|
|
148
|
+
data[idx + 0] = value;
|
|
149
|
+
data[idx + 1] = value;
|
|
150
|
+
data[idx + 2] = value;
|
|
151
|
+
data[idx + 3] = 255;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Create edge pattern texture with sharp vertical, horizontal, and diagonal lines. */
|
|
159
|
+
export function edgePatternTexture(
|
|
160
|
+
device: GPUDevice,
|
|
161
|
+
size: number,
|
|
162
|
+
): GPUTexture {
|
|
163
|
+
const cacheKey = `edges:${size}`;
|
|
164
|
+
const lineWidth = 2;
|
|
165
|
+
return cachedTexture(
|
|
166
|
+
device,
|
|
167
|
+
cacheKey,
|
|
168
|
+
`test-texture-edges-${size}`,
|
|
169
|
+
size,
|
|
170
|
+
size,
|
|
171
|
+
data => {
|
|
172
|
+
for (let y = 0; y < size; y++) {
|
|
173
|
+
for (let x = 0; x < size; x++) {
|
|
174
|
+
const idx = (y * size + x) * 4;
|
|
175
|
+
const isLine =
|
|
176
|
+
Math.abs(x - size / 2) < lineWidth ||
|
|
177
|
+
Math.abs(y - size / 2) < lineWidth ||
|
|
178
|
+
Math.abs(x - y) < lineWidth ||
|
|
179
|
+
Math.abs(x - (size - 1 - y)) < lineWidth;
|
|
180
|
+
const value = isLine ? 255 : 0;
|
|
181
|
+
data[idx + 0] = value;
|
|
182
|
+
data[idx + 1] = value;
|
|
183
|
+
data[idx + 2] = value;
|
|
184
|
+
data[idx + 3] = 255;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Create color bars texture (RGB primaries and secondaries). */
|
|
192
|
+
export function colorBarsTexture(device: GPUDevice, size: number): GPUTexture {
|
|
193
|
+
const cacheKey = `colorbars:${size}`;
|
|
194
|
+
const colors = [
|
|
195
|
+
[255, 0, 0],
|
|
196
|
+
[255, 255, 0],
|
|
197
|
+
[0, 255, 0],
|
|
198
|
+
[0, 255, 255],
|
|
199
|
+
[0, 0, 255],
|
|
200
|
+
[255, 0, 255],
|
|
201
|
+
];
|
|
202
|
+
const barWidth = size / colors.length;
|
|
203
|
+
return cachedTexture(
|
|
204
|
+
device,
|
|
205
|
+
cacheKey,
|
|
206
|
+
`test-texture-colorbars-${size}`,
|
|
207
|
+
size,
|
|
208
|
+
size,
|
|
209
|
+
data => {
|
|
210
|
+
for (let y = 0; y < size; y++) {
|
|
211
|
+
for (let x = 0; x < size; x++) {
|
|
212
|
+
const idx = (y * size + x) * 4;
|
|
213
|
+
const barIndex = Math.min(
|
|
214
|
+
Math.floor(x / barWidth),
|
|
215
|
+
colors.length - 1,
|
|
216
|
+
);
|
|
217
|
+
const color = colors[barIndex];
|
|
218
|
+
data[idx + 0] = color[0];
|
|
219
|
+
data[idx + 1] = color[1];
|
|
220
|
+
data[idx + 2] = color[2];
|
|
221
|
+
data[idx + 3] = 255;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Create seeded noise pattern (deterministic). */
|
|
229
|
+
export function noiseTexture(
|
|
230
|
+
device: GPUDevice,
|
|
231
|
+
size: number,
|
|
232
|
+
seed = 42,
|
|
233
|
+
): GPUTexture {
|
|
234
|
+
const cacheKey = `noise:${size}:${seed}`;
|
|
235
|
+
return cachedTexture(
|
|
236
|
+
device,
|
|
237
|
+
cacheKey,
|
|
238
|
+
`test-texture-noise-${size}`,
|
|
239
|
+
size,
|
|
240
|
+
size,
|
|
241
|
+
data => {
|
|
242
|
+
let rng = seed;
|
|
243
|
+
// Simple seeded PRNG (mulberry32)
|
|
244
|
+
const random = () => {
|
|
245
|
+
rng |= 0;
|
|
246
|
+
rng = (rng + 0x6d2b79f5) | 0;
|
|
247
|
+
let t = Math.imul(rng ^ (rng >>> 15), 1 | rng);
|
|
248
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
249
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
250
|
+
};
|
|
251
|
+
for (let i = 0; i < size * size * 4; i += 4) {
|
|
252
|
+
const value = Math.round(random() * 255);
|
|
253
|
+
data[i + 0] = value;
|
|
254
|
+
data[i + 1] = value;
|
|
255
|
+
data[i + 2] = value;
|
|
256
|
+
data[i + 3] = 255;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Common helper for creating cached textures with custom data generation. */
|
|
263
|
+
function cachedTexture(
|
|
264
|
+
device: GPUDevice,
|
|
265
|
+
cacheKey: string,
|
|
266
|
+
label: string,
|
|
267
|
+
width: number,
|
|
268
|
+
height: number,
|
|
269
|
+
generateData: (data: Uint8Array, width: number, height: number) => void,
|
|
270
|
+
): GPUTexture {
|
|
271
|
+
const cached = textureCache.get(device, cacheKey);
|
|
272
|
+
if (cached) return cached;
|
|
273
|
+
|
|
274
|
+
const texture = device.createTexture({
|
|
275
|
+
label,
|
|
276
|
+
size: { width, height, depthOrArrayLayers: 1 },
|
|
277
|
+
format: "rgba8unorm",
|
|
278
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const data = new Uint8Array(width * height * 4);
|
|
282
|
+
generateData(data, width, height);
|
|
283
|
+
|
|
284
|
+
device.queue.writeTexture(
|
|
285
|
+
{ texture },
|
|
286
|
+
data,
|
|
287
|
+
{ bytesPerRow: width * 4 },
|
|
288
|
+
{ width, height },
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
textureCache.set(device, cacheKey, texture);
|
|
292
|
+
return texture;
|
|
293
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { LinkParams, ModuleResolver, WeslBundle } from "wesl";
|
|
2
|
+
import { CompositeResolver, link, RecordResolver } from "wesl";
|
|
3
|
+
import { fullscreenTriangleVertex } from "./FullscreenVertex.ts";
|
|
4
|
+
import { createUniformsVirtualLib } from "./RenderUniforms.ts";
|
|
5
|
+
|
|
6
|
+
export interface LinkFragmentParams {
|
|
7
|
+
device: GPUDevice;
|
|
8
|
+
|
|
9
|
+
/** Fragment shader source (vertex shader is auto-provided) */
|
|
10
|
+
fragmentSource: string;
|
|
11
|
+
|
|
12
|
+
/** WESL library bundles for dependencies */
|
|
13
|
+
bundles?: WeslBundle[];
|
|
14
|
+
|
|
15
|
+
/** Resolver for lazy file loading (e.g., useSourceShaders mode in tests) */
|
|
16
|
+
resolver?: ModuleResolver;
|
|
17
|
+
|
|
18
|
+
/** Conditional compilation flags */
|
|
19
|
+
conditions?: Record<string, boolean>;
|
|
20
|
+
|
|
21
|
+
/** Compile-time constants */
|
|
22
|
+
constants?: Record<string, string | number>;
|
|
23
|
+
|
|
24
|
+
/** Package name (allows imports to mention the current package by name instead of package::) */
|
|
25
|
+
packageName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LinkAndCreateParams extends LinkFragmentParams {
|
|
29
|
+
format: GPUTextureFormat;
|
|
30
|
+
layout?: GPUPipelineLayout | "auto";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Combined: link WESL source and create pipeline in one step. */
|
|
34
|
+
export async function linkAndCreatePipeline(
|
|
35
|
+
params: LinkAndCreateParams,
|
|
36
|
+
): Promise<GPURenderPipeline> {
|
|
37
|
+
const module = await linkFragmentShader(params);
|
|
38
|
+
return createFragmentPipeline({
|
|
39
|
+
device: params.device,
|
|
40
|
+
module,
|
|
41
|
+
format: params.format,
|
|
42
|
+
layout: params.layout,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Link a WESL/WGSL fragment shader to a shader module.
|
|
48
|
+
* Adds:
|
|
49
|
+
* - vertex shader that covers the viewport with a fullscreen triangle
|
|
50
|
+
* - a virtual module containing a std uniform buffer (for size, etc.)
|
|
51
|
+
*/
|
|
52
|
+
export async function linkFragmentShader(
|
|
53
|
+
params: LinkFragmentParams,
|
|
54
|
+
): Promise<GPUShaderModule> {
|
|
55
|
+
const { device, fragmentSource, bundles = [], resolver } = params;
|
|
56
|
+
const { conditions, constants, packageName } = params;
|
|
57
|
+
|
|
58
|
+
const fullSource = `${fragmentSource}\n\n${fullscreenTriangleVertex}`;
|
|
59
|
+
|
|
60
|
+
// Use provided resolver or fall back to simple weslSrc
|
|
61
|
+
let sourceParams: Pick<LinkParams, "resolver" | "weslSrc">;
|
|
62
|
+
if (resolver) {
|
|
63
|
+
const srcResolver = new RecordResolver(
|
|
64
|
+
{ main: fullSource },
|
|
65
|
+
{ packageName },
|
|
66
|
+
);
|
|
67
|
+
sourceParams = { resolver: new CompositeResolver([srcResolver, resolver]) };
|
|
68
|
+
} else {
|
|
69
|
+
sourceParams = { weslSrc: { main: fullSource } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const linked = await link({
|
|
73
|
+
...sourceParams,
|
|
74
|
+
rootModuleName: "main",
|
|
75
|
+
packageName,
|
|
76
|
+
libs: bundles,
|
|
77
|
+
virtualLibs: createUniformsVirtualLib(),
|
|
78
|
+
conditions,
|
|
79
|
+
constants,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return linked.createShaderModule(device);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface CreatePipelineParams {
|
|
86
|
+
device: GPUDevice;
|
|
87
|
+
module: GPUShaderModule;
|
|
88
|
+
format: GPUTextureFormat;
|
|
89
|
+
layout?: GPUPipelineLayout | "auto";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Create fullscreen fragment render pipeline from a shader module. */
|
|
93
|
+
function createFragmentPipeline(
|
|
94
|
+
params: CreatePipelineParams,
|
|
95
|
+
): GPURenderPipeline {
|
|
96
|
+
const { device, module, format, layout = "auto" } = params;
|
|
97
|
+
|
|
98
|
+
return device.createRenderPipeline({
|
|
99
|
+
layout,
|
|
100
|
+
vertex: { module },
|
|
101
|
+
fragment: { module, targets: [{ format }] },
|
|
102
|
+
primitive: { topology: "triangle-list" },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface RenderFrameParams {
|
|
2
|
+
device: GPUDevice;
|
|
3
|
+
pipeline: GPURenderPipeline;
|
|
4
|
+
bindGroup?: GPUBindGroup;
|
|
5
|
+
targetView: GPUTextureView;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Execute one fullscreen fragment render pass to target view. */
|
|
9
|
+
export function renderFrame(params: RenderFrameParams): void {
|
|
10
|
+
const { device, pipeline, bindGroup, targetView } = params;
|
|
11
|
+
|
|
12
|
+
const encoder = device.createCommandEncoder();
|
|
13
|
+
const pass = encoder.beginRenderPass({
|
|
14
|
+
colorAttachments: [
|
|
15
|
+
{
|
|
16
|
+
view: targetView,
|
|
17
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
18
|
+
loadOp: "clear",
|
|
19
|
+
storeOp: "store",
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
pass.setPipeline(pipeline);
|
|
25
|
+
if (bindGroup) pass.setBindGroup(0, bindGroup);
|
|
26
|
+
pass.draw(3);
|
|
27
|
+
pass.end();
|
|
28
|
+
|
|
29
|
+
device.queue.submit([encoder.finish()]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Number of vertices drawn for fullscreen quad using triangle-strip topology */
|
|
2
|
+
export const fullscreenVertexCount = 4;
|
|
3
|
+
|
|
4
|
+
/** Fullscreen triangle vertex shader that covers viewport with 3 vertices, no vertex buffer needed */
|
|
5
|
+
export const fullscreenTriangleVertex = `
|
|
6
|
+
@vertex
|
|
7
|
+
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4f {
|
|
8
|
+
// Covers viewport with 3 vertices, no vertex buffer needed
|
|
9
|
+
var pos: vec2f;
|
|
10
|
+
if (idx == 0u) {
|
|
11
|
+
pos = vec2f(-1.0, -1.0);
|
|
12
|
+
} else if (idx == 1u) {
|
|
13
|
+
pos = vec2f(3.0, -1.0);
|
|
14
|
+
} else {
|
|
15
|
+
pos = vec2f(-1.0, 3.0);
|
|
16
|
+
}
|
|
17
|
+
return vec4f(pos, 0.0, 1.0);
|
|
18
|
+
}`;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { VirtualLibraryFn } from "wesl";
|
|
2
|
+
|
|
3
|
+
/** User provided uniform values */
|
|
4
|
+
export interface RenderUniforms {
|
|
5
|
+
/** Elapsed time in seconds (default: 0.0) */
|
|
6
|
+
time?: number;
|
|
7
|
+
|
|
8
|
+
/** Mouse position in [0,1] normalized coords (default: [0.0, 0.0]) */
|
|
9
|
+
mouse?: [number, number];
|
|
10
|
+
|
|
11
|
+
// Note: resolution is auto-populated from size parameter
|
|
12
|
+
// resolution?: [number, number];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a standard uniform buffer for running test fragment shaders.
|
|
17
|
+
*
|
|
18
|
+
* @param outputSize - Output texture dimensions (becomes uniforms.resolution)
|
|
19
|
+
* @param uniforms - User-provided uniform values (time, mouse)
|
|
20
|
+
* @returns GPUBuffer containing uniform data
|
|
21
|
+
*/
|
|
22
|
+
export function renderUniformBuffer(
|
|
23
|
+
device: GPUDevice,
|
|
24
|
+
outputSize: [number, number],
|
|
25
|
+
uniforms: RenderUniforms = {},
|
|
26
|
+
): GPUBuffer {
|
|
27
|
+
const resolution = outputSize;
|
|
28
|
+
const time = uniforms.time ?? 0.0;
|
|
29
|
+
const mouse = uniforms.mouse ?? [0.0, 0.0];
|
|
30
|
+
|
|
31
|
+
// WGSL struct layout with alignment:
|
|
32
|
+
// struct Uniforms {
|
|
33
|
+
// resolution: vec2f, // offset 0, size 8
|
|
34
|
+
// time: f32, // offset 8, size 4
|
|
35
|
+
// // implicit padding offset 12, size 4 (for vec2f alignment)
|
|
36
|
+
// mouse: vec2f, // offset 16, size 8
|
|
37
|
+
// // struct padding offset 24, size 8 (uniform buffer requires 16-byte struct alignment)
|
|
38
|
+
// }
|
|
39
|
+
// Total: 32 bytes
|
|
40
|
+
const buffer = device.createBuffer({
|
|
41
|
+
label: "standard-uniforms",
|
|
42
|
+
size: 32,
|
|
43
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const data = new Float32Array([
|
|
47
|
+
resolution[0],
|
|
48
|
+
resolution[1],
|
|
49
|
+
time,
|
|
50
|
+
0.0, // implicit padding for vec2f alignment
|
|
51
|
+
mouse[0],
|
|
52
|
+
mouse[1],
|
|
53
|
+
0.0, // struct padding to 32 bytes
|
|
54
|
+
0.0, // struct padding to 32 bytes
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
58
|
+
return buffer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Updates an existing uniform buffer with new values.
|
|
63
|
+
* Use this for per-frame updates in render loops (avoids buffer recreation).
|
|
64
|
+
*
|
|
65
|
+
* @param buffer - Existing uniform buffer to update
|
|
66
|
+
* @param device - GPU device
|
|
67
|
+
* @param resolution - Output texture dimensions
|
|
68
|
+
* @param time - Elapsed time in seconds
|
|
69
|
+
* @param mouse - Mouse position [0,1] normalized coords
|
|
70
|
+
*/
|
|
71
|
+
export function updateRenderUniforms(
|
|
72
|
+
buffer: GPUBuffer,
|
|
73
|
+
device: GPUDevice,
|
|
74
|
+
resolution: [number, number],
|
|
75
|
+
time: number,
|
|
76
|
+
mouse: [number, number] = [0.0, 0.0],
|
|
77
|
+
): void {
|
|
78
|
+
const data = new Float32Array([
|
|
79
|
+
resolution[0],
|
|
80
|
+
resolution[1],
|
|
81
|
+
time,
|
|
82
|
+
0.0, // padding for vec2f alignment
|
|
83
|
+
mouse[0],
|
|
84
|
+
mouse[1],
|
|
85
|
+
0.0, // struct padding
|
|
86
|
+
0.0, // struct padding
|
|
87
|
+
]);
|
|
88
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* return the WGSL struct for use in shaders as test::Uniforms.
|
|
93
|
+
*
|
|
94
|
+
* @returns virtual library object for passing to compileShader()
|
|
95
|
+
*/
|
|
96
|
+
export function createUniformsVirtualLib(): Record<string, VirtualLibraryFn> {
|
|
97
|
+
return {
|
|
98
|
+
test: () => `
|
|
99
|
+
struct Uniforms {
|
|
100
|
+
resolution: vec2f, // Output viewport dimensions
|
|
101
|
+
time: f32, // Elapsed time in seconds
|
|
102
|
+
mouse: vec2f, // Mouse position [0,1] normalized coords
|
|
103
|
+
}
|
|
104
|
+
`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { withTextureCopy } from "thimbleberry";
|
|
2
|
+
import { withErrorScopes } from "./ErrorScopes.ts";
|
|
3
|
+
import { renderFrame } from "./FragmentRender.ts";
|
|
4
|
+
|
|
5
|
+
export interface SimpleRenderParams {
|
|
6
|
+
device: GPUDevice;
|
|
7
|
+
|
|
8
|
+
/** shader module to run, presumed to have one vertex and one fragment entry */
|
|
9
|
+
module: GPUShaderModule;
|
|
10
|
+
|
|
11
|
+
/** format of the output texture. default "rgba32float" */
|
|
12
|
+
outputFormat?: GPUTextureFormat;
|
|
13
|
+
|
|
14
|
+
/** size of the output texture. default [1, 1]
|
|
15
|
+
* Use [2, 2] for derivative tests
|
|
16
|
+
* Use [512, 512] for visual image tests */
|
|
17
|
+
size?: [number, number];
|
|
18
|
+
|
|
19
|
+
/** Input textures to bind in group 0.
|
|
20
|
+
* Bindings: textures at [1..n], samplers at [n+1..n+m]. */
|
|
21
|
+
textures?: GPUTexture[];
|
|
22
|
+
|
|
23
|
+
/** Samplers for the input textures.
|
|
24
|
+
* Must be length 1 (reused for all textures) or match textures.length exactly. */
|
|
25
|
+
samplers?: GPUSampler[];
|
|
26
|
+
|
|
27
|
+
/** pass these uniforms to the shader in group 0 binding 0 */
|
|
28
|
+
uniformBuffer?: GPUBuffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Executes a render pipeline with the given shader module.
|
|
33
|
+
* Creates a texture with the specified format, renders to it, and returns all pixel data.
|
|
34
|
+
* @returns output texture contents in a flattened array (rows flattened, color channels interleaved).
|
|
35
|
+
*/
|
|
36
|
+
export async function simpleRender(
|
|
37
|
+
params: SimpleRenderParams,
|
|
38
|
+
): Promise<number[]> {
|
|
39
|
+
const { device, module } = params;
|
|
40
|
+
const { outputFormat = "rgba32float", size = [1, 1] } = params;
|
|
41
|
+
const { textures = [], samplers = [], uniformBuffer } = params;
|
|
42
|
+
|
|
43
|
+
return await withErrorScopes(device, async () => {
|
|
44
|
+
const texture = device.createTexture({
|
|
45
|
+
label: "fragment-test-output",
|
|
46
|
+
size: { width: size[0], height: size[1], depthOrArrayLayers: 1 },
|
|
47
|
+
format: outputFormat,
|
|
48
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
|
49
|
+
});
|
|
50
|
+
const bindings =
|
|
51
|
+
uniformBuffer || textures.length > 0
|
|
52
|
+
? createBindGroup(device, uniformBuffer, textures, samplers)
|
|
53
|
+
: undefined;
|
|
54
|
+
const pipelineLayout = bindings
|
|
55
|
+
? device.createPipelineLayout({ bindGroupLayouts: [bindings.layout] })
|
|
56
|
+
: "auto";
|
|
57
|
+
const pipeline = device.createRenderPipeline({
|
|
58
|
+
layout: pipelineLayout,
|
|
59
|
+
vertex: { module },
|
|
60
|
+
fragment: { module, targets: [{ format: outputFormat }] },
|
|
61
|
+
primitive: { topology: "triangle-list" },
|
|
62
|
+
});
|
|
63
|
+
renderFrame({
|
|
64
|
+
device,
|
|
65
|
+
pipeline,
|
|
66
|
+
bindGroup: bindings?.bindGroup,
|
|
67
|
+
targetView: texture.createView(),
|
|
68
|
+
});
|
|
69
|
+
const data = await withTextureCopy(device, texture, texData =>
|
|
70
|
+
Array.from(texData),
|
|
71
|
+
);
|
|
72
|
+
texture.destroy();
|
|
73
|
+
uniformBuffer?.destroy();
|
|
74
|
+
return data;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create bind group with optional uniforms, textures, and samplers.
|
|
80
|
+
* Binding layout: 0=uniform buffer (if provided), 1..n=textures, n+1..n+m=samplers.
|
|
81
|
+
* Samplers must be length 1 (reused for all textures) or match textures.length exactly.
|
|
82
|
+
*/
|
|
83
|
+
export function createBindGroup(
|
|
84
|
+
device: GPUDevice,
|
|
85
|
+
uniformBuffer: GPUBuffer | undefined,
|
|
86
|
+
textures: GPUTexture[] = [],
|
|
87
|
+
samplers: GPUSampler[] = [],
|
|
88
|
+
): { layout: GPUBindGroupLayout; bindGroup: GPUBindGroup } {
|
|
89
|
+
// Validate sampler count
|
|
90
|
+
if (textures.length > 0 && samplers.length > 0) {
|
|
91
|
+
if (samplers.length !== 1 && samplers.length !== textures.length) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Invalid sampler count: expected 1 or ${textures.length} samplers for ${textures.length} textures, got ${samplers.length}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const entries: GPUBindGroupLayoutEntry[] = [];
|
|
99
|
+
const bindGroupEntries: GPUBindGroupEntry[] = [];
|
|
100
|
+
|
|
101
|
+
if (uniformBuffer) {
|
|
102
|
+
entries.push({
|
|
103
|
+
binding: 0,
|
|
104
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
105
|
+
buffer: { type: "uniform" },
|
|
106
|
+
});
|
|
107
|
+
bindGroupEntries.push({ binding: 0, resource: { buffer: uniformBuffer } });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
textures.forEach((texture, i) => {
|
|
111
|
+
const binding = i + 1;
|
|
112
|
+
entries.push({
|
|
113
|
+
binding,
|
|
114
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
115
|
+
texture: { sampleType: "float" },
|
|
116
|
+
});
|
|
117
|
+
bindGroupEntries.push({ binding, resource: texture.createView() });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// If only one sampler provided, reuse it for all textures
|
|
121
|
+
const singleSampler = samplers.length === 1 ? samplers[0] : undefined;
|
|
122
|
+
for (let i = 0; i < textures.length; i++) {
|
|
123
|
+
const binding = textures.length + i + 1;
|
|
124
|
+
const sampler = singleSampler ?? samplers[i];
|
|
125
|
+
entries.push({
|
|
126
|
+
binding,
|
|
127
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
128
|
+
sampler: { type: "filtering" },
|
|
129
|
+
});
|
|
130
|
+
bindGroupEntries.push({ binding, resource: sampler });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const layout = device.createBindGroupLayout({ entries });
|
|
134
|
+
const bindGroup = device.createBindGroup({
|
|
135
|
+
layout,
|
|
136
|
+
entries: bindGroupEntries,
|
|
137
|
+
});
|
|
138
|
+
return { layout, bindGroup };
|
|
139
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./DeviceCache.ts";
|
|
2
|
+
export * from "./ErrorScopes.ts";
|
|
3
|
+
export * from "./ExampleTextures.ts";
|
|
4
|
+
export * from "./FragmentPipeline.ts";
|
|
5
|
+
export * from "./FragmentRender.ts";
|
|
6
|
+
export * from "./FullscreenVertex.ts";
|
|
7
|
+
export * from "./RenderUniforms.ts";
|
|
8
|
+
export * from "./SimpleRender.ts";
|
package/tsconfig.json
ADDED