diff --git a/Sources/Rendering/OpenGL/ImageCPRMapper/index.js b/Sources/Rendering/OpenGL/ImageCPRMapper/index.js index a1084a3c94b..548ebc0e65d 100644 --- a/Sources/Rendering/OpenGL/ImageCPRMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageCPRMapper/index.js @@ -39,6 +39,12 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { (coreObject) => renderWindow.unregisterGraphicsResourceUser(coreObject, publicAPI) ); + // The default lookup textures are not shared + [model._defaultColorTexture, model._defaultPwfTexture].forEach((entry) => + entry?.oglObject.releaseGraphicsResources(renderWindow) + ); + model._defaultColorTexture = null; + model._defaultPwfTexture = null; } publicAPI.buildPass = (prepass) => { @@ -275,18 +281,24 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { ); const firstColorTransferFunc = ppty.getRGBTransferFunction(); - const cachedColorEntry = - model._openGLRenderWindow.getGraphicsResourceForObject( - firstColorTransferFunc - ); + let cWidth = model.renderable.getColorTextureWidth(); + if (cWidth <= 0) { + cWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); + } + // Without a transfer function there is no core object to share the + // texture under, so the mapper keeps its own default grayscale ramp. + const colorHash = firstColorTransferFunc + ? colorTextureHash + : `default-${cWidth}`; + const cachedColorEntry = firstColorTransferFunc + ? model._openGLRenderWindow.getGraphicsResourceForObject( + firstColorTransferFunc + ) + : model._defaultColorTexture; const reBuildColorTexture = !cachedColorEntry?.oglObject?.getHandle() || - cachedColorEntry?.hash !== colorTextureHash; + cachedColorEntry?.hash !== colorHash; if (reBuildColorTexture) { - let cWidth = model.renderable.getColorTextureWidth(); - if (cWidth <= 0) { - cWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); - } const cSize = cWidth * textureHeight * 3; const cTable = new Uint8ClampedArray(cSize); model.colorTexture = vtkOpenGLTexture.newInstance(); @@ -350,6 +362,14 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { ); } model._colorTransferFunc = firstColorTransferFunc; + } else { + model._defaultColorTexture?.oglObject.releaseGraphicsResources( + model._openGLRenderWindow + ); + model._defaultColorTexture = { + oglObject: model.colorTexture, + hash: colorHash, + }; } } else { model.colorTexture = cachedColorEntry.oglObject; @@ -368,16 +388,20 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { numIComps ); const firstPwFunc = ppty.getPiecewiseFunction(); - const cachedPwfEntry = - model._openGLRenderWindow.getGraphicsResourceForObject(firstPwFunc); + let pwfWidth = model.renderable.getOpacityTextureWidth(); + if (pwfWidth <= 0) { + pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); + } + // Same as the color texture: without a piecewise function the mapper + // keeps its own fully opaque default. + const pwfHash = firstPwFunc ? pwfTextureHash : `default-${pwfWidth}`; + const cachedPwfEntry = firstPwFunc + ? model._openGLRenderWindow.getGraphicsResourceForObject(firstPwFunc) + : model._defaultPwfTexture; const reBuildPwf = !cachedPwfEntry?.oglObject?.getHandle() || - cachedPwfEntry?.hash !== pwfTextureHash; + cachedPwfEntry?.hash !== pwfHash; if (reBuildPwf) { - let pwfWidth = model.renderable.getOpacityTextureWidth(); - if (pwfWidth <= 0) { - pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); - } const pwfSize = pwfWidth * textureHeight; const pwfTable = new Uint8ClampedArray(pwfSize); model.pwfTexture = vtkOpenGLTexture.newInstance(); @@ -444,6 +468,14 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { ); } model._pwFunc = firstPwFunc; + } else { + model._defaultPwfTexture?.oglObject.releaseGraphicsResources( + model._openGLRenderWindow + ); + model._defaultPwfTexture = { + oglObject: model.pwfTexture, + hash: pwfHash, + }; } } else { model.pwfTexture = cachedPwfEntry.oglObject; @@ -603,8 +635,11 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { // (non indexed) vertex layout where elementCount matches the vertices. forceFlatten: true, }); - model.VBOBuildTime.modified(); } + + // Also mark property or actor only rebuilds, which do not rebuild the + // vertices, so the next render does not rebuild again. + model.VBOBuildTime.modified(); }; publicAPI.getNeedToRebuildShaders = (cellBO, ren, actor) => { @@ -1436,6 +1471,8 @@ const DEFAULT_VALUES = { // _scalars: null, // _colorTransferFunc: null, // _pwFunc: null, + // _defaultColorTexture: null, + // _defaultPwfTexture: null, }; // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..88be2d04ba8 --- /dev/null +++ b/Sources/Rendering/OpenGL/ImageCPRMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,123 @@ +import { it, expect } from 'vitest'; +import macro from 'vtk.js/Sources/macros'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +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 vtkOpenGLImageCPRMapper from 'vtk.js/Sources/Rendering/OpenGL/ImageCPRMapper'; +import vtkPolyData from 'vtk.js/Sources/Common/DataModel/PolyData'; +import { registerOverride } from 'vtk.js/Sources/Rendering/OpenGL/ViewNodeFactory'; + +const sideLen = 32; +const width = 20; + +// A CPR mapper whose OpenGL view node counts its buffer object builds +let bufferObjectBuilds = 0; +const newCountingImageCPRMapper = macro.newInstance( + (publicAPI, model, initialValues) => { + vtkImageCPRMapper.extend(publicAPI, model, initialValues); + model.classHierarchy.push('vtkCountingImageCPRMapper'); + } +); +registerOverride( + 'vtkCountingImageCPRMapper', + macro.newInstance((publicAPI, model, initialValues) => { + vtkOpenGLImageCPRMapper.extend(publicAPI, model, initialValues); + const { buildBufferObjects } = publicAPI; + publicAPI.buildBufferObjects = (ren, actor) => { + bufferObjectBuilds += 1; + buildBufferObjects(ren, actor); + }; + }) +); + +// The property has no transfer functions, so the mapper draws with its default +// grayscale ramp and fully opaque lookup textures. +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 mapper = gc.registerResource(newCountingImageCPRMapper()); + mapper.setUseUniformOrientation(true); + mapper.setImageData(imageData); + mapper.setCenterlineData(centerline); + mapper.setWidth(width); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + + tracked.renderer.addActor(actor); + tracked.renderer.resetCamera(); + tracked.renderWindow.render(); + return { ...tracked, actor, mapper }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'rebuilds once after a property change instead of on every render', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderWindow, actor } = renderCPRSlice(gc); + const objectsAfterFirstRender = tracker.count(); + const buildsAfterFirstRender = bufferObjectBuilds; + + actor.getProperty().setColorWindow(1000); + actor.getProperty().setColorLevel(500); + for (let i = 0; i < 20; ++i) { + renderWindow.render(); + } + expect(bufferObjectBuilds - buildsAfterFirstRender).toBe(1); + expect(tracker.count()).toBe(objectsAfterFirstRender); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'reuses its default lookup textures when the mapper changes', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderWindow, mapper } = renderCPRSlice(gc); + const objectsAfterFirstRender = tracker.count(); + + for (let i = 1; i < 16; ++i) { + mapper.setWidth(width + i); + renderWindow.render(); + } + expect(tracker.count()).toBe(objectsAfterFirstRender); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees its default lookup textures when the slice leaves the scene', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, actor } = renderCPRSlice(gc); + const objectsInScene = tracker.count(); + + renderer.removeActor(actor); + renderWindow.render(); + // at least the volume, color and opacity textures + expect(objectsInScene - tracker.count()).toBeGreaterThanOrEqual(3); + + gc.releaseResources(); + } +); diff --git a/Sources/Rendering/OpenGL/ImageMapper/index.js b/Sources/Rendering/OpenGL/ImageMapper/index.js index d808121e922..d4abc54ea4d 100644 --- a/Sources/Rendering/OpenGL/ImageMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageMapper/index.js @@ -57,6 +57,12 @@ function vtkOpenGLImageMapper(publicAPI, model) { function unregisterGraphicsResources(renderWindow) { // The openGLTexture is not shared model.openGLTexture.releaseGraphicsResources(renderWindow); + // Neither are the default lookup textures used without transfer functions + [model._defaultColorTexture, model._defaultPwfTexture].forEach((entry) => + entry?.oglObject.releaseGraphicsResources(renderWindow) + ); + model._defaultColorTexture = null; + model._defaultPwfTexture = null; // All these other resources are shared [ model._colorTransferFunc, @@ -977,21 +983,27 @@ function vtkOpenGLImageMapper(publicAPI, model) { numIComps ); const firstColorTransferFunc = actorProperty.getRGBTransferFunction(); - const cTex = model._openGLRenderWindow.getGraphicsResourceForObject( - firstColorTransferFunc - ); - - const reBuildC = - !cTex?.oglObject?.getHandle() || cTex?.hash !== cfunToString; + let cWidth = model.renderable.getColorTextureWidth(); + if (cWidth <= 0) { + cWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); + } + // Without a transfer function there is no core object to share the + // texture under, so the mapper keeps its own default grayscale ramp. + const cHash = firstColorTransferFunc + ? cfunToString + : `default-${cWidth}-${iType}`; + const cTex = firstColorTransferFunc + ? model._openGLRenderWindow.getGraphicsResourceForObject( + firstColorTransferFunc + ) + : model._defaultColorTexture; + + const reBuildC = !cTex?.oglObject?.getHandle() || cTex?.hash !== cHash; if (reBuildC) { model.colorTexture = vtkOpenGLTexture.newInstance({ resizable: true, }); model.colorTexture.setOpenGLRenderWindow(model._openGLRenderWindow); - let cWidth = model.renderable.getColorTextureWidth(); - if (cWidth <= 0) { - cWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); - } const cSize = cWidth * textureHeight * 3; const cTable = new Uint8ClampedArray(cSize); // set interpolation on the texture based on property setting @@ -1061,6 +1073,14 @@ function vtkOpenGLImageMapper(publicAPI, model) { ); } model._colorTransferFunc = firstColorTransferFunc; + } else { + model._defaultColorTexture?.oglObject.releaseGraphicsResources( + model._openGLRenderWindow + ); + model._defaultColorTexture = { + oglObject: model.colorTexture, + hash: cHash, + }; } } else { model.colorTexture = cTex.oglObject; @@ -1079,16 +1099,22 @@ function vtkOpenGLImageMapper(publicAPI, model) { numIComps ); const firstPwFunc = actorProperty.getPiecewiseFunction(); - const pwfTex = - model._openGLRenderWindow.getGraphicsResourceForObject(firstPwFunc); + let pwfWidth = model.renderable.getOpacityTextureWidth(); + if (pwfWidth <= 0) { + pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); + } + // Same as the color texture: without a piecewise function the mapper + // keeps its own fully opaque default. + const pwfHash = firstPwFunc + ? pwfunToString + : `default-${pwfWidth}-${iType}`; + const pwfTex = firstPwFunc + ? model._openGLRenderWindow.getGraphicsResourceForObject(firstPwFunc) + : model._defaultPwfTexture; // rebuild opacity tfun? const reBuildPwf = - !pwfTex?.oglObject?.getHandle() || pwfTex?.hash !== pwfunToString; + !pwfTex?.oglObject?.getHandle() || pwfTex?.hash !== pwfHash; if (reBuildPwf) { - let pwfWidth = model.renderable.getOpacityTextureWidth(); - if (pwfWidth <= 0) { - pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); - } const pwfSize = pwfWidth * textureHeight; const pwfTable = new Uint8ClampedArray(pwfSize); model.pwfTexture = vtkOpenGLTexture.newInstance({ @@ -1166,6 +1192,14 @@ function vtkOpenGLImageMapper(publicAPI, model) { ); } model._pwFunc = firstPwFunc; + } else { + model._defaultPwfTexture?.oglObject.releaseGraphicsResources( + model._openGLRenderWindow + ); + model._defaultPwfTexture = { + oglObject: model.pwfTexture, + hash: pwfHash, + }; } } else { model.pwfTexture = pwfTex.oglObject; @@ -1414,9 +1448,12 @@ function vtkOpenGLImageMapper(publicAPI, model) { // (non indexed) vertex layout where elementCount matches the vertices. forceFlatten: true, }); - model.VBOBuildTime.modified(); model.VBOBuildString = toString; } + + // Also mark property or actor only rebuilds, which do not change the VBO + // string, so the next render does not rebuild again. + model.VBOBuildTime.modified(); }; publicAPI.updateLabelOutlineOpacityTexture = (image) => { @@ -1622,6 +1659,8 @@ const DEFAULT_VALUES = { // _colorTransferFunc: null, // _pwFunc: null, // _labelOutlineThicknessArray: null, + // _defaultColorTexture: null, + // _defaultPwfTexture: null, }; // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..c6e0181b05f --- /dev/null +++ b/Sources/Rendering/OpenGL/ImageMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,123 @@ +import { it, expect } from 'vitest'; +import macro from 'vtk.js/Sources/macros'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +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 vtkOpenGLImageMapper from 'vtk.js/Sources/Rendering/OpenGL/ImageMapper'; +import { SlicingMode } from 'vtk.js/Sources/Rendering/Core/ImageMapper/Constants'; +import { registerOverride } from 'vtk.js/Sources/Rendering/OpenGL/ViewNodeFactory'; + +const sliceCount = 16; + +// An image mapper whose OpenGL view node counts its buffer object builds +let bufferObjectBuilds = 0; +const newCountingImageMapper = macro.newInstance( + (publicAPI, model, initialValues) => { + vtkImageMapper.extend(publicAPI, model, initialValues); + model.classHierarchy.push('vtkCountingImageMapper'); + } +); +registerOverride( + 'vtkCountingImageMapper', + macro.newInstance((publicAPI, model, initialValues) => { + vtkOpenGLImageMapper.extend(publicAPI, model, initialValues); + const { buildBufferObjects } = publicAPI; + publicAPI.buildBufferObjects = (ren, actor) => { + bufferObjectBuilds += 1; + buildBufferObjects(ren, actor); + }; + }) +); + +// The property has no transfer functions, so the mapper draws with its default +// grayscale ramp and fully opaque lookup textures. +function renderImageSlice(gc) { + const tracked = createTrackedRenderView(gc); + + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(32, 32, sliceCount); + imageData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Uint16Array.from( + { length: 32 * 32 * sliceCount }, + (_, i) => i % 4096 + ), + }) + ); + + const mapper = gc.registerResource(newCountingImageMapper()); + mapper.setInputData(imageData); + mapper.setSlicingMode(SlicingMode.K); + const actor = gc.registerResource(vtkImageSlice.newInstance()); + actor.setMapper(mapper); + + tracked.renderer.addActor(actor); + tracked.renderer.resetCamera(); + tracked.renderWindow.render(); + return { ...tracked, actor, mapper }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'rebuilds once after a property change instead of on every render', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderWindow, actor } = renderImageSlice(gc); + const objectsAfterFirstRender = tracker.count(); + const buildsAfterFirstRender = bufferObjectBuilds; + + actor.getProperty().setColorWindow(1000); + actor.getProperty().setColorLevel(500); + for (let i = 0; i < 20; ++i) { + renderWindow.render(); + } + expect(bufferObjectBuilds - buildsAfterFirstRender).toBe(1); + expect(tracker.count()).toBe(objectsAfterFirstRender); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'reuses its default lookup textures across slice and interpolation changes', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderWindow, actor, mapper } = renderImageSlice(gc); + const objectsAfterFirstRender = tracker.count(); + + for (let slice = 1; slice < sliceCount; ++slice) { + mapper.setSlice(slice); + renderWindow.render(); + } + expect(tracker.count()).toBe(objectsAfterFirstRender); + + // a new interpolation type replaces the color texture + actor.getProperty().setInterpolationTypeToNearest(); + renderWindow.render(); + actor.getProperty().setInterpolationTypeToLinear(); + renderWindow.render(); + expect(tracker.count()).toBe(objectsAfterFirstRender); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees its default lookup textures when the slice leaves the scene', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, actor } = renderImageSlice(gc); + const objectsInScene = tracker.count(); + + renderer.removeActor(actor); + renderWindow.render(); + // at least the scalar, color and opacity textures + expect(objectsInScene - tracker.count()).toBeGreaterThanOrEqual(3); + + gc.releaseResources(); + } +); diff --git a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js index 50a8a06bd67..539fdcbd89d 100644 --- a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js @@ -124,6 +124,12 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { [...graphicsResourceReferenceCount.keys()].forEach((coreObject) => renderWindow.unregisterGraphicsResourceUser(coreObject, publicAPI) ); + // The default lookup textures are not shared + [model._defaultColorTexture, model._defaultPwfTexture].forEach((entry) => + entry?.oglObject.releaseGraphicsResources(renderWindow) + ); + model._defaultColorTexture = null; + model._defaultPwfTexture = null; } publicAPI.buildPass = (prepass) => { @@ -431,16 +437,20 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { numIComps ); const firstColorTransferFunc = firstActorProperty.getRGBTransferFunction(); - const cTex = model._openGLRenderWindow.getGraphicsResourceForObject( - firstColorTransferFunc - ); - const reBuildC = - !cTex?.oglObject?.getHandle() || cTex?.hash !== colorFuncHash; + let cWidth = model.renderable.getColorTextureWidth(); + if (cWidth <= 0) { + cWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); + } + // Without a transfer function there is no core object to share the + // texture under, so the mapper keeps its own default grayscale ramp. + const cHash = firstColorTransferFunc ? colorFuncHash : `default-${cWidth}`; + const cTex = firstColorTransferFunc + ? model._openGLRenderWindow.getGraphicsResourceForObject( + firstColorTransferFunc + ) + : model._defaultColorTexture; + const reBuildC = !cTex?.oglObject?.getHandle() || cTex?.hash !== cHash; if (reBuildC) { - let cWidth = model.renderable.getColorTextureWidth(); - if (cWidth <= 0) { - cWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); - } const cSize = cWidth * textureHeight * 3; const cTable = new Uint8ClampedArray(cSize); const newColorTexture = vtkOpenGLTexture.newInstance(); @@ -500,6 +510,14 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { newColorTexture, colorFuncHash ); + } else { + model._defaultColorTexture?.oglObject.releaseGraphicsResources( + model._openGLRenderWindow + ); + model._defaultColorTexture = { + oglObject: newColorTexture, + hash: cHash, + }; } model.colorTexture = newColorTexture; } else { @@ -534,15 +552,21 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { numIComps ); const firstPwFunc = firstActorProperty.getPiecewiseFunction(); - const pwfTex = - model._openGLRenderWindow.getGraphicsResourceForObject(firstPwFunc); + let pwfWidth = model.renderable.getOpacityTextureWidth(); + if (pwfWidth <= 0) { + pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); + } + // Same as the color texture: without a piecewise function the mapper + // keeps its own fully opaque default. + const pwfHash = firstPwFunc + ? opacityFuncHash + : `default-${pwfWidth}-${textureHeight}`; + const pwfTex = firstPwFunc + ? model._openGLRenderWindow.getGraphicsResourceForObject(firstPwFunc) + : model._defaultPwfTexture; const reBuildPwf = - !pwfTex?.oglObject?.getHandle() || pwfTex?.hash !== opacityFuncHash; + !pwfTex?.oglObject?.getHandle() || pwfTex?.hash !== pwfHash; if (reBuildPwf) { - let pwfWidth = model.renderable.getOpacityTextureWidth(); - if (pwfWidth <= 0) { - pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); - } const pwfSize = pwfWidth * textureHeight; const pwfTable = new Uint8ClampedArray(pwfSize); const newOpacityTexture = vtkOpenGLTexture.newInstance(); @@ -599,6 +623,14 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { newOpacityTexture, opacityFuncHash ); + } else { + model._defaultPwfTexture?.oglObject.releaseGraphicsResources( + model._openGLRenderWindow + ); + model._defaultPwfTexture = { + oglObject: newOpacityTexture, + hash: pwfHash, + }; } model.pwfTexture = newOpacityTexture; } else { @@ -2050,6 +2082,8 @@ const DEFAULT_VALUES = { _colorTextureCore: null, pwfTexture: null, _pwfTextureCore: null, + _defaultColorTexture: null, + _defaultPwfTexture: null, labelOutlineProperties: [], labelOutlineThicknessTexture: null, _labelOutlineThicknessHash: null, diff --git a/Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..fb88e2afd95 --- /dev/null +++ b/Sources/Rendering/OpenGL/ImageResliceMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,75 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +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 vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane'; + +const sideLen = 32; + +// The property has no transfer functions, so the mapper draws with its default +// grayscale ramp and fully opaque lookup textures. +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 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); + + tracked.renderer.addActor(actor); + tracked.renderer.resetCamera(); + tracked.renderWindow.render(); + return { ...tracked, actor, slicePlane }; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'reuses its default lookup textures across slice plane and property changes', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderWindow, actor, slicePlane } = renderResliceSlice(gc); + const objectsAfterFirstRender = tracker.count(); + + for (let z = 1; z < sideLen / 2; ++z) { + slicePlane.setOrigin(0, 0, z); + renderWindow.render(); + } + actor.getProperty().setColorWindow(1000); + actor.getProperty().setColorLevel(500); + renderWindow.render(); + expect(tracker.count()).toBe(objectsAfterFirstRender); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees its default lookup textures when the slice leaves the scene', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, actor } = renderResliceSlice(gc); + const objectsInScene = tracker.count(); + + renderer.removeActor(actor); + renderWindow.render(); + // at least the scalar, color and opacity textures + expect(objectsInScene - tracker.count()).toBeGreaterThanOrEqual(3); + + gc.releaseResources(); + } +);