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 ADDED
@@ -0,0 +1,4 @@
1
+ # wesl-gpu
2
+
3
+ Reusable WebGPU helpers extracted from `wgsl-test` and `wesl-toy` for use in both Node.js and browser environments.
4
+
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
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../../tsconfig.browser.json"
3
+ }