diff --git a/packages/core/examples/genericPetRenderBackendGrid/index.ts b/packages/core/examples/genericPetRenderBackendGrid/index.ts new file mode 100644 index 0000000000..5c8eefc0f9 --- /dev/null +++ b/packages/core/examples/genericPetRenderBackendGrid/index.ts @@ -0,0 +1,242 @@ +import type { PlanarViewport, Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + getRenderingEngine, + utilities, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addButtonToToolbar, +} from '../../../../utils/demo/helpers'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { ViewportType, OrientationAxis } = Enums; + +const renderingEngineId = 'petPlanarBackendRenderingEngine'; + +const viewportIds = { + PET_STACK_GPU: 'PET_STACK_GPU', + PET_SAGITTAL_GPU: 'PET_SAGITTAL_GPU', + PET_STACK_CPU: 'PET_STACK_CPU', + PET_SAGITTAL_CPU: 'PET_SAGITTAL_CPU', +} as const; + +const petStackDataId = 'pet-planar-backend-grid:stack'; +const petVolumeDataId = 'pet-planar-backend-grid:volume'; + +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; +const petVolumeId = `${volumeLoaderScheme}:PET_PLANAR_BACKEND_GRID_VOLUME`; + +const wadoRsRoot = 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb'; +const StudyInstanceUID = + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463'; +const ptSeriesInstanceUID = + '1.3.6.1.4.1.14519.5.2.1.7009.2403.879445243400782656317561081015'; + +type ViewportSpec = { + viewportId: string; + dataId: string; + label: string; + renderBackend: 'gpu' | 'cpu'; + orientation?: typeof OrientationAxis.SAGITTAL; +}; + +const viewportSpecs: ViewportSpec[] = [ + { + viewportId: viewportIds.PET_STACK_GPU, + dataId: petStackDataId, + label: 'PET Stack - GPU', + renderBackend: 'gpu', + }, + { + viewportId: viewportIds.PET_SAGITTAL_GPU, + dataId: petVolumeDataId, + label: 'PET Sagittal - GPU', + renderBackend: 'gpu', + orientation: OrientationAxis.SAGITTAL, + }, + { + viewportId: viewportIds.PET_STACK_CPU, + dataId: petStackDataId, + label: 'PET Stack - CPU', + renderBackend: 'cpu', + }, + { + viewportId: viewportIds.PET_SAGITTAL_CPU, + dataId: petVolumeDataId, + label: 'PET Sagittal - CPU', + renderBackend: 'cpu', + orientation: OrientationAxis.SAGITTAL, + }, +]; + +setTitleAndDescription( + 'PET Planar Render Backend Grid', + 'Four PET Planar GenericViewport viewports. Top row is pinned to GPU rendering, bottom row is pinned to CPU rendering; left column is stack-like PET and right column is sagittal PET volume slicing.' +); + +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); +viewportGrid.style.display = 'grid'; +viewportGrid.style.gridTemplateColumns = 'repeat(2, 380px)'; +viewportGrid.style.gap = '8px'; +content.appendChild(viewportGrid); + +const viewportElements = new Map(); + +function createViewportElement(spec: ViewportSpec): HTMLDivElement { + const container = document.createElement('div'); + const caption = document.createElement('div'); + caption.innerText = spec.label; + caption.style.fontFamily = 'sans-serif'; + caption.style.fontSize = '14px'; + caption.style.marginBottom = '4px'; + + const element = document.createElement('div'); + element.style.width = '380px'; + element.style.height = '320px'; + element.oncontextmenu = (e) => e.preventDefault(); + + container.appendChild(caption); + container.appendChild(element); + viewportGrid.appendChild(container); + viewportElements.set(spec.viewportId, element); + + return element; +} + +for (const spec of viewportSpecs) { + createViewportElement(spec); +} + +const statusPanel = document.createElement('pre'); +statusPanel.id = 'pet-backend-grid-status'; +content.appendChild(statusPanel); + +function getViewport(viewportId: string): PlanarViewport | undefined { + return getRenderingEngine(renderingEngineId)?.getViewport( + viewportId + ); +} + +function describeViewport(spec: ViewportSpec): string { + const viewport = getViewport(spec.viewportId); + + if (!viewport) { + return `${spec.viewportId}: not mounted`; + } + + return `${spec.viewportId}: requested=${spec.renderBackend} actual=${viewport.getDisplaySetRenderMode( + spec.dataId + )} slice=${viewport.getCurrentImageIdIndex()}`; +} + +function updateStatusPanel(): void { + statusPanel.innerText = viewportSpecs.map(describeViewport).join('\n'); +} + +function renderAllViewports(): void { + for (const spec of viewportSpecs) { + getViewport(spec.viewportId)?.render(); + } + + updateStatusPanel(); +} + +addButtonToToolbar({ + title: 'Next Stack Image', + onClick: () => { + for (const viewportId of [ + viewportIds.PET_STACK_GPU, + viewportIds.PET_STACK_CPU, + ]) { + const viewport = getViewport(viewportId); + + if (!viewport) { + continue; + } + + const nextIndex = Math.min( + viewport.getCurrentImageIdIndex() + 1, + viewport.getImageIds().length - 1 + ); + + void viewport.setImageIdIndex(nextIndex); + } + + setTimeout(updateStatusPanel, 100); + }, +}); + +addButtonToToolbar({ + title: 'Reset Viewports', + onClick: () => { + for (const spec of viewportSpecs) { + const viewport = getViewport(spec.viewportId); + + viewport?.resetViewState(); + } + + renderAllViewports(); + }, +}); + +async function run() { + await initDemo(); + + const petImageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID: ptSeriesInstanceUID, + wadoRsRoot, + }); + + const renderingEngine = new RenderingEngine(renderingEngineId); + + renderingEngine.setViewports( + viewportSpecs.map((spec) => ({ + viewportId: spec.viewportId, + type: ViewportType.PLANAR_NEXT, + element: viewportElements.get(spec.viewportId), + defaultOptions: { + background: [0, 0, 0] as Types.Point3, + }, + })) + ); + + utilities.genericViewportDisplaySetMetadataProvider.add(petStackDataId, { + imageIds: petImageIds, + kind: 'planar', + initialImageIdIndex: Math.floor(petImageIds.length / 2), + }); + utilities.genericViewportDisplaySetMetadataProvider.add(petVolumeDataId, { + imageIds: petImageIds, + kind: 'planar', + initialImageIdIndex: Math.floor(petImageIds.length / 2), + volumeId: petVolumeId, + }); + + await Promise.all( + viewportSpecs.map(async (spec) => { + const viewport = getViewport(spec.viewportId); + + await viewport.setDisplaySets({ + displaySetId: spec.dataId, + options: { + renderBackend: spec.renderBackend, + ...(spec.orientation ? { orientation: spec.orientation } : {}), + }, + }); + }) + ); + + renderAllViewports(); +} + +run(); diff --git a/packages/core/examples/genericRenderBackendSwitch/index.ts b/packages/core/examples/genericRenderBackendSwitch/index.ts new file mode 100644 index 0000000000..41673deb0e --- /dev/null +++ b/packages/core/examples/genericRenderBackendSwitch/index.ts @@ -0,0 +1,651 @@ +import type { PlanarViewport, Types } from '@cornerstonejs/core'; +import * as cornerstone from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + eventTarget, + getRenderingEngine, + getRenderBackend, + getEffectiveRenderBackend, + imageLoader, + setRenderBackend, + triggerEvent, + utilities, + volumeLoader, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addButtonToToolbar, + ctVoiRange, +} from '../../../../utils/demo/helpers'; +import { + fillStackSegmentationWithMockData, + fillVolumeLabelmapWithMockData, +} from '../../../../utils/test/testUtils'; +import * as cornerstoneTools from '@cornerstonejs/tools'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { ViewportType, OrientationAxis, Events } = Enums; +const { + BidirectionalTool, + ToolGroupManager, + Enums: csToolsEnums, + segmentation, +} = cornerstoneTools; + +const renderingEngineId = 'myRenderingEngine'; +const toolGroupId = 'RENDER_BACKEND_SWITCH_TOOL_GROUP'; + +const stackViewportId = 'STACK_AUTO'; +const mprViewportId = 'MPR_AUTO'; +const petViewportId = 'PET_STACK_AUTO'; +const viewportIds = [stackViewportId, mprViewportId, petViewportId]; + +const stackDataId = 'render-backend-switch:stack'; +const volumeDataId = 'render-backend-switch:volume'; +const petDataId = 'render-backend-switch:pet-stack'; + +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; +const volumeId = `${volumeLoaderScheme}:RENDER_BACKEND_SWITCH_CT`; +const stackSegmentationId = 'RENDER_BACKEND_SWITCH_CT_STACK_SEGMENTATION'; +const mprSegmentationId = 'RENDER_BACKEND_SWITCH_CT_MPR_SEGMENTATION'; +const petSegmentationId = 'RENDER_BACKEND_SWITCH_PET_STACK_SEGMENTATION'; +const mprSegmentationVolumeId = `${volumeLoaderScheme}:${mprSegmentationId}`; +const wadoRsRoot = 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb'; +const StudyInstanceUID = + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463'; +const ctSeriesInstanceUID = + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561'; +const ptSeriesInstanceUID = + '1.3.6.1.4.1.14519.5.2.1.7009.2403.879445243400782656317561081015'; + +type BidirectionalAxis = [ + [Types.Point3, Types.Point3], + [Types.Point3, Types.Point3], +]; + +type FakeBidirectionalSpec = { + viewportId: string; + center: Types.Point2; + longAxisCanvasLength: number; + shortAxisCanvasLength: number; +}; + +const fakeBidirectionalSpecs: FakeBidirectionalSpec[] = [ + { + viewportId: stackViewportId, + center: [0.38, 0.46], + longAxisCanvasLength: 54, + shortAxisCanvasLength: 30, + }, + { + viewportId: mprViewportId, + center: [0.52, 0.48], + longAxisCanvasLength: 72, + shortAxisCanvasLength: 38, + }, + { + viewportId: petViewportId, + center: [0.5, 0.52], + longAxisCanvasLength: 56, + shortAxisCanvasLength: 32, + }, +]; + +setTitleAndDescription( + 'GenericViewport Render Backend Switch', + 'Live-switches the render backend (gpu | cpu | auto) of GenericViewport-based viewports without a page reload. ' + + 'The CT stack, CT MPR, and PET stack viewports follow the global setRenderBackend() value while keeping their slice, zoom/pan, and VOI.' +); + +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; +viewportGrid.style.gap = '4px'; +content.appendChild(viewportGrid); + +function createViewportElement(label: string): HTMLDivElement { + const container = document.createElement('div'); + const caption = document.createElement('div'); + caption.innerText = label; + const element = document.createElement('div'); + element.style.width = '350px'; + element.style.height = '350px'; + element.oncontextmenu = (e) => e.preventDefault(); + container.appendChild(caption); + container.appendChild(element); + viewportGrid.appendChild(container); + return element; +} + +const stackElement = createViewportElement('Stack (follows global backend)'); +const mprElement = createViewportElement('MPR (follows global backend)'); +const petElement = createViewportElement('PET Stack (follows global backend)'); + +const statusPanel = document.createElement('pre'); +statusPanel.id = 'backend-status'; +content.appendChild(statusPanel); + +const eventLog = document.createElement('pre'); +eventLog.id = 'backend-events'; +content.appendChild(eventLog); + +const contextLostModal = document.createElement('div'); +contextLostModal.id = 'context-lost-modal'; +contextLostModal.style.position = 'fixed'; +contextLostModal.style.inset = '0'; +contextLostModal.style.display = 'none'; +contextLostModal.style.alignItems = 'center'; +contextLostModal.style.justifyContent = 'center'; +contextLostModal.style.background = 'rgba(0, 0, 0, 0.45)'; +contextLostModal.style.zIndex = '1000'; + +const contextLostPanel = document.createElement('div'); +contextLostPanel.style.width = '360px'; +contextLostPanel.style.padding = '16px'; +contextLostPanel.style.borderRadius = '6px'; +contextLostPanel.style.background = '#fff'; +contextLostPanel.style.boxShadow = '0 16px 48px rgba(0, 0, 0, 0.3)'; +contextLostPanel.style.fontFamily = 'sans-serif'; + +const contextLostTitle = document.createElement('div'); +contextLostTitle.innerText = 'WebGL context lost'; +contextLostTitle.style.fontWeight = '700'; +contextLostTitle.style.marginBottom = '8px'; + +const contextLostMessage = document.createElement('div'); +contextLostMessage.id = 'context-lost-message'; +contextLostMessage.style.marginBottom = '16px'; + +const contextLostActions = document.createElement('div'); +contextLostActions.style.display = 'flex'; +contextLostActions.style.justifyContent = 'flex-end'; +contextLostActions.style.gap = '8px'; + +const contextLostDismissButton = document.createElement('button'); +contextLostDismissButton.innerText = 'Dismiss'; +contextLostDismissButton.onclick = () => { + contextLostModal.style.display = 'none'; +}; + +const contextLostFallbackButton = document.createElement('button'); +contextLostFallbackButton.innerText = 'Fallback to CPU'; +contextLostFallbackButton.onclick = () => { + setRenderBackend('cpu', 'webgl-context-lost-modal'); + contextLostModal.style.display = 'none'; + setTimeout(updateStatusPanel, 500); +}; + +contextLostActions.appendChild(contextLostDismissButton); +contextLostActions.appendChild(contextLostFallbackButton); +contextLostPanel.appendChild(contextLostTitle); +contextLostPanel.appendChild(contextLostMessage); +contextLostPanel.appendChild(contextLostActions); +contextLostModal.appendChild(contextLostPanel); +document.body.appendChild(contextLostModal); + +function getViewport(viewportId: string): PlanarViewport | undefined { + return getRenderingEngine(renderingEngineId)?.getViewport( + viewportId + ); +} + +function setupBidirectionalToolGroup(): void { + cornerstoneTools.addTool(BidirectionalTool); + + const toolGroup = + ToolGroupManager.getToolGroup(toolGroupId) ?? + ToolGroupManager.createToolGroup(toolGroupId); + + if (!toolGroup) { + return; + } + + if (!toolGroup.hasTool(BidirectionalTool.toolName)) { + toolGroup.addTool(BidirectionalTool.toolName); + } + + toolGroup.setToolPassive(BidirectionalTool.toolName); + + for (const viewportId of viewportIds) { + toolGroup.addViewport(viewportId, renderingEngineId); + } +} + +function createBidirectionalAxis( + viewport: PlanarViewport, + spec: FakeBidirectionalSpec +): BidirectionalAxis { + const { clientWidth, clientHeight } = viewport.element; + const centerCanvas: Types.Point2 = [ + clientWidth * spec.center[0], + clientHeight * spec.center[1], + ]; + const halfLongAxis = spec.longAxisCanvasLength / 2; + const halfShortAxis = spec.shortAxisCanvasLength / 2; + + return [ + [ + viewport.canvasToWorld([centerCanvas[0] - halfLongAxis, centerCanvas[1]]), + viewport.canvasToWorld([centerCanvas[0] + halfLongAxis, centerCanvas[1]]), + ], + [ + viewport.canvasToWorld([ + centerCanvas[0], + centerCanvas[1] - halfShortAxis, + ]), + viewport.canvasToWorld([ + centerCanvas[0], + centerCanvas[1] + halfShortAxis, + ]), + ], + ]; +} + +function addFakeBidirectionalAnnotations(): void { + for (const spec of fakeBidirectionalSpecs) { + const viewport = getViewport(spec.viewportId); + + if (!viewport) { + continue; + } + + BidirectionalTool.hydrate( + spec.viewportId, + createBidirectionalAxis(viewport, spec) + ); + } +} + +async function addSegmentationOverlays( + ctStackImageIds: string[], + ctVolumeImageIds: string[], + petStackImageIds: string[] +): Promise { + const stackLabelmapImages = + imageLoader.createAndCacheDerivedLabelmapImages(ctStackImageIds); + const petLabelmapImages = + imageLoader.createAndCacheDerivedLabelmapImages(petStackImageIds); + + volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: mprSegmentationVolumeId, + }); + + fillStackSegmentationWithMockData({ + cornerstone, + imageIds: ctStackImageIds, + segmentationImageIds: stackLabelmapImages.map((image) => image.imageId), + centerOffset: [-42, 0, 0], + innerValue: 1, + outerValue: 2, + }); + fillStackSegmentationWithMockData({ + cornerstone, + imageIds: petStackImageIds, + segmentationImageIds: petLabelmapImages.map((image) => image.imageId), + centerOffset: [38, 12, 0], + innerValue: 3, + outerValue: 4, + }); + fillVolumeLabelmapWithMockData({ + cornerstone, + volumeId: mprSegmentationVolumeId, + centerOffset: [32, -18, 0], + scale: [1.35, 1, 1.2], + }); + + segmentation.addSegmentations([ + { + segmentationId: stackSegmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + data: { + imageIds: stackLabelmapImages.map((image) => image.imageId), + referencedImageIds: ctStackImageIds, + }, + }, + config: { + label: 'CT Stack Labelmap', + }, + }, + { + segmentationId: mprSegmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + data: { + volumeId: mprSegmentationVolumeId, + referencedVolumeId: volumeId, + referencedImageIds: ctVolumeImageIds, + }, + }, + config: { + label: 'CT MPR Labelmap', + }, + }, + { + segmentationId: petSegmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + data: { + imageIds: petLabelmapImages.map((image) => image.imageId), + referencedImageIds: petStackImageIds, + }, + }, + config: { + label: 'PET Stack Labelmap', + }, + }, + ]); + + await segmentation.addLabelmapRepresentationToViewportMap({ + [stackViewportId]: [ + { + segmentationId: stackSegmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + ], + [mprViewportId]: [ + { + segmentationId: mprSegmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + config: { + useSliceRendering: true, + }, + }, + ], + [petViewportId]: [ + { + segmentationId: petSegmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + ], + }); + + segmentation.config.style.setStyle( + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + { + fillAlpha: 0.45, + fillAlphaInactive: 0.45, + renderFill: true, + renderFillInactive: true, + renderOutline: true, + renderOutlineInactive: true, + } + ); + + segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( + stackSegmentationId + ); + segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( + mprSegmentationId + ); + segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( + petSegmentationId + ); +} + +function describeViewport(viewportId: string, dataId: string): string { + const viewport = getViewport(viewportId); + + if (!viewport) { + return `${viewportId}: (not mounted)`; + } + + const renderMode = viewport._debug.renderModes[dataId]; + const zoom = viewport.getZoom().toFixed(2); + const imageIdIndex = viewport.getCurrentImageIdIndex(); + + return `${viewportId}: renderMode=${renderMode} slice=${imageIdIndex} zoom=${zoom}`; +} + +function updateStatusPanel(): void { + statusPanel.innerText = [ + `configured backend: ${getRenderBackend()}`, + `effective backend: ${getEffectiveRenderBackend()}`, + describeViewport(stackViewportId, stackDataId), + describeViewport(mprViewportId, volumeDataId), + describeViewport(petViewportId, petDataId), + ].join('\n'); +} + +function logEvent(message: string): void { + eventLog.innerText = `${message}\n${eventLog.innerText}`.slice(0, 2000); +} + +function showContextLostModal(detail: { + renderingEngineId?: string; + contextIndex?: number; + simulated?: boolean; +}): void { + const engineId = detail.renderingEngineId ?? renderingEngineId; + const contextIndex = detail.contextIndex ?? 0; + + contextLostMessage.innerText = `Context ${contextIndex} on ${engineId} was lost. Fallback to CPU rendering?`; + contextLostModal.style.display = 'flex'; +} + +function throwWebGLContextLost(): void { + triggerEvent(eventTarget, Events.WEBGL_CONTEXT_LOST, { + renderingEngineId, + contextIndex: 0, + simulated: true, + }); +} + +eventTarget.addEventListener(Events.RENDER_BACKEND_CHANGED, (evt) => { + const { previous, current, effectiveBackend, reason } = (evt as CustomEvent) + .detail; + + logEvent( + `RENDER_BACKEND_CHANGED: ${previous} -> ${current} (effective: ${effectiveBackend}${ + reason ? `, reason: ${reason}` : '' + })` + ); + // The render-path swap is asynchronous; refresh the panel after it settles. + setTimeout(updateStatusPanel, 500); +}); + +// Degradation signal demo: cornerstone never switches backends on its own. +// An application listens for this event and offers the user a switch to CPU +// rendering via setRenderBackend('cpu'). +eventTarget.addEventListener(Events.WEBGL_CONTEXT_LOST, (evt) => { + const detail = (evt as CustomEvent).detail || {}; + const { renderingEngineId: engineId, contextIndex } = detail; + + logEvent( + `WEBGL_CONTEXT_LOST on ${engineId} (context ${contextIndex}) - consider setRenderBackend('cpu')` + ); + showContextLostModal(detail); +}); + +addButtonToToolbar({ + title: 'Backend: Auto', + onClick: () => setRenderBackend('auto', 'example-toolbar'), +}); + +addButtonToToolbar({ + title: 'Backend: GPU', + onClick: () => setRenderBackend('gpu', 'example-toolbar'), +}); + +addButtonToToolbar({ + title: 'Backend: CPU', + onClick: () => setRenderBackend('cpu', 'example-toolbar'), +}); + +addButtonToToolbar({ + title: 'Throw Context Lost', + onClick: () => throwWebGLContextLost(), +}); + +addButtonToToolbar({ + title: 'Next Image (stacks)', + onClick: () => { + for (const viewportId of [stackViewportId, petViewportId]) { + const viewport = getViewport(viewportId); + + if (!viewport) { + continue; + } + + const nextIndex = Math.min( + viewport.getCurrentImageIdIndex() + 1, + viewport.getImageIds().length - 1 + ); + + void viewport.setImageIdIndex(nextIndex); + } + + setTimeout(updateStatusPanel, 100); + }, +}); + +addButtonToToolbar({ + title: 'Set VOI Range', + onClick: () => { + const targets: Array<[string, string]> = [ + [stackViewportId, stackDataId], + [mprViewportId, volumeDataId], + ]; + + for (const [viewportId, dataId] of targets) { + const viewport = getViewport(viewportId); + viewport?.setDisplaySetPresentation(dataId, { + voiRange: { lower: -1500, upper: 2500 }, + }); + viewport?.render(); + } + }, +}); + +addButtonToToolbar({ + title: 'Apply Zoom And Pan', + onClick: () => { + for (const viewportId of [stackViewportId, mprViewportId, petViewportId]) { + const viewport = getViewport(viewportId); + + if (!viewport) { + continue; + } + + viewport.setScale(1.35); + viewport.setPan([42, -28]); + viewport.render(); + } + + setTimeout(updateStatusPanel, 100); + }, +}); + +addButtonToToolbar({ + title: 'Add Fake Bidirectionals', + onClick: () => addFakeBidirectionalAnnotations(), +}); + +async function run() { + await initDemo(); + + const [ctImageIds, ptImageIds] = await Promise.all([ + createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID: ctSeriesInstanceUID, + wadoRsRoot, + }), + createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID: ptSeriesInstanceUID, + wadoRsRoot, + }), + ]); + + const renderingEngine = new RenderingEngine(renderingEngineId); + + renderingEngine.setViewports([ + { + viewportId: stackViewportId, + type: ViewportType.PLANAR_NEXT, + element: stackElement, + defaultOptions: { background: [0.2, 0, 0.2] as Types.Point3 }, + }, + { + viewportId: mprViewportId, + type: ViewportType.PLANAR_NEXT, + element: mprElement, + defaultOptions: { background: [0, 0.2, 0.2] as Types.Point3 }, + }, + { + viewportId: petViewportId, + type: ViewportType.PLANAR_NEXT, + element: petElement, + defaultOptions: { background: [0.2, 0.2, 0] as Types.Point3 }, + }, + ]); + + setupBidirectionalToolGroup(); + + const stack = [ctImageIds[0], ctImageIds[1], ctImageIds[2]]; + const ptStack = [ptImageIds[0], ptImageIds[1], ptImageIds[2]]; + const ctVolume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds: ctImageIds, + }); + + ctVolume.load(); + + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { + imageIds: stack, + kind: 'planar', + initialImageIdIndex: 0, + }); + utilities.genericViewportDisplaySetMetadataProvider.add(volumeDataId, { + imageIds: ctImageIds, + kind: 'planar', + initialImageIdIndex: Math.floor(ctImageIds.length / 2), + volumeId, + }); + utilities.genericViewportDisplaySetMetadataProvider.add(petDataId, { + imageIds: ptStack, + kind: 'planar', + initialImageIdIndex: 0, + }); + + const stackViewport = getViewport(stackViewportId); + await stackViewport.setDisplaySets({ + displaySetId: stackDataId, + options: {}, + }); + stackViewport.setDisplaySetPresentation(stackDataId, { + voiRange: ctVoiRange, + }); + + const mprViewport = getViewport(mprViewportId); + await mprViewport.setDisplaySets({ + displaySetId: volumeDataId, + options: { orientation: OrientationAxis.SAGITTAL }, + }); + mprViewport.setDisplaySetPresentation(volumeDataId, { + voiRange: ctVoiRange, + }); + + const petViewport = getViewport(petViewportId); + await petViewport.setDisplaySets({ + displaySetId: petDataId, + options: {}, + }); + + renderingEngine.render(); + await addSegmentationOverlays(stack, ctImageIds, ptStack); + addFakeBidirectionalAnnotations(); + renderingEngine.render(); + updateStatusPanel(); +} + +run(); diff --git a/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts b/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts index e7a393dd5b..cc6f2d4dad 100644 --- a/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts +++ b/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts @@ -47,7 +47,7 @@ class ContextPoolRenderingEngine extends BaseRenderingEngine { if (!this.contextPool) { const { rendering } = getConfiguration(); const { webGlContextCount } = rendering; - this.contextPool = new WebGLContextPool(webGlContextCount); + this.contextPool = new WebGLContextPool(webGlContextCount, this.id); } return this.contextPool; diff --git a/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts index 89162ceb61..bdd040fa50 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts @@ -464,6 +464,17 @@ abstract class GenericViewport< */ abstract render(): void; + /** + * Re-evaluates render paths after a global rendering-configuration change + * (setRenderBackend, or a deprecated CPU-rendering toggle). The default is + * a no-op; viewport families that support a live render-path swap override + * it. Present on every viewport so the global fan-out in init() can call it + * unconditionally. + */ + updateRenderingPipeline(): void { + // No-op by default; families with swappable render paths override this. + } + /** * Recomputes viewport-owned runtime sizing. Concrete viewport families may * override this when they need to resize canvases or external runtimes. @@ -538,12 +549,13 @@ abstract class GenericViewport< ); const renderPath = path.createRenderPath(); const ctx = path.selectContext?.(this.renderContext) ?? this.renderContext; - const existing = this.bindings.get(displaySetId); - - if (existing) { - existing.removeData(); - } + // Stage the replacement attachment before tearing down any existing one: + // if addData rejects, or the request goes stale mid-await, the previous + // render path stays mounted and the binding record stays accurate. A + // teardown-first order left a dead binding whose recorded renderMode made + // a later remount (e.g. switching the render backend back after a failed + // swap) skip as a no-op, blanking the display set. const attachment = await renderPath.addData(ctx, data, options); if (shouldIgnore?.()) { @@ -556,9 +568,12 @@ abstract class GenericViewport< throw new Error('Viewport has been destroyed'); } + // Whatever is bound now -- the attachment this call is replacing, or one + // a concurrent mount installed during the await -- is superseded by the + // staged attachment. const current = this.bindings.get(displaySetId); - if (current && current !== existing) { + if (current) { current.removeData(); } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts index 82c730385b..fcf17ff88a 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts @@ -88,7 +88,11 @@ export class CpuImageSliceRenderPath compatibilityActor.setVisibility(true); - const defaultViewport = getDefaultViewport(ctx.cpu.canvas, payload.image); + const defaultViewport = getDefaultViewport( + ctx.cpu.canvas, + payload.image, + getCPUFallbackViewportModality(payload.image) + ); defaultViewport.displayedArea = resolvePlanarCpuImageDisplayedArea( payload.image @@ -321,7 +325,11 @@ export class CpuImageSliceRenderPath }); rendering.fitScale = getCPUFallbackScalarScale( - getDefaultViewport(rendering.enabledElement.canvas, image).scale + getDefaultViewport( + rendering.enabledElement.canvas, + image, + getCPUFallbackViewportModality(image) + ).scale ); rendering.renderingInvalidated = true; @@ -590,6 +598,17 @@ function markCpuImagePreScaled(image: IImage): void { image.isPreScaled = image.preScale?.scaled; } +function getCPUFallbackViewportModality(image: IImage): string | undefined { + return ( + image.preScale?.scalingParameters?.modality || + ( + metaData.get(MetadataModules.GENERAL_SERIES, image.imageId) as + | { modality?: string } + | undefined + )?.modality + ); +} + export function buildPlanarImageData( image: IImage, frameOfReferenceUID?: string @@ -709,7 +728,11 @@ async function updateRenderedImage(args: { const enabledElement = rendering.enabledElement; markCpuImagePreScaled(image); const camera = ctx.viewport.getViewState(); - const defaultViewport = getDefaultViewport(ctx.cpu.canvas, image); + const defaultViewport = getDefaultViewport( + ctx.cpu.canvas, + image, + getCPUFallbackViewportModality(image) + ); const previousViewport = enabledElement.viewport; defaultViewport.displayedArea = resolvePlanarCpuImageDisplayedArea(image); diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts index 4a4618df40..d418513cd0 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts @@ -1,7 +1,8 @@ import { vec3 } from 'gl-matrix'; import cache from '../../../cache/cache'; -import { OrientationAxis } from '../../../enums'; -import { getConfiguration, getShouldUseCPURendering } from '../../../init'; +import { OrientationAxis, RenderBackend } from '../../../enums'; +import type { RenderBackendValue } from '../../../enums'; +import { getEffectiveRenderBackend } from '../../../init'; import * as metaData from '../../../metaData'; import { ActorRenderMode } from '../../../types'; import { isValidVolume } from '../../../utilities/isValidVolume'; @@ -13,14 +14,6 @@ import type { PlanarRegisteredDataSet, } from './PlanarViewportTypes'; -export const DEFAULT_PLANAR_CPU_IMAGE_THRESHOLD = 64 * 1024 * 1024; -// Default to no size-based CPU fallback for volumes: a non-finite threshold makes -// shouldUseCPU() return false on size, so volumes render on the GPU (matching the -// legacy ORTHOGRAPHIC viewport) unless the GPU is globally unavailable. A finite -// cap forced ordinary MPR volumes onto the CPU_VOLUME path. Callers can still opt -// into size-based fallback via rendering.planar.cpuThresholds.volume. -export const DEFAULT_PLANAR_CPU_VOLUME_THRESHOLD = Number.POSITIVE_INFINITY; - export interface SelectedPlanarRenderPath { acquisitionOrientation?: PlanarViewState['orientation']; renderMode: PlanarEffectiveRenderMode; @@ -29,12 +22,14 @@ export interface SelectedPlanarRenderPath { export interface PlanarRenderPathDecisionOptions { orientation?: PlanarOrientation; - cpuThresholds?: { - image?: number; - volume?: number; - }; useSliceRendering?: boolean; - webGLAvailable?: boolean; + /** + * Per-mount render backend override. 'cpu' and 'gpu' pin this dataset to + * that backend; 'auto' resolves from the capability detection regardless of + * the global pin. When omitted, the global + * rendering.planar.renderBackend configuration decides. + */ + renderBackend?: RenderBackend | RenderBackendValue; } /** @@ -42,9 +37,10 @@ export interface PlanarRenderPathDecisionOptions { * * Clean Planar Next callers do not pass a render mode. This service derives the * internal path from semantic inputs: dataset shape, requested orientation, - * runtime CPU/GPU configuration, WebGL availability, and segmentation slice - * rendering configuration. Binding role is deliberately not part of the - * decision; source and overlays are mounted through the same rules. + * and the render backend (per-mount override or global + * rendering.planar.renderBackend configuration, with 'auto' resolved from + * init-time capability detection). Binding role is deliberately not part of + * the decision; source and overlays are mounted through the same rules. */ export class PlanarRenderPathDecisionService { select( @@ -85,7 +81,7 @@ export class PlanarRenderPathDecisionService { return { acquisitionOrientation, renderMode: useVolumePath - ? this.selectVolumeRenderMode(dataSet, options) + ? this.selectVolumeRenderMode(options) : this.selectImageRenderMode(options), volumeId, }; @@ -135,53 +131,30 @@ export class PlanarRenderPathDecisionService { private selectImageRenderMode( options: PlanarRenderPathDecisionOptions ): PlanarEffectiveRenderMode { - return this.shouldUseCPUForImage(options) + return this.resolveBackend(options) === RenderBackend.CPU ? ActorRenderMode.CPU_IMAGE : ActorRenderMode.VTK_IMAGE; } private selectVolumeRenderMode( - dataSet: PlanarRegisteredDataSet, options: PlanarRenderPathDecisionOptions ): PlanarEffectiveRenderMode { - return this.shouldUseCPUForVolume(dataSet, options) + return this.resolveBackend(options) === RenderBackend.CPU ? ActorRenderMode.CPU_VOLUME : ActorRenderMode.VTK_VOLUME_SLICE; } - private shouldUseCPUForImage( - options: PlanarRenderPathDecisionOptions - ): boolean { - if (options.webGLAvailable === false) { - return true; - } - - // The GPU image path renders a single slice at a time (see - // createVTKImageDataFromImage), so neither stack depth nor per-slice size - // changes the GPU texture cost. Match the legacy StackViewport: the image - // render path falls back to CPU only when GPU rendering is globally - // unavailable. The volume path supports an opt-in size-based fallback (it - // uploads the full volume to the GPU), but that is disabled by default so - // ordinary volumes render on the GPU like the legacy ORTHOGRAPHIC viewport. - return getShouldUseCPURendering(); - } - - private shouldUseCPUForVolume( - dataSet: PlanarRegisteredDataSet, + /** + * Resolves the effective backend for one decision: the per-mount override + * when present ('auto' resolves from capability detection even when the + * global backend is pinned), the global configuration otherwise. The + * precedence ladder itself lives in getEffectiveRenderBackend so it cannot + * drift from the global resolution. + */ + private resolveBackend( options: PlanarRenderPathDecisionOptions - ): boolean { - if (options.webGLAvailable === false) { - return true; - } - - const configuredCpuThresholds = getConfiguredPlanarCpuThresholds(); - - return shouldUseCPU( - dataSet.imageIds, - options.cpuThresholds?.volume ?? - configuredCpuThresholds?.volume ?? - DEFAULT_PLANAR_CPU_VOLUME_THRESHOLD - ); + ): RenderBackend.GPU | RenderBackend.CPU { + return getEffectiveRenderBackend(options.renderBackend); } } @@ -242,44 +215,6 @@ export function getPlanarAcquisitionOrientation( return orientation; } -export function shouldUseCPU( - imageIds: string[], - threshold = DEFAULT_PLANAR_CPU_IMAGE_THRESHOLD -): boolean { - if (getShouldUseCPURendering()) { - return true; - } - - const imageId = imageIds[0]; - - if (!imageId) { - return false; - } - - const imagePlaneModule = metaData.get('imagePlaneModule', imageId); - const rows = imagePlaneModule?.rows; - const columns = imagePlaneModule?.columns; - - if (!isPositiveSafeInteger(rows) || !isPositiveSafeInteger(columns)) { - return false; - } - - if (!Number.isFinite(threshold)) { - return false; - } - - const normalizedThreshold = Math.trunc(threshold); - - if (!Number.isSafeInteger(normalizedThreshold) || normalizedThreshold < 0) { - return false; - } - - return ( - BigInt(rows) * BigInt(columns) * BigInt(imageIds.length) >= - BigInt(normalizedThreshold) - ); -} - function getVolumeId(dataSet: PlanarRegisteredDataSet): string { return dataSet.volumeId || cache.generateVolumeId(dataSet.imageIds); } @@ -306,11 +241,3 @@ function isVolumeBackedDataSet( return !isAcquisitionPath && supportsVolumeRendering(dataSet); } - -function isPositiveSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; -} - -function getConfiguredPlanarCpuThresholds() { - return getConfiguration().rendering?.planar?.cpuThresholds; -} diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts index 5afc6243f8..4ed7789054 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts @@ -1,6 +1,7 @@ import { Events, OrientationAxis, + RenderBackend, ViewportStatus, ViewportType, VOILUTFunctionType, @@ -30,11 +31,13 @@ import imageIdToURI from '../../../utilities/imageIdToURI'; import { getImageDataMetadata } from '../../../utilities/getImageDataMetadata'; import genericViewportDisplaySetMetadataProvider from '../../../utilities/genericViewportDisplaySetMetadataProvider'; import triggerEvent from '../../../utilities/triggerEvent'; +import eventTarget from '../../../eventTarget'; import getMinMax from '../../../utilities/getMinMax'; import renderingEngineCache from '../../renderingEngineCache'; import { getCameraVectors } from '../../helpers/getCameraVectors'; import type { LoadedData, + ViewportDataBinding, ViewportDataReference, } from '../ViewportArchitectureTypes'; import GenericViewport from '../GenericViewport'; @@ -164,6 +167,18 @@ class PlanarViewport extends GenericViewport< canvasWidth: number; }; private setDataRequestId = 0; + private renderPipelineSwapId = 0; + // Last reported render-path error message per display set; a successful + // render clears the entry so a genuine repeat failure after recovery is + // reported again. + private readonly lastRenderPathErrorByDataId = new Map(); + // Original mount options per display set, kept so a live render-backend + // switch (updateRenderingPipeline) can re-run the render-path decision with + // the same per-mount semantics (orientation, thresholds, backend pins). + private readonly mountOptionsByDataId = new Map< + string, + PlanarSetDataOptions + >(); // ── Static ─────────────────────────────────────────────────────────── @@ -421,6 +436,7 @@ class PlanarViewport extends GenericViewport< return; } + this.mountOptionsByDataId.set(dataId, resolvedOptions); this.clearResolvedViewCache(); this.setDefaultDataPresentation(dataId, { visible: true, @@ -458,6 +474,8 @@ class PlanarViewport extends GenericViewport< */ removeData(dataId: string): void { this.clearResolvedViewCache(); + this.mountOptionsByDataId.delete(dataId); + this.lastRenderPathErrorByDataId.delete(dataId); super.removeData(dataId); this.mountedData.handleRemovedData(dataId); @@ -1621,22 +1639,174 @@ class PlanarViewport extends GenericViewport< let renderedByAdapter = false; const sourceBinding = this.getCurrentBinding(); + const renderBinding = ( + binding: ViewportDataBinding, + dataId: string + ) => { + if (!binding.render) { + return; + } + + renderedByAdapter = true; - sourceBinding?.render?.(); - renderedByAdapter = renderedByAdapter || Boolean(sourceBinding?.render); + try { + binding.render(); + this.lastRenderPathErrorByDataId.delete(dataId); + } catch (error) { + this.reportRenderPathError(error, dataId); + } + }; - for (const binding of this.bindings.values()) { + for (const [dataId, binding] of this.bindings.entries()) { if (binding === sourceBinding) { - continue; + renderBinding(binding, dataId); } + } - binding.render?.(); - renderedByAdapter = renderedByAdapter || Boolean(binding.render); + for (const [dataId, binding] of this.bindings.entries()) { + if (binding !== sourceBinding) { + renderBinding(binding, dataId); + } } return renderedByAdapter; } + /** + * Live render-backend switch: re-runs the render-path decision for every + * mounted display set and remounts, in place, the ones whose effective + * render mode changed. The viewport instance, its id, mounted data, view + * state (slice/zoom/pan), per-display-set presentation, and tool + * annotations all survive -- addLoadedData re-applies presentation and view + * state to the rebuilt binding. Display sets pinned via a per-mount + * renderBackend re-resolve to the same path and are effectively skipped. + * + * Called by the global setRenderBackend()/setUseCPURendering() fan-out; the + * rebuild is async but the hook itself is fire-and-forget by contract. + */ + override updateRenderingPipeline(): void { + void this.applyRenderingPipelineUpdate(); + } + + private async applyRenderingPipelineUpdate(): Promise { + if (this.isDestroyed) { + return; + } + + const swapId = ++this.renderPipelineSwapId; + const isStale = () => + swapId !== this.renderPipelineSwapId || this.isDestroyed; + let changed = false; + + // Remount the source binding first: overlays without an explicit + // renderBackend inherit the source's mounted backend, so the source must + // resolve to the new backend before the overlays re-run their decisions + // (the source is not guaranteed to be first in insertion order after a + // promoteSourceDataId). + const sourceBinding = this.getCurrentBinding(); + const bindingEntries = Array.from(this.bindings.entries()); + const orderedEntries = [ + ...bindingEntries.filter(([, binding]) => binding === sourceBinding), + ...bindingEntries.filter(([, binding]) => binding !== sourceBinding), + ]; + + for (const [dataId, binding] of orderedEntries) { + if (isStale()) { + return; + } + + // Skip bindings that were removed or replaced (e.g. by a concurrent + // setDisplaySets) since this pass started. + if (this.bindings.get(dataId) !== binding) { + continue; + } + + const options = this.mountOptionsByDataId.get(dataId) ?? {}; + + try { + const { data, selectedPath } = await this.loadPlanarData(dataId, { + ...options, + role: binding.role, + }); + + if (isStale() || this.bindings.get(dataId) !== binding) { + continue; + } + + if (selectedPath.renderMode === binding.rendering.renderMode) { + continue; + } + + const added = await this.addLoadedData( + dataId, + data, + { + renderMode: selectedPath.renderMode, + role: binding.role, + }, + () => isStale() || this.bindings.get(dataId) !== binding + ); + + changed = changed || added; + } catch (error) { + this.reportRenderPathError(error, dataId); + } + } + + if (isStale() || !changed) { + return; + } + + // Re-assert the source binding's render mode: every remounted path + // activated its own mode while mounting, so with mixed CPU/GPU bindings + // the last mount, not the source, may have won the canvas-visibility + // toggle. + const sourceRenderMode = this.getCurrentPlanarRendering()?.renderMode; + + if (sourceRenderMode) { + this.renderContext.display.activateRenderMode(sourceRenderMode); + } + + this.clearResolvedViewCache(); + this.updateBindingsCameraState(); + this.render(); + + // The rebuilt render paths replaced this viewport's actors. Announce it so + // consumers that decorate actors (segmentation representations restyle + // their labelmap overlays through this) can re-reconcile against the new + // instances; the swap itself cannot know about them. + triggerEvent(eventTarget, Events.RENDERING_PIPELINE_CHANGED, { + renderingEngineId: this.renderingEngineId, + viewportId: this.id, + }); + } + + /** + * Emits the RENDER_PATH_ERROR degradation signal (and logs) for a render + * path that threw while mounting or rendering. Repeated identical failures + * for a display set are reported once so a per-frame render error does not + * flood the event bus; a successful render of that display set clears the + * record, so a genuine failure after a recovery is reported again. + * Applications listen for this to offer a backend switch. + */ + private reportRenderPathError(error: unknown, dataId?: string): void { + const message = error instanceof Error ? error.message : String(error); + const errorKey = dataId ?? ''; + + if (this.lastRenderPathErrorByDataId.get(errorKey) === message) { + return; + } + + this.lastRenderPathErrorByDataId.set(errorKey, message); + console.error('[PlanarViewport] Render path error', dataId ?? '', error); + triggerEvent(eventTarget, Events.RENDER_PATH_ERROR, { + renderingEngineId: this.renderingEngineId, + viewportId: this.id, + dataId, + error, + }); + } + private requestRenderingEngineRender(): void { const renderingEngine = renderingEngineCache.get(this.renderingEngineId); @@ -1892,10 +2062,12 @@ class PlanarViewport extends GenericViewport< ); const selectedPath = selectPlanarRenderPath(dataSet, { orientation: resolvedOrientation, - cpuThresholds: options.cpuThresholds, - // Per-mount CPU force: webGLAvailable=false routes the decision to the CPU - // path. Left undefined otherwise so global config + thresholds decide. - webGLAvailable: options.forceCPU ? false : undefined, + // Per-mount backend pin/override. Overlays without an explicit pin + // follow the source binding's mounted backend rather than the global + // configuration; only then does the global renderBackend decide. + renderBackend: + options.renderBackend ?? + this.getInheritedOverlayRenderBackend(options.role), }); const data = await (this.dataProvider as PlanarDataProvider).load(dataId, { acquisitionOrientation: selectedPath.acquisitionOrientation, @@ -1911,6 +2083,34 @@ class PlanarViewport extends GenericViewport< }; } + /** + * Backend an overlay mount inherits when it carries no explicit + * renderBackend: the source binding's mounted backend. Source and overlays + * must render through the same backend -- each backend draws to its own + * canvas and skips the other's actors -- and the source may be pinned + * per-mount to a backend that differs from the global configuration. + * Returns undefined for source mounts (they resolve on their own) and when + * no source is mounted yet, leaving the global configuration to decide. + */ + private getInheritedOverlayRenderBackend( + role: PlanarSetDataOptions['role'] + ): RenderBackend.GPU | RenderBackend.CPU | undefined { + if (role === 'source') { + return undefined; + } + + switch (this.getCurrentPlanarRendering()?.renderMode) { + case ActorRenderMode.CPU_IMAGE: + case ActorRenderMode.CPU_VOLUME: + return RenderBackend.CPU; + case ActorRenderMode.VTK_IMAGE: + case ActorRenderMode.VTK_VOLUME_SLICE: + return RenderBackend.GPU; + default: + return undefined; + } + } + private applyLoadedPlanarViewState( resolvedOrientation: PlanarViewState['orientation'], planarData: PlanarPayload, diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts index 4b7024603c..cef28cc35f 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts @@ -4,6 +4,8 @@ import type { BlendModes, InterpolationType, OrientationAxis, + RenderBackend, + RenderBackendValue, VOILUTFunctionType, } from '../../../enums'; import type { @@ -69,18 +71,17 @@ export interface PlanarRegisteredDataSet { export interface PlanarSetDataOptions { orientation?: PlanarOrientation; - cpuThresholds?: { - image?: number; - volume?: number; - }; /** - * Forces this display set to render through the CPU path regardless of the - * global rendering configuration or byte-size thresholds. Use when an - * individual display set / overlay must be CPU-rendered (e.g. to bound GPU - * memory). When omitted, the render path is decided by the global rendering - * configuration, WebGL availability, and the CPU thresholds above. + * Per-display-set render backend override, taking precedence over the + * global `rendering.planar.renderBackend` configuration. 'cpu' pins this + * display set to CPU rendering (e.g. to bound GPU memory for a thumbnail + * viewport mounted next to GPU viewports); 'gpu' pins it to the GPU; + * 'auto' resolves from capability detection even when the global backend + * is pinned. Pinned display sets keep their backend across global + * setRenderBackend() switches. When omitted, the global configuration + * decides. */ - forceCPU?: boolean; + renderBackend?: RenderBackend | RenderBackendValue; role?: BindingRole; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts index 53be5988a5..b7b230e4ea 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts @@ -65,6 +65,13 @@ export class VtkImageMapperRenderPath throw new Error('[PlanarViewport] VTK image rendering requires an image'); } + // 16-bit handling: this path uploads the image's native scalar type (e.g. + // Int16/Uint16) through the stock vtk.js vtkImageMapper, which enables + // EXT_texture_norm16 itself and runs its own linear-filtering probe + // (vtk.js OpenGL ImageMapper/Texture), falling back to half-float/float + // when norm16 is unusable. Cornerstone's getCanUseNorm16Texture() gate + // (used by the streaming volume texture on the VTK_VOLUME_SLICE path) is + // intentionally not wired in here. const mapper = vtkImageMapper.newInstance(); const actor = vtkImageSlice.newInstance(); const imageData = diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRenderPathSelector.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRenderPathSelector.ts index 3134f140f5..975dccdb95 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRenderPathSelector.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRenderPathSelector.ts @@ -3,13 +3,10 @@ import type { PlanarViewState, PlanarOrientation } from './PlanarViewportTypes'; import { clonePlanarOrientation } from './planarLegacyCompatibility'; export { - DEFAULT_PLANAR_CPU_IMAGE_THRESHOLD, - DEFAULT_PLANAR_CPU_VOLUME_THRESHOLD, PlanarRenderPathDecisionService, defaultPlanarRenderPathDecisionService, getPlanarAcquisitionOrientation, selectPlanarRenderPath, - shouldUseCPU, } from './PlanarRenderPathDecisionService'; export type { PlanarRenderPathDecisionOptions, diff --git a/packages/core/src/RenderingEngine/TiledRenderingEngine.ts b/packages/core/src/RenderingEngine/TiledRenderingEngine.ts index 8f80b4e389..b4d469603a 100644 --- a/packages/core/src/RenderingEngine/TiledRenderingEngine.ts +++ b/packages/core/src/RenderingEngine/TiledRenderingEngine.ts @@ -7,6 +7,7 @@ import getOrCreateCanvas from './helpers/getOrCreateCanvas'; import type IStackViewport from '../types/IStackViewport'; import type IVolumeViewport from '../types/IVolumeViewport'; import { vtkOffscreenMultiRenderWindow } from './vtkClasses'; +import { attachWebGLContextEvents } from './helpers/attachWebGLContextEvents'; import type * as EventTypes from '../types/EventTypes'; import type { @@ -74,6 +75,7 @@ class TiledRenderingEngine extends BaseRenderingEngine { this.offscreenMultiRenderWindow.setContainer( this.offScreenCanvasContainer ); + attachWebGLContextEvents(this.offscreenMultiRenderWindow, this.id); } } /** diff --git a/packages/core/src/RenderingEngine/WebGLContextPool.ts b/packages/core/src/RenderingEngine/WebGLContextPool.ts index e95f653cc9..145bca5e94 100644 --- a/packages/core/src/RenderingEngine/WebGLContextPool.ts +++ b/packages/core/src/RenderingEngine/WebGLContextPool.ts @@ -1,5 +1,6 @@ import { vtkOffscreenMultiRenderWindow } from './vtkClasses'; import type { VtkOffscreenMultiRenderWindow } from '../types'; +import { attachWebGLContextEvents } from './helpers/attachWebGLContextEvents'; /** * Manages a pool of WebGL contexts for parallel rendering. @@ -18,14 +19,19 @@ class WebGLContextPool { /** * Creates a pool with the specified number of WebGL contexts * @param count - Number of contexts to create + * @param renderingEngineId - Owning engine id, carried on the + * WEBGL_CONTEXT_LOST / WEBGL_CONTEXT_RESTORED events emitted for these + * contexts */ - constructor(count: number) { + constructor(count: number, renderingEngineId = '') { for (let i = 0; i < count; i++) { const offscreenMultiRenderWindow = vtkOffscreenMultiRenderWindow.newInstance(); const container = document.createElement('div'); offscreenMultiRenderWindow.setContainer(container); + attachWebGLContextEvents(offscreenMultiRenderWindow, renderingEngineId, i); + this.contexts.push(offscreenMultiRenderWindow); this.offScreenCanvasContainers.push(container); } diff --git a/packages/core/src/RenderingEngine/helpers/attachWebGLContextEvents.ts b/packages/core/src/RenderingEngine/helpers/attachWebGLContextEvents.ts new file mode 100644 index 0000000000..8888a451a9 --- /dev/null +++ b/packages/core/src/RenderingEngine/helpers/attachWebGLContextEvents.ts @@ -0,0 +1,47 @@ +import eventTarget from '../../eventTarget'; +import { Events } from '../../enums'; +import triggerEvent from '../../utilities/triggerEvent'; +import type { VtkOffscreenMultiRenderWindow } from '../../types'; + +/** + * Forwards WebGL context lost/restored events from a rendering engine's + * offscreen canvas to the cornerstone eventTarget as WEBGL_CONTEXT_LOST / + * WEBGL_CONTEXT_RESTORED. + * + * Cornerstone never switches render backends on its own: these events exist + * so applications can detect GPU degradation, prompt the user, and call + * setRenderBackend('cpu') themselves. vtk.js keeps its own listeners on the + * same canvas (preventDefault + restore attempt), which these do not disturb. + */ +export function attachWebGLContextEvents( + offscreenMultiRenderWindow: VtkOffscreenMultiRenderWindow, + renderingEngineId: string, + contextIndex = 0 +): void { + const canvas = ( + offscreenMultiRenderWindow.getOpenGLRenderWindow?.() as { + getCanvas?: () => HTMLCanvasElement | undefined; + } + )?.getCanvas?.(); + + if (!canvas) { + return; + } + + canvas.addEventListener('webglcontextlost', () => { + console.warn( + `CornerstoneRender: WebGL context lost (renderingEngine=${renderingEngineId}, context=${contextIndex})` + ); + triggerEvent(eventTarget, Events.WEBGL_CONTEXT_LOST, { + renderingEngineId, + contextIndex, + }); + }); + + canvas.addEventListener('webglcontextrestored', () => { + triggerEvent(eventTarget, Events.WEBGL_CONTEXT_RESTORED, { + renderingEngineId, + contextIndex, + }); + }); +} diff --git a/packages/core/src/enums/Events.ts b/packages/core/src/enums/Events.ts index 0a64fe8a32..f25ce5f751 100644 --- a/packages/core/src/enums/Events.ts +++ b/packages/core/src/enums/Events.ts @@ -303,6 +303,46 @@ enum Events { * Triggers on the viewport's element when the actors are changed via set operator */ ACTORS_CHANGED = 'CORNERSTONE_ACTORS_CHANGED', + + /** + * Triggers on the eventTarget after the global render backend changes + * (setRenderBackend, or a deprecated CPU-rendering toggle that changed the + * effective backend). Detail: `{ previous, current, effectiveBackend, reason }`. + */ + RENDER_BACKEND_CHANGED = 'CORNERSTONE_RENDER_BACKEND_CHANGED', + + /** + * Triggers on the eventTarget when one of the rendering engine's WebGL + * contexts is lost. Cornerstone does not switch backends on its own; + * applications can listen for this to offer the user a switch to CPU + * rendering via setRenderBackend('cpu'). + * Detail: `{ renderingEngineId, contextIndex }`. + */ + WEBGL_CONTEXT_LOST = 'CORNERSTONE_WEBGL_CONTEXT_LOST', + + /** + * Triggers on the eventTarget when a previously lost WebGL context is + * restored by the browser. Detail: `{ renderingEngineId, contextIndex }`. + */ + WEBGL_CONTEXT_RESTORED = 'CORNERSTONE_WEBGL_CONTEXT_RESTORED', + + /** + * Triggers on the eventTarget when a GenericViewport render path throws + * while mounting or rendering data. Like WEBGL_CONTEXT_LOST this is a + * degradation signal for applications; no automatic backend switch happens. + * Detail: `{ renderingEngineId, viewportId, dataId, error }`. + */ + RENDER_PATH_ERROR = 'CORNERSTONE_RENDER_PATH_ERROR', + + /** + * Triggers on the eventTarget after a viewport finishes rebuilding its + * render paths in place (live render-backend switch via setRenderBackend, + * or a deprecated CPU-rendering toggle). The viewport's actors were + * replaced, so consumers that decorate them (e.g. segmentation + * representations) listen for this to re-reconcile and restyle their + * overlays. Detail: `{ renderingEngineId, viewportId }`. + */ + RENDERING_PIPELINE_CHANGED = 'CORNERSTONE_RENDERING_PIPELINE_CHANGED', } export default Events; diff --git a/packages/core/src/enums/RenderBackend.ts b/packages/core/src/enums/RenderBackend.ts new file mode 100644 index 0000000000..eefe6935b6 --- /dev/null +++ b/packages/core/src/enums/RenderBackend.ts @@ -0,0 +1,21 @@ +/** + * The rendering backend preference for planar GenericViewports, configured + * globally at `rendering.planar.renderBackend` or per display set via the + * `renderBackend` mount option. + * + * - `Auto` (default): the backend is resolved from the capability detection + * performed at `init()` (WebGL availability, texture-format probes) and the + * deprecated `useCPURendering` flag. + * - `GPU`: pin to GPU rendering. + * - `CPU`: pin to CPU rendering. + */ +enum RenderBackend { + Auto = 'auto', + GPU = 'gpu', + CPU = 'cpu', +} + +/** String-literal form accepted anywhere a {@link RenderBackend} is expected. */ +export type RenderBackendValue = `${RenderBackend}`; + +export default RenderBackend; diff --git a/packages/core/src/enums/index.ts b/packages/core/src/enums/index.ts index 4daa64a297..85f20bff5d 100644 --- a/packages/core/src/enums/index.ts +++ b/packages/core/src/enums/index.ts @@ -18,6 +18,7 @@ import MetadataModules from './MetadataModules'; import { GenerateImageType } from './GenerateImageType'; import VoxelManagerEnum from './VoxelManagerEnum'; import RenderingEngineModeEnum from './RenderingEngineModeEnum'; +import RenderBackend, { type RenderBackendValue } from './RenderBackend'; export { Events, @@ -41,4 +42,6 @@ export { VoxelManagerEnum, GenerateImageType, RenderingEngineModeEnum, + RenderBackend, + type RenderBackendValue, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31ce33644a..1eff5b020d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -151,6 +151,9 @@ import { setUseCPURendering, setPreferSizeOverAccuracy, resetUseCPURendering, + getRenderBackend, + setRenderBackend, + getEffectiveRenderBackend, getConfiguration, setConfiguration, getWebWorkerManager, @@ -158,6 +161,10 @@ import { peerImport, resetInitialization, } from './init'; +import { + getRenderingCapabilities, + detectRenderingCapabilities, +} from './utilities/renderingCapabilities'; // Classes import Settings from './Settings'; @@ -358,6 +365,12 @@ export { setUseCPURendering, setPreferSizeOverAccuracy, resetUseCPURendering, + // Render backend (gpu | cpu | auto) + capability detection + getRenderBackend, + setRenderBackend, + getEffectiveRenderBackend, + getRenderingCapabilities, + detectRenderingCapabilities, // GenericViewport getUseGenericViewport, // Geometry Loader diff --git a/packages/core/src/init.ts b/packages/core/src/init.ts index 40c7434fb1..db38e12bb6 100644 --- a/packages/core/src/init.ts +++ b/packages/core/src/init.ts @@ -3,12 +3,14 @@ let csRenderInitialized = false; import deepMerge from './utilities/deepMerge'; import type { Cornerstone3DConfig } from './types'; import CentralizedWebWorkerManager from './webWorkerManager/webWorkerManager'; -import { getSupportedTextureFormats } from './utilities/textureSupport'; -import { RenderingEngineModeEnum } from './enums'; +import { getRenderingCapabilities } from './utilities/renderingCapabilities'; +import triggerEvent from './utilities/triggerEvent'; +import eventTarget from './eventTarget'; +import { Events, RenderBackend, RenderingEngineModeEnum } from './enums'; +import type { RenderBackendValue } from './enums'; // TODO: change config into a class with methods to better control get/set const defaultConfig: Cornerstone3DConfig = { - gpuTier: { tier: 2 }, // Assume medium tier by default isMobile: false, // is mobile device rendering: { useCPURendering: false, @@ -30,18 +32,12 @@ const defaultConfig: Cornerstone3DConfig = { */ webGlContextCount: 7, planar: { - cpuThresholds: { - image: 64 * 1024 * 1024, - // No size-based CPU fallback for volumes by default. The GPU volume-slice - // path streams the volume through the OpenGL reslice mapper (which splits - // across MAX_TEXTURE_SIZE), so a normal diagnostic volume must render on - // the GPU just like the legacy ORTHOGRAPHIC viewport did. A finite cap - // here forced everyday MPR (e.g. 512x512x295 = ~77M voxels) onto the slow - // CPU_VOLUME path. CPU volume rendering stays available as an explicit - // opt-in: set a finite threshold here, or force it globally with - // setUseCPURendering(true). - volume: Infinity, - }, + /** + * Render backend for planar GenericViewports: 'gpu' | 'cpu' pin the + * backend, 'auto' resolves it from capability detection at init() (and + * the deprecated useCPURendering flag). See setRenderBackend(). + */ + renderBackend: RenderBackend.Auto, cpuVolume: { useViewportSamplingForLinear: true, volumeModifiedThrottleMs: 5000, @@ -77,35 +73,10 @@ let config: Cornerstone3DConfig = { }; let webWorkerManager: CentralizedWebWorkerManager | null = null; -let canUseNorm16Texture = false; - -function _getGLContext(): RenderingContext { - // Create canvas element. The canvas is not added to the - // document itself, so it is never displayed in the - // browser window. - const canvas = document.createElement('canvas'); - // Get WebGLRenderingContext from canvas element. - const gl = - canvas.getContext('webgl2') || - canvas.getContext('webgl') || - canvas.getContext('experimental-webgl'); - - return gl; -} - -// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/By_example/Detect_WebGL -function _hasActiveWebGLContext() { - const gl = _getGLContext(); - - // Check if the context is either WebGLRenderingContext or WebGL2RenderingContext - return ( - gl instanceof WebGLRenderingContext || gl instanceof WebGL2RenderingContext - ); -} function _hasNorm16TextureSupport() { - const supportedTextureFormats = getSupportedTextureFormats(); - return supportedTextureFormats.norm16 && supportedTextureFormats.norm16Linear; + const capabilities = getRenderingCapabilities(); + return capabilities.norm16 && capabilities.norm16Linear; } function isIOS() { @@ -121,18 +92,16 @@ function isIOS() { } /** - * Initialize the cornerstone-core. This function checks for WebGL context availability - * to determine if GPU rendering is possible. By default, it assumes a medium GPU tier. + * Initialize the cornerstone-core. This function runs the GPU capability + * detection (WebGL availability, texture-format probes -- cached across page + * loads, see `getRenderingCapabilities`) which the 'auto' render backend and + * texture-format decisions resolve against. * - * It's the responsibility of the consumer application to provide accurate GPU tier information - * if needed. Libraries like 'detect-gpu' can be used for this purpose, and the result can be - * passed in the configuration object. + * If no WebGL context is available, rendering falls back to the CPU for + * supported operations. * - * If a WebGL context is available, GPU rendering will be used. Otherwise, it will fall back - * to CPU rendering for supported operations. - * - * @param configuration - A configuration object, which can include GPU tier information - * @returns A promise that resolves to true if cornerstone has been initialized successfully. + * @param configuration - A configuration object + * @returns true if cornerstone has been initialized successfully. * @category Initialization */ function init(configuration = config): boolean { @@ -140,8 +109,6 @@ function init(configuration = config): boolean { return csRenderInitialized; } - canUseNorm16Texture = _hasNorm16TextureSupport(); - // merge configs config = deepMerge(defaultConfig, configuration); @@ -153,19 +120,25 @@ function init(configuration = config): boolean { if (isIOS()) { if (configuration.rendering?.preferSizeOverAccuracy) { config.rendering.preferSizeOverAccuracy = true; - } else { + } else if (!_hasNorm16TextureSupport()) { console.log( 'norm16 texture not supported, you can turn on the preferSizeOverAccuracy flag to use native data type, but be aware of the inaccuracy of the rendering in high bits' ); } } - const hasWebGLContext = _hasActiveWebGLContext(); - if (!hasWebGLContext) { + const capabilities = getRenderingCapabilities(); + if (!capabilities.webgl) { console.log('CornerstoneRender: GPU not detected, using CPU rendering'); config.rendering.useCPURendering = true; } else { console.log('CornerstoneRender: using GPU rendering'); + + if (capabilities.softwareRasterizer) { + console.log( + `CornerstoneRender: software rasterizer detected (${capabilities.renderer}), GPU rendering may be slow` + ); + } } csRenderInitialized = true; @@ -177,24 +150,138 @@ function init(configuration = config): boolean { return csRenderInitialized; } +/** + * Whether norm16 (16-bit normalized integer) textures are usable, based on + * the probed capability profile. + */ function getCanUseNorm16Texture(): boolean { - return canUseNorm16Texture; + return _hasNorm16TextureSupport(); } /** - * It sets the useCPURenderingOnlyForDebugOrTests variable to the status value. - * This only should be used for debugging or tests. DO NOT USE IT IF YOU ARE NOT - * SURE WHAT YOU ARE DOING. - * @param status - boolean + * Returns the configured render backend preference for planar + * GenericViewport-based viewports ('auto' | 'gpu' | 'cpu'), stored at + * `rendering.planar.renderBackend`. Use {@link getEffectiveRenderBackend} + * for the resolved gpu/cpu decision. * @category Initialization + */ +function getRenderBackend(): RenderBackend { + return ( + (config.rendering.planar?.renderBackend as RenderBackend) ?? + RenderBackend.Auto + ); +} + +/** + * Resolves the 'auto' backend: CPU when the deprecated useCPURendering flag + * is set (init() sets it when no WebGL context is available) and GPU + * otherwise. Exposed for per-display-set `renderBackend: 'auto'` overrides, + * which resolve against this regardless of the configured global pin. + * @category Initialization + */ +function resolveAutoRenderBackend(): RenderBackend.GPU | RenderBackend.CPU { + return config.rendering.useCPURendering + ? RenderBackend.CPU + : RenderBackend.GPU; +} + +/** + * Returns the effective render backend for GenericViewport-based viewports: + * the configured 'gpu'/'cpu' pin, or the resolved 'auto' decision. * + * Pass `override` to resolve a per-mount `renderBackend` option with the same + * precedence: 'gpu'/'cpu' pin the result, 'auto' resolves from capability + * detection even when the global backend is pinned, and undefined falls back + * to the global configuration. + * @category Initialization + */ +function getEffectiveRenderBackend( + override?: RenderBackend | RenderBackendValue +): RenderBackend.GPU | RenderBackend.CPU { + const backend = (override ?? getRenderBackend()) as RenderBackend; + + if (backend === RenderBackend.GPU || backend === RenderBackend.CPU) { + return backend; + } + + return resolveAutoRenderBackend(); +} + +/** + * Sets the global render backend for GenericViewport-based viewports and + * live-switches all mounted viewports to the new backend in place: viewport + * ids, mounted data, cameras, presentation state and tool annotations are + * preserved; only the render paths are rebuilt. The switch is reversible in + * both directions at runtime. + * + * Cornerstone never switches backends on its own. Applications listening to + * the degradation events (WEBGL_CONTEXT_LOST, RENDER_PATH_ERROR) are expected + * to call this, typically after prompting the user. + * + * Emits RENDER_BACKEND_CHANGED on the eventTarget when the value changes. + * + * @param backend - 'auto' | 'gpu' | 'cpu' + * @param reason - Optional human-readable reason carried on the change event + * (e.g. 'webgl-context-lost'). + * @category Initialization + */ +function setRenderBackend( + backend: RenderBackend | RenderBackendValue, + reason?: string +): void { + if ( + backend !== RenderBackend.Auto && + backend !== RenderBackend.GPU && + backend !== RenderBackend.CPU + ) { + throw new Error( + `[setRenderBackend] Invalid render backend: ${String(backend)}` + ); + } + + const previous = getRenderBackend(); + + if (previous === backend) { + return; + } + + // Replace (not mutate) the planar object: before init() it may still be + // the shared defaultConfig reference. Unlike the deprecated toggles, this + // must NOT mark the library initialized: a pre-init call would otherwise + // turn the later init(configuration) into a no-op, silently dropping the + // user's configuration and the no-WebGL CPU fallback. + config.rendering.planar = { + ...config.rendering.planar, + renderBackend: backend, + }; + _updateRenderingPipelinesForAllViewports(); + + triggerEvent(eventTarget, Events.RENDER_BACKEND_CHANGED, { + previous, + current: backend, + effectiveBackend: getEffectiveRenderBackend(), + reason, + }); +} + +/** + * Forces CPU rendering for legacy viewports. The 'auto' render backend also + * honors this flag, so calling it affects GenericViewport-based viewports + * unless they are pinned to 'gpu'/'cpu' via renderBackend. + * @param status - boolean + * @category Initialization + * @deprecated Use `setRenderBackend('cpu' | 'auto')` instead. */ function setUseCPURendering(status: boolean, updateViewports = true): void { + const previousEffective = getEffectiveRenderBackend(); + config.rendering.useCPURendering = status; csRenderInitialized = true; if (updateViewports) { _updateRenderingPipelinesForAllViewports(); } + + _notifyEffectiveBackendChange(previousEffective, 'setUseCPURendering'); } function setPreferSizeOverAccuracy(status: boolean): void { @@ -204,33 +291,44 @@ function setPreferSizeOverAccuracy(status: boolean): void { } /** - * Only IPhone IOS cannot render float textures right now due to the lack of support for OES_texture_float_linear. - * So we should not use float textures on IOS devices. + * Whether float (32-bit) textures can be linearly sampled, based on the + * probed capability profile (OES_texture_float_linear draw + readback). + * Historically this was a user-agent iOS check; environments where the probe + * cannot run (no WebGL context, e.g. unit tests, or WebGL1-only browsers -- + * the texture probes require WebGL2) keep the legacy user-agent behavior so + * data-preparation code paths stay deterministic there. */ function canRenderFloatTextures(): boolean { - if (!isIOS()) { - return true; + const capabilities = getRenderingCapabilities(); + + if (capabilities.webgl2) { + return capabilities.floatLinear; } - return false; + return !isIOS(); } /** * Resets the cornerstone-core init state if it has been manually * initialized to force use the cpu rendering (e.g., for tests) * @category Initialization - * + * @deprecated Use `setRenderBackend('auto')` instead. */ function resetUseCPURendering(): void { - config.rendering.useCPURendering = !_hasActiveWebGLContext(); + const previousEffective = getEffectiveRenderBackend(); + + config.rendering.useCPURendering = !getRenderingCapabilities().webgl; _updateRenderingPipelinesForAllViewports(); + + _notifyEffectiveBackendChange(previousEffective, 'resetUseCPURendering'); } /** - * Returns whether or not we are using CPU rendering. + * Returns whether or not we are using CPU rendering on legacy viewports. + * GenericViewport-based viewports resolve through + * {@link getEffectiveRenderBackend} instead. * @returns true if we are using CPU rendering. * @category Initialization - * */ function getShouldUseCPURendering(): boolean { return config.rendering.useCPURendering; @@ -280,17 +378,42 @@ function setConfiguration(c: Cornerstone3DConfig) { /** * Update rendering pipelines for all viewports in all rendering engines. + * Viewport families that do not support a live pipeline swap simply omit the + * hook. * @returns {void} * @category Initialization */ function _updateRenderingPipelinesForAllViewports(): void { getRenderingEngines().forEach((engine) => { engine.getViewports().forEach((viewport) => { - viewport.updateRenderingPipeline(); + viewport.updateRenderingPipeline?.(); }); }); } +/** + * Emits RENDER_BACKEND_CHANGED when a deprecated CPU-rendering toggle changed + * the effective backend (the configured value stays whatever it was, so the + * event carries the effective transition). + */ +function _notifyEffectiveBackendChange( + previousEffective: RenderBackend, + reason: string +): void { + const effectiveBackend = getEffectiveRenderBackend(); + + if (previousEffective === effectiveBackend) { + return; + } + + triggerEvent(eventTarget, Events.RENDER_BACKEND_CHANGED, { + previous: previousEffective, + current: effectiveBackend, + effectiveBackend, + reason, + }); +} + function getWebWorkerManager() { if (!webWorkerManager) { webWorkerManager = new CentralizedWebWorkerManager(); @@ -311,6 +434,10 @@ export { setUseCPURendering, setPreferSizeOverAccuracy, resetUseCPURendering, + getRenderBackend, + setRenderBackend, + getEffectiveRenderBackend, + resolveAutoRenderBackend, getConfiguration, setConfiguration, getWebWorkerManager, diff --git a/packages/core/src/types/Cornerstone3DConfig.ts b/packages/core/src/types/Cornerstone3DConfig.ts index 54c4f5e26c..98b5db8fe5 100644 --- a/packages/core/src/types/Cornerstone3DConfig.ts +++ b/packages/core/src/types/Cornerstone3DConfig.ts @@ -1,7 +1,7 @@ import type { RenderingEngineModeType } from '../types'; +import type { RenderBackend, RenderBackendValue } from '../enums'; interface Cornerstone3DConfig { - gpuTier?: { tier?: number }; /** * Whether the device is mobile or not. */ @@ -20,7 +20,18 @@ interface Cornerstone3DConfig { // Read more in the following Pull Request: // 1. HalfFloat: https://github.com/Kitware/vtk-js/pull/2046 // 2. Norm16: https://github.com/Kitware/vtk-js/pull/2058 + // + // Applies to the legacy volume-actor pipeline; GenericViewport render + // paths resolve texture formats through the probed capability profile + // (see utilities/renderingCapabilities) and do not consult this flag. preferSizeOverAccuracy?: boolean; + /** + * Forces CPU rendering for legacy viewports. + * @deprecated For GenericViewport-based viewports use + * `renderBackend: 'cpu'` instead. This flag remains the control for + * legacy viewports and is still honored by the 'auto' backend + * resolution. + */ useCPURendering?: boolean; /** * Use the legacy camera field of view calculation method which uses bounds @@ -52,10 +63,16 @@ interface Cornerstone3DConfig { */ webGlContextCount?: number; planar?: { - cpuThresholds?: { - image?: number; - volume?: number; - }; + /** + * The render backend preference for planar GenericViewports: 'gpu' and + * 'cpu' pin the backend, 'auto' (default) resolves it from the + * capability detection performed at init() and the deprecated + * useCPURendering flag. Per-display-set `renderBackend` mount options + * override this value; use setRenderBackend() to change it at runtime + * with a live render-path swap. Legacy viewports (StackViewport et al.) + * keep reading `useCPURendering` and are not governed by this flag. + */ + renderBackend?: RenderBackend | RenderBackendValue; cpuVolume?: { /** * When true, LINEAR CPU volume slices are sampled into a viewport-sized diff --git a/packages/core/src/utilities/renderingCapabilities.ts b/packages/core/src/utilities/renderingCapabilities.ts new file mode 100644 index 0000000000..5b6a559adb --- /dev/null +++ b/packages/core/src/utilities/renderingCapabilities.ts @@ -0,0 +1,240 @@ +import type { TextureFormatSupport } from './textureSupport'; +import { getSupportedTextureFormats } from './textureSupport'; + +/** + * Bump when the probes (or the meaning of any profile field) change so that + * profiles cached by earlier versions are discarded. + */ +export const RENDERING_CAPABILITIES_PROBE_VERSION = 1; + +const STORAGE_KEY = 'cornerstone3D.renderingCapabilities'; + +const SOFTWARE_RASTERIZER_PATTERN = + /swiftshader|llvmpipe|softpipe|software|microsoft basic render/i; + +/** + * The GPU capability profile detected through offscreen WebGL probes. + * + * This is the single source the rendering configuration consults instead of + * scattered per-feature checks: backend selection reads `webgl`/`webgl2`, + * texture-format decisions read the {@link TextureFormatSupport} flags, and + * `renderer`/`softwareRasterizer` let applications surface or log degraded + * environments (e.g. SwiftShader after a driver denylist hit). + */ +export interface RenderingCapabilities extends TextureFormatSupport { + /** Any WebGL context (1 or 2) could be created. */ + webgl: boolean; + /** A WebGL2 context could be created. */ + webgl2: boolean; + /** MAX_TEXTURE_SIZE of the probed context, 0 when no context exists. */ + maxTextureSize: number; + /** Unmasked renderer string when exposed by the browser, '' otherwise. */ + renderer: string; + /** True when the renderer string identifies a software rasterizer. */ + softwareRasterizer: boolean; +} + +interface WebGLContextInfo { + webgl: boolean; + webgl2: boolean; + maxTextureSize: number; + renderer: string; +} + +interface CachedCapabilities { + probeVersion: number; + renderer: string; + webgl2: boolean; + formats: TextureFormatSupport; +} + +const NO_GPU_FORMATS: TextureFormatSupport = { + norm16: false, + norm16Linear: false, + float: false, + floatLinear: false, + halfFloat: false, + halfFloatLinear: false, +}; + +let cachedCapabilities: RenderingCapabilities | null = null; + +function getWebGLContextInfo(): WebGLContextInfo { + const info: WebGLContextInfo = { + webgl: false, + webgl2: false, + maxTextureSize: 0, + renderer: '', + }; + + if (typeof document === 'undefined') { + return info; + } + + try { + const canvas = document.createElement('canvas'); + const gl2 = canvas.getContext('webgl2'); + const gl = + gl2 || + (canvas.getContext('webgl') as WebGLRenderingContext | null) || + (canvas.getContext('experimental-webgl') as WebGLRenderingContext | null); + + if (!gl) { + return info; + } + + info.webgl = true; + info.webgl2 = !!gl2; + info.maxTextureSize = Number(gl.getParameter(gl.MAX_TEXTURE_SIZE)) || 0; + + // Modern browsers expose the unmasked renderer through RENDERER directly; + // older ones require the WEBGL_debug_renderer_info extension. + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + const renderer = debugInfo + ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER); + + info.renderer = typeof renderer === 'string' ? renderer : ''; + + const loseContext = gl.getExtension('WEBGL_lose_context'); + if (loseContext) { + loseContext.loseContext(); + } + } catch { + // A throwing context factory is equivalent to "no GPU". + } + + return info; +} + +function readCachedFormats( + renderer: string, + webgl2: boolean +): TextureFormatSupport | null { + try { + const raw = window.localStorage?.getItem(STORAGE_KEY); + + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as CachedCapabilities; + + // The texture probes require WebGL2, so a profile cached on a WebGL1-only + // run is all-false; invalidate it when WebGL2 availability changes for + // the same renderer (browser update/flag) instead of pinning it forever. + if ( + parsed?.probeVersion !== RENDERING_CAPABILITIES_PROBE_VERSION || + parsed?.renderer !== renderer || + parsed?.webgl2 !== webgl2 || + typeof parsed?.formats !== 'object' || + parsed?.formats === null + ) { + return null; + } + + return { ...NO_GPU_FORMATS, ...parsed.formats }; + } catch { + return null; + } +} + +function writeCachedFormats( + renderer: string, + webgl2: boolean, + formats: TextureFormatSupport +): void { + try { + const payload: CachedCapabilities = { + probeVersion: RENDERING_CAPABILITIES_PROBE_VERSION, + renderer, + webgl2, + formats, + }; + + window.localStorage?.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { + // Storage may be unavailable (privacy mode, quota); probing every load is + // the acceptable fallback. + } +} + +/** + * Runs the capability detection: one cheap context to gather renderer string, + * WebGL level and MAX_TEXTURE_SIZE, then the texture-format probes. + * + * Probe results are cached in localStorage keyed by renderer string, WebGL2 + * availability, and probe version, so repeat page loads on the same GPU skip + * the probe contexts entirely. Pass `useCache: false` to force a fresh probe + * run (also refreshes the stored cache). + */ +export function detectRenderingCapabilities({ + useCache = true, +}: { useCache?: boolean } = {}): RenderingCapabilities { + const contextInfo = getWebGLContextInfo(); + + if (!contextInfo.webgl) { + return { + ...contextInfo, + ...NO_GPU_FORMATS, + softwareRasterizer: false, + }; + } + + let formats = useCache + ? readCachedFormats(contextInfo.renderer, contextInfo.webgl2) + : null; + + if (!formats) { + const probed = getSupportedTextureFormats(); + + // A null probe result means the probes could not run (context limit hit, + // GPU process restarting, context lost mid-probe) — report no format + // support for this load but do NOT persist it, so a transient bad run + // costs one re-probe next load instead of poisoning the cache under an + // otherwise valid renderer key. + if (probed) { + writeCachedFormats(contextInfo.renderer, contextInfo.webgl2, probed); + } + + formats = probed ?? { ...NO_GPU_FORMATS }; + } + + return { + ...contextInfo, + ...formats, + softwareRasterizer: SOFTWARE_RASTERIZER_PATTERN.test(contextInfo.renderer), + }; +} + +/** + * Returns the memoized capability profile, detecting it on first access. + * This is the accessor the rest of the library (backend resolution, texture + * format selection) reads. + */ +export function getRenderingCapabilities(): RenderingCapabilities { + if (!cachedCapabilities) { + cachedCapabilities = detectRenderingCapabilities(); + } + + return cachedCapabilities; +} + +/** + * Drops the in-memory profile (and optionally the persisted cache) so the + * next {@link getRenderingCapabilities} call re-detects. Intended for tests + * and for applications that want to re-probe after a GPU change. + */ +export function resetRenderingCapabilities({ + clearStorage = false, +}: { clearStorage?: boolean } = {}): void { + cachedCapabilities = null; + + if (clearStorage) { + try { + window.localStorage?.removeItem(STORAGE_KEY); + } catch { + // Ignore storage failures on reset. + } + } +} diff --git a/packages/core/src/utilities/textureSupport.ts b/packages/core/src/utilities/textureSupport.ts index abd0258f9b..37716d20a1 100644 --- a/packages/core/src/utilities/textureSupport.ts +++ b/packages/core/src/utilities/textureSupport.ts @@ -3,14 +3,43 @@ const texWidth = 5; const texHeight = 1; const pixelToCheck = [1, 1]; -function main({ ext, filterType, texData, internalFormat, glDataType }) { +/** + * Per-format results of the offscreen WebGL texture probes. Each flag is true + * only when a real draw + readback through that texture format produced the + * expected pixels, so a browser that merely advertises an extension but + * renders garbage through it (e.g. broken EXT_texture_norm16 on some GPUs) + * still reports false. + */ +export interface TextureFormatSupport { + norm16: boolean; + norm16Linear: boolean; + float: boolean; + floatLinear: boolean; + halfFloat: boolean; + halfFloatLinear: boolean; +} + +interface FormatProbe { + ext?: string; + filterType: 'NEAREST' | 'LINEAR'; + texData: ArrayBufferView; + internalFormat: (gl: WebGL2RenderingContext, ext?) => number; + glDataType: (gl: WebGL2RenderingContext, ext?) => number; +} + +/** + * Creates the single offscreen context and point-sprite program shared by all + * format probes. Context creation and shader compilation dominate the probe + * cost, so they are paid once rather than once per format. + */ +function createProbeContext(): WebGL2RenderingContext | null { try { const canvas = document.createElement('canvas'); canvas.width = canvasSize; canvas.height = canvasSize; const gl = canvas.getContext('webgl2'); if (!gl) { - return false; + return null; } const vs = `#version 300 es @@ -34,26 +63,18 @@ function main({ ext, filterType, texData, internalFormat, glDataType }) { } `; - let extToUse; - if (ext) { - extToUse = gl.getExtension(ext); - if (!extToUse) { - return false; - } - } - const vertexShader = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vertexShader, vs); gl.compileShader(vertexShader); if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) { - return false; + return null; } const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(fragmentShader, fs); gl.compileShader(fragmentShader); if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) { - return false; + return null; } const program = gl.createProgram(); @@ -62,7 +83,28 @@ function main({ ext, filterType, texData, internalFormat, glDataType }) { gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - return false; + return null; + } + + gl.useProgram(program); + + return gl; + } catch (e) { + return null; + } +} + +function probeFormat( + gl: WebGL2RenderingContext, + { ext, filterType, texData, internalFormat, glDataType }: FormatProbe +): boolean { + try { + let extToUse; + if (ext) { + extToUse = gl.getExtension(ext); + if (!extToUse) { + return false; + } } const tex = gl.createTexture(); @@ -82,7 +124,11 @@ function main({ ext, filterType, texData, internalFormat, glDataType }) { const filter = filterType === 'LINEAR' ? gl.LINEAR : gl.NEAREST; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); - gl.useProgram(program); + + // Clear to black (which fails the readback check) so pixels drawn by a + // previous probe on the shared framebuffer cannot leak into this result. + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.POINTS, 0, 1); const pixel = new Uint8Array(4); @@ -97,10 +143,7 @@ function main({ ext, filterType, texData, internalFormat, glDataType }) { ); const [r, g, b] = pixel; - const webglLoseContext = gl.getExtension('WEBGL_lose_context'); - if (webglLoseContext) { - webglLoseContext.loseContext(); - } + gl.deleteTexture(tex); return r === g && g === b && r !== 0; } catch (e) { @@ -108,53 +151,77 @@ function main({ ext, filterType, texData, internalFormat, glDataType }) { } } -export function getSupportedTextureFormats() { +/** + * Probes each texture format through a real draw + readback. Returns `null` + * when the probes could not run at all (probe context creation failed — e.g. + * the browser's live-context limit was hit — or the context was lost + * mid-probe), so callers can tell "probing failed this run" apart from "the + * GPU genuinely does not support these formats" and avoid persisting the + * former. + */ +export function getSupportedTextureFormats(): TextureFormatSupport | null { + const gl = createProbeContext(); + if (!gl) { + return null; + } + const norm16TexData = new Int16Array([ 32767, 2000, 3000, 4000, 5000, 16784, 7000, 8000, 9000, 32767, ]); - // const floatTexData = new Float32Array([0.3, 0.2, 0.3, 0.4, 0.5]); - // const halfFloatTexData = new Uint16Array([13517, 12902, 13517, 13926, 14336]); + const floatTexData = new Float32Array([0.3, 0.2, 0.3, 0.4, 0.5]); + const halfFloatTexData = new Uint16Array([13517, 12902, 13517, 13926, 14336]); - return { - norm16: main({ + const result: TextureFormatSupport = { + norm16: probeFormat(gl, { ext: 'EXT_texture_norm16', filterType: 'NEAREST', texData: norm16TexData, - internalFormat: (gl, ext) => ext.R16_SNORM_EXT, - glDataType: (gl) => gl.SHORT, + internalFormat: (_gl, ext) => ext.R16_SNORM_EXT, + glDataType: (_gl) => _gl.SHORT, }), - norm16Linear: main({ + norm16Linear: probeFormat(gl, { ext: 'EXT_texture_norm16', filterType: 'LINEAR', texData: norm16TexData, - internalFormat: (gl, ext) => ext.R16_SNORM_EXT, - glDataType: (gl) => gl.SHORT, + internalFormat: (_gl, ext) => ext.R16_SNORM_EXT, + glDataType: (_gl) => _gl.SHORT, + }), + float: probeFormat(gl, { + filterType: 'NEAREST', + texData: floatTexData, + internalFormat: (_gl) => _gl.R32F, + glDataType: (_gl) => _gl.FLOAT, + }), + floatLinear: probeFormat(gl, { + ext: 'OES_texture_float_linear', + filterType: 'LINEAR', + texData: floatTexData, + internalFormat: (_gl) => _gl.R32F, + glDataType: (_gl) => _gl.FLOAT, + }), + halfFloat: probeFormat(gl, { + filterType: 'NEAREST', + texData: halfFloatTexData, + internalFormat: (_gl) => _gl.R16F, + glDataType: (_gl) => _gl.HALF_FLOAT, + }), + halfFloatLinear: probeFormat(gl, { + filterType: 'LINEAR', + texData: halfFloatTexData, + internalFormat: (_gl) => _gl.R16F, + glDataType: (_gl) => _gl.HALF_FLOAT, }), - // float: main({ - // filterType: 'NEAREST', - // texData: floatTexData, - // internalFormat: (gl) => gl.R16F, - // glDataType: (gl) => gl.FLOAT, - // }), - // floatLinear: main({ - // ext: 'OES_texture_float_linear', - // filterType: 'LINEAR', - // texData: floatTexData, - // internalFormat: (gl) => gl.R16F, - // glDataType: (gl) => gl.FLOAT, - // }), - // halfFloat: main({ - // filterType: 'NEAREST', - // texData: halfFloatTexData, - // internalFormat: (gl) => gl.R16F, - // glDataType: (gl) => gl.HALF_FLOAT, - // }), - // halfFloatLinear: main({ - // filterType: 'LINEAR', - // texData: halfFloatTexData, - // internalFormat: (gl) => gl.R16F, - // glDataType: (gl) => gl.HALF_FLOAT, - // }), }; + + // A context lost during the probes yields spurious all-false results + // (readPixels returns zeros); report failure instead of fake answers. + const contextLost = gl.isContextLost(); + + const webglLoseContext = gl.getExtension('WEBGL_lose_context'); + if (webglLoseContext) { + webglLoseContext.loseContext(); + } + + return contextLost ? null : result; } diff --git a/packages/core/test/planarComputedCamera.jest.js b/packages/core/test/planarComputedCamera.jest.js index 35c7639d6f..146f1db941 100644 --- a/packages/core/test/planarComputedCamera.jest.js +++ b/packages/core/test/planarComputedCamera.jest.js @@ -12,6 +12,7 @@ import { resolvePlanarRenderPathProjection, resolvePlanarStackImageIdIndex, } from '../src/RenderingEngine/GenericViewport/Planar'; +import { CpuImageSliceRenderPath } from '../src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath'; import { resolvePlanarCpuImageDisplayedArea } from '../src/RenderingEngine/GenericViewport/Planar/planarCpuViewportMath'; function createImage(imageId = 'image-1') { @@ -582,3 +583,55 @@ describe('Planar resolved cameras', () => { expect(camera.getFrameOfReferenceUID()).toBe('volume-for'); }); }); + +describe('Planar CPU image render path', () => { + it('passes PET modality into the CPU fallback viewport for prescaled stacks', async () => { + const canvas = document.createElement('canvas'); + const image = createImage('pet-image'); + + Object.defineProperties(canvas, { + clientHeight: { configurable: true, value: 64 }, + clientWidth: { configurable: true, value: 64 }, + }); + + image.preScale = { + enabled: true, + scaled: true, + scalingParameters: { + modality: 'PT', + suvbw: 1, + }, + }; + + const renderPath = new CpuImageSliceRenderPath(); + const attachment = await renderPath.addData( + { + viewportId: 'viewport', + renderingEngineId: 'rendering-engine', + viewport: { + element: document.createElement('div'), + }, + display: { + activateRenderMode: jest.fn(), + }, + cpu: { + canvas, + }, + }, + { + id: 'pet-stack', + type: 'image', + image, + imageIds: [image.imageId], + initialImageIdIndex: 0, + }, + {} + ); + + expect(attachment.rendering.enabledElement.viewport.modality).toBe('PT'); + expect(attachment.rendering.enabledElement.viewport.voi).toEqual({ + windowCenter: 2.5, + windowWidth: 5, + }); + }); +}); diff --git a/packages/core/test/planarRenderBackend.jest.js b/packages/core/test/planarRenderBackend.jest.js new file mode 100644 index 0000000000..12bd18e75d --- /dev/null +++ b/packages/core/test/planarRenderBackend.jest.js @@ -0,0 +1,203 @@ +import { ActorRenderMode } from '../src/types'; +import { Events, RenderBackend } from '../src/enums'; +import { + getEffectiveRenderBackend, + getRenderBackend, + isCornerstoneInitialized, + resetInitialization, + setRenderBackend, + setUseCPURendering, +} from '../src/init'; +import eventTarget from '../src/eventTarget'; +import cache from '../src/cache/cache'; +import { PlanarRenderPathDecisionService } from '../src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService'; + +const IMAGE_DATASET = { imageIds: ['plain-img-1'] }; +const VOLUME_DATASET = { + imageIds: ['vol-img-1', 'vol-img-2'], + volumeId: 'vol-1', +}; + +describe('Planar render backend resolution', () => { + let service; + let getVolumeSpy; + + beforeEach(() => { + service = new PlanarRenderPathDecisionService(); + getVolumeSpy = jest + .spyOn(cache, 'getVolume') + .mockImplementation((volumeId) => + volumeId === 'vol-1' ? { volumeId } : undefined + ); + }); + + afterEach(() => { + getVolumeSpy.mockRestore(); + setUseCPURendering(false, false); + setRenderBackend(RenderBackend.Auto); + eventTarget.reset(); + }); + + describe('image path', () => { + it('selects the GPU image path by default (auto, no CPU flag)', () => { + const { renderMode } = service.select(IMAGE_DATASET); + expect(renderMode).toBe(ActorRenderMode.VTK_IMAGE); + }); + + it('selects the CPU image path when the global backend is pinned to cpu', () => { + setRenderBackend('cpu'); + const { renderMode } = service.select(IMAGE_DATASET); + expect(renderMode).toBe(ActorRenderMode.CPU_IMAGE); + }); + + it('honors the deprecated useCPURendering flag under auto', () => { + setUseCPURendering(true, false); + const { renderMode } = service.select(IMAGE_DATASET); + expect(renderMode).toBe(ActorRenderMode.CPU_IMAGE); + }); + + it('lets a global gpu pin win over the deprecated CPU flag', () => { + setUseCPURendering(true, false); + setRenderBackend('gpu'); + const { renderMode } = service.select(IMAGE_DATASET); + expect(renderMode).toBe(ActorRenderMode.VTK_IMAGE); + }); + + it('lets a per-mount cpu pin win over a global gpu pin', () => { + setRenderBackend('gpu'); + const { renderMode } = service.select(IMAGE_DATASET, { + renderBackend: 'cpu', + }); + expect(renderMode).toBe(ActorRenderMode.CPU_IMAGE); + }); + + it('lets a per-mount gpu pin win over a global cpu pin', () => { + setRenderBackend('cpu'); + const { renderMode } = service.select(IMAGE_DATASET, { + renderBackend: 'gpu', + }); + expect(renderMode).toBe(ActorRenderMode.VTK_IMAGE); + }); + + it('resolves a per-mount auto from capability detection even under a global cpu pin', () => { + setRenderBackend('cpu'); + const { renderMode } = service.select(IMAGE_DATASET, { + renderBackend: 'auto', + }); + expect(renderMode).toBe(ActorRenderMode.VTK_IMAGE); + }); + }); + + describe('volume path', () => { + it('selects the GPU volume slice path by default', () => { + const { renderMode } = service.select(VOLUME_DATASET); + expect(renderMode).toBe(ActorRenderMode.VTK_VOLUME_SLICE); + }); + + it('honors a per-mount cpu pin for volume datasets', () => { + const { renderMode } = service.select(VOLUME_DATASET, { + renderBackend: 'cpu', + }); + expect(renderMode).toBe(ActorRenderMode.CPU_VOLUME); + }); + + it('honors a per-mount gpu pin over a global cpu pin for volume datasets', () => { + setRenderBackend('cpu'); + const { renderMode } = service.select(VOLUME_DATASET, { + renderBackend: 'gpu', + }); + expect(renderMode).toBe(ActorRenderMode.VTK_VOLUME_SLICE); + }); + + it('selects the CPU volume path with a global cpu pin', () => { + setRenderBackend('cpu'); + const { renderMode } = service.select(VOLUME_DATASET); + expect(renderMode).toBe(ActorRenderMode.CPU_VOLUME); + }); + }); + + describe('setRenderBackend', () => { + it('updates the configured and effective backends', () => { + expect(getRenderBackend()).toBe(RenderBackend.Auto); + expect(getEffectiveRenderBackend()).toBe(RenderBackend.GPU); + + setRenderBackend('cpu'); + + expect(getRenderBackend()).toBe(RenderBackend.CPU); + expect(getEffectiveRenderBackend()).toBe(RenderBackend.CPU); + }); + + it('rejects invalid values', () => { + expect(() => setRenderBackend('turbo')).toThrow(); + }); + + it('does not mark cornerstone initialized when called before init()', () => { + resetInitialization(); + + setRenderBackend('cpu'); + + // A pre-init setRenderBackend must leave init() able to run: marking + // the library initialized here would turn the later init(configuration) + // into a no-op and silently drop the user's configuration. + expect(isCornerstoneInitialized()).toBe(false); + expect(getRenderBackend()).toBe(RenderBackend.CPU); + }); + + it('emits RENDER_BACKEND_CHANGED with previous/current/effective detail', () => { + const listener = jest.fn(); + eventTarget.addEventListener(Events.RENDER_BACKEND_CHANGED, listener); + + setRenderBackend('cpu', 'unit-test'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].detail).toEqual({ + previous: RenderBackend.Auto, + current: RenderBackend.CPU, + effectiveBackend: RenderBackend.CPU, + reason: 'unit-test', + }); + + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); + }); + + it('does not emit when the backend does not change', () => { + setRenderBackend('cpu'); + + const listener = jest.fn(); + eventTarget.addEventListener(Events.RENDER_BACKEND_CHANGED, listener); + + setRenderBackend('cpu'); + + expect(listener).not.toHaveBeenCalled(); + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); + }); + + it('emits when the deprecated setUseCPURendering changes the effective backend', () => { + const listener = jest.fn(); + eventTarget.addEventListener(Events.RENDER_BACKEND_CHANGED, listener); + + setUseCPURendering(true); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].detail).toMatchObject({ + previous: RenderBackend.GPU, + current: RenderBackend.CPU, + reason: 'setUseCPURendering', + }); + + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); + }); + + it('does not emit for setUseCPURendering under an explicit gpu pin', () => { + setRenderBackend('gpu'); + + const listener = jest.fn(); + eventTarget.addEventListener(Events.RENDER_BACKEND_CHANGED, listener); + + setUseCPURendering(true); + + expect(listener).not.toHaveBeenCalled(); + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); + }); + }); +}); diff --git a/packages/core/test/renderingCapabilities.jest.js b/packages/core/test/renderingCapabilities.jest.js new file mode 100644 index 0000000000..aacad2d751 --- /dev/null +++ b/packages/core/test/renderingCapabilities.jest.js @@ -0,0 +1,208 @@ +import { + detectRenderingCapabilities, + getRenderingCapabilities, + resetRenderingCapabilities, + RENDERING_CAPABILITIES_PROBE_VERSION, +} from '../src/utilities/renderingCapabilities'; +import { getSupportedTextureFormats } from '../src/utilities/textureSupport'; + +jest.mock('../src/utilities/textureSupport', () => ({ + getSupportedTextureFormats: jest.fn(), +})); + +const STORAGE_KEY = 'cornerstone3D.renderingCapabilities'; + +const ALL_FORMATS = { + norm16: true, + norm16Linear: true, + float: true, + floatLinear: true, + halfFloat: true, + halfFloatLinear: true, +}; + +const MAX_TEXTURE_SIZE_PARAM = 0x0d33; +const RENDERER_PARAM = 0x1f01; + +function createFakeGL(renderer) { + return { + MAX_TEXTURE_SIZE: MAX_TEXTURE_SIZE_PARAM, + RENDERER: RENDERER_PARAM, + getParameter: (param) => { + if (param === MAX_TEXTURE_SIZE_PARAM) { + return 16384; + } + if (param === 'unmasked-renderer' || param === RENDERER_PARAM) { + return renderer; + } + return null; + }, + getExtension: (name) => { + if (name === 'WEBGL_debug_renderer_info') { + return { UNMASKED_RENDERER_WEBGL: 'unmasked-renderer' }; + } + if (name === 'WEBGL_lose_context') { + return { loseContext: () => undefined }; + } + return null; + }, + }; +} + +describe('renderingCapabilities', () => { + let getContextSpy; + + function mockWebGL(renderer = 'NVIDIA GeForce RTX 3080') { + const gl = createFakeGL(renderer); + getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation((type) => (type === 'webgl2' ? gl : null)); + } + + beforeEach(() => { + window.localStorage.clear(); + resetRenderingCapabilities(); + getSupportedTextureFormats.mockReset(); + getSupportedTextureFormats.mockReturnValue({ ...ALL_FORMATS }); + }); + + afterEach(() => { + getContextSpy?.mockRestore(); + getContextSpy = undefined; + resetRenderingCapabilities({ clearStorage: true }); + }); + + it('returns an all-false profile when no WebGL context is available', () => { + const capabilities = detectRenderingCapabilities(); + + expect(capabilities.webgl).toBe(false); + expect(capabilities.webgl2).toBe(false); + expect(capabilities.norm16).toBe(false); + expect(capabilities.maxTextureSize).toBe(0); + expect(getSupportedTextureFormats).not.toHaveBeenCalled(); + }); + + it('runs the probes and merges context info on a cache miss', () => { + mockWebGL(); + + const capabilities = detectRenderingCapabilities(); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(1); + expect(capabilities).toMatchObject({ + webgl: true, + webgl2: true, + maxTextureSize: 16384, + renderer: 'NVIDIA GeForce RTX 3080', + softwareRasterizer: false, + ...ALL_FORMATS, + }); + }); + + it('persists probe results and skips probing on the next detection', () => { + mockWebGL(); + + detectRenderingCapabilities(); + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(1); + + const second = detectRenderingCapabilities(); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(1); + expect(second.norm16).toBe(true); + }); + + it('re-probes when the renderer string changes', () => { + mockWebGL('Renderer A'); + detectRenderingCapabilities(); + getContextSpy.mockRestore(); + + mockWebGL('Renderer B'); + detectRenderingCapabilities(); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(2); + }); + + it('re-probes when WebGL2 availability changes for the same renderer', () => { + const gl = createFakeGL('Shared Renderer'); + getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation((type) => (type === 'webgl' ? gl : null)); + + detectRenderingCapabilities(); + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(1); + getContextSpy.mockRestore(); + + mockWebGL('Shared Renderer'); + detectRenderingCapabilities(); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(2); + }); + + it('re-probes when the cached probe version is stale', () => { + mockWebGL(); + detectRenderingCapabilities(); + + const cached = JSON.parse(window.localStorage.getItem(STORAGE_KEY)); + expect(cached.probeVersion).toBe(RENDERING_CAPABILITIES_PROBE_VERSION); + + cached.probeVersion = RENDERING_CAPABILITIES_PROBE_VERSION - 1; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cached)); + + detectRenderingCapabilities(); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(2); + }); + + it('re-probes when useCache is false', () => { + mockWebGL(); + detectRenderingCapabilities(); + detectRenderingCapabilities({ useCache: false }); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(2); + }); + + it('reports no format support without persisting when probing fails', () => { + mockWebGL(); + getSupportedTextureFormats.mockReturnValue(null); + + const capabilities = detectRenderingCapabilities(); + + expect(capabilities.webgl).toBe(true); + expect(capabilities.norm16).toBe(false); + expect(capabilities.float).toBe(false); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('re-probes on the next detection after a failed probe run', () => { + mockWebGL(); + getSupportedTextureFormats.mockReturnValueOnce(null); + + detectRenderingCapabilities(); + const second = detectRenderingCapabilities(); + + expect(getSupportedTextureFormats).toHaveBeenCalledTimes(2); + expect(second.norm16).toBe(true); + expect(window.localStorage.getItem(STORAGE_KEY)).not.toBeNull(); + }); + + it('flags software rasterizers from the renderer string', () => { + mockWebGL('Google SwiftShader'); + + const capabilities = detectRenderingCapabilities(); + + expect(capabilities.softwareRasterizer).toBe(true); + }); + + it('memoizes through getRenderingCapabilities until reset', () => { + mockWebGL(); + + const first = getRenderingCapabilities(); + const second = getRenderingCapabilities(); + + expect(second).toBe(first); + + resetRenderingCapabilities(); + + const third = getRenderingCapabilities(); + expect(third).not.toBe(first); + }); +}); diff --git a/packages/tools/src/eventListeners/segmentation/renderingPipelineChangedListener.ts b/packages/tools/src/eventListeners/segmentation/renderingPipelineChangedListener.ts new file mode 100644 index 0000000000..da2be4ff1e --- /dev/null +++ b/packages/tools/src/eventListeners/segmentation/renderingPipelineChangedListener.ts @@ -0,0 +1,28 @@ +import { triggerSegmentationRender } from '../../stateManagement/segmentation/SegmentationRenderingEngine'; +import { getSegmentationRepresentations } from '../../stateManagement/segmentation/getSegmentationRepresentation'; + +/** + * Listens to the core RENDERING_PIPELINE_CHANGED event, fired after a viewport + * rebuilds its render paths in place (live render-backend switch). The rebuild + * replaces the viewport's actors, so any segmentation representations mounted + * on it must be re-reconciled and restyled against the new actor instances -- + * without this, remounted labelmap overlays render unstyled (invisible). + * @param evt - The RENDERING_PIPELINE_CHANGED event object + */ +const renderingPipelineChangedListener = function (evt: CustomEvent): void { + const viewportId = evt.detail?.viewportId as string | undefined; + + if (!viewportId) { + return; + } + + const representations = getSegmentationRepresentations(viewportId); + + if (!representations?.length) { + return; + } + + triggerSegmentationRender(viewportId); +}; + +export default renderingPipelineChangedListener; diff --git a/packages/tools/src/init.ts b/packages/tools/src/init.ts index 1fb83d04b9..83ceca239b 100644 --- a/packages/tools/src/init.ts +++ b/packages/tools/src/init.ts @@ -19,6 +19,7 @@ import segmentationRepresentationModifiedListener from './eventListeners/segment import { setConfig } from './config'; import type { Config } from './config'; import segmentationRemovedListener from './eventListeners/segmentation/segmentationRemovedEventListener'; +import renderingPipelineChangedListener from './eventListeners/segmentation/renderingPipelineChangedListener'; import { registerBuiltInSegmentationRepresentationDisplays } from './tools/displayTools/registerBuiltInSegmentationRepresentationDisplays'; let csToolsInitialized = false; @@ -82,6 +83,12 @@ function _addCornerstoneEventListeners(): void { eventTarget.addEventListener(elementEnabledEvent, addEnabledElement); eventTarget.addEventListener(elementDisabledEvent, removeEnabledElement); + // A live render-backend switch rebuilds viewport render paths in place; + // segmentation representations must re-reconcile against the new actors. + eventTarget.addEventListener( + Enums.Events.RENDERING_PIPELINE_CHANGED, + renderingPipelineChangedListener + ); annotationInterpolationEventDispatcher.enable(); } @@ -96,6 +103,10 @@ function _removeCornerstoneEventListeners(): void { eventTarget.removeEventListener(elementEnabledEvent, addEnabledElement); eventTarget.removeEventListener(elementDisabledEvent, removeEnabledElement); + eventTarget.removeEventListener( + Enums.Events.RENDERING_PIPELINE_CHANGED, + renderingPipelineChangedListener + ); annotationInterpolationEventDispatcher.disable(); } diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/createLabelmapRenderPlan.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/createLabelmapRenderPlan.ts index ddf636106b..02ba5c5082 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/createLabelmapRenderPlan.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/createLabelmapRenderPlan.ts @@ -14,13 +14,17 @@ function createLabelmapRenderPlan({ viewport, canRenderCurrentViewport = () => kind !== 'unsupported', getExpectedRepresentationUIDs = () => [], + isActorEntryCompatible = () => true, mount = async () => undefined, update = () => undefined, }: CreateLabelmapRenderPlanArgs): LabelmapRenderPlan { const remove = () => removeLabelmapRepresentationFromViewport(viewport, segmentationId); const needsRemount = (actorEntries?: Types.ActorEntry[]) => - haveActorUIDsChanged(actorEntries, getExpectedRepresentationUIDs()); + haveActorUIDsChanged(actorEntries, getExpectedRepresentationUIDs()) || + (actorEntries ?? []).some( + (actorEntry) => !isActorEntryCompatible(actorEntry) + ); return { kind, @@ -62,6 +66,21 @@ function createLabelmapRenderPlan({ }; } +/** + * Render mode the actor entry was mounted with, when the viewport exposes it + * (GenericViewport actor entries carry it on actorMapper; legacy entries + * return undefined). + */ +function getActorEntryRenderMode( + actorEntry: Types.ActorEntry +): Types.ActorRenderMode | undefined { + return ( + actorEntry as Types.ActorEntry & { + actorMapper?: { renderMode?: Types.ActorRenderMode }; + } + ).actorMapper?.renderMode; +} + function haveActorUIDsChanged( actorEntries: Types.ActorEntry[] | undefined, expectedRepresentationUIDs: string[] @@ -84,4 +103,4 @@ function haveActorUIDsChanged( return false; } -export { createLabelmapRenderPlan }; +export { createLabelmapRenderPlan, getActorEntryRenderMode }; diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/legacyVolumePlan.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/legacyVolumePlan.ts index b176183e8d..7435fd4a19 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/legacyVolumePlan.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/legacyVolumePlan.ts @@ -1,5 +1,6 @@ import { type Types, + ActorRenderMode, addVolumesToViewports, Enums, cache, @@ -27,7 +28,10 @@ import { } from '../../../../stateManagement/segmentation/helpers/labelmapSegmentationState'; import { addVolumesAsIndependentComponents } from '../addVolumesAsIndependentComponents'; import { createLabelmapRepresentationUID } from '../labelmapRepresentationUID'; -import { createLabelmapRenderPlan } from './createLabelmapRenderPlan'; +import { + createLabelmapRenderPlan, + getActorEntryRenderMode, +} from './createLabelmapRenderPlan'; import { addLabelmapToPlanarGenericViewport, isPlanarNextVolumeViewport, @@ -65,6 +69,12 @@ function createLegacyVolumeLabelmapPlan({ viewport, getExpectedRepresentationUIDs: () => getExpectedVolumeLabelmapRepresentationUIDs(segmentation, segmentationId), + // An actor surviving from a per-slice image-mapper mount (e.g. after a + // live render-backend switch remounted it in place) shares this plan's + // representation UID but not its shape; force a remount through the + // volume path. + isActorEntryCompatible: (actorEntry) => + !isImageMountedActorEntry(actorEntry), mount: ({ labelMapData }) => mountLegacyVolumeLabelmap({ config, @@ -76,6 +86,15 @@ function createLegacyVolumeLabelmapPlan({ }); } +function isImageMountedActorEntry(actorEntry: Types.ActorEntry): boolean { + const renderMode = getActorEntryRenderMode(actorEntry); + + return ( + renderMode === ActorRenderMode.VTK_IMAGE || + renderMode === ActorRenderMode.CPU_IMAGE + ); +} + function getExpectedVolumeLabelmapRepresentationUIDs( segmentation: Segmentation, segmentationId: string diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/types.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/types.ts index f63c063cab..e76b7c34ca 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/types.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/types.ts @@ -58,6 +58,14 @@ type CreateLabelmapRenderPlanArgs = { viewport: Types.IViewport; canRenderCurrentViewport?: () => boolean; getExpectedRepresentationUIDs?: () => string[]; + /** + * Whether a mounted actor entry has the shape this plan would mount (e.g. a + * per-slice image mount vs a volume mount). Plans share representation UIDs + * across shapes, so after a live render-backend switch the UID check alone + * cannot detect that the surviving actor was mounted through a different + * plan kind; an incompatible entry forces a remount. + */ + isActorEntryCompatible?: (actorEntry: Types.ActorEntry) => boolean; mount?: ( args: LabelmapRenderPlanMountArgs ) => Promise; diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/volumeSliceImageMapperPlan.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/volumeSliceImageMapperPlan.ts index 06f4eb558e..dff623ca1f 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/volumeSliceImageMapperPlan.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/volumeSliceImageMapperPlan.ts @@ -1,4 +1,4 @@ -import type { Types } from '@cornerstonejs/core'; +import { ActorRenderMode, type Types } from '@cornerstonejs/core'; import type { ViewportLabelmapRenderMode } from '../../../../stateManagement/segmentation/helpers/getViewportLabelmapRenderMode'; import type { Segmentation } from '../../../../types/SegmentationStateTypes'; import { triggerSegmentationDataModified } from '../../../../stateManagement/segmentation/triggerSegmentationEvents'; @@ -7,7 +7,10 @@ import { getVolumeLabelmapImageMapperRepresentationUIDs, updateVolumeLabelmapImageMapperActors, } from '../volumeLabelmapImageMapper'; -import { createLabelmapRenderPlan } from './createLabelmapRenderPlan'; +import { + createLabelmapRenderPlan, + getActorEntryRenderMode, +} from './createLabelmapRenderPlan'; import type { LabelmapRenderPlan } from './types'; function createVolumeSliceImageMapperPlan({ @@ -38,6 +41,12 @@ function createVolumeSliceImageMapperPlan({ segmentationId, segmentation ), + // An actor surviving from a volume mount (e.g. remounted in place by a + // live render-backend switch back to the GPU) shares this plan's + // representation UID but not its shape; force a remount through the + // image-mapper path. + isActorEntryCompatible: (actorEntry) => + !isVolumeMountedActorEntry(actorEntry), mount: () => mountVolumeLabelmapImageMapper({ viewport, @@ -54,6 +63,16 @@ function createVolumeSliceImageMapperPlan({ }); } +function isVolumeMountedActorEntry(actorEntry: Types.ActorEntry): boolean { + const renderMode = getActorEntryRenderMode(actorEntry); + + return ( + renderMode === ActorRenderMode.VTK_VOLUME || + renderMode === ActorRenderMode.VTK_VOLUME_SLICE || + renderMode === ActorRenderMode.CPU_VOLUME + ); +} + async function mountVolumeLabelmapImageMapper({ viewport, segmentation, diff --git a/tests/genericViewport/genericRenderBackendSwitch.spec.ts b/tests/genericViewport/genericRenderBackendSwitch.spec.ts new file mode 100644 index 0000000000..edf4a8982e --- /dev/null +++ b/tests/genericViewport/genericRenderBackendSwitch.spec.ts @@ -0,0 +1,182 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + createExampleUrl, + expectGenericViewportRuntime, + getVisibleViewportCanvas, +} from '../utils/index'; + +const EXAMPLE = 'genericStackAPI'; +const ENGINE_ID = 'myRenderingEngine'; +const VIEWPORT_ID = 'CT_STACK_GENERIC'; +const DATA_ID = 'stack-api-next:primary'; +const SETTLE_MS = 5000; + +type CornerstoneWindow = typeof window & { + cornerstone?: { + getRenderingEngine?: (id: string) => { + getViewport?: (viewportId: string) => Record | null; + } | null; + setRenderBackend?: (backend: string, reason?: string) => void; + eventTarget?: EventTarget; + }; + __backendEvents?: unknown[]; +}; + +async function navigateToExample(page: Page) { + const url = createExampleUrl(EXAMPLE + '.html'); + + await page.goto(url.toString()); + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('div#content', { + state: 'visible', + timeout: 30000, + }); + await page.waitForSelector('#content canvas:visible', { + state: 'visible', + timeout: 30000, + }); + await page.waitForTimeout(SETTLE_MS); +} + +function getViewportState(page: Page) { + return page.evaluate( + ({ engineId, viewportId, dataId }) => { + const win = window as CornerstoneWindow; + const engine = win.cornerstone?.getRenderingEngine?.(engineId); + const viewport = engine?.getViewport?.(viewportId); + + return { + imageIdIndex: viewport?.getCurrentImageIdIndex?.(), + zoom: viewport?.getZoom?.(), + pan: viewport?.getPan?.(), + voiRange: viewport?.getDisplaySetPresentation?.(dataId)?.voiRange, + renderMode: viewport?.getDisplaySetRenderMode?.(dataId), + }; + }, + { engineId: ENGINE_ID, viewportId: VIEWPORT_ID, dataId: DATA_ID } + ); +} + +function setRenderBackend(page: Page, backend: string) { + return page.evaluate((value) => { + (window as CornerstoneWindow).cornerstone?.setRenderBackend?.( + value, + 'e2e-render-backend-switch' + ); + }, backend); +} + +test.describe('Render backend live switch', () => { + test.beforeEach(async ({ page }) => { + await navigateToExample(page); + }); + + test('switches a live viewport GPU -> CPU -> GPU preserving state', async ({ + page, + }) => { + await expectGenericViewportRuntime(page, [ + { + renderingEngineId: ENGINE_ID, + viewportId: VIEWPORT_ID, + constructorName: 'PlanarViewport', + type: 'planarNext', + renderModesByDataId: { [DATA_ID]: 'vtkImage' }, + }, + ]); + + // Put the viewport into a non-default state: slice 1, custom VOI, + // random zoom/pan. + await page.getByRole('button', { name: 'Next Image' }).click(); + await page.getByRole('button', { name: 'Set VOI Range' }).click(); + await page + .getByRole('button', { name: 'Apply Random Zoom And Pan' }) + .click(); + await page.waitForTimeout(500); + + const before = await getViewportState(page); + expect(before.renderMode).toBe('vtkImage'); + expect(before.imageIdIndex).toBe(1); + + // Live-switch to the CPU backend; no page reload, same viewport instance. + await setRenderBackend(page, 'cpu'); + + await expect + .poll(async () => (await getViewportState(page)).renderMode, { + timeout: 15000, + }) + .toBe('cpuImage'); + + const afterCpu = await getViewportState(page); + expect(afterCpu.imageIdIndex).toBe(before.imageIdIndex); + expect(afterCpu.voiRange).toEqual(before.voiRange); + expect(afterCpu.zoom).toBeCloseTo(before.zoom, 3); + expect(afterCpu.pan[0]).toBeCloseTo(before.pan[0], 2); + expect(afterCpu.pan[1]).toBeCloseTo(before.pan[1], 2); + + // The CPU path must actually render and stay interactive: a next-image + // click changes the visible canvas. + const canvas = getVisibleViewportCanvas(page); + const cpuShotBefore = await canvas.screenshot(); + + await page.getByRole('button', { name: 'Next Image' }).click(); + + await expect + .poll(async () => (await getViewportState(page)).imageIdIndex) + .toBe(2); + + const cpuShotAfter = await canvas.screenshot(); + expect(cpuShotBefore.equals(cpuShotAfter)).toBe(false); + + // And back to the GPU: the switch is reversible at runtime. + await setRenderBackend(page, 'gpu'); + + await expect + .poll(async () => (await getViewportState(page)).renderMode, { + timeout: 15000, + }) + .toBe('vtkImage'); + + const afterGpu = await getViewportState(page); + expect(afterGpu.imageIdIndex).toBe(2); + expect(afterGpu.voiRange).toEqual(before.voiRange); + expect(afterGpu.zoom).toBeCloseTo(before.zoom, 3); + }); + + test('emits RENDER_BACKEND_CHANGED with previous/current detail', async ({ + page, + }) => { + await page.evaluate(() => { + const win = window as CornerstoneWindow; + win.__backendEvents = []; + win.cornerstone?.eventTarget?.addEventListener( + 'CORNERSTONE_RENDER_BACKEND_CHANGED', + (evt) => { + win.__backendEvents?.push((evt as CustomEvent).detail); + } + ); + }); + + await setRenderBackend(page, 'cpu'); + await setRenderBackend(page, 'cpu'); // no-op, must not re-emit + await setRenderBackend(page, 'auto'); + + const events = await page.evaluate( + () => (window as CornerstoneWindow).__backendEvents + ); + + expect(events).toEqual([ + { + previous: 'auto', + current: 'cpu', + effectiveBackend: 'cpu', + reason: 'e2e-render-backend-switch', + }, + { + previous: 'cpu', + current: 'auto', + effectiveBackend: 'gpu', + reason: 'e2e-render-backend-switch', + }, + ]); + }); +}); diff --git a/tests/utils/compatibilityMode.ts b/tests/utils/compatibilityMode.ts index 860d8acaa8..a157504237 100644 --- a/tests/utils/compatibilityMode.ts +++ b/tests/utils/compatibilityMode.ts @@ -26,6 +26,7 @@ export async function validateCompatibilityRuntime( cornerstone?: { getShouldUseCPURendering?: () => boolean; getUseGenericViewport?: () => boolean; + getEffectiveRenderBackend?: () => string; }; }).cornerstone; @@ -35,6 +36,7 @@ export async function validateCompatibilityRuntime( cpuParam: searchParams.get('cpu'), useGenericViewport: cornerstone?.getUseGenericViewport?.(), useCPURendering: cornerstone?.getShouldUseCPURendering?.(), + effectiveRenderBackend: cornerstone?.getEffectiveRenderBackend?.(), }; }); @@ -58,5 +60,9 @@ export async function validateCompatibilityRuntime( runtime.useCPURendering, `${title ?? 'example'} should enable CPU rendering` ).toBe(true); + expect( + runtime.effectiveRenderBackend, + `${title ?? 'example'} should resolve the cpu render backend` + ).toBe('cpu'); } } diff --git a/utils/demo/helpers/exampleParameters.ts b/utils/demo/helpers/exampleParameters.ts index 0e802283ef..56c3b072c9 100644 --- a/utils/demo/helpers/exampleParameters.ts +++ b/utils/demo/helpers/exampleParameters.ts @@ -5,10 +5,7 @@ type DemoConfig = { rendering?: { useCPURendering?: boolean; planar?: { - cpuThresholds?: { - image?: number; - volume?: number; - }; + renderBackend?: 'auto' | 'gpu' | 'cpu'; }; useGenericViewport?: boolean; }; @@ -71,12 +68,12 @@ export function applyUrlParameterOverridesToDemoConfig( return utilities.deepMerge(config, { core: { rendering: { + // useCPURendering forces the legacy viewports; the planar + // renderBackend pin covers GenericViewport-based viewports (both + // image and volume paths), so no threshold overrides are needed. useCPURendering: true, planar: { - cpuThresholds: { - image: 0, - volume: 0, - }, + renderBackend: 'cpu', }, }, },