diff --git a/Sources/Rendering/WebGPU/OpaquePass/index.js b/Sources/Rendering/WebGPU/OpaquePass/index.js index a4d2f23a9a9..86b69cf2231 100644 --- a/Sources/Rendering/WebGPU/OpaquePass/index.js +++ b/Sources/Rendering/WebGPU/OpaquePass/index.js @@ -22,50 +22,94 @@ function vtkWebGPUOpaquePass(publicAPI, model) { model._currentParent = viewNode; const device = viewNode.getDevice(); + const sampleCount = viewNode.getMultiSample ? viewNode.getMultiSample() : 1; + + // If sampleCount changed since last render, tear down and recreate + if (model.renderEncoder && model._currentSampleCount !== sampleCount) { + model.renderEncoder = null; + model.colorTexture = null; + model.depthTexture = null; + model.resolveColorTexture = null; + model._resolveColorTextureView = null; + } if (!model.renderEncoder) { - publicAPI.createRenderEncoder(); + publicAPI.createRenderEncoder(sampleCount); + model._currentSampleCount = sampleCount; + + const width = viewNode.getCanvas().width; + const height = viewNode.getCanvas().height; + + // Color texture — multisampled when sampleCount > 1 model.colorTexture = vtkWebGPUTexture.newInstance({ label: 'opaquePassColor', }); + /* eslint-disable no-undef */ + /* eslint-disable no-bitwise */ model.colorTexture.create(device, { - width: viewNode.getCanvas().width, - height: viewNode.getCanvas().height, + width, + height, format: 'rgba16float', - /* eslint-disable no-undef */ - /* eslint-disable no-bitwise */ + sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT | - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_SRC, + (sampleCount === 1 + ? GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC + : 0), }); const ctView = model.colorTexture.createView('opaquePassColorTexture'); model.renderEncoder.setColorTextureView(0, ctView); + // When MSAA is active, create a resolve target (1-sample) for + // downstream passes that need to sample the color result + if (sampleCount > 1) { + model.resolveColorTexture = vtkWebGPUTexture.newInstance({ + label: 'opaquePassResolveColor', + }); + model.resolveColorTexture.create(device, { + width, + height, + format: 'rgba16float', + usage: + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC, + }); + model._resolveColorTextureView = model.resolveColorTexture.createView( + 'opaquePassColorTexture' + ); + const resolveView = model._resolveColorTextureView; + model.renderEncoder.setResolveTextureView(0, resolveView); + } + + // Depth texture — also multisampled model.depthFormat = 'depth32float'; model.depthTexture = vtkWebGPUTexture.newInstance({ label: 'opaquePassDepth', }); model.depthTexture.create(device, { - width: viewNode.getCanvas().width, - height: viewNode.getCanvas().height, + width, + height, format: model.depthFormat, + sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT | - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_SRC, + (sampleCount === 1 + ? GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC + : 0), }); + /* eslint-enable no-undef */ + /* eslint-enable no-bitwise */ const dView = model.depthTexture.createView('opaquePassDepthTexture'); model.renderEncoder.setDepthTextureView(dView); } else { - model.colorTexture.resize( - viewNode.getCanvas().width, - viewNode.getCanvas().height - ); - model.depthTexture.resize( - viewNode.getCanvas().width, - viewNode.getCanvas().height - ); + const width = viewNode.getCanvas().width; + const height = viewNode.getCanvas().height; + model.colorTexture.resize(width, height); + model.depthTexture.resize(width, height); + if (model.resolveColorTexture) { + model.resolveColorTexture.resize(width, height); + } } model.renderEncoder.attachTextureViews(); @@ -74,18 +118,30 @@ function vtkWebGPUOpaquePass(publicAPI, model) { renNode.traverse(publicAPI); }; - publicAPI.getColorTextureView = () => - model.renderEncoder.getColorTextureViews()[0]; + // When MSAA is active, downstream passes must sample from the resolved + // (1-sample) texture, not the multisampled one + publicAPI.getColorTextureView = () => { + if (model._resolveColorTextureView) { + return model._resolveColorTextureView; + } + return model.renderEncoder.getColorTextureViews()[0]; + }; publicAPI.getDepthTextureView = () => model.renderEncoder.getDepthTextureView(); - publicAPI.createRenderEncoder = () => { + publicAPI.createRenderEncoder = (sampleCount = 1) => { model.renderEncoder = vtkWebGPURenderEncoder.newInstance({ label: 'OpaquePass', }); // default settings are fine for this model.renderEncoder.setPipelineHash('op'); + // Set multisample state in pipeline settings when MSAA is active + if (sampleCount > 1) { + const settings = model.renderEncoder.getPipelineSettings(); + settings.multisample = { count: sampleCount }; + model.renderEncoder.setPipelineSettings(settings); + } }; } @@ -97,6 +153,7 @@ const DEFAULT_VALUES = { renderEncoder: null, colorTexture: null, depthTexture: null, + resolveColorTexture: null, }; // ---------------------------------------------------------------------------- @@ -107,7 +164,11 @@ export function extend(publicAPI, model, initialValues = {}) { // Build VTK API vtkRenderPass.extend(publicAPI, model, initialValues); - macro.get(publicAPI, model, ['colorTexture', 'depthTexture']); + macro.get(publicAPI, model, [ + 'colorTexture', + 'depthTexture', + 'resolveColorTexture', + ]); // Object methods vtkWebGPUOpaquePass(publicAPI, model); diff --git a/Sources/Rendering/WebGPU/OrderIndependentTranslucentPass/index.js b/Sources/Rendering/WebGPU/OrderIndependentTranslucentPass/index.js index 77d433337a2..cd35c9b2b99 100644 --- a/Sources/Rendering/WebGPU/OrderIndependentTranslucentPass/index.js +++ b/Sources/Rendering/WebGPU/OrderIndependentTranslucentPass/index.js @@ -36,14 +36,15 @@ fn main( } `; +// ---------------------------------------------------------------------------- + function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { // Set our className model.classHierarchy.push('vtkWebGPUOrderIndependentTranslucentPass'); - // this pass implements a forward rendering pipeline - // if both volumes and opaque geometry are present - // it will mix the two together by capturing a zbuffer - // first + // This pass implements a forward rendering pipeline for translucent geometry. + // It uses order-independent transparency (OIT) with weighted blended + // compositing, reading the opaque depth buffer for depth testing. publicAPI.traverse = (renNode, viewNode) => { if (model.deleted) { return; @@ -53,10 +54,26 @@ function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { model._currentParent = viewNode; const device = viewNode.getDevice(); + const sampleCount = viewNode.getMultiSample ? viewNode.getMultiSample() : 1; + + // If sampleCount changed, tear down + if ( + model.translucentRenderEncoder && + model._currentSampleCount !== sampleCount + ) { + model.translucentRenderEncoder = null; + model.translucentColorTexture = null; + model.translucentAccumulateTexture = null; + model.translucentResolveColorTexture = null; + model.translucentResolveAccumulateTexture = null; + model._resolveColorView = null; + model._resolveAccumulateView = null; + } if (!model.translucentRenderEncoder) { - publicAPI.createRenderEncoder(); + publicAPI.createRenderEncoder(sampleCount); publicAPI.createFinalEncoder(); + model._currentSampleCount = sampleCount; model.translucentColorTexture = vtkWebGPUTexture.newInstance({ label: 'translucentPassColor', }); @@ -64,14 +81,36 @@ function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { width: viewNode.getCanvas().width, height: viewNode.getCanvas().height, format: 'rgba16float', + sampleCount, /* eslint-disable no-undef */ /* eslint-disable no-bitwise */ usage: - GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + GPUTextureUsage.RENDER_ATTACHMENT | + (sampleCount === 1 ? GPUTextureUsage.TEXTURE_BINDING : 0), }); const v1 = model.translucentColorTexture.createView('oitpColorTexture'); model.translucentRenderEncoder.setColorTextureView(0, v1); + // Resolve color texture for MSAA + if (sampleCount > 1) { + model.translucentResolveColorTexture = vtkWebGPUTexture.newInstance({ + label: 'translucentPassResolveColor', + }); + model.translucentResolveColorTexture.create(device, { + width: viewNode.getCanvas().width, + height: viewNode.getCanvas().height, + format: 'rgba16float', + usage: + GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }); + model._resolveColorView = + model.translucentResolveColorTexture.createView('oitpColorTexture'); + model.translucentRenderEncoder.setResolveTextureView( + 0, + model._resolveColorView + ); + } + model.translucentAccumulateTexture = vtkWebGPUTexture.newInstance({ label: 'translucentPassAccumulate', }); @@ -79,20 +118,48 @@ function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { width: viewNode.getCanvas().width, height: viewNode.getCanvas().height, format: 'r16float', + sampleCount, /* eslint-disable no-undef */ /* eslint-disable no-bitwise */ usage: - GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + GPUTextureUsage.RENDER_ATTACHMENT | + (sampleCount === 1 ? GPUTextureUsage.TEXTURE_BINDING : 0), }); const v2 = model.translucentAccumulateTexture.createView('oitpAccumTexture'); model.translucentRenderEncoder.setColorTextureView(1, v2); + + // Resolve accumulate texture for MSAA + if (sampleCount > 1) { + model.translucentResolveAccumulateTexture = + vtkWebGPUTexture.newInstance({ + label: 'translucentPassResolveAccumulate', + }); + model.translucentResolveAccumulateTexture.create(device, { + width: viewNode.getCanvas().width, + height: viewNode.getCanvas().height, + format: 'r16float', + usage: + GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }); + model._resolveAccumulateView = + model.translucentResolveAccumulateTexture.createView( + 'oitpAccumTexture' + ); + model.translucentRenderEncoder.setResolveTextureView( + 1, + model._resolveAccumulateView + ); + } model.fullScreenQuad = vtkWebGPUFullScreenQuad.newInstance(); model.fullScreenQuad.setDevice(viewNode.getDevice()); model.fullScreenQuad.setPipelineHash('oitpfsq'); - model.fullScreenQuad.setTextureViews( - model.translucentRenderEncoder.getColorTextureViews() - ); + // Use resolved textures for the full screen quad if MSAA is on + const views = + sampleCount > 1 + ? [model._resolveColorView, model._resolveAccumulateView] + : model.translucentRenderEncoder.getColorTextureViews(); + model.fullScreenQuad.setTextureViews(views); model.fullScreenQuad.setFragmentShaderTemplate(oitpFragTemplate); } else { model.translucentColorTexture.resizeToMatch( @@ -101,6 +168,14 @@ function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { model.translucentAccumulateTexture.resizeToMatch( model.colorTextureView.getTexture() ); + if (model.translucentResolveColorTexture) { + model.translucentResolveColorTexture.resizeToMatch( + model.colorTextureView.getTexture() + ); + model.translucentResolveAccumulateTexture.resizeToMatch( + model.colorTextureView.getTexture() + ); + } } model.translucentRenderEncoder.setDepthTextureView(model.depthTextureView); @@ -129,10 +204,16 @@ function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { model.translucentAccumulateTexture, ]; - publicAPI.createRenderEncoder = () => { + publicAPI.createRenderEncoder = (sampleCount = 1) => { model.translucentRenderEncoder = vtkWebGPURenderEncoder.newInstance({ label: 'translucentRender', }); + // Set multisample state if needed + if (sampleCount > 1) { + const settings = model.translucentRenderEncoder.getPipelineSettings(); + settings.multisample = { count: sampleCount }; + model.translucentRenderEncoder.setPipelineSettings(settings); + } const rDesc = model.translucentRenderEncoder.getDescription(); rDesc.colorAttachments = [ { @@ -261,6 +342,8 @@ function vtkWebGPUOrderIndependentTranslucentPass(publicAPI, model) { const DEFAULT_VALUES = { colorTextureView: null, depthTextureView: null, + translucentResolveColorTexture: null, + translucentResolveAccumulateTexture: null, }; // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/WebGPU/RenderEncoder/index.js b/Sources/Rendering/WebGPU/RenderEncoder/index.js index 92f47619d3d..e68cf34961e 100644 --- a/Sources/Rendering/WebGPU/RenderEncoder/index.js +++ b/Sources/Rendering/WebGPU/RenderEncoder/index.js @@ -96,6 +96,13 @@ function vtkWebGPURenderEncoder(publicAPI, model) { model.colorTextureViews[idx] = view; }; + publicAPI.setResolveTextureView = (idx, view) => { + if (model.resolveTextureViews[idx] === view) { + return; + } + model.resolveTextureViews[idx] = view; + }; + publicAPI.activateBindGroup = (bg) => { const device = model.boundPipeline.getDevice(); const midx = model.boundPipeline.getBindGroupLayoutCount(bg.getLabel()); @@ -126,6 +133,14 @@ function vtkWebGPURenderEncoder(publicAPI, model) { model.description.colorAttachments[i].view = model.colorTextureViews[i].getHandle(); } + // MSAA: set resolveTarget if a resolve texture view is provided + if (model.resolveTextureViews[i]) { + model.description.colorAttachments[i].resolveTarget = + model.resolveTextureViews[i].getHandle(); + // When using MSAA, the multisampled texture is transient; + // store only into the resolve target + model.description.colorAttachments[i].storeOp = 'discard'; + } } if (model.depthTextureView) { model.description.depthStencilAttachment.view = @@ -225,6 +240,7 @@ export function extend(publicAPI, model, initialValues = {}) { }; model.colorTextureViews = []; + model.resolveTextureViews = []; macro.get(publicAPI, model, ['boundPipeline', 'colorTextureViews']); diff --git a/Sources/Rendering/WebGPU/RenderWindow/api.md b/Sources/Rendering/WebGPU/RenderWindow/api.md deleted file mode 100644 index fd409211c84..00000000000 --- a/Sources/Rendering/WebGPU/RenderWindow/api.md +++ /dev/null @@ -1,4 +0,0 @@ -WebGPU rendering window - -vtkWebGPURenderWindow is designed to view/render a vkRenderWindow -using the WebGPU API diff --git a/Sources/Rendering/WebGPU/RenderWindow/index.d.ts b/Sources/Rendering/WebGPU/RenderWindow/index.d.ts new file mode 100644 index 00000000000..5e04d93e8db --- /dev/null +++ b/Sources/Rendering/WebGPU/RenderWindow/index.d.ts @@ -0,0 +1,372 @@ +import { vtkObject, vtkSubscription } from '../../../interfaces'; +import { vtkViewNode } from '../../SceneGraph/ViewNode'; + +export interface IWebGPURenderWindowInitialValues { + initialized?: boolean; + context?: any; + canvas?: HTMLCanvasElement; + cursor?: string; + useOffScreen?: boolean; + imageFormat?: string; + useBackgroundImage?: boolean; + xrSupported?: boolean; + presentationFormat?: string | null; + multiSample?: 1 | 4; + size?: [number, number]; +} + +/** + * vtkWebGPURenderWindow is designed to view/render a vtkRenderWindow + * using the WebGPU API. + */ +export interface vtkWebGPURenderWindow extends vtkViewNode { + // ------------------------------------------------------------------- + // Explicitly defined methods on publicAPI + // ------------------------------------------------------------------- + + /** + * Get the view-node factory used to create WebGPU scene-graph nodes. + */ + getViewNodeFactory(): any; + + /** + * Unconfigure and reconfigure the swap chain. Called automatically when + * the canvas size changes. + */ + recreateSwapChain(): void; + + /** + * Get the current swap-chain texture from the canvas context. + */ + getCurrentTexture(): any; + + /** + * Build pass callback invoked by the scene-graph traversal. + * @param prepass Whether this is the pre-pass (`true`) or post-pass (`false`). + */ + buildPass(prepass: boolean): void; + + /** + * Initialize the render window. Triggers async GPU adapter/device + * creation; fires the `initialized` event on completion. + */ + initialize(): void; + + /** + * Set the container element for the render window. + * @param el The HTML element to use as the container, or `null` to detach. + */ + setContainer(el: HTMLElement | null): void; + + /** + * Get the current container element. + */ + getContainer(): HTMLElement | null; + + /** + * Get the size of the container element in pixels. + * @returns [width, height] + */ + getContainerSize(): [number, number]; + + /** + * Get the framebuffer size. + * @returns [width, height] + */ + getFramebufferSize(): [number, number]; + + /** + * Create the WebGPU 3D context asynchronously. Requests the GPU adapter + * and device, then configures the canvas context. + */ + create3DContextAsync(): Promise; + + /** + * Release all GPU resources and clean up the rendering context. + */ + releaseGraphicsResources(): void; + + /** + * Set the background image element. + * @param img The image element. + */ + setBackgroundImage(img: HTMLImageElement): void; + + /** + * Enable or disable rendering of a background image behind the scene. + * @param value Whether to use the background image. + */ + setUseBackgroundImage(value: boolean): void; + + /** + * Capture the next rendered frame as an image. + * @param format The image format (default: 'image/png'). + * @param opts Options for capture. + * @param opts.resetCamera Whether to reset the camera before capture. + * @param opts.size Override the render size as [width, height]. + * @param opts.scale Scale factor for the render size. + */ + captureNextImage( + format?: string, + opts?: { resetCamera?: boolean; size?: [number, number]; scale?: number } + ): Promise; + + /** + * Traverse all registered render passes. + */ + traverseAllPasses(): void; + + /** + * Set a view stream for remote rendering. + * @param stream The view stream instance. + */ + setViewStream(stream: any): boolean; + + /** + * Get a unique prop ID for hardware selection. + */ + getUniquePropID(): number; + + /** + * Get a prop (actor) by its hardware-selection ID. + * @param id The prop ID. + * @returns The matching prop, or `null` if not found. + */ + getPropFromID(id: number): vtkObject | null; + + /** + * Read pixels from the current framebuffer asynchronously. + * Returns an object with `colorValues` (Uint8ClampedArray), `width`, and `height`. + */ + getPixelsAsync(): Promise<{ + colorValues: Uint8ClampedArray; + width: number; + height: number; + }>; + + /** + * Create a hardware selector bound to this render window. + */ + createSelector(): any; + + /** + * Set the size of the render window. + * @param width Width in pixels. + * @param height Height in pixels. + */ + setSize(width: number, height: number): boolean; + + /** + * Controls the number of MSAA samples per pixel. + * + * - **Default:** `1` (no anti-aliasing) + * + * @param count The sample count (`1` or `4`). + * @returns `true` if the value changed, `false` otherwise. + */ + setMultiSample(count: 1 | 4): boolean; + + // ------------------------------------------------------------------- + // macro.get (getter-only) + // ------------------------------------------------------------------- + + /** + * Get the WebGPU command encoder for the current frame. + */ + getCommandEncoder(): any; + + /** + * Get whether a background image is being used. + */ + getUseBackgroundImage(): boolean; + + /** + * Get whether XR (WebXR) is supported. + */ + getXrSupported(): boolean; + + /** + * Get the current MSAA sample count. + */ + getMultiSample(): 1 | 4; + + // ------------------------------------------------------------------- + // macro.setGet + // ------------------------------------------------------------------- + + /** + * Get whether the render window has been initialized. + */ + getInitialized(): boolean; + + /** + * Set the initialized state. + * @param initialized Whether initialized. + */ + setInitialized(initialized: boolean): boolean; + + /** + * Get the WebGPU canvas context. + */ + getContext(): any; + + /** + * Set the WebGPU canvas context. + * @param context The GPU canvas context. + */ + setContext(context: any): boolean; + + /** + * Get the current canvas element. + */ + getCanvas(): HTMLCanvasElement; + + /** + * Set the canvas element. + * @param canvas The canvas element. + */ + setCanvas(canvas: HTMLCanvasElement): boolean; + + /** + * Get the WebGPU device wrapper. + */ + getDevice(): any; + + /** + * Set the WebGPU device wrapper. + * @param device The device wrapper instance. + */ + setDevice(device: any): boolean; + + /** + * Get the render passes. + */ + getRenderPasses(): any[]; + + /** + * Set the render passes. + * @param passes Array of render passes. + */ + setRenderPasses(passes: any[]): boolean; + + /** + * Get whether image capture notification is enabled. + */ + getNotifyStartCaptureImage(): boolean; + + /** + * Set whether image capture notification is enabled. + * @param notify Whether to notify on capture start. + */ + setNotifyStartCaptureImage(notify: boolean): boolean; + + /** + * Get the current cursor style. + */ + getCursor(): string; + + /** + * Set the cursor style. + * @param cursor CSS cursor value. + */ + setCursor(cursor: string): boolean; + + /** + * Get whether off-screen rendering is enabled. + */ + getUseOffScreen(): boolean; + + /** + * Set whether to use off-screen rendering. + * @param useOffScreen Whether to render off-screen. + */ + setUseOffScreen(useOffScreen: boolean): boolean; + + /** + * Get the presentation texture format used by the swap chain. + */ + getPresentationFormat(): string | null; + + // ------------------------------------------------------------------- + // macro.setGetArray + // ------------------------------------------------------------------- + + /** + * Get the current render window size. + * @returns [width, height] + */ + getSize(): [number, number]; + + // ------------------------------------------------------------------- + // macro.event + // ------------------------------------------------------------------- + + /** + * Register a callback for when a captured image is ready. + * @param callback Called with the image data URL string. + */ + onImageReady(callback: (imageURL: string) => void): vtkSubscription; + + /** + * Programmatically fire the imageReady event. + * @param imageURL The image data URL. + */ + invokeImageReady(imageURL: string): void; + + /** + * Register a callback for when the render window has finished initializing. + * @param callback Called once GPU context creation is complete. + */ + onInitialized(callback: () => void): vtkSubscription; + + /** + * Programmatically fire the initialized event. + */ + invokeInitialized(): void; + + /** + * Register a callback for window resize events. + * @param callback Called with `{ width, height }` when the window is resized. + */ + onWindowResizeEvent( + callback: (event: { width: number; height: number }) => void + ): vtkSubscription; + + /** + * Programmatically fire the windowResizeEvent. + * @param event The resize event payload. + */ + invokeWindowResizeEvent(event: { width: number; height: number }): void; +} + +/** + * Method used to decorate a given object (publicAPI+model) with + * vtkWebGPURenderWindow characteristics. + * + * @param publicAPI object on which methods will be bound (public) + * @param model object on which data structure will be bound (protected) + * @param initialValues (default: {}) + */ +export function extend( + publicAPI: object, + model: object, + initialValues?: IWebGPURenderWindowInitialValues +): void; + +/** + * Method used to create a new instance of vtkWebGPURenderWindow. + * @param initialValues Initial property values. + */ +export function newInstance( + initialValues?: IWebGPURenderWindowInitialValues +): vtkWebGPURenderWindow; + +/** + * vtkWebGPURenderWindow is designed to view/render a vtkRenderWindow + * using the WebGPU API. + */ +export declare const vtkWebGPURenderWindow: { + newInstance: typeof newInstance; + extend: typeof extend; +}; +export default vtkWebGPURenderWindow; diff --git a/Sources/Rendering/WebGPU/RenderWindow/index.js b/Sources/Rendering/WebGPU/RenderWindow/index.js index 5908e81f299..4fc041626bc 100644 --- a/Sources/Rendering/WebGPU/RenderWindow/index.js +++ b/Sources/Rendering/WebGPU/RenderWindow/index.js @@ -469,7 +469,11 @@ function vtkWebGPURenderWindow(publicAPI, model) { publicAPI.getPixelsAsync = async () => { const device = model.device; - const texture = model.renderPasses[0].getOpaquePass().getColorTexture(); + const opaquePass = model.renderPasses[0].getOpaquePass(); + // When MSAA is active, use the resolved (1-sample) texture for readback + const texture = opaquePass.getResolveColorTexture() + ? opaquePass.getResolveColorTexture() + : opaquePass.getColorTexture(); // as this is async we really don't want to store things in // the class as multiple calls may start before resolving @@ -554,6 +558,18 @@ function vtkWebGPURenderWindow(publicAPI, model) { return modified; }; + // Validate multiSample — WebGPU only supports 1 or 4 + const superSetMultiSample = publicAPI.setMultiSample; + publicAPI.setMultiSample = (count) => { + if (count !== 1 && count !== 4) { + vtkErrorMacro( + `Invalid multiSample ${count}. WebGPU only supports multiSample of 1 or 4. Ignoring.` + ); + return false; + } + return superSetMultiSample(count); + }; + publicAPI.delete = macro.chain(publicAPI.delete, publicAPI.setViewStream); } @@ -578,6 +594,7 @@ const DEFAULT_VALUES = { nextPropID: 1, xrSupported: false, presentationFormat: null, + multiSample: 1, }; // ---------------------------------------------------------------------------- @@ -621,6 +638,7 @@ export function extend(publicAPI, model, initialValues = {}) { 'presentationFormat', 'useBackgroundImage', 'xrSupported', + 'multiSample', ]); macro.setGet(publicAPI, model, [ @@ -632,6 +650,7 @@ export function extend(publicAPI, model, initialValues = {}) { 'notifyStartCaptureImage', 'cursor', 'useOffScreen', + 'multiSample', ]); macro.setGetArray(publicAPI, model, ['size'], 2); diff --git a/Sources/Rendering/WebGPU/RenderWindow/test/testMSAA.js b/Sources/Rendering/WebGPU/RenderWindow/test/testMSAA.js new file mode 100644 index 00000000000..c1cdf52d938 --- /dev/null +++ b/Sources/Rendering/WebGPU/RenderWindow/test/testMSAA.js @@ -0,0 +1,107 @@ +import test from 'tape'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; +import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkSphereSource from 'vtk.js/Sources/Filters/Sources/SphereSource'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Detect whether the current runtime actually supports WebGPU. +function isWebGPUAvailable() { + return typeof navigator !== 'undefined' && !!navigator.gpu; +} + +// --------------------------------------------------------------------------- +// Test: MSAA opaque + translucent rendering (WebGPU) +// --------------------------------------------------------------------------- + +test.onlyIfWebGPU('Test WebGPU MSAA rendering', (t) => { + const gc = testUtils.createGarbageCollector(t); + + const container = document.querySelector('body'); + const renderWindowContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(renderWindowContainer); + + // ------ Scene setup ------ + const renderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const renderer = gc.registerResource(vtkRenderer.newInstance()); + renderWindow.addRenderer(renderer); + renderer.setBackground(0.32, 0.34, 0.43); + + // Opaque cone + const coneSource = gc.registerResource( + vtkConeSource.newInstance({ height: 1.0, resolution: 60 }) + ); + const coneMapper = gc.registerResource(vtkMapper.newInstance()); + coneMapper.setInputConnection(coneSource.getOutputPort()); + const coneActor = gc.registerResource(vtkActor.newInstance()); + coneActor.setMapper(coneMapper); + renderer.addActor(coneActor); + + // Translucent sphere (exercises OrderIndependentTranslucentPass MSAA path) + const sphereSource = gc.registerResource( + vtkSphereSource.newInstance({ radius: 0.35, center: [0.3, 0.3, 0.0] }) + ); + const sphereMapper = gc.registerResource(vtkMapper.newInstance()); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + const sphereActor = gc.registerResource(vtkActor.newInstance()); + sphereActor.setMapper(sphereMapper); + sphereActor.getProperty().setOpacity(0.5); + sphereActor.getProperty().setColor(0.2, 0.6, 0.9); + renderer.addActor(sphereActor); + + // ------ Render window view ------ + const apiView = gc.registerResource( + renderWindow.newAPISpecificView('WebGPU') + ); + apiView.setContainer(renderWindowContainer); + renderWindow.addView(apiView); + apiView.setSize(400, 400); + + // ------ MSAA configuration ------ + const webgpuAvailable = isWebGPUAvailable(); + const desiredSampleCount = webgpuAvailable ? 4 : 1; + + if (apiView.setMultiSample) { + // Validate that invalid sample counts are rejected + t.notOk( + apiView.setMultiSample(2), + 'setMultiSample(2) should return false (invalid)' + ); + + // Set the desired sample count + apiView.setMultiSample(desiredSampleCount); + } + + t.equal( + apiView.getMultiSample ? apiView.getMultiSample() : 1, + desiredSampleCount, + `multiSample should be ${desiredSampleCount}` + ); + + renderer.resetCamera(); + + // ------ Capture and verify ------ + const promise = apiView + .captureNextImage() + .then((image) => { + // The rendering completed without errors — this is the primary + // regression check. MSAA misconfiguration (sample count mismatches, + // missing resolve targets, etc.) would cause a GPU validation error + // before we reach this point. + t.ok(image, 'MSAA render produced an image without GPU errors'); + }) + .finally(gc.releaseResources); + + renderWindow.render(); + return promise; +}); diff --git a/Sources/Rendering/WebGPU/Texture/index.js b/Sources/Rendering/WebGPU/Texture/index.js index 710449e649b..364ba57e032 100644 --- a/Sources/Rendering/WebGPU/Texture/index.js +++ b/Sources/Rendering/WebGPU/Texture/index.js @@ -24,6 +24,7 @@ function vtkWebGPUTexture(publicAPI, model) { const dimension = model.depth === 1 ? '2d' : '3d'; model.format = options.format ? options.format : 'rgba8unorm'; model.mipLevel = options.mipLevel ? options.mipLevel : 0; + model.sampleCount = options.sampleCount ? options.sampleCount : 1; /* eslint-disable no-undef */ /* eslint-disable no-bitwise */ model.usage = options.usage @@ -37,6 +38,7 @@ function vtkWebGPUTexture(publicAPI, model) { usage: model.usage, label: model.label, dimension, + sampleCount: model.sampleCount, mipLevelCount: model.mipLevel + 1, }); }; @@ -266,6 +268,7 @@ function vtkWebGPUTexture(publicAPI, model) { format: model.format, usage: model.usage, label: model.label, + sampleCount: model.sampleCount, }); } }; @@ -284,6 +287,7 @@ function vtkWebGPUTexture(publicAPI, model) { format: model.format, usage: model.usage, label: model.label, + sampleCount: model.sampleCount, }); } }; @@ -309,6 +313,7 @@ const DEFAULT_VALUES = { buffer: null, ready: false, label: null, + sampleCount: 1, }; // ---------------------------------------------------------------------------- @@ -327,6 +332,7 @@ export function extend(publicAPI, model, initialValues = {}) { 'depth', 'format', 'usage', + 'sampleCount', ]); macro.setGet(publicAPI, model, ['device', 'label']);