From 83a39461c4f77de4977183bd23685a89c8ccfb2c Mon Sep 17 00:00:00 2001 From: Christian Jensen Date: Wed, 14 Oct 2026 01:00:18 -0700 Subject: [PATCH] fix(OpenGL): release image mappers' vertex buffer and VAO vtkOpenGLImageMapper, vtkOpenGLImageResliceMapper and vtkOpenGLImageCPRMapper each keep their image quad's vertex buffer and VAO in model.tris, a vtkOpenGLHelper instance. None of them called model.tris.releaseGraphicsResources(), so every removed or deleted image slice left its vertex buffer and VAO behind. The mappers now release tris in unregisterGraphicsResources(), the function each of them already calls both when the mapper moves to a different render window and when the mapper is deleted. That matches how VolumeMapper releases its own tris helper. New tests render one image slice with a real transfer function and piecewise function, so the color and opacity textures stay on the render window's shared cache. One test per mapper removes the slice, and another moves its renderer to a second render window and checks the slice still draws there. Both check that the live GL object count returns to what it was before. --- .../Rendering/OpenGL/ImageCPRMapper/index.js | 2 + .../test/testReleaseGraphicsResources.js | 161 ++++++++++++++++++ Sources/Rendering/OpenGL/ImageMapper/index.js | 3 +- .../test/testReleaseGraphicsResources.js | 150 ++++++++++++++++ .../OpenGL/ImageResliceMapper/index.js | 2 + .../test/testReleaseGraphicsResources.js | 147 ++++++++++++++++ 6 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js create mode 100644 Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js create mode 100644 Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js diff --git a/Sources/Rendering/OpenGL/ImageCPRMapper/index.js b/Sources/Rendering/OpenGL/ImageCPRMapper/index.js index a1084a3c94b..8da39f9202c 100644 --- a/Sources/Rendering/OpenGL/ImageCPRMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageCPRMapper/index.js @@ -35,6 +35,8 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { model.classHierarchy.push('vtkOpenGLImageCPRMapper'); function unregisterGraphicsResources(renderWindow) { + // The tris helper is not shared + model.tris.releaseGraphicsResources(renderWindow); [model._scalars, model._colorTransferFunc, model._pwFunc].forEach( (coreObject) => renderWindow.unregisterGraphicsResourceUser(coreObject, publicAPI) diff --git a/Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..33c10c2fa4f --- /dev/null +++ b/Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,161 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkImageCPRMapper from 'vtk.js/Sources/Rendering/Core/ImageCPRMapper'; +import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData'; +import vtkImageSlice from 'vtk.js/Sources/Rendering/Core/ImageSlice'; +import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction'; +import vtkPolyData from 'vtk.js/Sources/Common/DataModel/PolyData'; + +const sideLen = 32; +const width = 20; + +// A real transfer function and piecewise function put the color and opacity +// lookup textures on the render window's shared cache, which already frees +// them on removal. That isolates this test to the tris VAO and vertex +// buffer, the pair of GL objects the mapper never releases. +function renderCPRSlice(gc) { + const tracked = createTrackedRenderView(gc); + + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(sideLen, sideLen, sideLen); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from({ length: sideLen ** 3 }, (_, i) => i % 4096), + }) + ); + + // A straight centerline through the middle of the volume + const centerline = vtkPolyData.newInstance(); + centerline + .getPoints() + .setData(Float32Array.from([16, 16, 2, 16, 16, 16, 16, 16, 29]), 3); + centerline.getLines().setData(Uint16Array.from([3, 0, 1, 2])); + + const color = gc.registerResource(vtkColorTransferFunction.newInstance()); + color.addRGBPoint(0, 0, 0, 0); + color.addRGBPoint(4095, 1, 1, 1); + const opacity = gc.registerResource(vtkPiecewiseFunction.newInstance()); + opacity.addPoint(0, 1); + opacity.addPoint(4095, 1); + + const mapper = gc.registerResource(vtkImageCPRMapper.newInstance()); + mapper.setUseUniformOrientation(true); + mapper.setImageData(imageData); + mapper.setCenterlineData(centerline); + mapper.setWidth(width); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + actor.getProperty().setRGBTransferFunction(0, color); + actor.getProperty().setPiecewiseFunction(0, opacity); + + tracked.renderer.addActor(actor); + tracked.renderer.resetCamera(); + tracked.renderWindow.render(); + return { ...tracked, actor }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees its vertex buffer and VAO when the slice leaves the scene', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, actor, emptySceneObjects } = + renderCPRSlice(gc); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +// Every voxel is the same bright value, so any pixel inside the slice reads +// back near white regardless of exactly where the camera frames it. The +// background is pure blue, which no grayscale transfer function can produce, +// so a red channel near zero means nothing was drawn. +function createUniformCPRActor(gc) { + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(sideLen, sideLen, sideLen); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from({ length: sideLen ** 3 }, () => 4095), + }) + ); + + const centerline = vtkPolyData.newInstance(); + centerline + .getPoints() + .setData(Float32Array.from([16, 16, 2, 16, 16, 16, 16, 16, 29]), 3); + centerline.getLines().setData(Uint16Array.from([3, 0, 1, 2])); + + const color = gc.registerResource(vtkColorTransferFunction.newInstance()); + color.addRGBPoint(0, 0, 0, 0); + color.addRGBPoint(4095, 1, 1, 1); + const opacity = gc.registerResource(vtkPiecewiseFunction.newInstance()); + opacity.addPoint(0, 1); + opacity.addPoint(4095, 1); + + const mapper = gc.registerResource(vtkImageCPRMapper.newInstance()); + mapper.setUseUniformOrientation(true); + mapper.setImageData(imageData); + mapper.setCenterlineData(centerline); + mapper.setWidth(width); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + actor.getProperty().setRGBTransferFunction(0, color); + actor.getProperty().setPiecewiseFunction(0, opacity); + return actor; +} + +function readCenterPixel(view) { + const gl = view.getContext(); + const pixel = new Uint8Array(4); + gl.readPixels(100, 100, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return pixel; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'keeps drawing the slice after its renderer moves to a different render window', + () => { + const gc = testUtils.createGarbageCollector(); + const windowA = createTrackedRenderView(gc); + windowA.renderer.setBackground(0, 0, 1); + + const actor = createUniformCPRActor(gc); + windowA.renderer.addActor(actor); + windowA.renderer.resetCamera(); + windowA.renderWindow.render(); + expect(readCenterPixel(windowA.view)[0]).toBeGreaterThan(200); + + const objectsWithActorInA = windowA.tracker.count(); + + // A second render window, sharing nothing with the first. Moving the + // renderer (not just the actor) into it is what makes the mapper's own + // buildPass see a different vtkOpenGLRenderWindow ancestor. + const windowB = createTrackedRenderView(gc); + windowB.renderer.setBackground(0, 0, 1); + + const { renderer } = windowA; + windowA.renderWindow.removeRenderer(renderer); + windowB.renderWindow.removeRenderer(windowB.renderer); + windowB.renderWindow.addRenderer(renderer); + renderer.resetCamera(); + windowB.renderWindow.render(); + + expect(readCenterPixel(windowB.view)[0]).toBeGreaterThan(200); + + // Give window A's view tree a chance to prune the renderer it no longer + // has, which is where the departed mapper's resources get released. + windowA.renderWindow.render(); + expect(windowA.tracker.count()).toBe(objectsWithActorInA); + + gc.releaseResources(); + } +); diff --git a/Sources/Rendering/OpenGL/ImageMapper/index.js b/Sources/Rendering/OpenGL/ImageMapper/index.js index d808121e922..1cc1bf80ce6 100644 --- a/Sources/Rendering/OpenGL/ImageMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageMapper/index.js @@ -55,8 +55,9 @@ function vtkOpenGLImageMapper(publicAPI, model) { model.classHierarchy.push('vtkOpenGLImageMapper'); function unregisterGraphicsResources(renderWindow) { - // The openGLTexture is not shared + // The openGLTexture and the tris helper are not shared model.openGLTexture.releaseGraphicsResources(renderWindow); + model.tris.releaseGraphicsResources(renderWindow); // All these other resources are shared [ model._colorTransferFunc, diff --git a/Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..3edcf50374b --- /dev/null +++ b/Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,150 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData'; +import vtkImageMapper from 'vtk.js/Sources/Rendering/Core/ImageMapper'; +import vtkImageSlice from 'vtk.js/Sources/Rendering/Core/ImageSlice'; +import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction'; +import { SlicingMode } from 'vtk.js/Sources/Rendering/Core/ImageMapper/Constants'; + +const sideLen = 32; +const sliceCount = 16; + +// A real transfer function and piecewise function put the color and opacity +// lookup textures on the render window's shared cache, which already frees +// them on removal. That isolates this test to the tris VAO and vertex +// buffer, the pair of GL objects the mapper never releases. +function renderImageSlice(gc) { + const tracked = createTrackedRenderView(gc); + + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(sideLen, sideLen, sliceCount); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from( + { length: sideLen * sideLen * sliceCount }, + (_, i) => i % 4096 + ), + }) + ); + + const color = gc.registerResource(vtkColorTransferFunction.newInstance()); + color.addRGBPoint(0, 0, 0, 0); + color.addRGBPoint(4095, 1, 1, 1); + const opacity = gc.registerResource(vtkPiecewiseFunction.newInstance()); + opacity.addPoint(0, 1); + opacity.addPoint(4095, 1); + + const mapper = gc.registerResource(vtkImageMapper.newInstance()); + mapper.setInputData(imageData); + mapper.setSlicingMode(SlicingMode.K); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + actor.getProperty().setRGBTransferFunction(0, color); + actor.getProperty().setPiecewiseFunction(0, opacity); + + tracked.renderer.addActor(actor); + tracked.renderer.resetCamera(); + tracked.renderWindow.render(); + return { ...tracked, actor }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees its vertex buffer and VAO when the slice leaves the scene', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, actor, emptySceneObjects } = + renderImageSlice(gc); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +// Every voxel is the same bright value, so any pixel inside the slice reads +// back near white regardless of exactly where the camera frames it. The +// background is pure blue, which no grayscale transfer function can produce, +// so a red channel near zero means nothing was drawn. +function createUniformImageSliceActor(gc) { + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(sideLen, sideLen, sliceCount); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from( + { length: sideLen * sideLen * sliceCount }, + () => 4095 + ), + }) + ); + + const color = gc.registerResource(vtkColorTransferFunction.newInstance()); + color.addRGBPoint(0, 0, 0, 0); + color.addRGBPoint(4095, 1, 1, 1); + const opacity = gc.registerResource(vtkPiecewiseFunction.newInstance()); + opacity.addPoint(0, 1); + opacity.addPoint(4095, 1); + + const mapper = gc.registerResource(vtkImageMapper.newInstance()); + mapper.setInputData(imageData); + mapper.setSlicingMode(SlicingMode.K); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + actor.getProperty().setRGBTransferFunction(0, color); + actor.getProperty().setPiecewiseFunction(0, opacity); + return actor; +} + +function readCenterPixel(view) { + const gl = view.getContext(); + const pixel = new Uint8Array(4); + gl.readPixels(100, 100, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return pixel; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'keeps drawing the slice after its renderer moves to a different render window', + () => { + const gc = testUtils.createGarbageCollector(); + const windowA = createTrackedRenderView(gc); + windowA.renderer.setBackground(0, 0, 1); + + const actor = createUniformImageSliceActor(gc); + windowA.renderer.addActor(actor); + windowA.renderer.resetCamera(); + windowA.renderWindow.render(); + expect(readCenterPixel(windowA.view)[0]).toBeGreaterThan(200); + + const objectsWithActorInA = windowA.tracker.count(); + + // A second render window, sharing nothing with the first. Moving the + // renderer (not just the actor) into it is what makes the mapper's own + // buildPass see a different vtkOpenGLRenderWindow ancestor. + const windowB = createTrackedRenderView(gc); + windowB.renderer.setBackground(0, 0, 1); + + const { renderer } = windowA; + windowA.renderWindow.removeRenderer(renderer); + windowB.renderWindow.removeRenderer(windowB.renderer); + windowB.renderWindow.addRenderer(renderer); + renderer.resetCamera(); + windowB.renderWindow.render(); + + expect(readCenterPixel(windowB.view)[0]).toBeGreaterThan(200); + + // Give window A's view tree a chance to prune the renderer it no longer + // has, which is where the departed mapper's resources get released. + windowA.renderWindow.render(); + expect(windowA.tracker.count()).toBe(objectsWithActorInA); + + gc.releaseResources(); + } +); diff --git a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js index 50a8a06bd67..87f82c88daa 100644 --- a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js @@ -120,6 +120,8 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { } function unregisterGraphicsResources(renderWindow) { + // The tris helper is not shared + model.tris.releaseGraphicsResources(renderWindow); // Convert to an array using the spread operator as Firefox doesn't support Iterator.forEach() [...graphicsResourceReferenceCount.keys()].forEach((coreObject) => renderWindow.unregisterGraphicsResourceUser(coreObject, publicAPI) diff --git a/Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..e310158f0a6 --- /dev/null +++ b/Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,147 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData'; +import vtkImageResliceMapper from 'vtk.js/Sources/Rendering/Core/ImageResliceMapper'; +import vtkImageSlice from 'vtk.js/Sources/Rendering/Core/ImageSlice'; +import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction'; +import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane'; + +const sideLen = 32; + +// A real transfer function and piecewise function put the color and opacity +// lookup textures on the render window's shared cache, which already frees +// them on removal. That isolates this test to the tris VAO and vertex +// buffer, the pair of GL objects the mapper never releases. +function renderResliceSlice(gc) { + const tracked = createTrackedRenderView(gc); + + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(sideLen, sideLen, sideLen); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from({ length: sideLen ** 3 }, (_, i) => i % 4096), + }) + ); + + const color = gc.registerResource(vtkColorTransferFunction.newInstance()); + color.addRGBPoint(0, 0, 0, 0); + color.addRGBPoint(4095, 1, 1, 1); + const opacity = gc.registerResource(vtkPiecewiseFunction.newInstance()); + opacity.addPoint(0, 1); + opacity.addPoint(4095, 1); + + const slicePlane = gc.registerResource(vtkPlane.newInstance()); + slicePlane.setNormal(0, 0, 1); + const mapper = gc.registerResource(vtkImageResliceMapper.newInstance()); + mapper.setInputData(imageData); + mapper.setSlicePlane(slicePlane); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + actor.getProperty().setRGBTransferFunction(0, color); + actor.getProperty().setPiecewiseFunction(0, opacity); + + tracked.renderer.addActor(actor); + tracked.renderer.resetCamera(); + tracked.renderWindow.render(); + return { ...tracked, actor }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees its vertex buffer and VAO when the slice leaves the scene', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, actor, emptySceneObjects } = + renderResliceSlice(gc); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +// Every voxel is the same bright value, so any pixel inside the slice reads +// back near white regardless of exactly where the camera frames it. The +// background is pure blue, which no grayscale transfer function can produce, +// so a red channel near zero means nothing was drawn. +function createUniformResliceActor(gc) { + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(sideLen, sideLen, sideLen); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from({ length: sideLen ** 3 }, () => 4095), + }) + ); + + const color = gc.registerResource(vtkColorTransferFunction.newInstance()); + color.addRGBPoint(0, 0, 0, 0); + color.addRGBPoint(4095, 1, 1, 1); + const opacity = gc.registerResource(vtkPiecewiseFunction.newInstance()); + opacity.addPoint(0, 1); + opacity.addPoint(4095, 1); + + const slicePlane = gc.registerResource(vtkPlane.newInstance()); + slicePlane.setNormal(0, 0, 1); + const mapper = gc.registerResource(vtkImageResliceMapper.newInstance()); + mapper.setInputData(imageData); + mapper.setSlicePlane(slicePlane); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + actor.getProperty().setRGBTransferFunction(0, color); + actor.getProperty().setPiecewiseFunction(0, opacity); + return actor; +} + +function readCenterPixel(view) { + const gl = view.getContext(); + const pixel = new Uint8Array(4); + gl.readPixels(100, 100, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return pixel; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'keeps drawing the slice after its renderer moves to a different render window', + () => { + const gc = testUtils.createGarbageCollector(); + const windowA = createTrackedRenderView(gc); + windowA.renderer.setBackground(0, 0, 1); + + const actor = createUniformResliceActor(gc); + windowA.renderer.addActor(actor); + windowA.renderer.resetCamera(); + windowA.renderWindow.render(); + expect(readCenterPixel(windowA.view)[0]).toBeGreaterThan(200); + + const objectsWithActorInA = windowA.tracker.count(); + + // A second render window, sharing nothing with the first. Moving the + // renderer (not just the actor) into it is what makes the mapper's own + // buildPass see a different vtkOpenGLRenderWindow ancestor. + const windowB = createTrackedRenderView(gc); + windowB.renderer.setBackground(0, 0, 1); + + const { renderer } = windowA; + windowA.renderWindow.removeRenderer(renderer); + windowB.renderWindow.removeRenderer(windowB.renderer); + windowB.renderWindow.addRenderer(renderer); + renderer.resetCamera(); + windowB.renderWindow.render(); + + expect(readCenterPixel(windowB.view)[0]).toBeGreaterThan(200); + + // Give window A's view tree a chance to prune the renderer it no longer + // has, which is where the departed mapper's resources get released. + windowA.renderWindow.render(); + expect(windowA.tracker.count()).toBe(objectsWithActorInA); + + gc.releaseResources(); + } +);