diff --git a/Documentation/public/gallery/SharedContext.jpg b/Documentation/public/gallery/SharedContext.jpg new file mode 100644 index 00000000000..d802321ba67 Binary files /dev/null and b/Documentation/public/gallery/SharedContext.jpg differ diff --git a/Examples/Rendering/SharedContext/index.js b/Examples/Rendering/SharedContext/index.js new file mode 100644 index 00000000000..f83d6b4eec9 --- /dev/null +++ b/Examples/Rendering/SharedContext/index.js @@ -0,0 +1,405 @@ +import '@kitware/vtk.js/favicon'; + +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/Geometry'; + +import vtkCellArray from '@kitware/vtk.js/Common/Core/CellArray'; +import vtkPoints from '@kitware/vtk.js/Common/Core/Points'; +import vtkPolyData from '@kitware/vtk.js/Common/DataModel/PolyData'; +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkConeSource from '@kitware/vtk.js/Filters/Sources/ConeSource'; +import vtkSphereSource from '@kitware/vtk.js/Filters/Sources/SphereSource'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; +import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; +import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow'; +import vtkLight from '@kitware/vtk.js/Rendering/Core/Light'; +import vtkSharedRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/SharedRenderWindow'; +import { mat4, vec3 } from 'gl-matrix'; + +// ---------------------------------------------------------------------------- +// Alpine marker data for geo-positioned cones on terrain +// ---------------------------------------------------------------------------- + +const cities = [ + { name: 'Seefeld', lng: 11.1871, lat: 47.3305, color: [1.0, 0.5, 0.0] }, + { name: 'Innsbruck', lng: 11.4041, lat: 47.2692, color: [0.5, 1.0, 0.0] }, + { + name: 'Hall in Tirol', + lng: 11.5079, + lat: 47.2839, + color: [0.0, 0.5, 1.0], + }, +]; + +const CONE_HEIGHT_METERS = 900.0; +const CONE_CENTER_HEIGHT_RATIO = 0.25; +const SPHERE_CENTER_HEIGHT_RATIO = 1.05; + +function createRouteActor() { + const points = vtkPoints.newInstance({ dataType: 'Float64Array' }); + const pointValues = new Float64Array(cities.length * 3); + + points.setData(pointValues, 3); + const polyData = vtkPolyData.newInstance(); + polyData.setPoints(points); + polyData.setLines( + vtkCellArray.newInstance({ + values: Uint16Array.from([cities.length, 0, 1, 2]), + }) + ); + + const mapper = vtkMapper.newInstance(); + mapper.setInputData(polyData); + + const actor = vtkActor.newInstance(); + actor.setMapper(mapper); + actor.getProperty().setColor(0.95, 0.15, 0.15); + actor.getProperty().setLighting(false); + actor.getProperty().setLineWidth(3.0); + + return { actor, points, polyData }; +} + +function createCityActors(city) { + const coneSource = vtkConeSource.newInstance({ + height: 1.0, + radius: 0.3, + resolution: 12, + direction: [0, 0, -1], + capping: true, + }); + const coneMapper = vtkMapper.newInstance(); + coneMapper.setInputConnection(coneSource.getOutputPort()); + const coneActor = vtkActor.newInstance(); + coneActor.setMapper(coneMapper); + coneActor.getProperty().setColor(...city.color); + coneActor.getProperty().setAmbient(0.4); + coneActor.getProperty().setDiffuse(0.6); + + const sphereSource = vtkSphereSource.newInstance({ + radius: 0.3, + thetaResolution: 32, + phiResolution: 32, + }); + const sphereMapper = vtkMapper.newInstance(); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + const sphereActor = vtkActor.newInstance(); + sphereActor.setMapper(sphereMapper); + sphereActor.getProperty().setColor(...city.color); + sphereActor.getProperty().setAmbient(0.0); + sphereActor.getProperty().setDiffuse(1.0); + + return { city, coneActor, sphereActor }; +} + +// ---------------------------------------------------------------------------- +// Load MapLibre GL JS dynamically +// ---------------------------------------------------------------------------- + +function loadMapLibre() { + return new Promise((resolve, reject) => { + if (window.maplibregl) { + resolve(window.maplibregl); + return; + } + + const link = document.createElement('link'); + link.href = 'https://unpkg.com/maplibre-gl@5.21.1/dist/maplibre-gl.css'; + link.rel = 'stylesheet'; + document.head.appendChild(link); + + const script = document.createElement('script'); + script.src = 'https://unpkg.com/maplibre-gl@5.21.1/dist/maplibre-gl.js'; + script.onload = () => resolve(window.maplibregl); + script.onerror = reject; + document.head.appendChild(script); + }); +} + +// ---------------------------------------------------------------------------- +// Setup page layout +// ---------------------------------------------------------------------------- + +document.body.style.margin = '0'; +document.body.style.padding = '0'; + +const mapContainer = document.createElement('div'); +mapContainer.id = 'map'; +mapContainer.style.width = '100vw'; +mapContainer.style.height = '100vh'; +document.body.appendChild(mapContainer); + +const MAPLIBRE_NORTH_UP = [0, -1, 0]; +function computeCameraTargetMercator(maplibregl, transform) { + return maplibregl.MercatorCoordinate.fromLngLat( + transform.center, + transform.elevation + ); +} + +function computeCameraMercator(targetMercator, transform) { + const cameraToCenterDistanceMeters = + transform.cameraToCenterDistance / transform.pixelsPerMeter; + const metersToMercator = targetMercator.meterInMercatorCoordinateUnits(); + const cameraToCenterDistanceMercator = + cameraToCenterDistanceMeters * metersToMercator; + const dzMercator = + cameraToCenterDistanceMercator * Math.cos(transform.pitchInRadians); + const dhMercator = Math.sqrt( + Math.max( + 0, + cameraToCenterDistanceMercator * cameraToCenterDistanceMercator - + dzMercator * dzMercator + ) + ); + + return { + x: targetMercator.x + dhMercator * Math.sin(-transform.bearingInRadians), + y: targetMercator.y + dhMercator * Math.cos(-transform.bearingInRadians), + z: targetMercator.z + dzMercator, + }; +} + +function computeViewUp(transform) { + const cameraToWorldRotation = new Float64Array(16); + const viewUp = vec3.fromValues(...MAPLIBRE_NORTH_UP); + + mat4.identity(cameraToWorldRotation); + mat4.rotateZ( + cameraToWorldRotation, + cameraToWorldRotation, + transform.bearingInRadians + ); + mat4.rotateX( + cameraToWorldRotation, + cameraToWorldRotation, + -transform.pitchInRadians + ); + mat4.rotateZ( + cameraToWorldRotation, + cameraToWorldRotation, + transform.rollInRadians + ); + vec3.transformMat4(viewUp, viewUp, cameraToWorldRotation); + vec3.normalize(viewUp, viewUp); + + return viewUp; +} + +function computeViewMatrix(cameraMercator, targetMercator, viewUp) { + const eye = vec3.fromValues( + cameraMercator.x, + cameraMercator.y, + cameraMercator.z + ); + const target = vec3.fromValues( + targetMercator.x, + targetMercator.y, + targetMercator.z + ); + const viewMatrix = new Float64Array(16); + + mat4.lookAt(viewMatrix, eye, target, viewUp); + return viewMatrix; +} + +// ---------------------------------------------------------------------------- +// Main initialization +// ---------------------------------------------------------------------------- + +async function init() { + const maplibregl = await loadMapLibre(); + + // Create MapLibre map + const map = new maplibregl.Map({ + container: 'map', + zoom: 12, + center: [11.39085, 47.27574], + pitch: 70, + maxZoom: 18, + maxPitch: 85, + antialias: true, + style: { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap Contributors', + maxzoom: 19, + }, + terrainSource: { + type: 'raster-dem', + url: 'https://demotiles.maplibre.org/terrain-tiles/tiles.json', + tileSize: 256, + }, + hillshadeSource: { + type: 'raster-dem', + url: 'https://demotiles.maplibre.org/terrain-tiles/tiles.json', + tileSize: 256, + }, + }, + layers: [ + { + id: 'osm', + type: 'raster', + source: 'osm', + }, + { + id: 'hills', + type: 'hillshade', + source: 'hillshadeSource', + layout: { visibility: 'visible' }, + paint: { 'hillshade-shadow-color': '#473B24' }, + }, + ], + terrain: { + source: 'terrainSource', + exaggeration: 1, + }, + }, + }); + + await new Promise((resolve) => { + map.on('load', resolve); + }); + + // Create VTK render window and renderer + const renderWindow = vtkRenderWindow.newInstance(); + const renderer = vtkRenderer.newInstance(); + renderer.setBackground(0, 0, 0, 0); + renderer.setPreserveColorBuffer(true); + renderer.setPreserveDepthBuffer(true); + // Shared-context rendering uses a MapLibre-provided matrix, so bypass + // vtk.js automatic headlights and drive a camera-following scene light. + renderer.setAutomaticLightCreation(false); + renderWindow.addRenderer(renderer); + + const viewLight = vtkLight.newInstance(); + viewLight.setLightTypeToSceneLight(); + viewLight.setPositional(false); + renderer.addLight(viewLight); + + const cityActors = cities.map((city) => { + const actors = createCityActors(city); + actors.coneActor.setVisibility(false); + actors.sphereActor.setVisibility(false); + renderer.addActor(actors.coneActor); + renderer.addActor(actors.sphereActor); + return actors; + }); + const route = createRouteActor(); + route.actor.setVisibility(false); + renderer.addActor(route.actor); + let scenePlaced = false; + + function placeSceneOnTerrain() { + if (scenePlaced) { + return; + } + + const routePointValues = new Float64Array(cities.length * 3); + + cityActors.forEach(({ city, coneActor, sphereActor }, index) => { + const elevation = map.queryTerrainElevation([city.lng, city.lat]) || 0; + const mercator = maplibregl.MercatorCoordinate.fromLngLat( + [city.lng, city.lat], + elevation + ); + const scale = + mercator.meterInMercatorCoordinateUnits() * CONE_HEIGHT_METERS; + const sphereCenterZ = mercator.z + scale * SPHERE_CENTER_HEIGHT_RATIO; + + coneActor.setPosition( + mercator.x, + mercator.y, + mercator.z + scale * CONE_CENTER_HEIGHT_RATIO + ); + coneActor.setScale(scale, scale, scale); + coneActor.setVisibility(true); + + sphereActor.setPosition(mercator.x, mercator.y, sphereCenterZ); + sphereActor.setScale(scale * 0.9, -scale * 0.9, scale * 0.9); + sphereActor.setVisibility(true); + + routePointValues.set([mercator.x, mercator.y, sphereCenterZ], index * 3); + }); + + route.points.setData(routePointValues, 3); + route.polyData.modified(); + route.actor.setVisibility(true); + scenePlaced = true; + map.triggerRepaint(); + } + + // Store VTK objects that will be initialized in onAdd + let openglRenderWindow = null; + + // Use CustomLayerInterface for proper matrix access + const vtkLayer = { + id: 'vtk-cones', + type: 'custom', + renderingMode: '3d', + + onAdd(mapInstance, gl) { + const canvas = mapInstance.getCanvas(); + openglRenderWindow = vtkSharedRenderWindow.createFromContext(canvas, gl); + renderWindow.addView(openglRenderWindow); + }, + + render(renderGl, args) { + if (!openglRenderWindow || !scenePlaced) return; + const camera = renderer.getActiveCamera(); + const transform = map.transform; + const targetMercator = computeCameraTargetMercator(maplibregl, transform); + const cameraMercator = computeCameraMercator(targetMercator, transform); + const viewMatrix = computeViewMatrix( + cameraMercator, + targetMercator, + computeViewUp(transform) + ); + const inverseViewMatrix = new Float64Array(16); + const projectionMatrix = new Float64Array(16); + + viewLight.setPosition( + cameraMercator.x, + cameraMercator.y, + cameraMercator.z + ); + viewLight.setFocalPoint( + targetMercator.x, + targetMercator.y, + targetMercator.z + ); + + mat4.invert(inverseViewMatrix, viewMatrix); + mat4.multiply( + projectionMatrix, + args.defaultProjectionData.mainMatrix, + inverseViewMatrix + ); + + camera.setViewMatrix(viewMatrix); + camera.setProjectionMatrix(projectionMatrix); + camera.modified(); + + // Shared rendering does not preserve host GL state, so restore any state + // this layer changes after vtk.js renders. + // MapLibre's projection includes a handedness flip, so compensate while + // rendering vtk.js geometry in the shared context. + const previousFrontFace = renderGl.getParameter(renderGl.FRONT_FACE); + renderGl.frontFace(renderGl.CW); + try { + openglRenderWindow.renderShared(); + } finally { + renderGl.frontFace(previousFrontFace); + } + }, + }; + + map.addLayer(vtkLayer); + map.once('idle', placeSceneOnTerrain); +} + +init(); diff --git a/Sources/Rendering/OpenGL/RenderWindow/index.d.ts b/Sources/Rendering/OpenGL/RenderWindow/index.d.ts index e37100c7c30..6971098bad2 100644 --- a/Sources/Rendering/OpenGL/RenderWindow/index.d.ts +++ b/Sources/Rendering/OpenGL/RenderWindow/index.d.ts @@ -21,6 +21,7 @@ export interface IOpenGLRenderWindowInitialValues { context?: WebGLRenderingContext | WebGL2RenderingContext; context2D?: CanvasRenderingContext2D; canvas?: HTMLCanvasElement; + manageCanvas?: boolean; cursorVisibility?: boolean; cursor?: string; textureUnitManager?: null; @@ -93,6 +94,10 @@ export interface vtkOpenGLRenderWindow extends vtkViewNode { */ setCanvas(canvas: Nullable): boolean; + getManageCanvas(): boolean; + + setManageCanvas(manageCanvas: boolean): boolean; + /** * Check if a point is in the viewport. * @param {Number} x The x coordinate. diff --git a/Sources/Rendering/OpenGL/RenderWindow/index.js b/Sources/Rendering/OpenGL/RenderWindow/index.js index 2385223fe0d..dea32cec402 100644 --- a/Sources/Rendering/OpenGL/RenderWindow/index.js +++ b/Sources/Rendering/OpenGL/RenderWindow/index.js @@ -141,7 +141,7 @@ function vtkOpenGLRenderWindow(publicAPI, model) { const previousSize = [0, 0]; function updateWindow() { // Canvas size - if (model.renderable) { + if (model.renderable && model.manageCanvas) { if ( model.size[0] !== previousSize[0] || model.size[1] !== previousSize[1] @@ -160,7 +160,9 @@ function vtkOpenGLRenderWindow(publicAPI, model) { } // Offscreen ? - model.canvas.style.display = model.useOffScreen ? 'none' : 'block'; + if (model.manageCanvas) { + model.canvas.style.display = model.useOffScreen ? 'none' : 'block'; + } // Cursor type if (model.el) { @@ -549,15 +551,28 @@ function vtkOpenGLRenderWindow(publicAPI, model) { if (model.deleted) { return null; } + const requestedSize = + !!size || scale !== 1 + ? size || model.size.map((val) => val * scale) + : null; + const requiresCanvasResize = + requestedSize !== null && + (requestedSize[0] !== model.size[0] || + requestedSize[1] !== model.size[1]); + const screenshotSize = requiresCanvasResize ? requestedSize : null; + + if (!model.manageCanvas && requiresCanvasResize) { + throw new Error( + 'Resizing screenshot capture requires manageCanvas=true on vtkOpenGLRenderWindow' + ); + } + model.imageFormat = format; const previous = model.notifyStartCaptureImage; model.notifyStartCaptureImage = true; model._screenshot = { - size: - !!size || scale !== 1 - ? size || model.size.map((val) => val * scale) - : null, + size: screenshotSize, }; return new Promise((resolve, reject) => { @@ -1307,6 +1322,7 @@ const DEFAULT_VALUES = { context: null, context2D: null, canvas: null, + manageCanvas: true, cursorVisibility: true, cursor: 'pointer', textureUnitManager: null, @@ -1377,6 +1393,7 @@ export function extend(publicAPI, model, initialValues = {}) { 'context', 'context2D', 'canvas', + 'manageCanvas', 'renderPasses', 'notifyStartCaptureImage', 'defaultToWebgl2', diff --git a/Sources/Rendering/OpenGL/SharedRenderWindow/index.d.ts b/Sources/Rendering/OpenGL/SharedRenderWindow/index.d.ts new file mode 100644 index 00000000000..03d8e109c78 --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderWindow/index.d.ts @@ -0,0 +1,56 @@ +import vtkOpenGLRenderWindow, { + IOpenGLRenderWindowInitialValues, +} from '../RenderWindow'; + +export interface ISharedRenderWindowInitialValues + extends IOpenGLRenderWindowInitialValues { + autoClear?: boolean; + autoClearColor?: boolean; + autoClearDepth?: boolean; +} + +export type SharedRenderCallback = () => void; + +export interface vtkSharedRenderWindow extends vtkOpenGLRenderWindow { + /** Reset vtk.js render state and render into a host-owned WebGL2 context. */ + renderShared(options?: Record): void; + + /** Reset vtk.js GL state and sync size before shared-context rendering. */ + prepareSharedRender(options?: Record): void; + + syncSizeFromCanvas(): boolean; + + setRenderCallback(callback?: SharedRenderCallback | null): void; + + setAutoClear(autoClear: boolean): boolean; + getAutoClear(): boolean; + + setAutoClearColor(autoClearColor: boolean): boolean; + getAutoClearColor(): boolean; + + setAutoClearDepth(autoClearDepth: boolean): boolean; + getAutoClearDepth(): boolean; +} + +export function extend( + publicAPI: object, + model: object, + initialValues?: ISharedRenderWindowInitialValues +): void; + +export function newInstance( + initialValues?: ISharedRenderWindowInitialValues +): vtkSharedRenderWindow; + +export function createFromContext( + canvas: HTMLCanvasElement, + gl: WebGL2RenderingContext, + options?: ISharedRenderWindowInitialValues +): vtkSharedRenderWindow; + +export declare const vtkSharedRenderWindow: { + newInstance: typeof newInstance; + extend: typeof extend; + createFromContext: typeof createFromContext; +}; +export default vtkSharedRenderWindow; diff --git a/Sources/Rendering/OpenGL/SharedRenderWindow/index.js b/Sources/Rendering/OpenGL/SharedRenderWindow/index.js new file mode 100644 index 00000000000..173cc862c8e --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderWindow/index.js @@ -0,0 +1,266 @@ +import macro from 'vtk.js/Sources/macros'; +import { extend as extendOpenGLRenderWindow } from 'vtk.js/Sources/Rendering/OpenGL/RenderWindow'; +import vtkSharedRenderer from 'vtk.js/Sources/Rendering/OpenGL/SharedRenderer'; + +const PIXEL_STORE_STATE = [ + ['packAlignment', 'PACK_ALIGNMENT', 4], + ['unpackAlignment', 'UNPACK_ALIGNMENT', 4], + ['unpackFlipY', 'UNPACK_FLIP_Y_WEBGL', false], + ['unpackPremultiplyAlpha', 'UNPACK_PREMULTIPLY_ALPHA_WEBGL', false], + [ + 'unpackColorspaceConversion', + 'UNPACK_COLORSPACE_CONVERSION_WEBGL', + 'BROWSER_DEFAULT_WEBGL', + ], + ['packRowLength', 'PACK_ROW_LENGTH', 0], + ['packSkipRows', 'PACK_SKIP_ROWS', 0], + ['packSkipPixels', 'PACK_SKIP_PIXELS', 0], + ['unpackRowLength', 'UNPACK_ROW_LENGTH', 0], + ['unpackImageHeight', 'UNPACK_IMAGE_HEIGHT', 0], + ['unpackSkipRows', 'UNPACK_SKIP_ROWS', 0], + ['unpackSkipPixels', 'UNPACK_SKIP_PIXELS', 0], + ['unpackSkipImages', 'UNPACK_SKIP_IMAGES', 0], +]; + +function getSupportedState(gl, stateSpecs) { + return stateSpecs.filter(([, valueName]) => gl[valueName] !== undefined); +} + +function isWebGL2Context(gl) { + return ( + typeof WebGL2RenderingContext !== 'undefined' && + gl instanceof WebGL2RenderingContext + ); +} + +function resetGLState(gl, shaderCache) { + const pixelStoreState = getSupportedState(gl, PIXEL_STORE_STATE); + + gl.disable(gl.BLEND); + gl.disable(gl.CULL_FACE); + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.POLYGON_OFFSET_FILL); + gl.disable(gl.SCISSOR_TEST); + gl.disable(gl.STENCIL_TEST); + if (gl.SAMPLE_ALPHA_TO_COVERAGE) { + gl.disable(gl.SAMPLE_ALPHA_TO_COVERAGE); + } + + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc(gl.ONE, gl.ZERO); + gl.blendFuncSeparate(gl.ONE, gl.ZERO, gl.ONE, gl.ZERO); + gl.blendColor(0, 0, 0, 0); + + gl.colorMask(true, true, true, true); + gl.clearColor(0, 0, 0, 0); + + gl.depthMask(true); + gl.depthFunc(gl.LESS); + gl.clearDepth(1); + + gl.stencilMask(0xffffffff); + gl.stencilFunc(gl.ALWAYS, 0, 0xffffffff); + gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); + gl.clearStencil(0); + + gl.cullFace(gl.BACK); + gl.frontFace(gl.CCW); + + gl.polygonOffset(0, 0); + + gl.activeTexture(gl.TEXTURE0); + + pixelStoreState.forEach(([, paramName, defaultValue]) => { + const value = + typeof defaultValue === 'string' ? gl[defaultValue] : defaultValue; + gl.pixelStorei(gl[paramName], value); + }); + + if (gl.bindRenderbuffer) { + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + } + + gl.useProgram(null); + + gl.lineWidth(1); + + const width = gl.drawingBufferWidth; + const height = gl.drawingBufferHeight; + gl.scissor(0, 0, width, height); + gl.viewport(0, 0, width, height); + + if (gl.bindVertexArray) { + gl.bindVertexArray(null); + } + + if (shaderCache) { + shaderCache.setLastShaderProgramBound(null); + } +} + +function vtkSharedRenderWindow(publicAPI, model) { + model.classHierarchy.push('vtkSharedRenderWindow'); + let renderEventSubscription = null; + let renderCallback = null; + let suppressRenderEvent = false; + let savedEnableRender = null; + const superGet3DContext = publicAPI.get3DContext; + + function getInteractor() { + return model.renderable?.getInteractor?.(); + } + + function clearRenderEventSubscription() { + if (renderEventSubscription) { + renderEventSubscription.unsubscribe(); + renderEventSubscription = null; + } + } + + function bindRenderEvent(interactor) { + if (!interactor?.onRenderEvent || !renderCallback) { + return; + } + + renderEventSubscription = interactor.onRenderEvent(() => { + if (!suppressRenderEvent) { + renderCallback?.(); + } + }); + } + + publicAPI.renderShared = (options = {}) => { + publicAPI.prepareSharedRender(options); + try { + if (model.renderable) { + if (renderCallback && !renderEventSubscription) { + publicAPI.setRenderCallback(renderCallback); + } + + const interactor = getInteractor(); + let previousEnableRender; + if (interactor?.getEnableRender) { + previousEnableRender = interactor.getEnableRender(); + if (!previousEnableRender) { + interactor.setEnableRender(true); + } + } + + suppressRenderEvent = true; + try { + model.renderable.preRender?.(); + if (interactor) { + interactor.render(); + } else { + const views = model.renderable.getViews?.() || []; + views.forEach((view) => view.traverseAllPasses()); + } + } finally { + suppressRenderEvent = false; + if ( + interactor?.setEnableRender && + previousEnableRender !== undefined + ) { + interactor.setEnableRender(previousEnableRender); + } + } + } + } finally { + const shaderCache = publicAPI.getShaderCache(); + if (shaderCache) { + shaderCache.setLastShaderProgramBound(null); + } + } + }; + + publicAPI.get3DContext = (options) => { + if (model.context) { + return model.context; + } + return superGet3DContext(options); + }; + + /** + * Sync internal size state from the canvas's actual drawing buffer dimensions. + * Use this when sharing a WebGL context with another library (like MapLibre) + * that manages the canvas size. Returns true if size changed. + */ + publicAPI.syncSizeFromCanvas = () => { + if (!model.context) return false; + const width = model.context.drawingBufferWidth; + const height = model.context.drawingBufferHeight; + return publicAPI.setSize(width, height); + }; + + publicAPI.prepareSharedRender = () => { + publicAPI.syncSizeFromCanvas(); + const gl = model.context; + if (!gl) return; + resetGLState(gl, publicAPI.getShaderCache()); + }; + + publicAPI.setRenderCallback = (callback) => { + renderCallback = callback || null; + clearRenderEventSubscription(); + + const interactor = getInteractor(); + if (renderCallback && interactor?.onRenderEvent) { + // Render requests flow through the interactor RenderEvent; redirect those + // to the host render loop while keeping draw calls inside renderShared(). + if (savedEnableRender === null && interactor.getEnableRender) { + savedEnableRender = interactor.getEnableRender(); + } + interactor?.setEnableRender?.(false); + bindRenderEvent(interactor); + return; + } + + if (!renderCallback && interactor && savedEnableRender !== null) { + interactor.setEnableRender?.(savedEnableRender); + savedEnableRender = null; + } + }; +} + +const DEFAULT_VALUES = { + autoClear: false, + autoClearColor: true, + autoClearDepth: true, +}; + +export function extend(publicAPI, model, initialValues = {}) { + const mergedValues = { ...DEFAULT_VALUES, ...initialValues }; + extendOpenGLRenderWindow(publicAPI, model, mergedValues); + macro.setGet(publicAPI, model, [ + 'autoClear', + 'autoClearColor', + 'autoClearDepth', + ]); + vtkSharedRenderWindow(publicAPI, model); + publicAPI + .getViewNodeFactory() + .registerOverride('vtkRenderer', vtkSharedRenderer.newInstance); +} + +export const newInstance = macro.newInstance(extend, 'vtkSharedRenderWindow'); + +export function createFromContext(canvas, gl, options = {}) { + if (!isWebGL2Context(gl)) { + throw new Error('vtkSharedRenderWindow requires a WebGL2 context'); + } + if (gl.canvas && gl.canvas !== canvas) { + throw new Error( + 'vtkSharedRenderWindow requires the provided canvas to match gl.canvas' + ); + } + + return newInstance({ + ...options, + canvas, + context: gl, + manageCanvas: false, + webgl2: true, + }); +} + +export default { newInstance, extend, createFromContext }; diff --git a/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindow.js b/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindow.js new file mode 100644 index 00000000000..9642c16dbaf --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindow.js @@ -0,0 +1,272 @@ +import test from 'tape'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; +import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkSphereSource from 'vtk.js/Sources/Filters/Sources/SphereSource'; +import vtkCubeSource from 'vtk.js/Sources/Filters/Sources/CubeSource'; +import vtkSharedRenderWindow from 'vtk.js/Sources/Rendering/OpenGL/SharedRenderWindow'; +import { GET_UNDERLYING_CONTEXT } from 'vtk.js/Sources/Rendering/OpenGL/RenderWindow/ContextProxy'; + +import baseline from '../../../Core/RenderWindow/test/testMultipleRenderers.png'; +import baseline2 from '../../../Core/RenderWindow/test/testMultipleRenderers2.png'; + +test.onlyIfWebGL('Test shared render window from existing context', (t) => { + const gc = testUtils.createGarbageCollector(); + + // Create some control UI + const container = document.querySelector('body'); + const renderWindowContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(renderWindowContainer); + + // create what we will view + const renderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + + // Upper renderer + const upperRenderer = gc.registerResource(vtkRenderer.newInstance()); + upperRenderer.setViewport(0, 0.5, 1, 1); + renderWindow.addRenderer(upperRenderer); + upperRenderer.setBackground(0.32, 0.34, 0.43); + + const coneActor = gc.registerResource(vtkActor.newInstance()); + upperRenderer.addActor(coneActor); + + const coneMapper = gc.registerResource(vtkMapper.newInstance()); + coneActor.setMapper(coneMapper); + + const coneSource = gc.registerResource( + vtkConeSource.newInstance({ height: 1.0 }) + ); + coneMapper.setInputConnection(coneSource.getOutputPort()); + + // Lower left renderer + const lowerLeftRenderer = gc.registerResource(vtkRenderer.newInstance()); + lowerLeftRenderer.setViewport(0, 0, 0.5, 0.5); + renderWindow.addRenderer(lowerLeftRenderer); + lowerLeftRenderer.setBackground(0, 0.5, 0); + + const sphereActor = gc.registerResource(vtkActor.newInstance()); + lowerLeftRenderer.addActor(sphereActor); + + const sphereMapper = gc.registerResource(vtkMapper.newInstance()); + sphereActor.setMapper(sphereMapper); + + const sphereSource = gc.registerResource(vtkSphereSource.newInstance()); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + + // Lower right renderer + const lowerRightRenderer = gc.registerResource(vtkRenderer.newInstance()); + lowerRightRenderer.setViewport(0.5, 0, 1, 0.5); + renderWindow.addRenderer(lowerRightRenderer); + lowerRightRenderer.setBackground(0, 0, 0.5); + + const cubeActor = gc.registerResource(vtkActor.newInstance()); + lowerRightRenderer.addActor(cubeActor); + + const cubeMapper = gc.registerResource(vtkMapper.newInstance()); + cubeActor.setMapper(cubeMapper); + + const cubeSource = gc.registerResource(vtkCubeSource.newInstance()); + cubeMapper.setInputConnection(cubeSource.getOutputPort()); + + const glWindow = gc.registerResource(renderWindow.newAPISpecificView()); + glWindow.setContainer(renderWindowContainer); + renderWindow.addView(glWindow); + glWindow.setSize(400, 400); + + // Force context creation on the OpenGL render window + const glProxy = glWindow.get3DContext(); + const gl = glProxy?.[GET_UNDERLYING_CONTEXT]?.(); + t.ok(gl, 'Shared WebGL context created'); + + const sharedWindow = gc.registerResource( + vtkSharedRenderWindow.createFromContext(glWindow.getCanvas(), gl) + ); + sharedWindow.setAutoClear(true); + sharedWindow.setSize(400, 400); + + renderWindow.removeView(glWindow); + renderWindow.addView(sharedWindow); + + upperRenderer.resetCamera(); + lowerLeftRenderer.resetCamera(); + lowerRightRenderer.resetCamera(); + + const promise = sharedWindow + .captureNextImage() + .then((image) => + testUtils.compareImages( + image, + [baseline, baseline2], + 'Rendering/OpenGL/SharedRenderWindow/testSharedRenderWindow', + t, + 5 + ) + ) + .finally(gc.releaseResources); + sharedWindow.renderShared(); + return promise; +}); + +test.onlyIfWebGL( + 'Test shared render window keeps vtkSharedRenderer local to its factory', + (t) => { + const gc = testUtils.createGarbageCollector(); + const container = document.querySelector('body'); + + const sharedContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(sharedContainer); + + const sharedRenderWindow = gc.registerResource( + vtkRenderWindow.newInstance() + ); + const sharedRenderer = gc.registerResource(vtkRenderer.newInstance()); + sharedRenderWindow.addRenderer(sharedRenderer); + + const sharedGlWindow = gc.registerResource( + sharedRenderWindow.newAPISpecificView() + ); + sharedGlWindow.setContainer(sharedContainer); + sharedRenderWindow.addView(sharedGlWindow); + sharedGlWindow.setSize(200, 200); + + const sharedGlProxy = sharedGlWindow.get3DContext(); + const sharedGl = sharedGlProxy?.[GET_UNDERLYING_CONTEXT]?.(); + t.ok(sharedGl, 'Shared-context source window created'); + + const sharedWindow = gc.registerResource( + vtkSharedRenderWindow.createFromContext( + sharedGlWindow.getCanvas(), + sharedGl + ) + ); + sharedRenderWindow.removeView(sharedGlWindow); + sharedRenderWindow.addView(sharedWindow); + sharedRenderWindow.render(); + + const sharedRendererNode = sharedWindow.getViewNodeFor(sharedRenderer); + t.ok( + sharedRendererNode?.isA('vtkSharedRenderer'), + 'Shared window uses vtkSharedRenderer' + ); + + const normalContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(normalContainer); + + const normalRenderWindow = gc.registerResource( + vtkRenderWindow.newInstance() + ); + const normalRenderer = gc.registerResource(vtkRenderer.newInstance()); + normalRenderWindow.addRenderer(normalRenderer); + + const normalGlWindow = gc.registerResource( + normalRenderWindow.newAPISpecificView() + ); + normalGlWindow.setContainer(normalContainer); + normalRenderWindow.addView(normalGlWindow); + normalGlWindow.setSize(200, 200); + normalRenderWindow.render(); + + const normalRendererNode = normalGlWindow.getViewNodeFor(normalRenderer); + t.ok( + normalRendererNode?.isA('vtkOpenGLRenderer'), + 'Normal window keeps vtkOpenGLRenderer' + ); + t.notOk( + normalRendererNode?.isA('vtkSharedRenderer'), + 'Normal window does not inherit vtkSharedRenderer' + ); + + gc.releaseResources(); + t.end(); + } +); + +test.onlyIfWebGL('Test shared render window rejects WebGL1 contexts', (t) => { + const canvas = document.createElement('canvas'); + const gl = + canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + + if (!gl) { + t.pass('WebGL1 unavailable in this environment'); + t.end(); + return; + } + + t.throws( + () => vtkSharedRenderWindow.createFromContext(canvas, gl), + /WebGL2 context/, + 'createFromContext rejects WebGL1 contexts' + ); + t.end(); +}); + +test.onlyIfWebGL( + 'Test shared render window does not manage external canvas DOM state', + (t) => { + const gc = testUtils.createGarbageCollector(); + const container = document.querySelector('body'); + const renderWindowContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(renderWindowContainer); + + const renderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const renderer = gc.registerResource(vtkRenderer.newInstance()); + renderWindow.addRenderer(renderer); + + const glWindow = gc.registerResource(renderWindow.newAPISpecificView()); + glWindow.setContainer(renderWindowContainer); + renderWindow.addView(glWindow); + glWindow.setSize(200, 200); + + const glProxy = glWindow.get3DContext(); + const gl = glProxy?.[GET_UNDERLYING_CONTEXT]?.(); + t.ok(gl, 'Shared WebGL context created'); + + const canvas = glWindow.getCanvas(); + canvas.style.display = 'inline-block'; + const originalWidth = canvas.width; + const originalHeight = canvas.height; + const originalDisplay = canvas.style.display; + + const sharedWindow = gc.registerResource( + vtkSharedRenderWindow.createFromContext(canvas, gl) + ); + renderWindow.removeView(glWindow); + renderWindow.addView(sharedWindow); + + sharedWindow.setSize(123, 77); + sharedWindow.setUseOffScreen(true); + + t.equal(canvas.width, originalWidth, 'External canvas width preserved'); + t.equal(canvas.height, originalHeight, 'External canvas height preserved'); + t.equal( + canvas.style.display, + originalDisplay, + 'External canvas display preserved' + ); + + t.throws( + () => + sharedWindow.captureNextImage('image/png', { + size: [100, 100], + }), + /manageCanvas=true/, + 'Resize capture rejects when canvas management is disabled' + ); + + gc.releaseResources(); + t.end(); + } +); diff --git a/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindowGLState.js b/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindowGLState.js new file mode 100644 index 00000000000..597105ba877 --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindowGLState.js @@ -0,0 +1,194 @@ +import test from 'tape'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; +import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkSharedRenderWindow from 'vtk.js/Sources/Rendering/OpenGL/SharedRenderWindow'; +import { GET_UNDERLYING_CONTEXT } from 'vtk.js/Sources/Rendering/OpenGL/RenderWindow/ContextProxy'; + +function createDirtyHostResources(gl) { + return { + arrayBuffer: gl.createBuffer(), + elementArrayBuffer: gl.createBuffer(), + renderbuffer: gl.createRenderbuffer(), + texture: gl.createTexture(), + }; +} + +function deleteDirtyHostResources(gl, resources) { + gl.deleteBuffer(resources.arrayBuffer); + gl.deleteBuffer(resources.elementArrayBuffer); + gl.deleteRenderbuffer(resources.renderbuffer); + gl.deleteTexture(resources.texture); +} + +function dirtyHostGLState(gl, resources) { + gl.enable(gl.BLEND); + gl.enable(gl.CULL_FACE); + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.SCISSOR_TEST); + gl.depthMask(false); + gl.colorMask(true, false, true, false); + gl.clearColor(1, 0, 0, 1); + gl.scissor(20, 30, 80, 90); + gl.viewport(20, 30, 120, 130); + + gl.bindBuffer(gl.ARRAY_BUFFER, resources.arrayBuffer); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, resources.elementArrayBuffer); + gl.bindRenderbuffer(gl.RENDERBUFFER, resources.renderbuffer); + + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, resources.texture); + + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 2); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); +} + +function createHostFramebuffer(gl, width, height) { + const framebuffer = gl.createFramebuffer(); + const colorTexture = gl.createTexture(); + const depthRenderbuffer = gl.createRenderbuffer(); + + gl.bindTexture(gl.TEXTURE_2D, colorTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null + ); + + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + colorTexture, + 0 + ); + + gl.bindRenderbuffer(gl.RENDERBUFFER, depthRenderbuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + gl.DEPTH_ATTACHMENT, + gl.RENDERBUFFER, + depthRenderbuffer + ); + + return { framebuffer, colorTexture, depthRenderbuffer }; +} + +function deleteHostFramebuffer(gl, fbo) { + gl.deleteFramebuffer(fbo.framebuffer); + gl.deleteTexture(fbo.colorTexture); + gl.deleteRenderbuffer(fbo.depthRenderbuffer); +} + +function createSharedWindow(gc, t) { + const container = document.querySelector('body'); + const renderWindowContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(renderWindowContainer); + + const renderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const renderer = gc.registerResource(vtkRenderer.newInstance()); + renderWindow.addRenderer(renderer); + renderer.setBackground(0.2, 0.3, 0.4); + + const actor = gc.registerResource(vtkActor.newInstance()); + renderer.addActor(actor); + const mapper = gc.registerResource(vtkMapper.newInstance()); + actor.setMapper(mapper); + const cone = gc.registerResource(vtkConeSource.newInstance()); + mapper.setInputConnection(cone.getOutputPort()); + + const glWindow = gc.registerResource(renderWindow.newAPISpecificView()); + glWindow.setContainer(renderWindowContainer); + renderWindow.addView(glWindow); + glWindow.setSize(400, 400); + + const glProxy = glWindow.get3DContext(); + const gl = glProxy?.[GET_UNDERLYING_CONTEXT]?.(); + t.ok(gl, 'WebGL context created'); + + const sharedWindow = gc.registerResource( + vtkSharedRenderWindow.createFromContext(glWindow.getCanvas(), gl) + ); + sharedWindow.setAutoClear(true); + sharedWindow.setSize(400, 400); + renderWindow.removeView(glWindow); + renderWindow.addView(sharedWindow); + renderer.resetCamera(); + + return { gl, sharedWindow }; +} + +test.onlyIfWebGL( + 'Test renderShared resets vtk.js GL state after host modifications', + (t) => { + const gc = testUtils.createGarbageCollector(); + const { gl, sharedWindow } = createSharedWindow(gc, t); + + const hostResources = createDirtyHostResources(gl); + dirtyHostGLState(gl, hostResources); + + sharedWindow.renderShared(); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const px = new Uint8Array(4); + gl.readPixels(5, 5, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + t.ok( + px[0] > 20 && px[1] > 40 && px[2] > 60, + `Shared render cleared the full framebuffer despite host state, got rgba(${px[0]},${px[1]},${px[2]},${px[3]})` + ); + + deleteDirtyHostResources(gl, hostResources); + gc.releaseResources(); + t.end(); + } +); + +test.onlyIfWebGL( + 'Test renderShared draws into the currently bound host framebuffer', + (t) => { + const gc = testUtils.createGarbageCollector(); + const { gl, sharedWindow } = createSharedWindow(gc, t); + + const hostFramebuffer = createHostFramebuffer(gl, 400, 400); + gl.bindFramebuffer(gl.FRAMEBUFFER, hostFramebuffer.framebuffer); + t.equal( + gl.checkFramebufferStatus(gl.FRAMEBUFFER), + gl.FRAMEBUFFER_COMPLETE, + 'Host framebuffer is complete' + ); + + sharedWindow.renderShared(); + + gl.bindFramebuffer(gl.FRAMEBUFFER, hostFramebuffer.framebuffer); + const px = new Uint8Array(4); + gl.readPixels(200, 200, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + t.ok( + px[0] > 0 || px[1] > 0 || px[2] > 0, + `Shared render wrote into the host framebuffer, got rgba(${px[0]},${px[1]},${px[2]},${px[3]})` + ); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + deleteHostFramebuffer(gl, hostFramebuffer); + gc.releaseResources(); + t.end(); + } +); diff --git a/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindowHostSurvival.js b/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindowHostSurvival.js new file mode 100644 index 00000000000..f5840d267e0 --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderWindow/test/testSharedRenderWindowHostSurvival.js @@ -0,0 +1,145 @@ +import test from 'tape'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; +import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkSharedRenderWindow from 'vtk.js/Sources/Rendering/OpenGL/SharedRenderWindow'; +import { GET_UNDERLYING_CONTEXT } from 'vtk.js/Sources/Rendering/OpenGL/RenderWindow/ContextProxy'; + +const VERT_SRC = ` + attribute vec2 aPos; + void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +`; +const FRAG_SRC = ` + precision mediump float; + uniform vec4 uColor; + void main() { gl_FragColor = uColor; } +`; + +function createHostProgram(gl) { + const vs = gl.createShader(gl.VERTEX_SHADER); + gl.shaderSource(vs, VERT_SRC); + gl.compileShader(vs); + + const fs = gl.createShader(gl.FRAGMENT_SHADER); + gl.shaderSource(fs, FRAG_SRC); + gl.compileShader(fs); + + const prog = gl.createProgram(); + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + return prog; +} + +function createHostVAO(gl, prog) { + const verts = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW); + const loc = gl.getAttribLocation(prog, 'aPos'); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + return vao; +} + +function setupHostState(gl, prog, vao, width, height) { + gl.useProgram(prog); + gl.bindVertexArray(vao); + gl.viewport(width / 2, 0, width / 2, height); + gl.scissor(width / 2, 0, width / 2, height); + gl.enable(gl.SCISSOR_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); +} + +function hostDraw(gl, prog) { + const colorLoc = gl.getUniformLocation(prog, 'uColor'); + gl.uniform4f(colorLoc, 0.0, 0.8, 0.0, 1.0); + gl.drawArrays(gl.TRIANGLES, 0, 6); +} + +test.onlyIfWebGL( + 'Test host raw WebGL rendering can resume after renderShared when state is rebound', + (t) => { + const gc = testUtils.createGarbageCollector(); + + const container = document.querySelector('body'); + const renderWindowContainer = gc.registerDOMElement( + document.createElement('div') + ); + container.appendChild(renderWindowContainer); + + const renderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const renderer = gc.registerResource(vtkRenderer.newInstance()); + renderWindow.addRenderer(renderer); + renderer.setBackground(0.1, 0.1, 0.2); + + const actor = gc.registerResource(vtkActor.newInstance()); + renderer.addActor(actor); + const mapper = gc.registerResource(vtkMapper.newInstance()); + actor.setMapper(mapper); + const cone = gc.registerResource(vtkConeSource.newInstance()); + mapper.setInputConnection(cone.getOutputPort()); + + const glWindow = gc.registerResource(renderWindow.newAPISpecificView()); + glWindow.setContainer(renderWindowContainer); + renderWindow.addView(glWindow); + glWindow.setSize(400, 400); + + const glProxy = glWindow.get3DContext(); + const gl = glProxy?.[GET_UNDERLYING_CONTEXT]?.(); + t.ok(gl, 'WebGL context created'); + + const sharedWindow = gc.registerResource( + vtkSharedRenderWindow.createFromContext(glWindow.getCanvas(), gl) + ); + sharedWindow.setAutoClear(true); + sharedWindow.setSize(400, 400); + renderWindow.removeView(glWindow); + renderWindow.addView(sharedWindow); + renderer.resetCamera(); + + const hostProg = createHostProgram(gl); + const hostVAO = createHostVAO(gl, hostProg); + const W = 400; + const H = 400; + + setupHostState(gl, hostProg, hostVAO, W, H); + hostDraw(gl, hostProg); + + sharedWindow.renderShared(); + + setupHostState(gl, hostProg, hostVAO, W, H); + hostDraw(gl, hostProg); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const px = new Uint8Array(4); + gl.readPixels(300, 200, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + t.ok( + px[1] > 150 && px[0] < 50 && px[2] < 50, + `Right half center should be green, got rgba(${px[0]},${px[1]},${px[2]},${px[3]})` + ); + + gl.readPixels(100, 200, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + const isHostGreen = px[1] > 150 && px[0] < 50 && px[2] < 50; + t.notOk( + isHostGreen, + `Left half should not be host green, got rgba(${px[0]},${px[1]},${px[2]},${px[3]})` + ); + + gl.deleteProgram(hostProg); + gl.deleteVertexArray(hostVAO); + gc.releaseResources(); + t.end(); + } +); diff --git a/Sources/Rendering/OpenGL/SharedRenderer/index.d.ts b/Sources/Rendering/OpenGL/SharedRenderer/index.d.ts new file mode 100644 index 00000000000..d7f13777a9b --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderer/index.d.ts @@ -0,0 +1,58 @@ +import vtkViewNode, { + IViewNodeInitialValues, +} from '../../../Rendering/SceneGraph/ViewNode'; + +export interface ISharedRendererInitialValues extends IViewNodeInitialValues { + context?: WebGLRenderingContext | WebGL2RenderingContext | null; + selector?: any; + _openGLRenderWindow?: any; +} + +export interface vtkSharedRenderer extends vtkViewNode { + buildPass(prepass: boolean): void; + + updateLights(): number; + + zBufferPass(prepass: boolean): void; + + opaqueZBufferPass(prepass: boolean): void; + + cameraPass(prepass: boolean): void; + + getAspectRatio(): number; + + getTiledSizeAndOrigin(): { + usize: number; + vsize: number; + lowerLeftU: number; + lowerLeftV: number; + }; + + clear(): void; + + releaseGraphicsResources(): void; + + setOpenGLRenderWindow(rw: any): void; + + getShaderCache(): any; + + getSelector(): any; + + setSelector(selector: any): boolean; +} + +export function extend( + publicAPI: object, + model: object, + initialValues?: ISharedRendererInitialValues +): void; + +export function newInstance( + initialValues?: ISharedRendererInitialValues +): vtkSharedRenderer; + +export declare const vtkSharedRenderer: { + newInstance: typeof newInstance; + extend: typeof extend; +}; +export default vtkSharedRenderer; diff --git a/Sources/Rendering/OpenGL/SharedRenderer/index.js b/Sources/Rendering/OpenGL/SharedRenderer/index.js new file mode 100644 index 00000000000..a873113a0cf --- /dev/null +++ b/Sources/Rendering/OpenGL/SharedRenderer/index.js @@ -0,0 +1,62 @@ +import macro from 'vtk.js/Sources/macros'; +import { extend as extendOpenGLRenderer } from 'vtk.js/Sources/Rendering/OpenGL/Renderer'; + +function vtkSharedRenderer(publicAPI, model) { + model.classHierarchy.push('vtkSharedRenderer'); + + publicAPI.clear = () => { + const gl = model.context; + const openGLRenderWindow = model._openGLRenderWindow; + + const autoClear = openGLRenderWindow?.getAutoClear?.() ?? true; + if (autoClear === false) { + const ts = publicAPI.getTiledSizeAndOrigin(); + gl.enable(gl.SCISSOR_TEST); + gl.scissor(ts.lowerLeftU, ts.lowerLeftV, ts.usize, ts.vsize); + gl.viewport(ts.lowerLeftU, ts.lowerLeftV, ts.usize, ts.vsize); + gl.enable(gl.DEPTH_TEST); + return; + } + + const shouldClearColor = openGLRenderWindow?.getAutoClearColor?.() ?? true; + const shouldClearDepth = openGLRenderWindow?.getAutoClearDepth?.() ?? true; + + let clearMask = 0; + + if (!model.renderable.getTransparent() && shouldClearColor) { + const background = model.renderable.getBackgroundByReference(); + gl.clearColor(background[0], background[1], background[2], background[3]); + // eslint-disable-next-line no-bitwise + clearMask |= gl.COLOR_BUFFER_BIT; + } + + if (!model.renderable.getPreserveDepthBuffer() && shouldClearDepth) { + gl.clearDepth(1.0); + // eslint-disable-next-line no-bitwise + clearMask |= gl.DEPTH_BUFFER_BIT; + gl.depthMask(true); + } + + gl.colorMask(true, true, true, true); + + const ts = publicAPI.getTiledSizeAndOrigin(); + gl.enable(gl.SCISSOR_TEST); + gl.scissor(ts.lowerLeftU, ts.lowerLeftV, ts.usize, ts.vsize); + gl.viewport(ts.lowerLeftU, ts.lowerLeftV, ts.usize, ts.vsize); + + if (clearMask) { + gl.clear(clearMask); + } + + gl.enable(gl.DEPTH_TEST); + }; +} + +export function extend(publicAPI, model, initialValues = {}) { + extendOpenGLRenderer(publicAPI, model, initialValues); + vtkSharedRenderer(publicAPI, model); +} + +export const newInstance = macro.newInstance(extend, 'vtkSharedRenderer'); + +export default { newInstance, extend }; diff --git a/Sources/Rendering/OpenGL/ViewNodeFactory/index.js b/Sources/Rendering/OpenGL/ViewNodeFactory/index.js index c72cbc1b9eb..116489666c1 100644 --- a/Sources/Rendering/OpenGL/ViewNodeFactory/index.js +++ b/Sources/Rendering/OpenGL/ViewNodeFactory/index.js @@ -27,12 +27,17 @@ const DEFAULT_VALUES = {}; export function extend(publicAPI, model, initialValues = {}) { Object.assign(model, DEFAULT_VALUES, initialValues); - // Static class mapping shared across instances - model.overrides = CLASS_MAPPING; + // Each factory inherits the shared OpenGL mappings while still allowing + // instance-local overrides such as vtkSharedRenderer. + model.overrides = Object.create(CLASS_MAPPING); // Inheritance vtkViewNodeFactory.extend(publicAPI, model, initialValues); + publicAPI.registerOverride = (className, fn) => { + model.overrides[className] = fn; + }; + // Object methods vtkOpenGLViewNodeFactory(publicAPI, model); } diff --git a/Sources/Rendering/SceneGraph/ViewNodeFactory/index.js b/Sources/Rendering/SceneGraph/ViewNodeFactory/index.js index d36b128a38e..b7904e1999f 100644 --- a/Sources/Rendering/SceneGraph/ViewNodeFactory/index.js +++ b/Sources/Rendering/SceneGraph/ViewNodeFactory/index.js @@ -21,9 +21,8 @@ function vtkViewNodeFactory(publicAPI, model) { let cpt = 0; let className = dataObject.getClassName(cpt++); let isObject = false; - const keys = Object.keys(model.overrides); while (className && !isObject) { - if (keys.indexOf(className) !== -1) { + if (className in model.overrides) { isObject = true; } else { className = dataObject.getClassName(cpt++);