diff --git a/Sources/Rendering/OpenGL/Glyph3DMapper/index.js b/Sources/Rendering/OpenGL/Glyph3DMapper/index.js index 49456d56706..75ac2e591f5 100644 --- a/Sources/Rendering/OpenGL/Glyph3DMapper/index.js +++ b/Sources/Rendering/OpenGL/Glyph3DMapper/index.js @@ -546,6 +546,28 @@ function vtkOpenGLGlyph3DMapper(publicAPI, model) { ); } }; + + const glyphBuffers = () => + [ + model.matrixBuffer, + model.normalBuffer, + model.colorBuffer, + model.pickBuffer, + ].filter(Boolean); + + publicAPI.getAllocatedGPUMemoryInBytes = () => + superClass.getAllocatedGPUMemoryInBytes() + + glyphBuffers().reduce( + (memUsed, buffer) => memUsed + buffer.getAllocatedGPUMemoryInBytes(), + 0 + ); + + publicAPI.releaseGraphicsResources = (renderWindow) => { + glyphBuffers().forEach((buffer) => buffer.releaseGraphicsResources()); + // glyph buffers re-upload only when the renderable is newer than this + model.glyphBOBuildTime.set({ mtime: 0 }); + superClass.releaseGraphicsResources(renderWindow); + }; } // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..771cfedc7a7 --- /dev/null +++ b/Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,49 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { + createTrackedRenderView, + expectSameImageAfterRelease, +} from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkGlyph3DMapper from 'vtk.js/Sources/Rendering/Core/Glyph3DMapper'; +import vtkPlaneSource from 'vtk.js/Sources/Filters/Sources/PlaneSource'; + +function createGlyphActor(gc) { + const planeSource = gc.registerResource(vtkPlaneSource.newInstance()); + const coneSource = gc.registerResource(vtkConeSource.newInstance()); + const mapper = gc.registerResource(vtkGlyph3DMapper.newInstance()); + mapper.setInputConnection(planeSource.getOutputPort(), 0); + mapper.setInputConnection(coneSource.getOutputPort(), 1); + const actor = gc.registerResource(vtkActor.newInstance()); + actor.setMapper(mapper); + return actor; +} + +// Glyphs add per-instance matrix, normal, color and pick buffers. +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees the GPU objects of a glyph actor removed from a view', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, emptySceneObjects } = + createTrackedRenderView(gc); + + const actor = createGlyphActor(gc); + renderer.addActor(actor); + renderer.resetCamera(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'rebuilds the same image after releaseGraphicsResources', + () => expectSameImageAfterRelease(createGlyphActor) +); diff --git a/Sources/Rendering/OpenGL/Helper/index.js b/Sources/Rendering/OpenGL/Helper/index.js index 46f48cf57a4..f025fdddb58 100644 --- a/Sources/Rendering/OpenGL/Helper/index.js +++ b/Sources/Rendering/OpenGL/Helper/index.js @@ -340,4 +340,17 @@ export const newInstance = macro.newInstance(extend); // ---------------------------------------------------------------------------- +// Shared by the poly data mappers, whose primitives are vtkHelper instances. +export function releasePolyDataMapperResources(publicAPI, model, renderWindow) { + model.primitives.forEach((prim) => prim.releaseGraphicsResources()); + model.internalColorTexture?.releaseGraphicsResources( + renderWindow ?? model._openGLRenderWindow + ); + // Force the buffers to be rebuilt on the next render + model.VBOBuildString = null; + publicAPI.modified(); +} + +// ---------------------------------------------------------------------------- + export default { newInstance, extend, primTypes }; diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/api.md b/Sources/Rendering/OpenGL/PolyDataMapper/api.md index c7684583756..e93b1d85268 100644 --- a/Sources/Rendering/OpenGL/PolyDataMapper/api.md +++ b/Sources/Rendering/OpenGL/PolyDataMapper/api.md @@ -10,6 +10,12 @@ preparefor rendering do the rendering +### releaseGraphicsResources(openGLRenderWindow) + +Release the GPU resources owned by this OpenGL mapper view node. If the view +node remains active, the resources are recreated as needed on a subsequent +render. + ## Shader customization In order to provide specific properties for rendering, you will have to add @@ -144,4 +150,3 @@ mapperSpecificProp.ShaderCallbacks.push({ Defined 'userData' will be the first parameters which will be passed to the callback. These callbacks will be executed when updateShaders() of OpenGLPolyDataMapper is called. - diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/index.js b/Sources/Rendering/OpenGL/PolyDataMapper/index.js index 2fa9850a396..2a28fe99a5d 100755 --- a/Sources/Rendering/OpenGL/PolyDataMapper/index.js +++ b/Sources/Rendering/OpenGL/PolyDataMapper/index.js @@ -1,7 +1,9 @@ import { mat3, mat4, vec3 } from 'gl-matrix'; import * as macro from 'vtk.js/Sources/macros'; -import vtkHelper from 'vtk.js/Sources/Rendering/OpenGL/Helper'; +import vtkHelper, { + releasePolyDataMapperResources, +} from 'vtk.js/Sources/Rendering/OpenGL/Helper'; import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; import * as vtkMath from 'vtk.js/Sources/Common/Core/Math'; import vtkOpenGLTexture from 'vtk.js/Sources/Rendering/OpenGL/Texture'; @@ -2025,6 +2027,14 @@ function vtkOpenGLPolyDataMapper(publicAPI, model) { // Return in MB return memUsed; }; + + publicAPI.releaseGraphicsResources = (renderWindow) => + releasePolyDataMapperResources(publicAPI, model, renderWindow); + + publicAPI.delete = macro.chain( + () => publicAPI.releaseGraphicsResources(), + publicAPI.delete + ); } // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..2e2374693da --- /dev/null +++ b/Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,135 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { + createTrackedRenderView, + expectSameImageAfterRelease, +} from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; +import vtkOpenGLRenderWindow from 'vtk.js/Sources/Rendering/OpenGL/RenderWindow'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; + +function createConeActor(gc) { + const cone = gc.registerResource(vtkConeSource.newInstance()); + const mapper = gc.registerResource(vtkMapper.newInstance()); + mapper.setInputConnection(cone.getOutputPort()); + const actor = gc.registerResource(vtkActor.newInstance()); + actor.setMapper(mapper); + return actor; +} + +// A lookup table makes the mapper own a color texture. +function createScalarColoredConeActor(gc) { + const cone = gc.registerResource(vtkConeSource.newInstance()); + cone.update(); + const polyData = cone.getOutputData(); + const pointCount = polyData.getPoints().getNumberOfPoints(); + polyData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Float32Array.from({ length: pointCount }, (_, i) => i), + }) + ); + + const lookupTable = gc.registerResource( + vtkColorTransferFunction.newInstance() + ); + lookupTable.addRGBPoint(0, 0, 0, 1); + lookupTable.addRGBPoint(pointCount, 1, 0, 0); + + const mapper = gc.registerResource(vtkMapper.newInstance()); + mapper.setInputData(polyData); + mapper.setLookupTable(lookupTable); + mapper.setUseLookupTableScalarRange(true); + mapper.setInterpolateScalarsBeforeMapping(true); + const actor = gc.registerResource(vtkActor.newInstance()); + actor.setMapper(mapper); + return actor; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees the GPU objects of actors removed from a view', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, emptySceneObjects } = + createTrackedRenderView(gc); + + const actors = [ + createConeActor(gc), + createConeActor(gc), + createScalarColoredConeActor(gc), + ]; + actors.forEach((actor) => renderer.addActor(actor)); + renderer.resetCamera(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + actors.forEach((actor) => renderer.removeActor(actor)); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees only the closed view GPU objects on a shared context', + async () => { + const gc = testUtils.createGarbageCollector(); + const tracker = testUtils.trackWebGLObjects(); + + const rootRenderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const rootView = gc.registerResource(vtkOpenGLRenderWindow.newInstance()); + rootRenderWindow.addView(rootView); + rootView.initialize(); + + // The child render windows, their view nodes and their renderers are not + // gc-registered: the closing one is deleted explicitly below. + const addChildView = () => { + const childRenderWindow = vtkRenderWindow.newInstance(); + rootRenderWindow.addRenderWindow(childRenderWindow); + const childView = rootView.addMissingNode(childRenderWindow); + childRenderWindow.addView(childView); + childView.setContainer(testUtils.createRenderContainer(gc)); + childView.setSize(200, 200); + + const renderer = vtkRenderer.newInstance(); + childRenderWindow.addRenderer(renderer); + renderer.addActor(createConeActor(gc)); + renderer.resetCamera(); + + return { childRenderWindow, childView }; + }; + + const closing = addChildView(); + const surviving = addChildView(); + rootRenderWindow.render(); + + const bothViewsObjects = tracker.count(); + const survivingBefore = surviving.childView.captureNextImage(); + rootRenderWindow.render(); + expect(tracker.count()).toBe(bothViewsObjects); + + rootRenderWindow.removeRenderWindow(closing.childRenderWindow); + closing.childRenderWindow.delete(); + + const survivingAfter = surviving.childView.captureNextImage(); + rootRenderWindow.render(); + + expect(tracker.count()).toBeLessThan(bothViewsObjects); + expect(await survivingAfter).toBe(await survivingBefore); + + gc.releaseResources(); + } +); + +// The scalar-colored actor makes the released color texture come back too. +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'rebuilds the same image after releaseGraphicsResources', + () => expectSameImageAfterRelease(createScalarColoredConeActor) +); diff --git a/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md b/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md index bfc9680a259..8e67dc527d7 100644 --- a/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md +++ b/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md @@ -10,6 +10,12 @@ Prepare for rendering. Do the rendering. +### releaseGraphicsResources(openGLRenderWindow) + +Release the GPU resources owned by this OpenGL mapper view node. If the view +node remains active, the resources are recreated as needed on a subsequent +render. + ## Shader customization In order to provide specific properties for rendering, you will have to add @@ -144,4 +150,3 @@ mapperSpecificProp.ShaderCallbacks.push({ Defined 'userData' will be the first parameters which will be passed to the callback. These callbacks will be executed when updateShaders() of OpenGLPolyDataMapper is called. - diff --git a/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js b/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js index d8e9315fac6..4ea0c6cc018 100644 --- a/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js +++ b/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js @@ -2,7 +2,9 @@ import { mat4 } from 'gl-matrix'; import * as macro from 'vtk.js/Sources/macros'; -import vtkHelper from 'vtk.js/Sources/Rendering/OpenGL/Helper'; +import vtkHelper, { + releasePolyDataMapperResources, +} from 'vtk.js/Sources/Rendering/OpenGL/Helper'; import vtkPoints from 'vtk.js/Sources/Common/Core/Points'; import vtkPolyData2DFS from 'vtk.js/Sources/Rendering/OpenGL/glsl/vtkPolyData2DFS.glsl'; import vtkPolyData2DVS from 'vtk.js/Sources/Rendering/OpenGL/glsl/vtkPolyData2DVS.glsl'; @@ -755,6 +757,14 @@ function vtkOpenGLPolyDataMapper2D(publicAPI, model) { // Return in MB return memUsed; }; + + publicAPI.releaseGraphicsResources = (renderWindow) => + releasePolyDataMapperResources(publicAPI, model, renderWindow); + + publicAPI.delete = macro.chain( + () => publicAPI.releaseGraphicsResources(), + publicAPI.delete + ); } // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..70a269c8855 --- /dev/null +++ b/Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js @@ -0,0 +1,46 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkActor2D from 'vtk.js/Sources/Rendering/Core/Actor2D'; +import vtkCoordinate from 'vtk.js/Sources/Rendering/Core/Coordinate'; +import vtkLineSource from 'vtk.js/Sources/Filters/Sources/LineSource'; +import vtkMapper2D from 'vtk.js/Sources/Rendering/Core/Mapper2D'; + +function createLineActor2D(gc) { + const line = gc.registerResource( + vtkLineSource.newInstance({ point1: [2, 2, 0], point2: [14, 14, 0] }) + ); + const coordinate = gc.registerResource(vtkCoordinate.newInstance()); + coordinate.setCoordinateSystemToWorld(); + + const mapper = gc.registerResource(vtkMapper2D.newInstance()); + mapper.setInputConnection(line.getOutputPort()); + mapper.setTransformCoordinate(coordinate); + mapper.setScalarVisibility(false); + + const actor = gc.registerResource(vtkActor2D.newInstance()); + actor.setMapper(mapper); + return actor; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees the GPU objects of a 2D actor removed from a view', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, emptySceneObjects } = + createTrackedRenderView(gc); + + const actor = createLineActor2D(gc); + renderer.addActor2D(actor); + renderer.resetCamera(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor2D(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); diff --git a/Sources/Testing/renderTestUtils.js b/Sources/Testing/renderTestUtils.js new file mode 100644 index 00000000000..5f20018e17c --- /dev/null +++ b/Sources/Testing/renderTestUtils.js @@ -0,0 +1,48 @@ +import { expect } from 'vitest'; +import vtkGenericRenderWindow from 'vtk.js/Sources/Rendering/Misc/GenericRenderWindow'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +// A tracked view that has rendered an empty scene: any count beyond +// emptySceneObjects belongs to the code under test. +export function createTrackedRenderView(gc, initialValues = {}) { + const tracker = testUtils.trackWebGLObjects(); + const genericRenderWindow = gc.registerResource( + vtkGenericRenderWindow.newInstance({ + listenWindowResize: false, + ...initialValues, + }) + ); + genericRenderWindow.setContainer(testUtils.createRenderContainer(gc)); + genericRenderWindow.resize(); + const renderer = genericRenderWindow.getRenderer(); + const renderWindow = genericRenderWindow.getRenderWindow(); + const view = genericRenderWindow.getApiSpecificRenderWindow(); + renderWindow.render(); + const emptySceneObjects = tracker.count(); + return { tracker, renderer, renderWindow, view, emptySceneObjects }; +} + +// Releasing a mapper's resources must leave no GPU object behind and must not +// change what the next render draws. +export async function expectSameImageAfterRelease(createActor) { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, view, emptySceneObjects } = + createTrackedRenderView(gc); + + const actor = createActor(gc); + renderer.addActor(actor); + renderer.resetCamera(); + const beforeRelease = view.captureNextImage(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + view.getViewNodeFor(actor.getMapper()).releaseGraphicsResources(view); + expect(tracker.count()).toBe(emptySceneObjects); + + const afterRelease = view.captureNextImage(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + expect(await afterRelease).toBe(await beforeRelease); + + gc.releaseResources(); +} diff --git a/Sources/Testing/testUtils.js b/Sources/Testing/testUtils.js index 056a3c2488d..f1b60c8ad1c 100644 --- a/Sources/Testing/testUtils.js +++ b/Sources/Testing/testUtils.js @@ -134,6 +134,10 @@ function createGarbageCollector() { } } + // A failing assertion skips the releaseResources() call at the end of a test + // body, so run it on test teardown too. Releasing twice is a no-op. + onTestFinished(releaseResources); + return { registerResource, registerDOMElement, @@ -197,6 +201,74 @@ function alignTo256(value) { return Math.ceil(value / 256) * 256; } +// Counts live WebGL objects by wrapping the create/delete pairs on the +// rendering context prototypes. +function trackWebGLObjects() { + const pairs = [ + ['createBuffer', 'deleteBuffer'], + ['createFramebuffer', 'deleteFramebuffer'], + ['createRenderbuffer', 'deleteRenderbuffer'], + ['createTexture', 'deleteTexture'], + ['createVertexArray', 'deleteVertexArray'], + ]; + const prototypes = [ + globalThis.WebGL2RenderingContext?.prototype, + globalThis.WebGLRenderingContext?.prototype, + ].filter(Boolean); + + const live = new Set(); + + function trackObjectType(prototype, createName, deleteName) { + const create = prototype[createName]; + const remove = prototype[deleteName]; + function trackedCreate(...args) { + const glObject = create.apply(this, args); + live.add(glObject); + return glObject; + } + function trackedDelete(glObject, ...args) { + live.delete(glObject); + return remove.call(this, glObject, ...args); + } + prototype[createName] = trackedCreate; + prototype[deleteName] = trackedDelete; + return () => { + // Unwrap only our own wrappers, so overlapping trackers cannot + // reinstall a dead one on the prototype + if (prototype[createName] === trackedCreate) { + prototype[createName] = create; + } + if (prototype[deleteName] === trackedDelete) { + prototype[deleteName] = remove; + } + }; + } + + const restoreCallbacks = prototypes.flatMap((prototype) => + pairs + // WebGL 1 has no vertex array objects + .filter(([create, remove]) => prototype[create] && prototype[remove]) + .map(([create, remove]) => trackObjectType(prototype, create, remove)) + ); + + function stop() { + restoreCallbacks.forEach((restore) => restore()); + // Stop pinning the objects this tracker exists to detect + live.clear(); + } + onTestFinished(stop); + + return { count: () => live.size, stop }; +} + +function createRenderContainer(gc, size = 200) { + const container = gc.registerDOMElement(document.createElement('div')); + container.style.width = `${size}px`; + container.style.height = `${size}px`; + document.querySelector('body').appendChild(container); + return container; +} + async function createWebGPUTestDevice() { const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance', @@ -270,10 +342,12 @@ export default { compareImages, createGarbageCollector, createImage, + createRenderContainer, createWebGPUTestDevice, getImageDataFromURI, readWebGPUTexture2D, keepDOM, objEquals, removeDOM, + trackWebGLObjects, };