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(); + } +);