Skip to content

Commit 52fff31

Browse files
MaxKopaczfinetjul
authored andcommitted
test(WebGPU): add OIT MSAA regression test
Cover order independent translucency with MSAA enabled: the resolve targets are allocated single sample and bound to the compositing quad, the frame actually composites, MSAA and non-MSAA agree on interior pixels while differing on the silhouettes, and disabling MSAA releases the resolve targets. The existing MSAA test was written for the tape and karma harness that the Vite and Vitest migration removed, so it could not run. Port it to Vitest and split out the multiSample validation. Both files are gated behind the WebGPU test flag.
1 parent 292c0cf commit 52fff31

3 files changed

Lines changed: 313 additions & 46 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { it, expect } from 'vitest';
2+
import testUtils from 'vtk.js/Sources/Testing/testUtils';
3+
4+
import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs';
5+
import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor';
6+
import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper';
7+
import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer';
8+
import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow';
9+
import vtkSphereSource from 'vtk.js/Sources/Filters/Sources/SphereSource';
10+
11+
const SIZE = 300;
12+
const BACKGROUND = [0.32, 0.34, 0.43];
13+
14+
// ----------------------------------------------------------------------------
15+
// Helpers
16+
// ----------------------------------------------------------------------------
17+
18+
// Two overlapping translucent spheres: every pixel inside the overlap is the
19+
// result of the weighted-blended OIT compositing, and the sphere silhouettes
20+
// give the multisample resolve something to anti-alias.
21+
22+
function buildTranslucentScene(gc) {
23+
const container = document.querySelector('body');
24+
const renderWindowContainer = gc.registerDOMElement(
25+
document.createElement('div')
26+
);
27+
container.appendChild(renderWindowContainer);
28+
29+
const renderWindow = gc.registerResource(vtkRenderWindow.newInstance());
30+
const renderer = gc.registerResource(vtkRenderer.newInstance());
31+
renderWindow.addRenderer(renderer);
32+
renderer.setBackground(...BACKGROUND);
33+
34+
const addSphere = (center, color) => {
35+
const source = gc.registerResource(
36+
vtkSphereSource.newInstance({
37+
center,
38+
radius: 0.5,
39+
thetaResolution: 60,
40+
phiResolution: 60,
41+
})
42+
);
43+
const mapper = gc.registerResource(vtkMapper.newInstance());
44+
mapper.setInputConnection(source.getOutputPort());
45+
const actor = gc.registerResource(vtkActor.newInstance());
46+
actor.setMapper(mapper);
47+
actor.getProperty().setOpacity(0.5);
48+
actor.getProperty().setColor(...color);
49+
renderer.addActor(actor);
50+
};
51+
52+
addSphere([-0.25, 0.0, 0.0], [0.9, 0.2, 0.2]);
53+
addSphere([0.25, 0.0, 0.3], [0.2, 0.6, 0.9]);
54+
55+
const apiView = gc.registerResource(
56+
renderWindow.newAPISpecificView('WebGPU')
57+
);
58+
apiView.setContainer(renderWindowContainer);
59+
renderWindow.addView(apiView);
60+
apiView.setSize(SIZE, SIZE);
61+
62+
// Fixed camera so the two renders below are directly comparable.
63+
renderer.resetCamera();
64+
65+
return { apiView, renderWindow, renderer };
66+
}
67+
68+
function getTranslucentPass(apiView) {
69+
return apiView.getRenderPasses()[0].getTranslucentPass();
70+
}
71+
72+
function renderAndCapture(apiView, renderWindow) {
73+
const promise = apiView.captureNextImage();
74+
renderWindow.render();
75+
return promise;
76+
}
77+
78+
function pixelAt(imageData, x, y) {
79+
const offset = (y * imageData.width + x) * 4;
80+
return Array.from(imageData.data.slice(offset, offset + 4));
81+
}
82+
83+
function channelDistance(a, b) {
84+
return Math.max(
85+
Math.abs(a[0] - b[0]),
86+
Math.abs(a[1] - b[1]),
87+
Math.abs(a[2] - b[2])
88+
);
89+
}
90+
91+
// Fraction of pixels that differ between two renders of the same scene.
92+
function mismatchFraction(a, b, threshold = 8) {
93+
let differing = 0;
94+
for (let i = 0; i < a.data.length; i += 4) {
95+
const pa = [a.data[i], a.data[i + 1], a.data[i + 2]];
96+
const pb = [b.data[i], b.data[i + 1], b.data[i + 2]];
97+
if (channelDistance(pa, pb) > threshold) {
98+
differing++;
99+
}
100+
}
101+
return differing / (a.width * a.height);
102+
}
103+
104+
// ----------------------------------------------------------------------------
105+
// Test: the OIT pass composites from the resolved (single sample) textures
106+
// ----------------------------------------------------------------------------
107+
108+
it.skipIf(!__VTK_TEST_WEBGPU__)(
109+
'Test WebGPU OIT composites from resolved textures when MSAA is enabled',
110+
async () => {
111+
const gc = testUtils.createGarbageCollector();
112+
const { apiView, renderWindow } = buildTranslucentScene(gc);
113+
114+
try {
115+
expect(apiView.setMultiSample(4), 'multiSample 4 accepted').not.toBe(
116+
false
117+
);
118+
119+
const image = await renderAndCapture(apiView, renderWindow);
120+
const translucentPass = getTranslucentPass(apiView);
121+
122+
expect(
123+
translucentPass,
124+
'translucent pass ran for translucent actors'
125+
).toBeTruthy();
126+
127+
const resolveColor = translucentPass.getTranslucentResolveColorTexture();
128+
const resolveAccum =
129+
translucentPass.getTranslucentResolveAccumulateTexture();
130+
131+
expect(resolveColor, 'color resolve target allocated').toBeTruthy();
132+
expect(resolveAccum, 'accumulate resolve target allocated').toBeTruthy();
133+
134+
// The compositing quad samples the OIT buffers with textureLoad, which
135+
// is only legal on a single sampled texture. Reading the multisampled
136+
// attachments directly would be a WebGPU validation error.
137+
expect(
138+
resolveColor.getSampleCount(),
139+
'color resolve is single sample'
140+
).toBe(1);
141+
expect(
142+
resolveAccum.getSampleCount(),
143+
'accumulate resolve is single sample'
144+
).toBe(1);
145+
146+
const quadViews = translucentPass.getFullScreenQuad().getTextureViews();
147+
expect(
148+
quadViews.map((view) => view.getTexture()),
149+
'full screen quad reads the resolve targets'
150+
).toEqual([resolveColor, resolveAccum]);
151+
152+
// A mismatch between the pipelines and the multisampled attachments
153+
// invalidates the whole command buffer, so nothing reaches the canvas
154+
// and the capture comes back fully transparent.
155+
const imageData = await testUtils.getImageDataFromURI(image);
156+
const center = pixelAt(imageData, SIZE / 2, SIZE / 2);
157+
expect(center[3], `the frame was rendered (got ${center})`).toBe(255);
158+
159+
// A broken resolve would leave the compositing quad sampling empty
160+
// textures, which shows up as an untouched background.
161+
const background = BACKGROUND.map((c) => Math.round(c * 255));
162+
expect(
163+
channelDistance(center, background),
164+
`translucent geometry composited over the background (got ${center})`
165+
).toBeGreaterThan(20);
166+
} finally {
167+
gc.releaseResources();
168+
}
169+
}
170+
);
171+
172+
// ----------------------------------------------------------------------------
173+
// Test: MSAA anti-aliases the silhouettes without changing the OIT result
174+
// ----------------------------------------------------------------------------
175+
176+
it.skipIf(!__VTK_TEST_WEBGPU__)(
177+
'Test WebGPU OIT with MSAA preserves the blended result',
178+
async () => {
179+
const gc = testUtils.createGarbageCollector();
180+
const { apiView, renderWindow } = buildTranslucentScene(gc);
181+
182+
try {
183+
apiView.setMultiSample(1);
184+
const plainImage = await renderAndCapture(apiView, renderWindow);
185+
186+
apiView.setMultiSample(4);
187+
const msaaImage = await renderAndCapture(apiView, renderWindow);
188+
189+
const plain = await testUtils.getImageDataFromURI(plainImage);
190+
const msaa = await testUtils.getImageDataFromURI(msaaImage);
191+
192+
// Interior pixels are fully covered in both renders, so the resolved
193+
// OIT result there must match the non-MSAA one.
194+
const samples = [
195+
[SIZE / 2, SIZE / 2],
196+
[SIZE / 2, SIZE / 3],
197+
[SIZE / 3, SIZE / 2],
198+
];
199+
samples.forEach(([x, y]) => {
200+
const distance = channelDistance(
201+
pixelAt(plain, x, y),
202+
pixelAt(msaa, x, y)
203+
);
204+
expect(
205+
distance,
206+
`interior pixel (${x}, ${y}) matches the non-MSAA render`
207+
).toBeLessThan(12);
208+
});
209+
210+
// ... but the silhouettes are smoothed, so the images are not identical
211+
// and differ only on a small band of edge pixels.
212+
const fraction = mismatchFraction(plain, msaa);
213+
expect(fraction, 'MSAA changed the silhouette pixels').toBeGreaterThan(0);
214+
expect(
215+
fraction,
216+
'MSAA only changed edge pixels, not the whole image'
217+
).toBeLessThan(0.15);
218+
} finally {
219+
gc.releaseResources();
220+
}
221+
}
222+
);
223+
224+
// ----------------------------------------------------------------------------
225+
// Test: turning MSAA off rebuilds the pass without resolve targets
226+
// ----------------------------------------------------------------------------
227+
228+
it.skipIf(!__VTK_TEST_WEBGPU__)(
229+
'Test WebGPU OIT releases resolve textures when MSAA is disabled',
230+
async () => {
231+
const gc = testUtils.createGarbageCollector();
232+
const { apiView, renderWindow } = buildTranslucentScene(gc);
233+
234+
try {
235+
apiView.setMultiSample(4);
236+
await renderAndCapture(apiView, renderWindow);
237+
expect(
238+
getTranslucentPass(apiView).getTranslucentResolveColorTexture(),
239+
'resolve target allocated while MSAA is on'
240+
).toBeTruthy();
241+
242+
apiView.setMultiSample(1);
243+
await renderAndCapture(apiView, renderWindow);
244+
245+
const translucentPass = getTranslucentPass(apiView);
246+
expect(
247+
translucentPass.getTranslucentResolveColorTexture(),
248+
'color resolve target released'
249+
).toBe(null);
250+
expect(
251+
translucentPass.getTranslucentResolveAccumulateTexture(),
252+
'accumulate resolve target released'
253+
).toBe(null);
254+
255+
// Back on the single sample path the quad reads the attachments directly.
256+
const quadViews = translucentPass.getFullScreenQuad().getTextureViews();
257+
expect(quadViews.length, 'quad still bound to two OIT buffers').toBe(2);
258+
expect(
259+
quadViews.every((view) => view.getTexture().getSampleCount() === 1),
260+
'quad reads single sample attachments'
261+
).toBe(true);
262+
} finally {
263+
gc.releaseResources();
264+
}
265+
}
266+
);
Lines changed: 46 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,48 @@
1-
import test from 'tape';
1+
import { it, expect } from 'vitest';
22
import testUtils from 'vtk.js/Sources/Testing/testUtils';
33

