From 88c409dfde779141155e044560d0a1c909a473ca Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 16 Jul 2026 09:11:25 -0400 Subject: [PATCH 1/2] perf(OpenGL/Helper): read the depth mask only for point picking drawArrays read DEPTH_WRITEMASK back with gl.getParameter on every draw call, but the value is only used to save/restore the depth mask around the pointPicking path. Each such readback is a synchronous CPU/GPU sync point, so ordinary rendering paid one pipeline stall per drawn polydata for a value it never used. Scope the readback (and the restore) to pointPicking. --- Sources/Rendering/OpenGL/Helper/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Rendering/OpenGL/Helper/index.js b/Sources/Rendering/OpenGL/Helper/index.js index 78941e6ef80..360cef11fd7 100644 --- a/Sources/Rendering/OpenGL/Helper/index.js +++ b/Sources/Rendering/OpenGL/Helper/index.js @@ -44,8 +44,12 @@ function vtkOpenGLHelper(publicAPI, model) { const mode = publicAPI.getOpenGLMode(rep); const wideLines = publicAPI.haveWideLines(ren, actor); const gl = model.context; - const depthMask = gl.getParameter(gl.DEPTH_WRITEMASK); + // Only the point-picking pass alters the depth mask, and reading it + // back with gl.getParameter can stall, so scope the save/restore to + // the pointPicking path instead of paying it on every draw call. + let depthMask; if (model.pointPicking) { + depthMask = gl.getParameter(gl.DEPTH_WRITEMASK); gl.depthMask(false); } const drawingLines = mode === gl.LINES; From d8fd140676d6128433e91eea09166ab73a4fc86a Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 16 Jul 2026 13:41:21 -0400 Subject: [PATCH 2/2] perf(OpenGL/Framebuffer): track the framebuffer binding JS-side for save/restore saveCurrentBindings read the binding back with gl.getParameter(FRAMEBUFFER_BINDING), a synchronous CPU/GPU sync point, on every save, e.g. once per ForwardPass zbuffer capture. OpenGLRenderWindow now carries a JS-side mirror of the raw binding (setFramebufferBinding/getFramebufferBinding). It is inert (undefined) by default, so stock behavior is unchanged. Once a caller seeds it (e.g. a host embedding vtk.js in its own context, which knows what it bound), saves use the mirror instead of querying, and vtkFramebuffer bind/restore keep it in sync. Covered by a test asserting both modes: unseeded saves still query GL, seeded saves issue zero FRAMEBUFFER_BINDING queries and the mirror follows nested bind/save/restore. --- Sources/Rendering/OpenGL/Framebuffer/index.js | 23 ++++- .../test/testFramebufferBindingTracking.js | 95 +++++++++++++++++++ .../Rendering/OpenGL/RenderWindow/index.d.ts | 16 ++++ .../Rendering/OpenGL/RenderWindow/index.js | 11 +++ 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 Sources/Rendering/OpenGL/Framebuffer/test/testFramebufferBindingTracking.js diff --git a/Sources/Rendering/OpenGL/Framebuffer/index.js b/Sources/Rendering/OpenGL/Framebuffer/index.js index 045eaea3466..dde2f694763 100644 --- a/Sources/Rendering/OpenGL/Framebuffer/index.js +++ b/Sources/Rendering/OpenGL/Framebuffer/index.js @@ -14,6 +14,16 @@ function vtkFramebuffer(publicAPI, model) { // publicAPI.getDrawMode = () => model.context.DRAW_FRAMEBUFFER; // publicAPI.getReadMode = () => model.context.READ_FRAMEBUFFER; + // Keep the render window's JS-side binding mirror in step with binds made + // through this class. Inert until someone seeds the tracker (see + // OpenGLRenderWindow.setFramebufferBinding). + function syncTrackedBinding(binding) { + const renderWindow = model._openGLRenderWindow; + if (renderWindow && renderWindow.getFramebufferBinding() !== undefined) { + renderWindow.setFramebufferBinding(binding); + } + } + publicAPI.saveCurrentBindingsAndBuffers = (modeIn) => { const mode = typeof modeIn !== 'undefined' ? modeIn : publicAPI.getBothMode(); @@ -29,10 +39,12 @@ function vtkFramebuffer(publicAPI, model) { return; } + // gl.getParameter(FRAMEBUFFER_BINDING) is a synchronous CPU/GPU sync + // point; prefer the render window's JS-side mirror when it is seeded. const gl = model.context; - model.previousDrawBinding = gl.getParameter( - model.context.FRAMEBUFFER_BINDING - ); + const tracked = model._openGLRenderWindow.getFramebufferBinding(); + model.previousDrawBinding = + tracked !== undefined ? tracked : gl.getParameter(gl.FRAMEBUFFER_BINDING); model.previousActiveFramebuffer = model._openGLRenderWindow.getActiveFramebuffer(); }; @@ -58,6 +70,7 @@ function vtkFramebuffer(publicAPI, model) { const gl = model.context; gl.bindFramebuffer(gl.FRAMEBUFFER, model.previousDrawBinding); + syncTrackedBinding(model.previousDrawBinding); model._openGLRenderWindow.setActiveFramebuffer( model.previousActiveFramebuffer ); @@ -73,6 +86,10 @@ function vtkFramebuffer(publicAPI, model) { mode = model.context.FRAMEBUFFER; } model.context.bindFramebuffer(mode, model.glFramebuffer); + if (mode !== model.context.READ_FRAMEBUFFER) { + // READ_FRAMEBUFFER binds do not change FRAMEBUFFER_BINDING. + syncTrackedBinding(model.glFramebuffer); + } for (let i = 0; i < model.colorBuffers.length; i++) { model.colorBuffers[i].bind(); } diff --git a/Sources/Rendering/OpenGL/Framebuffer/test/testFramebufferBindingTracking.js b/Sources/Rendering/OpenGL/Framebuffer/test/testFramebufferBindingTracking.js new file mode 100644 index 00000000000..41a4a71b273 --- /dev/null +++ b/Sources/Rendering/OpenGL/Framebuffer/test/testFramebufferBindingTracking.js @@ -0,0 +1,95 @@ +import { it, expect } from 'vitest'; + +import vtkFramebuffer from 'vtk.js/Sources/Rendering/OpenGL/Framebuffer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; +import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs'; + +function createContextHarness() { + const renderWindow = vtkRenderWindow.newInstance(); + const view = renderWindow.newAPISpecificView('WebGL'); + renderWindow.addView(view); + view.initialize(); + const gl = view.getContext(); + + // Count FRAMEBUFFER_BINDING readbacks while keeping getParameter working. + const rawGetParameter = gl.getParameter.bind(gl); + const counter = { bindingQueries: 0 }; + gl.getParameter = (pname) => { + if (pname === gl.FRAMEBUFFER_BINDING) { + counter.bindingQueries += 1; + } + return rawGetParameter(pname); + }; + + const makeFramebuffer = () => { + const framebuffer = vtkFramebuffer.newInstance(); + framebuffer.setOpenGLRenderWindow(view); + framebuffer.create(4, 4); + return framebuffer; + }; + + const currentBinding = () => rawGetParameter(gl.FRAMEBUFFER_BINDING); + + // Deleting the own property restores the prototype method. + const removeSpy = () => delete gl.getParameter; + + return { view, counter, makeFramebuffer, currentBinding, removeSpy }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'save/restore reads the binding back from GL when tracking is not seeded', + () => { + const { view, counter, makeFramebuffer, currentBinding, removeSpy } = + createContextHarness(); + const framebuffer = makeFramebuffer(); + + expect(view.getFramebufferBinding()).toBeUndefined(); + + framebuffer.saveCurrentBindingsAndBuffers(); + expect(counter.bindingQueries).toBe(1); + + framebuffer.bind(); + // Binds do not seed tracking on their own. + expect(view.getFramebufferBinding()).toBeUndefined(); + + framebuffer.restorePreviousBindingsAndBuffers(); + expect(currentBinding()).toBe(null); + + framebuffer.saveCurrentBindingsAndBuffers(); + expect(counter.bindingQueries).toBe(2); + removeSpy(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'seeded binding tracking avoids getParameter and follows bind/restore', + () => { + const { view, counter, makeFramebuffer, currentBinding, removeSpy } = + createContextHarness(); + const outer = makeFramebuffer(); + const inner = makeFramebuffer(); + + // The caller (e.g. a host embedding vtk.js) declares what is bound. + view.setFramebufferBinding(null); + + outer.saveCurrentBindingsAndBuffers(); + outer.bind(); + expect(view.getFramebufferBinding()).toBe(outer.getGLFramebuffer()); + + // Nested save/restore (a mapper capturing state inside a render pass) + // must see the outer binding through the mirror. + inner.saveCurrentBindingsAndBuffers(); + inner.bind(); + expect(view.getFramebufferBinding()).toBe(inner.getGLFramebuffer()); + inner.restorePreviousBindingsAndBuffers(); + expect(currentBinding()).toBe(outer.getGLFramebuffer()); + expect(view.getFramebufferBinding()).toBe(outer.getGLFramebuffer()); + + outer.restorePreviousBindingsAndBuffers(); + expect(currentBinding()).toBe(null); + expect(view.getFramebufferBinding()).toBe(null); + + expect(counter.bindingQueries).toBe(0); + removeSpy(); + } +); diff --git a/Sources/Rendering/OpenGL/RenderWindow/index.d.ts b/Sources/Rendering/OpenGL/RenderWindow/index.d.ts index 09a34851448..b6837ec7713 100644 --- a/Sources/Rendering/OpenGL/RenderWindow/index.d.ts +++ b/Sources/Rendering/OpenGL/RenderWindow/index.d.ts @@ -251,6 +251,22 @@ export interface vtkOpenGLRenderWindow extends vtkViewNode { options: WebGLContextAttributes ): Nullable; + /** + * Seed/update the JS-side mirror of the raw WebGLFramebuffer binding + * (null = default framebuffer) so framebuffer save/restore can avoid + * gl.getParameter(FRAMEBUFFER_BINDING) readbacks. Tracking is inert until + * first seeded; once seeded, callers binding framebuffers directly on the + * context must keep it in sync. + * @param binding + */ + setFramebufferBinding(binding: Nullable): void; + + /** + * The tracked raw framebuffer binding, or undefined when tracking has not + * been seeded. + */ + getFramebufferBinding(): Nullable | undefined; + /** * * @param {CanvasRenderingContext2DSettings} options diff --git a/Sources/Rendering/OpenGL/RenderWindow/index.js b/Sources/Rendering/OpenGL/RenderWindow/index.js index ffa5fef592e..6a6a2da1939 100644 --- a/Sources/Rendering/OpenGL/RenderWindow/index.js +++ b/Sources/Rendering/OpenGL/RenderWindow/index.js @@ -1212,6 +1212,17 @@ function vtkOpenGLRenderWindow(publicAPI, model) { model.activeFramebuffer = newActiveFramebuffer; }; + // JS-side mirror of the raw WebGLFramebuffer binding (null = default + // framebuffer), so FBO save/restore can avoid the CPU/GPU sync stall of a + // gl.getParameter(FRAMEBUFFER_BINDING) readback. Tracking is inert + // (undefined = unknown, readback fallback) until a caller seeds it; once + // seeded, vtkFramebuffer keeps it in sync, and callers binding framebuffers + // directly on the context must do the same. + publicAPI.setFramebufferBinding = (binding) => { + model.framebufferBinding = binding; + }; + publicAPI.getFramebufferBinding = () => model.framebufferBinding; + const superSetSize = publicAPI.setSize; publicAPI.setSize = (width, height) => { const modified = superSetSize(width, height);