|
| 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 | +); |
0 commit comments