From ec15509d46000096048aa531490677d22c0f4988 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 3 Jul 2026 10:33:33 -0400 Subject: [PATCH 1/5] wip --- .../genericRenderBackendSwitch/index.ts | 301 ++++++++++++++++++ .../ContextPoolRenderingEngine.ts | 2 +- .../GenericViewport/GenericViewport.ts | 11 + .../Planar/PlanarRenderPathDecisionService.ts | 131 +++----- .../GenericViewport/Planar/PlanarViewport.ts | 160 +++++++++- .../Planar/PlanarViewportTypes.ts | 21 +- .../Planar/VtkImageMapperRenderPath.ts | 7 + .../Planar/planarRenderPathSelector.ts | 3 - .../RenderingEngine/TiledRenderingEngine.ts | 2 + .../src/RenderingEngine/WebGLContextPool.ts | 8 +- .../helpers/attachWebGLContextEvents.ts | 47 +++ packages/core/src/enums/Events.ts | 30 ++ packages/core/src/enums/RenderBackend.ts | 21 ++ packages/core/src/enums/index.ts | 3 + packages/core/src/index.ts | 13 + packages/core/src/init.ts | 261 ++++++++++----- .../core/src/types/Cornerstone3DConfig.ts | 27 +- .../src/utilities/renderingCapabilities.ts | 221 +++++++++++++ packages/core/src/utilities/textureSupport.ts | 86 +++-- .../core/test/planarRenderBackend.jest.js | 201 ++++++++++++ .../core/test/renderingCapabilities.jest.js | 168 ++++++++++ .../genericRenderBackendSwitch.spec.ts | 182 +++++++++++ tests/utils/compatibilityMode.ts | 6 + utils/demo/helpers/exampleParameters.ts | 13 +- 24 files changed, 1692 insertions(+), 233 deletions(-) create mode 100644 packages/core/examples/genericRenderBackendSwitch/index.ts create mode 100644 packages/core/src/RenderingEngine/helpers/attachWebGLContextEvents.ts create mode 100644 packages/core/src/enums/RenderBackend.ts create mode 100644 packages/core/src/utilities/renderingCapabilities.ts create mode 100644 packages/core/test/planarRenderBackend.jest.js create mode 100644 packages/core/test/renderingCapabilities.jest.js create mode 100644 tests/genericViewport/genericRenderBackendSwitch.spec.ts diff --git a/packages/core/examples/genericRenderBackendSwitch/index.ts b/packages/core/examples/genericRenderBackendSwitch/index.ts new file mode 100644 index 0000000000..8b62d13360 --- /dev/null +++ b/packages/core/examples/genericRenderBackendSwitch/index.ts @@ -0,0 +1,301 @@ +import type { PlanarViewport, Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + eventTarget, + getRenderingEngine, + getRenderBackend, + getEffectiveRenderBackend, + setRenderBackend, + utilities, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addButtonToToolbar, + ctVoiRange, +} 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, Events } = Enums; + +const renderingEngineId = 'myRenderingEngine'; + +const stackViewportId = 'STACK_AUTO'; +const mprViewportId = 'MPR_AUTO'; +const pinnedViewportId = 'STACK_PINNED_CPU'; + +const stackDataId = 'render-backend-switch:stack'; +const volumeDataId = 'render-backend-switch:volume'; +const pinnedDataId = 'render-backend-switch:pinned'; + +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; +const volumeId = `${volumeLoaderScheme}:RENDER_BACKEND_SWITCH_CT`; + +setTitleAndDescription( + 'GenericViewport Render Backend Switch', + 'Live-switches the render backend (gpu | cpu | auto) of GenericViewport-based viewports without a page reload. ' + + 'The stack and MPR viewports follow the global setRenderBackend() value while keeping their slice, zoom/pan, and VOI; ' + + 'the third viewport is pinned to the CPU via a per-display-set renderBackend option and never switches.' +); + +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 pinnedElement = createViewportElement('Stack (pinned to CPU)'); + +const statusPanel = document.createElement('pre'); +statusPanel.id = 'backend-status'; +content.appendChild(statusPanel); + +const eventLog = document.createElement('pre'); +eventLog.id = 'backend-events'; +content.appendChild(eventLog); + +function getViewport(viewportId: string): PlanarViewport | undefined { + return getRenderingEngine(renderingEngineId)?.getViewport( + viewportId + ); +} + +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(pinnedViewportId, pinnedDataId), + ].join('\n'); +} + +function logEvent(message: string): void { + eventLog.innerText = `${message}\n${eventLog.innerText}`.slice(0, 2000); +} + +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 { renderingEngineId: engineId, contextIndex } = (evt as CustomEvent) + .detail; + + logEvent( + `WEBGL_CONTEXT_LOST on ${engineId} (context ${contextIndex}) - consider setRenderBackend('cpu')` + ); +}); + +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: 'Next Image (stacks)', + onClick: () => { + for (const viewportId of [stackViewportId, pinnedViewportId]) { + 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], + [pinnedViewportId, pinnedDataId], + ]; + + 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]) { + const viewport = getViewport(viewportId); + + if (!viewport) { + continue; + } + + viewport.setScale(1.35); + viewport.setPan([42, -28]); + viewport.render(); + } + + setTimeout(updateStatusPanel, 100); + }, +}); + +async function run() { + await initDemo(); + + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }); + + 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: pinnedViewportId, + type: ViewportType.PLANAR_NEXT, + element: pinnedElement, + defaultOptions: { background: [0.2, 0.2, 0] as Types.Point3 }, + }, + ]); + + const stack = [imageIds[0], imageIds[1], imageIds[2]]; + + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { + imageIds: stack, + kind: 'planar', + initialImageIdIndex: 0, + }); + utilities.genericViewportDisplaySetMetadataProvider.add(volumeDataId, { + imageIds, + kind: 'planar', + initialImageIdIndex: Math.floor(imageIds.length / 2), + volumeId, + }); + utilities.genericViewportDisplaySetMetadataProvider.add(pinnedDataId, { + imageIds: stack, + 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, + }); + + // Per-display-set pin: this viewport renders through the CPU path no + // matter what the global renderBackend is, demonstrating mixed CPU/GPU + // viewports living in the same rendering engine. + const pinnedViewport = getViewport(pinnedViewportId); + await pinnedViewport.setDisplaySets({ + displaySetId: pinnedDataId, + options: { renderBackend: 'cpu' }, + }); + pinnedViewport.setDisplaySetPresentation(pinnedDataId, { + voiRange: ctVoiRange, + }); + + 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..80e841ef09 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. diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts index 4a4618df40..4ead2cdf91 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts @@ -1,7 +1,11 @@ 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, + resolveAutoRenderBackend, +} from '../../../init'; import * as metaData from '../../../metaData'; import { ActorRenderMode } from '../../../types'; import { isValidVolume } from '../../../utilities/isValidVolume'; @@ -13,14 +17,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 +25,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 +40,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 +84,7 @@ export class PlanarRenderPathDecisionService { return { acquisitionOrientation, renderMode: useVolumePath - ? this.selectVolumeRenderMode(dataSet, options) + ? this.selectVolumeRenderMode(options) : this.selectImageRenderMode(options), volumeId, }; @@ -135,53 +134,42 @@ 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( + /** + * 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. + */ + private resolveBackend( options: PlanarRenderPathDecisionOptions - ): boolean { - if (options.webGLAvailable === false) { - return true; - } + ): RenderBackend.GPU | RenderBackend.CPU { + const requested = options.renderBackend; - // 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(); - } + if (requested === RenderBackend.GPU) { + return RenderBackend.GPU; + } - private shouldUseCPUForVolume( - dataSet: PlanarRegisteredDataSet, - options: PlanarRenderPathDecisionOptions - ): boolean { - if (options.webGLAvailable === false) { - return true; + if (requested === RenderBackend.CPU) { + return RenderBackend.CPU; } - const configuredCpuThresholds = getConfiguredPlanarCpuThresholds(); + if (requested === RenderBackend.Auto) { + return resolveAutoRenderBackend(); + } - return shouldUseCPU( - dataSet.imageIds, - options.cpuThresholds?.volume ?? - configuredCpuThresholds?.volume ?? - DEFAULT_PLANAR_CPU_VOLUME_THRESHOLD - ); + return getEffectiveRenderBackend(); } } @@ -242,44 +230,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); } @@ -307,10 +257,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..041b766a9e 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts @@ -30,11 +30,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 +166,15 @@ class PlanarViewport extends GenericViewport< canvasWidth: number; }; private setDataRequestId = 0; + private renderPipelineSwapId = 0; + private lastRenderPathErrorKey?: string; + // 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 +432,7 @@ class PlanarViewport extends GenericViewport< return; } + this.mountOptionsByDataId.set(dataId, resolvedOptions); this.clearResolvedViewCache(); this.setDefaultDataPresentation(dataId, { visible: true, @@ -458,6 +470,7 @@ class PlanarViewport extends GenericViewport< */ removeData(dataId: string): void { this.clearResolvedViewCache(); + this.mountOptionsByDataId.delete(dataId); super.removeData(dataId); this.mountedData.handleRemovedData(dataId); @@ -1621,22 +1634,150 @@ class PlanarViewport extends GenericViewport< let renderedByAdapter = false; const sourceBinding = this.getCurrentBinding(); + const renderBinding = ( + binding: ViewportDataBinding, + dataId: string + ) => { + if (!binding.render) { + return; + } - sourceBinding?.render?.(); - renderedByAdapter = renderedByAdapter || Boolean(sourceBinding?.render); + renderedByAdapter = true; + + try { + binding.render(); + } 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; + + for (const [dataId, binding] of Array.from(this.bindings.entries())) { + 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(); + } + + /** + * Emits the RENDER_PATH_ERROR degradation signal (and logs) for a render + * path that threw while mounting or rendering. Consecutive identical + * failures are reported once so a per-frame render error does not flood the + * event bus; 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 ?? ''}:${message}`; + + if (errorKey === this.lastRenderPathErrorKey) { + return; + } + + this.lastRenderPathErrorKey = errorKey; + 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 +2033,9 @@ 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; undefined leaves the global + // renderBackend configuration to decide. + renderBackend: options.renderBackend, }); const data = await (this.dataProvider as PlanarDataProvider).load(dataId, { acquisitionOrientation: selectedPath.acquisitionOrientation, 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..f0f975c177 100644 --- a/packages/core/src/enums/Events.ts +++ b/packages/core/src/enums/Events.ts @@ -303,6 +303,36 @@ 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', } 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..d67d986391 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,129 @@ 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. + * @category Initialization + */ +function getEffectiveRenderBackend(): RenderBackend.GPU | RenderBackend.CPU { + const backend = getRenderBackend(); + + 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. + config.rendering.planar = { + ...config.rendering.planar, + renderBackend: backend, + }; + csRenderInitialized = true; + _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 +282,43 @@ 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 without any + * WebGL context (e.g. unit tests) 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.webgl) { + 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 +368,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 +424,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..9d547d1b6f --- /dev/null +++ b/packages/core/src/utilities/renderingCapabilities.ts @@ -0,0 +1,221 @@ +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): TextureFormatSupport | null { + try { + const raw = window.localStorage?.getItem(STORAGE_KEY); + + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as CachedCapabilities; + + if ( + parsed?.probeVersion !== RENDERING_CAPABILITIES_PROBE_VERSION || + parsed?.renderer !== renderer || + 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 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) : null; + + if (!formats) { + formats = getSupportedTextureFormats(); + writeCachedFormats(contextInfo.renderer, contextInfo.webgl2, 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..5717beadb1 100644 --- a/packages/core/src/utilities/textureSupport.ts +++ b/packages/core/src/utilities/textureSupport.ts @@ -3,7 +3,35 @@ 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; +} + +function main({ + ext, + filterType, + texData, + internalFormat, + glDataType, +}: { + ext?: string; + filterType: 'NEAREST' | 'LINEAR'; + texData: ArrayBufferView; + internalFormat: (gl: WebGL2RenderingContext, ext?) => number; + glDataType: (gl: WebGL2RenderingContext, ext?) => number; +}) { try { const canvas = document.createElement('canvas'); canvas.width = canvasSize; @@ -108,13 +136,13 @@ function main({ ext, filterType, texData, internalFormat, glDataType }) { } } -export function getSupportedTextureFormats() { +export function getSupportedTextureFormats(): TextureFormatSupport { 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({ @@ -131,30 +159,30 @@ export function getSupportedTextureFormats() { internalFormat: (gl, ext) => ext.R16_SNORM_EXT, glDataType: (gl) => gl.SHORT, }), - // 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, - // }), + float: main({ + filterType: 'NEAREST', + texData: floatTexData, + internalFormat: (gl) => gl.R32F, + glDataType: (gl) => gl.FLOAT, + }), + floatLinear: main({ + ext: 'OES_texture_float_linear', + filterType: 'LINEAR', + texData: floatTexData, + internalFormat: (gl) => gl.R32F, + 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, + }), }; } diff --git a/packages/core/test/planarRenderBackend.jest.js b/packages/core/test/planarRenderBackend.jest.js new file mode 100644 index 0000000000..4b9adb4f60 --- /dev/null +++ b/packages/core/test/planarRenderBackend.jest.js @@ -0,0 +1,201 @@ +import { ActorRenderMode } from '../src/types'; +import { Events, RenderBackend } from '../src/enums'; +import { + getEffectiveRenderBackend, + getRenderBackend, + 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('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..3dcd2dd9c7 --- /dev/null +++ b/packages/core/test/renderingCapabilities.jest.js @@ -0,0 +1,168 @@ +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 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('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/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', }, }, }, From 57467ba58126b490b425a46f2c89f8bffbf0b5e4 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 3 Jul 2026 11:43:42 -0400 Subject: [PATCH 2/5] feat: Enhance CPU image rendering and backend resolution - Updated CpuImageSliceRenderPath to include modality handling for fallback viewports. - Refactored PlanarRenderPathDecisionService to streamline backend resolution logic. - Improved PlanarViewport to manage render path errors more effectively and trigger events on rendering pipeline changes. - Added new RENDERING_PIPELINE_CHANGED event to notify consumers of actor replacements after viewport rebuilds. - Enhanced rendering capability detection to re-probe when WebGL2 availability changes. - Introduced compatibility checks for actor entries in labelmap render plans to ensure correct remounting after backend switches. - Added tests for CPU image render path and backend resolution behaviors. --- .../genericPetRenderBackendGrid/index.ts | 242 ++++++++++ .../genericRenderBackendSwitch/index.ts | 420 ++++++++++++++++-- .../Planar/CpuImageSliceRenderPath.ts | 29 +- .../Planar/PlanarRenderPathDecisionService.ts | 26 +- .../GenericViewport/Planar/PlanarViewport.ts | 30 +- packages/core/src/enums/Events.ts | 10 + packages/core/src/init.ts | 24 +- .../src/utilities/renderingCapabilities.ts | 33 +- .../core/test/planarComputedCamera.jest.js | 53 +++ .../core/test/planarRenderBackend.jest.js | 34 +- .../core/test/renderingCapabilities.jest.js | 16 + .../renderingPipelineChangedListener.ts | 28 ++ packages/tools/src/init.ts | 11 + .../createLabelmapRenderPlan.ts | 23 +- .../labelmapRenderPlan/legacyVolumePlan.ts | 21 +- .../Labelmap/labelmapRenderPlan/types.ts | 8 + .../volumeSliceImageMapperPlan.ts | 23 +- 17 files changed, 925 insertions(+), 106 deletions(-) create mode 100644 packages/core/examples/genericPetRenderBackendGrid/index.ts create mode 100644 packages/tools/src/eventListeners/segmentation/renderingPipelineChangedListener.ts 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 index 8b62d13360..41673deb0e 100644 --- a/packages/core/examples/genericRenderBackendSwitch/index.ts +++ b/packages/core/examples/genericRenderBackendSwitch/index.ts @@ -1,4 +1,5 @@ import type { PlanarViewport, Types } from '@cornerstonejs/core'; +import * as cornerstone from '@cornerstonejs/core'; import { RenderingEngine, Enums, @@ -6,8 +7,11 @@ import { getRenderingEngine, getRenderBackend, getEffectiveRenderBackend, + imageLoader, setRenderBackend, + triggerEvent, utilities, + volumeLoader, } from '@cornerstonejs/core'; import { initDemo, @@ -16,6 +20,11 @@ import { 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( @@ -23,25 +32,76 @@ console.warn( ); 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 pinnedViewportId = 'STACK_PINNED_CPU'; +const petViewportId = 'PET_STACK_AUTO'; +const viewportIds = [stackViewportId, mprViewportId, petViewportId]; const stackDataId = 'render-backend-switch:stack'; const volumeDataId = 'render-backend-switch:volume'; -const pinnedDataId = 'render-backend-switch:pinned'; +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 stack and MPR viewports follow the global setRenderBackend() value while keeping their slice, zoom/pan, and VOI; ' + - 'the third viewport is pinned to the CPU via a per-display-set renderBackend option and never switches.' + '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'); @@ -67,7 +127,7 @@ function createViewportElement(label: string): HTMLDivElement { const stackElement = createViewportElement('Stack (follows global backend)'); const mprElement = createViewportElement('MPR (follows global backend)'); -const pinnedElement = createViewportElement('Stack (pinned to CPU)'); +const petElement = createViewportElement('PET Stack (follows global backend)'); const statusPanel = document.createElement('pre'); statusPanel.id = 'backend-status'; @@ -77,12 +137,262 @@ 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); @@ -103,7 +413,7 @@ function updateStatusPanel(): void { `effective backend: ${getEffectiveRenderBackend()}`, describeViewport(stackViewportId, stackDataId), describeViewport(mprViewportId, volumeDataId), - describeViewport(pinnedViewportId, pinnedDataId), + describeViewport(petViewportId, petDataId), ].join('\n'); } @@ -111,6 +421,26 @@ 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; @@ -128,12 +458,13 @@ eventTarget.addEventListener(Events.RENDER_BACKEND_CHANGED, (evt) => { // 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 { renderingEngineId: engineId, contextIndex } = (evt as CustomEvent) - .detail; + 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({ @@ -151,10 +482,15 @@ addButtonToToolbar({ onClick: () => setRenderBackend('cpu', 'example-toolbar'), }); +addButtonToToolbar({ + title: 'Throw Context Lost', + onClick: () => throwWebGLContextLost(), +}); + addButtonToToolbar({ title: 'Next Image (stacks)', onClick: () => { - for (const viewportId of [stackViewportId, pinnedViewportId]) { + for (const viewportId of [stackViewportId, petViewportId]) { const viewport = getViewport(viewportId); if (!viewport) { @@ -179,7 +515,6 @@ addButtonToToolbar({ const targets: Array<[string, string]> = [ [stackViewportId, stackDataId], [mprViewportId, volumeDataId], - [pinnedViewportId, pinnedDataId], ]; for (const [viewportId, dataId] of targets) { @@ -195,7 +530,7 @@ addButtonToToolbar({ addButtonToToolbar({ title: 'Apply Zoom And Pan', onClick: () => { - for (const viewportId of [stackViewportId, mprViewportId]) { + for (const viewportId of [stackViewportId, mprViewportId, petViewportId]) { const viewport = getViewport(viewportId); if (!viewport) { @@ -211,16 +546,26 @@ addButtonToToolbar({ }, }); +addButtonToToolbar({ + title: 'Add Fake Bidirectionals', + onClick: () => addFakeBidirectionalAnnotations(), +}); + async function run() { await initDemo(); - const imageIds = await createImageIdsAndCacheMetaData({ - StudyInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', - SeriesInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', - wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', - }); + const [ctImageIds, ptImageIds] = await Promise.all([ + createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID: ctSeriesInstanceUID, + wadoRsRoot, + }), + createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID: ptSeriesInstanceUID, + wadoRsRoot, + }), + ]); const renderingEngine = new RenderingEngine(renderingEngineId); @@ -238,14 +583,22 @@ async function run() { defaultOptions: { background: [0, 0.2, 0.2] as Types.Point3 }, }, { - viewportId: pinnedViewportId, + viewportId: petViewportId, type: ViewportType.PLANAR_NEXT, - element: pinnedElement, + element: petElement, defaultOptions: { background: [0.2, 0.2, 0] as Types.Point3 }, }, ]); - const stack = [imageIds[0], imageIds[1], imageIds[2]]; + 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, @@ -253,13 +606,13 @@ async function run() { initialImageIdIndex: 0, }); utilities.genericViewportDisplaySetMetadataProvider.add(volumeDataId, { - imageIds, + imageIds: ctImageIds, kind: 'planar', - initialImageIdIndex: Math.floor(imageIds.length / 2), + initialImageIdIndex: Math.floor(ctImageIds.length / 2), volumeId, }); - utilities.genericViewportDisplaySetMetadataProvider.add(pinnedDataId, { - imageIds: stack, + utilities.genericViewportDisplaySetMetadataProvider.add(petDataId, { + imageIds: ptStack, kind: 'planar', initialImageIdIndex: 0, }); @@ -282,18 +635,15 @@ async function run() { voiRange: ctVoiRange, }); - // Per-display-set pin: this viewport renders through the CPU path no - // matter what the global renderBackend is, demonstrating mixed CPU/GPU - // viewports living in the same rendering engine. - const pinnedViewport = getViewport(pinnedViewportId); - await pinnedViewport.setDisplaySets({ - displaySetId: pinnedDataId, - options: { renderBackend: 'cpu' }, - }); - pinnedViewport.setDisplaySetPresentation(pinnedDataId, { - voiRange: ctVoiRange, + const petViewport = getViewport(petViewportId); + await petViewport.setDisplaySets({ + displaySetId: petDataId, + options: {}, }); + renderingEngine.render(); + await addSegmentationOverlays(stack, ctImageIds, ptStack); + addFakeBidirectionalAnnotations(); renderingEngine.render(); updateStatusPanel(); } 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 4ead2cdf91..d418513cd0 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts @@ -2,10 +2,7 @@ import { vec3 } from 'gl-matrix'; import cache from '../../../cache/cache'; import { OrientationAxis, RenderBackend } from '../../../enums'; import type { RenderBackendValue } from '../../../enums'; -import { - getEffectiveRenderBackend, - resolveAutoRenderBackend, -} from '../../../init'; +import { getEffectiveRenderBackend } from '../../../init'; import * as metaData from '../../../metaData'; import { ActorRenderMode } from '../../../types'; import { isValidVolume } from '../../../utilities/isValidVolume'; @@ -150,26 +147,14 @@ export class PlanarRenderPathDecisionService { /** * 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. + * 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 ): RenderBackend.GPU | RenderBackend.CPU { - const requested = options.renderBackend; - - if (requested === RenderBackend.GPU) { - return RenderBackend.GPU; - } - - if (requested === RenderBackend.CPU) { - return RenderBackend.CPU; - } - - if (requested === RenderBackend.Auto) { - return resolveAutoRenderBackend(); - } - - return getEffectiveRenderBackend(); + return getEffectiveRenderBackend(options.renderBackend); } } @@ -256,4 +241,3 @@ function isVolumeBackedDataSet( return !isAcquisitionPath && supportsVolumeRendering(dataSet); } - diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts index 041b766a9e..51bee95c7a 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts @@ -167,7 +167,10 @@ class PlanarViewport extends GenericViewport< }; private setDataRequestId = 0; private renderPipelineSwapId = 0; - private lastRenderPathErrorKey?: string; + // 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). @@ -471,6 +474,7 @@ 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); @@ -1646,6 +1650,7 @@ class PlanarViewport extends GenericViewport< try { binding.render(); + this.lastRenderPathErrorByDataId.delete(dataId); } catch (error) { this.reportRenderPathError(error, dataId); } @@ -1752,23 +1757,34 @@ class PlanarViewport extends GenericViewport< 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. Consecutive identical - * failures are reported once so a per-frame render error does not flood the - * event bus; applications listen for this to offer a backend switch. + * 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 ?? ''}:${message}`; + const errorKey = dataId ?? ''; - if (errorKey === this.lastRenderPathErrorKey) { + if (this.lastRenderPathErrorByDataId.get(errorKey) === message) { return; } - this.lastRenderPathErrorKey = errorKey; + this.lastRenderPathErrorByDataId.set(errorKey, message); console.error('[PlanarViewport] Render path error', dataId ?? '', error); triggerEvent(eventTarget, Events.RENDER_PATH_ERROR, { renderingEngineId: this.renderingEngineId, diff --git a/packages/core/src/enums/Events.ts b/packages/core/src/enums/Events.ts index f0f975c177..f25ce5f751 100644 --- a/packages/core/src/enums/Events.ts +++ b/packages/core/src/enums/Events.ts @@ -333,6 +333,16 @@ enum Events { * 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/init.ts b/packages/core/src/init.ts index d67d986391..db38e12bb6 100644 --- a/packages/core/src/init.ts +++ b/packages/core/src/init.ts @@ -188,10 +188,17 @@ function resolveAutoRenderBackend(): RenderBackend.GPU | RenderBackend.CPU { /** * 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(): RenderBackend.GPU | RenderBackend.CPU { - const backend = getRenderBackend(); +function getEffectiveRenderBackend( + override?: RenderBackend | RenderBackendValue +): RenderBackend.GPU | RenderBackend.CPU { + const backend = (override ?? getRenderBackend()) as RenderBackend; if (backend === RenderBackend.GPU || backend === RenderBackend.CPU) { return backend; @@ -239,12 +246,14 @@ function setRenderBackend( } // Replace (not mutate) the planar object: before init() it may still be - // the shared defaultConfig reference. + // 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, }; - csRenderInitialized = true; _updateRenderingPipelinesForAllViewports(); triggerEvent(eventTarget, Events.RENDER_BACKEND_CHANGED, { @@ -284,14 +293,15 @@ function setPreferSizeOverAccuracy(status: boolean): void { /** * 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 without any - * WebGL context (e.g. unit tests) keep the legacy user-agent behavior so + * 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 { const capabilities = getRenderingCapabilities(); - if (capabilities.webgl) { + if (capabilities.webgl2) { return capabilities.floatLinear; } diff --git a/packages/core/src/utilities/renderingCapabilities.ts b/packages/core/src/utilities/renderingCapabilities.ts index 9d547d1b6f..62bf708562 100644 --- a/packages/core/src/utilities/renderingCapabilities.ts +++ b/packages/core/src/utilities/renderingCapabilities.ts @@ -107,7 +107,10 @@ function getWebGLContextInfo(): WebGLContextInfo { return info; } -function readCachedFormats(renderer: string): TextureFormatSupport | null { +function readCachedFormats( + renderer: string, + webgl2: boolean +): TextureFormatSupport | null { try { const raw = window.localStorage?.getItem(STORAGE_KEY); @@ -117,9 +120,13 @@ function readCachedFormats(renderer: string): TextureFormatSupport | 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 ) { @@ -156,14 +163,14 @@ function writeCachedFormats( * 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 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). + * 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 { +export function detectRenderingCapabilities({ + useCache = true, +}: { useCache?: boolean } = {}): RenderingCapabilities { const contextInfo = getWebGLContextInfo(); if (!contextInfo.webgl) { @@ -174,7 +181,9 @@ export function detectRenderingCapabilities( }; } - let formats = useCache ? readCachedFormats(contextInfo.renderer) : null; + let formats = useCache + ? readCachedFormats(contextInfo.renderer, contextInfo.webgl2) + : null; if (!formats) { formats = getSupportedTextureFormats(); @@ -206,9 +215,9 @@ export function getRenderingCapabilities(): RenderingCapabilities { * 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 { +export function resetRenderingCapabilities({ + clearStorage = false, +}: { clearStorage?: boolean } = {}): void { cachedCapabilities = null; if (clearStorage) { 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 index 4b9adb4f60..12bd18e75d 100644 --- a/packages/core/test/planarRenderBackend.jest.js +++ b/packages/core/test/planarRenderBackend.jest.js @@ -3,6 +3,8 @@ import { Events, RenderBackend } from '../src/enums'; import { getEffectiveRenderBackend, getRenderBackend, + isCornerstoneInitialized, + resetInitialization, setRenderBackend, setUseCPURendering, } from '../src/init'; @@ -129,6 +131,18 @@ describe('Planar render backend resolution', () => { 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); @@ -143,10 +157,7 @@ describe('Planar render backend resolution', () => { reason: 'unit-test', }); - eventTarget.removeEventListener( - Events.RENDER_BACKEND_CHANGED, - listener - ); + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); }); it('does not emit when the backend does not change', () => { @@ -158,10 +169,7 @@ describe('Planar render backend resolution', () => { setRenderBackend('cpu'); expect(listener).not.toHaveBeenCalled(); - eventTarget.removeEventListener( - Events.RENDER_BACKEND_CHANGED, - listener - ); + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); }); it('emits when the deprecated setUseCPURendering changes the effective backend', () => { @@ -177,10 +185,7 @@ describe('Planar render backend resolution', () => { reason: 'setUseCPURendering', }); - eventTarget.removeEventListener( - Events.RENDER_BACKEND_CHANGED, - listener - ); + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); }); it('does not emit for setUseCPURendering under an explicit gpu pin', () => { @@ -192,10 +197,7 @@ describe('Planar render backend resolution', () => { setUseCPURendering(true); expect(listener).not.toHaveBeenCalled(); - eventTarget.removeEventListener( - Events.RENDER_BACKEND_CHANGED, - listener - ); + eventTarget.removeEventListener(Events.RENDER_BACKEND_CHANGED, listener); }); }); }); diff --git a/packages/core/test/renderingCapabilities.jest.js b/packages/core/test/renderingCapabilities.jest.js index 3dcd2dd9c7..eb4285686a 100644 --- a/packages/core/test/renderingCapabilities.jest.js +++ b/packages/core/test/renderingCapabilities.jest.js @@ -121,6 +121,22 @@ describe('renderingCapabilities', () => { 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(); 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, From e201f93faa1f459e2dbb12d9eb16ba6d7e23d8ee Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 3 Jul 2026 12:49:03 -0400 Subject: [PATCH 3/5] fix(rendering): stage render-path swaps and inherit source backend for overlays Two live render-backend switch fixes from review: - GenericViewport.addLoadedData now mounts the replacement attachment before tearing down the existing one. Teardown-first left a dead binding whose recorded renderMode made a later remount (switching the backend back after a failed or stale swap) skip as a no-op, blanking the display set. - Overlays mounted without an explicit renderBackend now inherit the source binding's mounted backend instead of the global configuration. Source and overlays must share a canvas (each backend skips the other's actors), and the source may be pinned per-mount away from the global backend. The swap loop remounts the source binding first so overlays inherit the new backend during a switch. --- .../GenericViewport/GenericViewport.ts | 16 +++--- .../GenericViewport/Planar/PlanarViewport.ts | 52 +++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts index 80e841ef09..bdd040fa50 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts @@ -549,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?.()) { @@ -567,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/PlanarViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts index 51bee95c7a..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, @@ -1697,7 +1698,19 @@ class PlanarViewport extends GenericViewport< swapId !== this.renderPipelineSwapId || this.isDestroyed; let changed = false; - for (const [dataId, binding] of Array.from(this.bindings.entries())) { + // 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; } @@ -2049,9 +2062,12 @@ class PlanarViewport extends GenericViewport< ); const selectedPath = selectPlanarRenderPath(dataSet, { orientation: resolvedOrientation, - // Per-mount backend pin/override; undefined leaves the global - // renderBackend configuration to decide. - renderBackend: options.renderBackend, + // 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, @@ -2067,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, From a43de478b68a0248bc21485b38935cd17d6ad892 Mon Sep 17 00:00:00 2001 From: Alireza Date: Tue, 7 Jul 2026 13:30:50 -0400 Subject: [PATCH 4/5] perf(rendering): share one WebGL context across texture format probes Address review feedback on probe startup cost: the six texture format probes previously each created their own canvas, WebGL2 context and shader program. They now share a single context and program, with a clear between probes so results cannot leak across the shared framebuffer, and per-probe textures are deleted after readback. Measured in Chrome on Apple Silicon: the probe run drops from ~20-70ms to ~5-16ms, and it only executes on the first page load for a given renderer/browser combination since results are persisted in localStorage (keyed by renderer string, WebGL2 availability and probe version); subsequent loads read the cached profile and create no probe contexts at all. --- packages/core/src/utilities/textureSupport.ts | 124 +++++++++++------- 1 file changed, 80 insertions(+), 44 deletions(-) diff --git a/packages/core/src/utilities/textureSupport.ts b/packages/core/src/utilities/textureSupport.ts index 5717beadb1..9cc9fd0579 100644 --- a/packages/core/src/utilities/textureSupport.ts +++ b/packages/core/src/utilities/textureSupport.ts @@ -19,26 +19,36 @@ export interface TextureFormatSupport { halfFloatLinear: boolean; } -function main({ - ext, - filterType, - texData, - internalFormat, - glDataType, -}: { +interface FormatProbe { ext?: string; filterType: 'NEAREST' | 'LINEAR'; texData: ArrayBufferView; internalFormat: (gl: WebGL2RenderingContext, ext?) => number; glDataType: (gl: WebGL2RenderingContext, ext?) => number; -}) { +} + +const NO_SUPPORT: TextureFormatSupport = { + norm16: false, + norm16Linear: false, + float: false, + floatLinear: false, + halfFloat: false, + halfFloatLinear: false, +}; + +/** + * 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 @@ -62,26 +72,18 @@ function main({ } `; - 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(); @@ -90,7 +92,28 @@ function main({ 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(); @@ -110,7 +133,11 @@ function main({ 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); @@ -125,10 +152,7 @@ function main({ ); 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) { @@ -137,6 +161,11 @@ function main({ } export function getSupportedTextureFormats(): TextureFormatSupport { + const gl = createProbeContext(); + if (!gl) { + return { ...NO_SUPPORT }; + } + const norm16TexData = new Int16Array([ 32767, 2000, 3000, 4000, 5000, 16784, 7000, 8000, 9000, 32767, ]); @@ -144,45 +173,52 @@ export function getSupportedTextureFormats(): TextureFormatSupport { 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: main({ + float: probeFormat(gl, { filterType: 'NEAREST', texData: floatTexData, - internalFormat: (gl) => gl.R32F, - glDataType: (gl) => gl.FLOAT, + internalFormat: (_gl) => _gl.R32F, + glDataType: (_gl) => _gl.FLOAT, }), - floatLinear: main({ + floatLinear: probeFormat(gl, { ext: 'OES_texture_float_linear', filterType: 'LINEAR', texData: floatTexData, - internalFormat: (gl) => gl.R32F, - glDataType: (gl) => gl.FLOAT, + internalFormat: (_gl) => _gl.R32F, + glDataType: (_gl) => _gl.FLOAT, }), - halfFloat: main({ + halfFloat: probeFormat(gl, { filterType: 'NEAREST', texData: halfFloatTexData, - internalFormat: (gl) => gl.R16F, - glDataType: (gl) => gl.HALF_FLOAT, + internalFormat: (_gl) => _gl.R16F, + glDataType: (_gl) => _gl.HALF_FLOAT, }), - halfFloatLinear: main({ + halfFloatLinear: probeFormat(gl, { filterType: 'LINEAR', texData: halfFloatTexData, - internalFormat: (gl) => gl.R16F, - glDataType: (gl) => gl.HALF_FLOAT, + internalFormat: (_gl) => _gl.R16F, + glDataType: (_gl) => _gl.HALF_FLOAT, }), }; + + const webglLoseContext = gl.getExtension('WEBGL_lose_context'); + if (webglLoseContext) { + webglLoseContext.loseContext(); + } + + return result; } From 7494a404bc3c587478f50d098875f65da4555613 Mon Sep 17 00:00:00 2001 From: Alireza Date: Tue, 7 Jul 2026 14:34:27 -0400 Subject: [PATCH 5/5] fix(rendering): do not cache texture probe results from failed probe runs A transient probe failure (live-context limit hit, GPU process restarting, context lost mid-probe) used to produce an all-false format profile that was persisted in localStorage under the valid renderer key, silently pinning the degraded path on every later load. getSupportedTextureFormats now returns null when the probes could not actually run, and detection skips persisting that result, so a bad run costs one re-probe on the next load instead of poisoning the cache. --- .../src/utilities/renderingCapabilities.ts | 14 ++++++++-- packages/core/src/utilities/textureSupport.ts | 27 ++++++++++--------- .../core/test/renderingCapabilities.jest.js | 24 +++++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utilities/renderingCapabilities.ts b/packages/core/src/utilities/renderingCapabilities.ts index 62bf708562..5b6a559adb 100644 --- a/packages/core/src/utilities/renderingCapabilities.ts +++ b/packages/core/src/utilities/renderingCapabilities.ts @@ -186,8 +186,18 @@ export function detectRenderingCapabilities({ : null; if (!formats) { - formats = getSupportedTextureFormats(); - writeCachedFormats(contextInfo.renderer, contextInfo.webgl2, 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 { diff --git a/packages/core/src/utilities/textureSupport.ts b/packages/core/src/utilities/textureSupport.ts index 9cc9fd0579..37716d20a1 100644 --- a/packages/core/src/utilities/textureSupport.ts +++ b/packages/core/src/utilities/textureSupport.ts @@ -27,15 +27,6 @@ interface FormatProbe { glDataType: (gl: WebGL2RenderingContext, ext?) => number; } -const NO_SUPPORT: TextureFormatSupport = { - norm16: false, - norm16Linear: false, - float: false, - floatLinear: false, - halfFloat: false, - halfFloatLinear: false, -}; - /** * Creates the single offscreen context and point-sprite program shared by all * format probes. Context creation and shader compilation dominate the probe @@ -160,10 +151,18 @@ function probeFormat( } } -export function getSupportedTextureFormats(): TextureFormatSupport { +/** + * 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 { ...NO_SUPPORT }; + return null; } const norm16TexData = new Int16Array([ @@ -215,10 +214,14 @@ export function getSupportedTextureFormats(): TextureFormatSupport { }), }; + // 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 result; + return contextLost ? null : result; } diff --git a/packages/core/test/renderingCapabilities.jest.js b/packages/core/test/renderingCapabilities.jest.js index eb4285686a..aacad2d751 100644 --- a/packages/core/test/renderingCapabilities.jest.js +++ b/packages/core/test/renderingCapabilities.jest.js @@ -160,6 +160,30 @@ describe('renderingCapabilities', () => { 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');