From c5efaf1929a408206f7329a6686924ff95d391ed Mon Sep 17 00:00:00 2001 From: Christian Jensen Date: Tue, 13 Oct 2026 14:40:25 -0700 Subject: [PATCH] fix(OpenGL): stop image mappers leaking their default lookup textures With no RGB transfer function or piecewise function on the property, vtkOpenGLImageMapper, vtkOpenGLImageResliceMapper and vtkOpenGLImageCPRMapper built a new default color or opacity texture on every buffer object rebuild. The shared texture cache is keyed on the transfer function, so the default texture was never cached, and the texture it replaced was never released. Each rebuild leaked two textures. The mappers now keep their default textures, rebuild them only when their size or filtering changes, release the one they replace and release them with the rest of the mapper's resources. vtkOpenGLImageMapper and vtkOpenGLImageCPRMapper also rebuilt on every render after an actor or property change that left the vertices alone, such as a new color window. They compare VBOBuildTime with the actor and property mtimes but only marked it when the vertices changed. VBOBuildTime is now marked at the end of every rebuild. --- .../Rendering/OpenGL/ImageCPRMapper/index.js | 71 +++++++--- .../test/testReleaseGraphicsResources.js | 123 ++++++++++++++++++ Sources/Rendering/OpenGL/ImageMapper/index.js | 75 ++++++++--- .../test/testReleaseGraphicsResources.js | 123 ++++++++++++++++++ .../OpenGL/ImageResliceMapper/index.js | 66 +++++++--- .../test/testReleaseGraphicsResources.js | 75 +++++++++++ 6 files changed, 482 insertions(+), 51 deletions(-) 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..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(); + } +);