wgsl-test 0.2.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 +111 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.js +11556 -0
- package/dist/index.js.map +1 -0
- package/images/lemur256.png +0 -0
- package/images/lemur512.png +0 -0
- package/package.json +61 -0
- package/src/CompileShader.ts +185 -0
- package/src/ExampleImages.ts +27 -0
- package/src/ImageHelpers.ts +38 -0
- package/src/ShaderModuleLoader.ts +46 -0
- package/src/TestComputeShader.ts +178 -0
- package/src/TestFragmentShader.ts +216 -0
- package/src/WebGPUTestSetup.ts +30 -0
- package/src/index.ts +41 -0
- package/src/test/ImageSnapshot.test.ts +212 -0
- package/src/test/PackageImport.test.ts +110 -0
- package/src/test/TestComputeShader.test.ts +231 -0
- package/src/test/TestFragmentShader.test.ts +397 -0
- package/src/test/__image_snapshots__/box-blur.png +0 -0
- package/src/test/__image_snapshots__/edge-detection.png +0 -0
- package/src/test/__image_snapshots__/effects-checkerboard.png +0 -0
- package/src/test/__image_snapshots__/grayscale.png +0 -0
- package/src/test/__image_snapshots__/lemur-sharpen.png +0 -0
- package/src/test/__image_snapshots__/solid-red-small.png +0 -0
- package/src/test/__image_snapshots__/solid_red.png +0 -0
- package/src/test/fixtures/test_shader_pkg/package.json +10 -0
- package/src/test/fixtures/test_shader_pkg/shaders/algorithms/compute_multiply.wgsl +5 -0
- package/src/test/fixtures/test_shader_pkg/shaders/compute_sum.wgsl +5 -0
- package/src/test/fixtures/test_shader_pkg/shaders/effects/checkerboard.wgsl +11 -0
- package/src/test/fixtures/test_shader_pkg/shaders/foo/bar/zap.wesl +5 -0
- package/src/test/fixtures/test_shader_pkg/shaders/foo/bar.wesl +4 -0
- package/src/test/fixtures/test_shader_pkg/shaders/legacy.wgsl +3 -0
- package/src/test/fixtures/test_shader_pkg/shaders/math.wesl +3 -0
- package/src/test/fixtures/test_shader_pkg/shaders/nested/deeper/func.wesl +3 -0
- package/src/test/fixtures/test_shader_pkg/shaders/priority.wesl +9 -0
- package/src/test/fixtures/test_shader_pkg/shaders/solid_red.wgsl +4 -0
- package/src/test/fixtures/test_shader_pkg/shaders/utils.wesl +3 -0
- package/src/test/fixtures/test_shader_pkg/wesl.toml +3 -0
- package/src/test/fixtures/test_shader_pkg/weslBundle.js +16 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { componentByteSize, numComponents, texelLoadType } from "thimbleberry";
|
|
2
|
+
import type { ImageData } from "vitest-image-snapshot";
|
|
3
|
+
import type { LinkParams } from "wesl";
|
|
4
|
+
import { normalizeModuleName } from "wesl";
|
|
5
|
+
import {
|
|
6
|
+
linkFragmentShader,
|
|
7
|
+
type RenderUniforms,
|
|
8
|
+
renderUniformBuffer,
|
|
9
|
+
simpleRender,
|
|
10
|
+
} from "wesl-gpu";
|
|
11
|
+
import { resolveShaderContext } from "./CompileShader.ts";
|
|
12
|
+
import { resolveShaderSource } from "./ShaderModuleLoader.ts";
|
|
13
|
+
|
|
14
|
+
export interface FragmentTestParams {
|
|
15
|
+
/** WESL/WGSL source code for the fragment shader to test.
|
|
16
|
+
* Either src or moduleName must be provided, but not both. */
|
|
17
|
+
src?: string;
|
|
18
|
+
|
|
19
|
+
/** Name of shader module to load from filesystem.
|
|
20
|
+
* Supports: bare name (blur.wgsl), path (effects/blur.wgsl), or module path (package::effects::blur).
|
|
21
|
+
* Either src or moduleName must be provided, but not both. */
|
|
22
|
+
moduleName?: string;
|
|
23
|
+
|
|
24
|
+
/** Project directory for resolving shader dependencies.
|
|
25
|
+
* Allows the shader to import from npm shader libraries.
|
|
26
|
+
* Optional: defaults to searching upward from cwd for package.json or wesl.toml.
|
|
27
|
+
* Typically use `import.meta.url`. */
|
|
28
|
+
projectDir?: string;
|
|
29
|
+
|
|
30
|
+
/** GPU device for running the tests.
|
|
31
|
+
* Typically use `getGPUDevice()` from wgsl-test. */
|
|
32
|
+
device: GPUDevice;
|
|
33
|
+
|
|
34
|
+
/** Texture format for the output texture. Default: "rgba32float" */
|
|
35
|
+
textureFormat?: GPUTextureFormat;
|
|
36
|
+
|
|
37
|
+
/** Size of the output texture. Default: [1, 1] for simple color tests.
|
|
38
|
+
* Use [2, 2] for derivative tests (forms a complete 2x2 quad for dpdx/dpdy). */
|
|
39
|
+
size?: [width: number, height: number];
|
|
40
|
+
|
|
41
|
+
/** Flags for conditional compilation to test shader specialization.
|
|
42
|
+
* Useful for testing `@if` statements in the shader. */
|
|
43
|
+
conditions?: LinkParams["conditions"];
|
|
44
|
+
|
|
45
|
+
/** Constants for shader compilation.
|
|
46
|
+
* Injects host-provided values via the `constants::` namespace. */
|
|
47
|
+
constants?: LinkParams["constants"];
|
|
48
|
+
|
|
49
|
+
/** Uniform values for the shader (time, mouse).
|
|
50
|
+
* Resolution is auto-populated from the size parameter.
|
|
51
|
+
* Creates test::Uniforms struct available in the shader. */
|
|
52
|
+
uniforms?: RenderUniforms;
|
|
53
|
+
|
|
54
|
+
/** Input textures for the shader.
|
|
55
|
+
* Bindings: textures at [1..n], samplers at [n+1..n+m].
|
|
56
|
+
* Binding 0 is reserved for uniforms. */
|
|
57
|
+
textures?: GPUTexture[];
|
|
58
|
+
|
|
59
|
+
/** Samplers for the input textures.
|
|
60
|
+
* Must be length 1 (reused for all textures) or match textures.length exactly. */
|
|
61
|
+
samplers?: GPUSampler[];
|
|
62
|
+
|
|
63
|
+
/** Use source shaders from current package instead of built bundles.
|
|
64
|
+
* Default: true for faster iteration during development. */
|
|
65
|
+
useSourceShaders?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FragmentImageTestParams
|
|
69
|
+
extends Omit<FragmentTestParams, "src" | "moduleName" | "device"> {
|
|
70
|
+
/** Optional snapshot name override. If not provided, derived from shader name. */
|
|
71
|
+
snapshotName?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Run a fragment shader test and validate image snapshot.
|
|
75
|
+
* @param device GPU device for rendering
|
|
76
|
+
* @param moduleName Shader name to load - supports:
|
|
77
|
+
* - Bare name: "blur.wgsl" → resolves to shaders/blur.wgsl
|
|
78
|
+
* - Relative path: "effects/blur.wgsl" → resolves to shaders/effects/blur.wgsl
|
|
79
|
+
* - Module path: "package::effects::blur" → same resolution
|
|
80
|
+
* @param opts Test parameters (size defaults to 256×256 for snapshots)
|
|
81
|
+
*/
|
|
82
|
+
export async function expectFragmentImage(
|
|
83
|
+
device: GPUDevice,
|
|
84
|
+
moduleName: string,
|
|
85
|
+
opts: FragmentImageTestParams = {},
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const { textureFormat = "rgba32float", size = [256, 256] } = opts;
|
|
88
|
+
|
|
89
|
+
// Render shader image using moduleName
|
|
90
|
+
const imageData = await testFragmentImage({
|
|
91
|
+
...opts,
|
|
92
|
+
device,
|
|
93
|
+
moduleName,
|
|
94
|
+
textureFormat,
|
|
95
|
+
size,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const snapshotName =
|
|
99
|
+
opts.snapshotName ?? moduleNameToSnapshotName(moduleName);
|
|
100
|
+
const { imageMatcher } = await import("vitest-image-snapshot");
|
|
101
|
+
imageMatcher();
|
|
102
|
+
const { expect } = await import("vitest");
|
|
103
|
+
await expect(imageData).toMatchImage(snapshotName);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Renders a fragment shader and returns pixel (0,0) color values for validation.
|
|
108
|
+
*
|
|
109
|
+
* Useful for simple color tests where you only need to check a single pixel result.
|
|
110
|
+
*
|
|
111
|
+
* @returns Array of color component values from pixel (0,0)
|
|
112
|
+
*/
|
|
113
|
+
export async function testFragment(
|
|
114
|
+
params: FragmentTestParams,
|
|
115
|
+
): Promise<number[]> {
|
|
116
|
+
const { textureFormat = "rgba32float" } = params;
|
|
117
|
+
const data = await runFragment(params);
|
|
118
|
+
const count = numComponents(textureFormat);
|
|
119
|
+
return data.slice(0, count);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Renders a fragment shader and returns the complete rendered image.
|
|
124
|
+
*
|
|
125
|
+
* Useful for image snapshot testing or when you need to validate the entire output.
|
|
126
|
+
* For snapshot testing shader files, consider using `expectFragmentImage` instead.
|
|
127
|
+
*
|
|
128
|
+
* @returns ImageData containing the full rendered output
|
|
129
|
+
*/
|
|
130
|
+
export async function testFragmentImage(
|
|
131
|
+
params: FragmentTestParams,
|
|
132
|
+
): Promise<ImageData> {
|
|
133
|
+
const { textureFormat = "rgba32float", size = [1, 1] } = params;
|
|
134
|
+
const texData = await runFragment(params);
|
|
135
|
+
const data = imageToUint8(texData, textureFormat, size[0], size[1]);
|
|
136
|
+
return {
|
|
137
|
+
data: new Uint8ClampedArray(data),
|
|
138
|
+
width: size[0],
|
|
139
|
+
height: size[1],
|
|
140
|
+
colorSpace: "srgb" as const,
|
|
141
|
+
} as ImageData;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runFragment(params: FragmentTestParams): Promise<number[]> {
|
|
145
|
+
const { projectDir, device, src, moduleName, useSourceShaders } = params;
|
|
146
|
+
const { conditions = {}, constants } = params;
|
|
147
|
+
const { textureFormat = "rgba32float", size = [1, 1] } = params;
|
|
148
|
+
const { textures, samplers, uniforms = {} } = params;
|
|
149
|
+
|
|
150
|
+
// Resolve shader source from either src or moduleName
|
|
151
|
+
const fragmentSrc = await resolveShaderSource(src, moduleName, projectDir);
|
|
152
|
+
|
|
153
|
+
const ctx = await resolveShaderContext({
|
|
154
|
+
src: fragmentSrc,
|
|
155
|
+
projectDir,
|
|
156
|
+
useSourceShaders,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const module = await linkFragmentShader({
|
|
160
|
+
device,
|
|
161
|
+
fragmentSource: fragmentSrc,
|
|
162
|
+
bundles: ctx.libs,
|
|
163
|
+
resolver: ctx.resolver,
|
|
164
|
+
packageName: ctx.packageName,
|
|
165
|
+
conditions,
|
|
166
|
+
constants,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const uniformBuffer = renderUniformBuffer(device, size, uniforms);
|
|
170
|
+
return await simpleRender({
|
|
171
|
+
device,
|
|
172
|
+
module,
|
|
173
|
+
outputFormat: textureFormat,
|
|
174
|
+
size,
|
|
175
|
+
textures,
|
|
176
|
+
samplers,
|
|
177
|
+
uniformBuffer,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Convert texture data to RGBA Uint8ClampedArray for image comparison. */
|
|
182
|
+
function imageToUint8(
|
|
183
|
+
data: ArrayLike<number>,
|
|
184
|
+
format: GPUTextureFormat,
|
|
185
|
+
width: number,
|
|
186
|
+
height: number,
|
|
187
|
+
): Uint8ClampedArray {
|
|
188
|
+
const totalPixels = width * height;
|
|
189
|
+
const components = numComponents(format);
|
|
190
|
+
const byteSize = componentByteSize(format);
|
|
191
|
+
const texelType = texelLoadType(format);
|
|
192
|
+
|
|
193
|
+
if (byteSize === 1 && format.includes("unorm")) {
|
|
194
|
+
return data instanceof Uint8ClampedArray
|
|
195
|
+
? data
|
|
196
|
+
: new Uint8ClampedArray(Array.from(data));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (texelType === "f32") {
|
|
200
|
+
const uint8Data = new Uint8ClampedArray(totalPixels * 4);
|
|
201
|
+
for (let i = 0; i < totalPixels * components; i++) {
|
|
202
|
+
uint8Data[i] = Math.round(Math.max(0, Math.min(1, data[i])) * 255);
|
|
203
|
+
}
|
|
204
|
+
return uint8Data;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error(`Unsupported texture format for image export: ${format}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Convert module path to snapshot name (e.g., "package::effects::blur" → "effects-blur") */
|
|
211
|
+
function moduleNameToSnapshotName(moduleName: string): string {
|
|
212
|
+
const normalized = normalizeModuleName(moduleName);
|
|
213
|
+
return normalized
|
|
214
|
+
.replace(/^package::/, "") // Strip "package::" prefix
|
|
215
|
+
.replaceAll("::", "-"); // Replace :: with -
|
|
216
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as webgpu from "webgpu";
|
|
2
|
+
|
|
3
|
+
let sharedGpu: GPU | undefined;
|
|
4
|
+
let sharedDevice: GPUDevice | undefined;
|
|
5
|
+
|
|
6
|
+
/** get or create shared GPU device for testing */
|
|
7
|
+
export async function getGPUDevice(): Promise<GPUDevice> {
|
|
8
|
+
if (!sharedDevice) {
|
|
9
|
+
const gpu = await setupWebGPU();
|
|
10
|
+
const adapter = await gpu.requestAdapter();
|
|
11
|
+
if (!adapter) throw new Error("Failed to get GPU adapter");
|
|
12
|
+
sharedDevice = await adapter.requestDevice();
|
|
13
|
+
}
|
|
14
|
+
return sharedDevice;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** destroy globally shared GPU test device */
|
|
18
|
+
export function destroySharedDevice(): void {
|
|
19
|
+
sharedDevice?.destroy();
|
|
20
|
+
sharedDevice = undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** initialize WebGPU for testing */
|
|
24
|
+
async function setupWebGPU(): Promise<GPU> {
|
|
25
|
+
if (!sharedGpu) {
|
|
26
|
+
Object.assign(globalThis, webgpu.globals);
|
|
27
|
+
sharedGpu = webgpu.create([]);
|
|
28
|
+
}
|
|
29
|
+
return sharedGpu;
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* oxlint-disable no-unused-vars */
|
|
2
|
+
export type { WgslElementType } from "thimbleberry";
|
|
3
|
+
export type { ImageData } from "vitest-image-snapshot";
|
|
4
|
+
|
|
5
|
+
// Re-export from wesl-gpu for convenience
|
|
6
|
+
export type { RenderUniforms, SamplerOptions } from "wesl-gpu";
|
|
7
|
+
export {
|
|
8
|
+
checkerboardTexture,
|
|
9
|
+
colorBarsTexture,
|
|
10
|
+
createSampler,
|
|
11
|
+
createUniformsVirtualLib,
|
|
12
|
+
DeviceCache,
|
|
13
|
+
edgePatternTexture,
|
|
14
|
+
fullscreenTriangleVertex,
|
|
15
|
+
gradientTexture,
|
|
16
|
+
noiseTexture,
|
|
17
|
+
radialGradientTexture,
|
|
18
|
+
renderUniformBuffer,
|
|
19
|
+
simpleRender,
|
|
20
|
+
solidTexture,
|
|
21
|
+
updateRenderUniforms,
|
|
22
|
+
withErrorScopes,
|
|
23
|
+
} from "wesl-gpu";
|
|
24
|
+
|
|
25
|
+
export * from "./CompileShader.ts";
|
|
26
|
+
export * from "./ExampleImages.ts";
|
|
27
|
+
export * from "./ImageHelpers.ts";
|
|
28
|
+
export * from "./TestComputeShader.ts";
|
|
29
|
+
export * from "./TestFragmentShader.ts";
|
|
30
|
+
export * from "./WebGPUTestSetup.ts";
|
|
31
|
+
|
|
32
|
+
// Re-export module augmentation from vitest-image-snapshot for packaged builds
|
|
33
|
+
import type {} from "vitest";
|
|
34
|
+
import type { MatchImageOptions } from "vitest-image-snapshot";
|
|
35
|
+
|
|
36
|
+
declare module "vitest" {
|
|
37
|
+
// biome-ignore lint/correctness/noUnusedVariables: T must match Vitest's Matchers<T> signature
|
|
38
|
+
interface Matchers<T = any> {
|
|
39
|
+
toMatchImage(nameOrOptions?: string | MatchImageOptions): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "vitest";
|
|
2
|
+
import { imageMatcher } from "vitest-image-snapshot";
|
|
3
|
+
import { lemurTexture } from "../ExampleImages.ts";
|
|
4
|
+
import {
|
|
5
|
+
checkerboardTexture,
|
|
6
|
+
colorBarsTexture,
|
|
7
|
+
createSampler,
|
|
8
|
+
destroySharedDevice,
|
|
9
|
+
edgePatternTexture,
|
|
10
|
+
expectFragmentImage,
|
|
11
|
+
getGPUDevice,
|
|
12
|
+
testFragmentImage,
|
|
13
|
+
} from "../index.ts";
|
|
14
|
+
|
|
15
|
+
imageMatcher();
|
|
16
|
+
|
|
17
|
+
let device: GPUDevice;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
device = await getGPUDevice();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
destroySharedDevice();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("simple box blur", async () => {
|
|
28
|
+
const inputTex = checkerboardTexture(device, 128, 128, 16);
|
|
29
|
+
const sampler = createSampler(device);
|
|
30
|
+
|
|
31
|
+
const src = `
|
|
32
|
+
@group(0) @binding(1) var input_tex: texture_2d<f32>;
|
|
33
|
+
@group(0) @binding(2) var input_samp: sampler;
|
|
34
|
+
|
|
35
|
+
@fragment
|
|
36
|
+
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
37
|
+
let uv = pos.xy / 128.0;
|
|
38
|
+
let pixel_size = 1.0 / 128.0;
|
|
39
|
+
|
|
40
|
+
// 3x3 box blur
|
|
41
|
+
var color = vec4f(0.0);
|
|
42
|
+
for (var y = -1; y <= 1; y++) {
|
|
43
|
+
for (var x = -1; x <= 1; x++) {
|
|
44
|
+
let offset = vec2f(f32(x), f32(y)) * pixel_size;
|
|
45
|
+
color += textureSample(input_tex, input_samp, uv + offset);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return color / 9.0;
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const result = await testFragmentImage({
|
|
54
|
+
projectDir: import.meta.url,
|
|
55
|
+
device,
|
|
56
|
+
src,
|
|
57
|
+
size: [128, 128],
|
|
58
|
+
textures: [inputTex],
|
|
59
|
+
samplers: [sampler],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await expect(result).toMatchImage("box-blur");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("simple edge detection", async () => {
|
|
66
|
+
const inputTex = edgePatternTexture(device, 128);
|
|
67
|
+
const sampler = createSampler(device);
|
|
68
|
+
|
|
69
|
+
const src = `
|
|
70
|
+
@group(0) @binding(1) var input_tex: texture_2d<f32>;
|
|
71
|
+
@group(0) @binding(2) var input_samp: sampler;
|
|
72
|
+
|
|
73
|
+
@fragment
|
|
74
|
+
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
75
|
+
let uv = pos.xy / 128.0;
|
|
76
|
+
let pixel_size = 1.0 / 128.0;
|
|
77
|
+
|
|
78
|
+
// Simple Sobel operator
|
|
79
|
+
let tl = textureSample(input_tex, input_samp, uv + vec2f(-pixel_size, -pixel_size)).rgb;
|
|
80
|
+
let tr = textureSample(input_tex, input_samp, uv + vec2f( pixel_size, -pixel_size)).rgb;
|
|
81
|
+
let bl = textureSample(input_tex, input_samp, uv + vec2f(-pixel_size, pixel_size)).rgb;
|
|
82
|
+
let br = textureSample(input_tex, input_samp, uv + vec2f( pixel_size, pixel_size)).rgb;
|
|
83
|
+
|
|
84
|
+
let gx = -tl + tr - bl + br;
|
|
85
|
+
let gy = -tl - tr + bl + br;
|
|
86
|
+
let mag = length(vec2f(dot(gx, vec3f(0.299, 0.587, 0.114)),
|
|
87
|
+
dot(gy, vec3f(0.299, 0.587, 0.114))));
|
|
88
|
+
|
|
89
|
+
return vec4f(vec3f(mag), 1.0);
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const result = await testFragmentImage({
|
|
94
|
+
projectDir: import.meta.url,
|
|
95
|
+
device,
|
|
96
|
+
src,
|
|
97
|
+
size: [128, 128],
|
|
98
|
+
textures: [inputTex],
|
|
99
|
+
samplers: [sampler],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await expect(result).toMatchImage({
|
|
103
|
+
name: "edge-detection",
|
|
104
|
+
allowedPixelRatio: 0.01,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("grayscale conversion", async () => {
|
|
109
|
+
const inputTex = colorBarsTexture(device, 128);
|
|
110
|
+
const sampler = createSampler(device);
|
|
111
|
+
|
|
112
|
+
const src = `
|
|
113
|
+
@group(0) @binding(1) var input_tex: texture_2d<f32>;
|
|
114
|
+
@group(0) @binding(2) var input_samp: sampler;
|
|
115
|
+
|
|
116
|
+
@fragment
|
|
117
|
+
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
118
|
+
let uv = pos.xy / 128.0;
|
|
119
|
+
let color = textureSample(input_tex, input_samp, uv);
|
|
120
|
+
|
|
121
|
+
// Perceptual grayscale
|
|
122
|
+
let gray = dot(color.rgb, vec3f(0.299, 0.587, 0.114));
|
|
123
|
+
|
|
124
|
+
return vec4f(vec3f(gray), 1.0);
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const result = await testFragmentImage({
|
|
129
|
+
projectDir: import.meta.url,
|
|
130
|
+
device,
|
|
131
|
+
src,
|
|
132
|
+
size: [128, 128],
|
|
133
|
+
textures: [inputTex],
|
|
134
|
+
samplers: [sampler],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(result).toMatchImage("grayscale");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("sharpen filter on photo sample", async () => {
|
|
141
|
+
const inputTex = await lemurTexture(device);
|
|
142
|
+
const sampler = createSampler(device);
|
|
143
|
+
|
|
144
|
+
const src = `
|
|
145
|
+
@group(0) @binding(1) var input_tex: texture_2d<f32>;
|
|
146
|
+
@group(0) @binding(2) var input_samp: sampler;
|
|
147
|
+
|
|
148
|
+
@fragment
|
|
149
|
+
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
150
|
+
let uv = pos.xy / 512.0;
|
|
151
|
+
let pixel_size = 1.0 / 512.0;
|
|
152
|
+
|
|
153
|
+
// Simple sharpen kernel
|
|
154
|
+
let center = textureSample(input_tex, input_samp, uv);
|
|
155
|
+
let top = textureSample(input_tex, input_samp, uv + vec2f(0.0, -pixel_size));
|
|
156
|
+
let bottom = textureSample(input_tex, input_samp, uv + vec2f(0.0, pixel_size));
|
|
157
|
+
let left = textureSample(input_tex, input_samp, uv + vec2f(-pixel_size, 0.0));
|
|
158
|
+
let right = textureSample(input_tex, input_samp, uv + vec2f(pixel_size, 0.0));
|
|
159
|
+
|
|
160
|
+
let sharpened = center * 5.0 - top - bottom - left - right;
|
|
161
|
+
return vec4f(clamp(sharpened.rgb, vec3f(0.0), vec3f(1.0)), 1.0);
|
|
162
|
+
}
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
const result = await testFragmentImage({
|
|
166
|
+
projectDir: import.meta.url,
|
|
167
|
+
device,
|
|
168
|
+
src,
|
|
169
|
+
size: [512, 512],
|
|
170
|
+
textures: [inputTex],
|
|
171
|
+
samplers: [sampler],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await expect(result).toMatchImage("lemur-sharpen");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("expectFragmentImage with bare name", async () => {
|
|
178
|
+
const testPkgDir = new URL("./fixtures/test_shader_pkg/", import.meta.url)
|
|
179
|
+
.href;
|
|
180
|
+
await expectFragmentImage(device, "solid_red", {
|
|
181
|
+
projectDir: testPkgDir,
|
|
182
|
+
size: [128, 128],
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("expectFragmentImage with relative path", async () => {
|
|
187
|
+
const testPkgDir = new URL("./fixtures/test_shader_pkg/", import.meta.url)
|
|
188
|
+
.href;
|
|
189
|
+
await expectFragmentImage(device, "effects/checkerboard.wgsl", {
|
|
190
|
+
projectDir: testPkgDir,
|
|
191
|
+
size: [128, 128],
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("expectFragmentImage with module path", async () => {
|
|
196
|
+
const testPkgDir = new URL("./fixtures/test_shader_pkg/", import.meta.url)
|
|
197
|
+
.href;
|
|
198
|
+
await expectFragmentImage(device, "package::solid_red", {
|
|
199
|
+
projectDir: testPkgDir,
|
|
200
|
+
size: [128, 128],
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("expectFragmentImage with custom snapshot name", async () => {
|
|
205
|
+
const testPkgDir = new URL("./fixtures/test_shader_pkg/", import.meta.url)
|
|
206
|
+
.href;
|
|
207
|
+
await expectFragmentImage(device, "solid_red.wgsl", {
|
|
208
|
+
projectDir: testPkgDir,
|
|
209
|
+
size: [64, 64],
|
|
210
|
+
snapshotName: "solid-red-small",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "vitest";
|
|
2
|
+
import { testCompute } from "../TestComputeShader.ts";
|
|
3
|
+
import { destroySharedDevice, getGPUDevice } from "../WebGPUTestSetup.ts";
|
|
4
|
+
|
|
5
|
+
let device: GPUDevice;
|
|
6
|
+
const testPkgDir = new URL("./fixtures/test_shader_pkg/", import.meta.url).href;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
device = await getGPUDevice();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
destroySharedDevice();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function runTest(src: string, options = {}) {
|
|
17
|
+
return await testCompute({
|
|
18
|
+
projectDir: testPkgDir,
|
|
19
|
+
device,
|
|
20
|
+
src,
|
|
21
|
+
...options,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("import from current package with default useSourceShaders", async () => {
|
|
26
|
+
const result = await runTest(
|
|
27
|
+
`import package::utils::helper;
|
|
28
|
+
@compute @workgroup_size(1)
|
|
29
|
+
fn main() {
|
|
30
|
+
test::results[0] = helper();
|
|
31
|
+
}`,
|
|
32
|
+
);
|
|
33
|
+
expect(result[0]).toBe(42);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("import from bundled package with useSourceShaders: false", async () => {
|
|
37
|
+
const result = await runTest(
|
|
38
|
+
`import test_shader_pkg::utils::helper;
|
|
39
|
+
@compute @workgroup_size(1)
|
|
40
|
+
fn main() {
|
|
41
|
+
test::results[0] = helper();
|
|
42
|
+
}`,
|
|
43
|
+
{ useSourceShaders: false },
|
|
44
|
+
);
|
|
45
|
+
expect(result[0]).toBe(43);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("import using actual package name instead of 'package::'", async () => {
|
|
49
|
+
const result = await runTest(
|
|
50
|
+
`import test_shader_pkg::utils::helper;
|
|
51
|
+
@compute @workgroup_size(1)
|
|
52
|
+
fn main() {
|
|
53
|
+
test::results[0] = helper();
|
|
54
|
+
}`,
|
|
55
|
+
);
|
|
56
|
+
expect(result[0]).toBe(42);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("import from .wgsl file when .wesl doesn't exist", async () => {
|
|
60
|
+
const result = await runTest(
|
|
61
|
+
`import package::legacy::legacyHelper;
|
|
62
|
+
@compute @workgroup_size(1)
|
|
63
|
+
fn main() {
|
|
64
|
+
test::results[0] = legacyHelper();
|
|
65
|
+
}`,
|
|
66
|
+
);
|
|
67
|
+
expect(result[0]).toBe(99);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Incremental module resolution per WESL spec:
|
|
71
|
+
* For "import foo::bar::zap", tries: foo.wesl::bar, then foo/bar.wesl::zap, then foo/bar/zap.wesl
|
|
72
|
+
* Item definitions are prioritized over filesystem structure
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
test("import from deeply nested path", async () => {
|
|
76
|
+
const result = await runTest(
|
|
77
|
+
`import package::nested::deeper::func::deepHelper;
|
|
78
|
+
@compute @workgroup_size(1)
|
|
79
|
+
fn main() {
|
|
80
|
+
test::results[0] = deepHelper();
|
|
81
|
+
}`,
|
|
82
|
+
);
|
|
83
|
+
expect(result[0]).toBe(123);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("resolve when intermediate module exists but lacks the item", async () => {
|
|
87
|
+
// foo/bar.wesl exists but lacks "zap" item
|
|
88
|
+
// Resolution continues to foo/bar/zap.wesl
|
|
89
|
+
const result = await runTest(
|
|
90
|
+
`import package::foo::bar::zap::zapValue;
|
|
91
|
+
@compute @workgroup_size(1)
|
|
92
|
+
fn main() {
|
|
93
|
+
test::results[0] = zapValue;
|
|
94
|
+
}`,
|
|
95
|
+
);
|
|
96
|
+
expect(result[0]).toBe(88);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("item in module takes priority over submodule file", async () => {
|
|
100
|
+
// priority.wesl contains const sub = 777u
|
|
101
|
+
// Per spec: "item definitions prioritized over filesystem"
|
|
102
|
+
const result = await runTest(
|
|
103
|
+
`import package::priority::sub;
|
|
104
|
+
@compute @workgroup_size(1)
|
|
105
|
+
fn main() {
|
|
106
|
+
test::results[0] = sub;
|
|
107
|
+
}`,
|
|
108
|
+
);
|
|
109
|
+
expect(result[0]).toBe(777);
|
|
110
|
+
});
|