4+
import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs';
45
import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor';
6+
import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource';
57
import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper';
6-
import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs';
78
import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer';
89
import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow';
9-
import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource';
1010
import vtkSphereSource from 'vtk.js/Sources/Filters/Sources/SphereSource';
1111

1212
// ---------------------------------------------------------------------------
13-
// Helpers
13+
// Test: multiSample validation
1414
// ---------------------------------------------------------------------------
1515

16-
// Detect whether the current runtime actually supports WebGPU.
17-
function isWebGPUAvailable() {
18-
return typeof navigator !== 'undefined' && !!navigator.gpu;
19-
}
16+
it.skipIf(!__VTK_TEST_WEBGPU__)(
17+
'Test WebGPU multiSample only accepts 1 and 4',
18+
() => {
19+
const gc = testUtils.createGarbageCollector();
20+
const renderWindow = gc.registerResource(vtkRenderWindow.newInstance());
21+
const apiView = gc.registerResource(
22+
renderWindow.newAPISpecificView('WebGPU')
23+
);
24+
25+
try {
26+
expect(apiView.getMultiSample(), 'MSAA is off by default').toBe(1);
27+
expect(apiView.setMultiSample(2), 'multiSample 2 rejected').toBe(false);
28+
expect(apiView.setMultiSample(3), 'multiSample 3 rejected').toBe(false);
29+
expect(apiView.getMultiSample(), 'rejected values are ignored').toBe(1);
30+
expect(apiView.setMultiSample(4), 'multiSample 4 accepted').not.toBe(
31+
false
32+
);
33+
expect(apiView.getMultiSample(), 'multiSample updated to 4').toBe(4);
34+
} finally {
35+
gc.releaseResources();
36+
}
37+
}
38+
);
2039

2140
// ---------------------------------------------------------------------------
22-
// Test: MSAA opaque + translucent rendering (WebGPU)
41+
// Test: MSAA opaque + translucent rendering
2342
// ---------------------------------------------------------------------------
2443

25-
test.onlyIfWebGPU('Test WebGPU MSAA rendering', (t) => {
26-
const gc = testUtils.createGarbageCollector(t);
44+
it.skipIf(!__VTK_TEST_WEBGPU__)('Test WebGPU MSAA rendering', async () => {
45+
const gc = testUtils.createGarbageCollector();
2746

2847
const container = document.querySelector('body');
2948
const renderWindowContainer = gc.registerDOMElement(
@@ -66,42 +85,23 @@ test.onlyIfWebGPU('Test WebGPU MSAA rendering', (t) => {
6685
apiView.setContainer(renderWindowContainer);
6786
renderWindow.addView(apiView);
6887
apiView.setSize(400, 400);
69-
70-
// ------ MSAA configuration ------
71-
const webgpuAvailable = isWebGPUAvailable();
72-
const desiredSampleCount = webgpuAvailable ? 4 : 1;
73-
74-
if (apiView.setMultiSample) {
75-
// Validate that invalid sample counts are rejected
76-
t.notOk(
77-
apiView.setMultiSample(2),
78-
'setMultiSample(2) should return false (invalid)'
79-
);
80-
81-
// Set the desired sample count
82-
apiView.setMultiSample(desiredSampleCount);
83-
}
84-
85-
t.equal(
86-
apiView.getMultiSample ? apiView.getMultiSample() : 1,
87-
desiredSampleCount,
88-
`multiSample should be ${desiredSampleCount}`
89-
);
88+
apiView.setMultiSample(4);
9089

9190
renderer.resetCamera();
9291

93-
// ------ Capture and verify ------
94-
const promise = apiView
95-
.captureNextImage()
96-
.then((image) => {
97-
// The rendering completed without errors — this is the primary
98-
// regression check. MSAA misconfiguration (sample count mismatches,
99-
// missing resolve targets, etc.) would cause a GPU validation error
100-
// before we reach this point.
101-
t.ok(image, 'MSAA render produced an image without GPU errors');
102-
})
103-
.finally(gc.releaseResources);
104-
105-
renderWindow.render();
106-
return promise;
92+
try {
93+
const promise = apiView.captureNextImage();
94+
renderWindow.render();
95+
const image = await promise;
96+
97+
// The rendering completed without errors — this is the primary regression
98+
// check. MSAA misconfiguration (sample count mismatches, missing resolve
99+
// targets, etc.) would cause a GPU validation error before we get here.
100+
expect(
101+
image,
102+
'MSAA render produced an image without GPU errors'
103+
).toBeTruthy();
104+
} finally {
105+
gc.releaseResources();
106+
}
107107
});

Sources/Testing/testUtils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export default {
268268
createGarbageCollector,
269269
createImage,
270270
createWebGPUTestDevice,
271+
getImageDataFromURI,
271272
readWebGPUTexture2D,
272273
keepDOM,
273274
objEquals,

0 commit comments

Comments
 (0)