diff --git a/Sources/Interaction/Manipulators/MouseCameraUnicamRotateManipulator/index.js b/Sources/Interaction/Manipulators/MouseCameraUnicamRotateManipulator/index.js index a0127e47b77..903ad1cc9ca 100644 --- a/Sources/Interaction/Manipulators/MouseCameraUnicamRotateManipulator/index.js +++ b/Sources/Interaction/Manipulators/MouseCameraUnicamRotateManipulator/index.js @@ -268,11 +268,14 @@ function vtkMouseCameraUnicamRotateManipulator(publicAPI, model) { // to the distance from the camera's position to the focal point. // This seems like an arbitrary, but perhaps reasonable, default value. let selections = null; - if (model.useHardwareSelector) { - const selector = interactor.getView().getSelector(); + // A selector without a synchronous select() (WebGPU) uses the point + // picker. + const view = interactor.getView(); + if (model.useHardwareSelector && view.getSelector().select) { + const selector = view.getSelector(); selector.setCaptureZValues(true); selector.setFieldAssociation(FieldAssociations.FIELD_ASSOCIATION_POINTS); - selector.attach(interactor.getView(), renderer); + selector.attach(view, renderer); selector.setArea(position.x, position.y, position.x, position.y); selections = selector.select(); diff --git a/Sources/Rendering/Core/HardwareSelector/Constants.d.ts b/Sources/Rendering/Core/HardwareSelector/Constants.d.ts new file mode 100644 index 00000000000..a711b3885cf --- /dev/null +++ b/Sources/Rendering/Core/HardwareSelector/Constants.d.ts @@ -0,0 +1,8 @@ +export declare enum PassTypes { + MIN_KNOWN_PASS = 0, + ACTOR_PASS = 0, + COMPOSITE_INDEX_PASS = 1, + ID_LOW24 = 2, + ID_HIGH24 = 3, + MAX_KNOWN_PASS = 3, +} diff --git a/Sources/Rendering/Core/HardwareSelector/Constants.js b/Sources/Rendering/Core/HardwareSelector/Constants.js new file mode 100644 index 00000000000..8a63669a5c3 --- /dev/null +++ b/Sources/Rendering/Core/HardwareSelector/Constants.js @@ -0,0 +1,12 @@ +export const PassTypes = { + MIN_KNOWN_PASS: 0, + ACTOR_PASS: 0, + COMPOSITE_INDEX_PASS: 1, + ID_LOW24: 2, + ID_HIGH24: 3, + MAX_KNOWN_PASS: 3, +}; + +export default { + PassTypes, +}; diff --git a/Sources/Rendering/Core/HardwareSelector/PixelSelectionHelper.js b/Sources/Rendering/Core/HardwareSelector/PixelSelectionHelper.js new file mode 100644 index 00000000000..d7596882097 --- /dev/null +++ b/Sources/Rendering/Core/HardwareSelector/PixelSelectionHelper.js @@ -0,0 +1,163 @@ +import macro from 'vtk.js/Sources/macros'; +import vtkSelectionNode from 'vtk.js/Sources/Common/DataModel/SelectionNode'; +import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; + +const { SelectionContent, SelectionField } = vtkSelectionNode; +const { FieldAssociations } = vtkDataSet; +const { vtkErrorMacro } = macro; + +// These functions build selections from the pixels that a backend read back. +// The backend gives readPixel(displayPosition), which decodes one pixel and +// returns null when the pixel is outside the read area or hits no prop. +// Otherwise it returns { valid: true, prop, propID, compositeID, +// attributeID } and, with captured z values, zValue and displayPosition. + +function readPixelAt(readPixel, position, outSelectedPosition) { + outSelectedPosition[0] = position[0]; + outSelectedPosition[1] = position[1]; + const info = readPixel(position); + if (info && info.valid) { + return info; + } + return null; +} + +// Look at the pixel, then at the sides of boxes that grow around it, up to +// maxDistance - 1 pixels. Returns the first hit. outSelectedPosition gets +// the position of that hit. +export function getPixelInformation( + readPixel, + inDisplayPosition, + maxDistance, + outSelectedPosition +) { + let info = readPixelAt(readPixel, inDisplayPosition, outSelectedPosition); + if (info || !(maxDistance > 0)) { + return info; + } + + const dispPos = [inDisplayPosition[0], inDisplayPosition[1]]; + const curPos = [0, 0]; + for (let dist = 1; dist < maxDistance; ++dist) { + // Vertical sides of the box. + for (let y = Math.max(dispPos[1] - dist, 0); y <= dispPos[1] + dist; ++y) { + curPos[1] = y; + if (dispPos[0] >= dist) { + curPos[0] = dispPos[0] - dist; + info = readPixelAt(readPixel, curPos, outSelectedPosition); + if (info) { + return info; + } + } + curPos[0] = dispPos[0] + dist; + info = readPixelAt(readPixel, curPos, outSelectedPosition); + if (info) { + return info; + } + } + // Horizontal sides of the box. + for ( + let x = Math.max(dispPos[0] - (dist - 1), 0); + x <= dispPos[0] + (dist - 1); + ++x + ) { + curPos[0] = x; + if (dispPos[1] >= dist) { + curPos[1] = dispPos[1] - dist; + info = readPixelAt(readPixel, curPos, outSelectedPosition); + if (info) { + return info; + } + } + curPos[1] = dispPos[1] + dist; + info = readPixelAt(readPixel, curPos, outSelectedPosition); + if (info) { + return info; + } + } + } + + // nothing hit. + outSelectedPosition[0] = inDisplayPosition[0]; + outSelectedPosition[1] = inDisplayPosition[1]; + return null; +} + +function getInfoHash(info) { + return `${info.propID} ${info.compositeID}`; +} + +function convertSelection(dataMap, options) { + const sel = []; + dataMap.forEach((value) => { + const child = vtkSelectionNode.newInstance(); + child.setContentType(SelectionContent.INDICES); + switch (options.fieldAssociation) { + case FieldAssociations.FIELD_ASSOCIATION_CELLS: + child.setFieldType(SelectionField.CELL); + break; + case FieldAssociations.FIELD_ASSOCIATION_POINTS: + child.setFieldType(SelectionField.POINT); + break; + default: + vtkErrorMacro('Unknown field association'); + } + const properties = child.getProperties(); + properties.propID = value.info.propID; + properties.prop = value.info.prop; + properties.compositeID = value.info.compositeID; + properties.attributeID = value.info.attributeID; + properties.pixelCount = value.pixelCount; + if (options.captureZValues) { + const [x, y] = value.info.displayPosition; + properties.displayPosition = [x, y, value.info.zValue]; + properties.worldPosition = options.displayToWorld( + x, + y, + value.info.zValue + ); + } + child.setSelectionList(value.attributeIDs); + sel.push(child); + }); + return sel; +} + +// Make one selection node for each prop and composite id that the pixels of +// the area fx1, fy1, fx2, fy2 hit. options has fieldAssociation, +// captureZValues and, with captureZValues, displayToWorld(x, y, z). +export function generateSelection(readPixel, fx1, fy1, fx2, fy2, options) { + const x1 = Math.floor(fx1); + const y1 = Math.floor(fy1); + const x2 = Math.floor(fx2); + const y2 = Math.floor(fy2); + + const dataMap = new Map(); + for (let yy = y1; yy <= y2; yy++) { + for (let xx = x1; xx <= x2; xx++) { + const info = readPixel([xx, yy]); + if (info && info.valid) { + const hash = getInfoHash(info); + let dmv = dataMap.get(hash); + if (!dmv) { + dmv = { info, pixelCount: 0, attributeIDs: [] }; + dataMap.set(hash, dmv); + } else if (options.captureZValues && info.zValue < dmv.info.zValue) { + dmv.info = info; + } + dmv.pixelCount++; + // A pixel can hit a prop with no attribute id: in point selection, + // the WebGL backend writes ids only where it draws the points. + if ( + info.attributeID !== undefined && + dmv.attributeIDs.indexOf(info.attributeID) === -1 + ) { + dmv.attributeIDs.push(info.attributeID); + } + } + } + } + return convertSelection(dataMap, options); +} + +export default { getPixelInformation, generateSelection }; diff --git a/Sources/Rendering/Core/HardwareSelector/example/index.js b/Sources/Rendering/Core/HardwareSelector/example/index.js index 56d6e0cef41..a1cd8b24dfe 100644 --- a/Sources/Rendering/Core/HardwareSelector/example/index.js +++ b/Sources/Rendering/Core/HardwareSelector/example/index.js @@ -3,6 +3,8 @@ import '@kitware/vtk.js/favicon'; +import GUI from 'lil-gui'; + // Load the rendering pieces we want to use (for both WebGL and WebGPU) import '@kitware/vtk.js/Rendering/Profiles/Geometry'; import '@kitware/vtk.js/Rendering/Profiles/Glyph'; @@ -24,6 +26,24 @@ import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import { FieldAssociations } from '@kitware/vtk.js/Common/DataModel/DataSet/Constants'; import { Representation } from '@kitware/vtk.js/Rendering/Core/Property/Constants'; +// ---------------------------------------------------------------------------- +// Renderer selector +// ---------------------------------------------------------------------------- + +const rendererSelectorParams = { + viewAPI: + new URLSearchParams(window.location.search).get('viewAPI') || 'WebGL', +}; +const rendererSelectorGui = new GUI(); +rendererSelectorGui + .add(rendererSelectorParams, 'viewAPI', ['WebGL', 'WebGPU']) + .name('Renderer') + .onChange((api) => { + const query = new URLSearchParams(window.location.search); + query.set('viewAPI', api); + window.location.search = query.toString(); + }); + // ---------------------------------------------------------------------------- // Constants // ---------------------------------------------------------------------------- @@ -32,28 +52,34 @@ const WHITE = [1, 1, 1]; const GREEN = [0.1, 0.8, 0.1]; // ---------------------------------------------------------------------------- -// Create DOM tooltip +// Selection information in a read only text area of the GUI // ---------------------------------------------------------------------------- -const tooltipsElem = document.createElement('div'); -tooltipsElem.style.position = 'absolute'; -tooltipsElem.style.top = 0; -tooltipsElem.style.left = 0; -tooltipsElem.style.padding = '10px'; -tooltipsElem.style.zIndex = 1; -tooltipsElem.style.background = 'white'; -tooltipsElem.style.textAlign = 'center'; - -const positionTooltipElem = document.createElement('div'); -const fieldIdTooltipElem = document.createElement('div'); -const compositeIdTooltipElem = document.createElement('div'); -const propIdTooltipElem = document.createElement('div'); -tooltipsElem.appendChild(positionTooltipElem); -tooltipsElem.appendChild(propIdTooltipElem); -tooltipsElem.appendChild(fieldIdTooltipElem); -tooltipsElem.appendChild(compositeIdTooltipElem); - -document.querySelector('body').appendChild(tooltipsElem); +const pickInfo = { position: '', prop: '', field: '', composite: '' }; + +const pickInfoElem = document.createElement('textarea'); +pickInfoElem.readOnly = true; +pickInfoElem.rows = 4; +pickInfoElem.style.width = '100%'; +pickInfoElem.style.boxSizing = 'border-box'; +pickInfoElem.style.resize = 'none'; +pickInfoElem.style.font = 'inherit'; +pickInfoElem.style.color = 'inherit'; +pickInfoElem.style.background = 'var(--widget-color)'; +pickInfoElem.style.border = 'none'; +pickInfoElem.style.padding = '4px'; +rendererSelectorGui.addFolder('Selection').$children.appendChild(pickInfoElem); + +const updatePickInfo = () => { + pickInfoElem.value = [ + pickInfo.position, + pickInfo.prop, + pickInfo.field, + pickInfo.composite, + ] + .filter(Boolean) + .join('\n'); +}; // ---------------------------------------------------------------------------- // Create 4 objects @@ -198,7 +224,9 @@ pointerMapper.setInputConnection(pointerSource.getOutputPort()); // Create rendering infrastructure // ---------------------------------------------------------------------------- -const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance(); +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({ + viewAPI: rendererSelectorParams.viewAPI, +}); const renderer = fullScreenRenderer.getRenderer(); const renderWindow = renderer.getRenderWindow(); const interactor = renderWindow.getInteractor(); @@ -222,12 +250,18 @@ renderWindow.render(); const hardwareSelector = apiSpecificRenderWindow.getSelector(); hardwareSelector.setCaptureZValues(true); -// TODO: bug in FIELD_ASSOCIATION_POINTS mode -// hardwareSelector.setFieldAssociation( -// FieldAssociations.FIELD_ASSOCIATION_POINTS -// ); hardwareSelector.setFieldAssociation(FieldAssociations.FIELD_ASSOCIATION_CELLS); +rendererSelectorGui + .add({ field: FieldAssociations.FIELD_ASSOCIATION_CELLS }, 'field', { + Cells: FieldAssociations.FIELD_ASSOCIATION_CELLS, + Points: FieldAssociations.FIELD_ASSOCIATION_POINTS, + }) + .name('Field') + .onChange((value) => { + hardwareSelector.setFieldAssociation(Number(value)); + }); + // ---------------------------------------------------------------------------- // Create Mouse listener for picking on mouse move // ---------------------------------------------------------------------------- @@ -247,35 +281,38 @@ function eventToWindowXY(event) { let needGlyphCleanup = false; let lastProcessedActor = null; -const updatePositionTooltip = (worldPosition) => { +const updatePositionInfo = (worldPosition) => { if (lastProcessedActor) { - positionTooltipElem.innerHTML = `Position: ${worldPosition + pickInfo.position = `Position: ${worldPosition .map((v) => v.toFixed(3)) .join(' , ')}`; } else { - positionTooltipElem.innerHTML = ''; + pickInfo.position = ''; } + updatePickInfo(); }; -const updateAssociationTooltip = (type, id) => { +const updateAssociationInfo = (type, id) => { if (type !== undefined && id !== undefined) { - fieldIdTooltipElem.innerHTML = `${type} ID: ${id}`; + pickInfo.field = `${type} ID: ${id}`; } else { - fieldIdTooltipElem.innerHTML = ''; + pickInfo.field = ''; } + updatePickInfo(); }; -const updateCompositeAndPropIdTooltip = (compositeID, propID) => { +const updateCompositeAndPropIdInfo = (compositeID, propID) => { if (compositeID !== undefined) { - compositeIdTooltipElem.innerHTML = `Composite ID: ${compositeID}`; + pickInfo.composite = `Composite ID: ${compositeID}`; } else { - compositeIdTooltipElem.innerHTML = ''; + pickInfo.composite = ''; } if (propID !== undefined) { - propIdTooltipElem.innerHTML = `Prop ID: ${propID}`; + pickInfo.prop = `Prop ID: ${propID}`; } else { - propIdTooltipElem.innerHTML = ''; + pickInfo.prop = ''; } + updatePickInfo(); }; const updateCursor = (worldPosition) => { @@ -286,16 +323,16 @@ const updateCursor = (worldPosition) => { pointerActor.setVisibility(false); } renderWindow.render(); - updatePositionTooltip(worldPosition); + updatePositionInfo(worldPosition); }; function processSelections(selections) { renderer.getActors().forEach((a) => a.getProperty().setColor(...WHITE)); if (!selections || selections.length === 0) { lastProcessedActor = null; - updateAssociationTooltip(); + updateAssociationInfo(); updateCursor(); - updateCompositeAndPropIdTooltip(); + updateCompositeAndPropIdInfo(); return; } @@ -307,7 +344,7 @@ function processSelections(selections) { attributeID, } = selections[0].getProperties(); - updateCompositeAndPropIdTooltip(compositeID, propID); + updateCompositeAndPropIdInfo(compositeID, propID); let closestCellPointWorldPosition = [...rayHitWorldPosition]; if (attributeID || attributeID === 0) { @@ -335,11 +372,11 @@ function processSelections(selections) { ...input.getPoints().getTuple(attributeID), ]; propToWorld.apply(closestCellPointWorldPosition); - updateAssociationTooltip('Point', attributeID); + updateAssociationInfo('Point', attributeID); } else { // Selecting cells const cellPoints = input.getCellPoints(attributeID); - updateAssociationTooltip('Cell', attributeID); + updateAssociationInfo('Cell', attributeID); if (cellPoints?.cellPointIds) { const pointIds = cellPoints.cellPointIds; // Find the closest cell point, and use that as cursor position diff --git a/Sources/Rendering/Core/HardwareSelector/index.d.ts b/Sources/Rendering/Core/HardwareSelector/index.d.ts index 5e67132dd2a..ea4ba3f7dcc 100644 --- a/Sources/Rendering/Core/HardwareSelector/index.d.ts +++ b/Sources/Rendering/Core/HardwareSelector/index.d.ts @@ -21,6 +21,16 @@ export interface vtkHardwareSelector extends vtkObject { fy2: number ): Promise; + /** + * Used by the backends before they render the selection. Returns false + * with an error when the view or the renderer is not set. Otherwise it + * gets the scene ready as vtkRenderWindow.render() does. + * + * @param {object} view The API specific render window. + * @param {vtkRenderer} renderer + */ + prepareCapture(view: unknown, renderer: vtkRenderer): boolean; + /** * Generates a selection. * diff --git a/Sources/Rendering/Core/HardwareSelector/index.js b/Sources/Rendering/Core/HardwareSelector/index.js index 76abb383cc0..38f7360e3b7 100644 --- a/Sources/Rendering/Core/HardwareSelector/index.js +++ b/Sources/Rendering/Core/HardwareSelector/index.js @@ -2,6 +2,7 @@ import macro from 'vtk.js/Sources/macros'; import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; const { FieldAssociations } = vtkDataSet; +const { vtkErrorMacro } = macro; // ---------------------------------------------------------------------------- // vtkHardwareSelector methods @@ -16,6 +17,18 @@ function vtkHardwareSelector(publicAPI, model) { // call this once and then make multiple calls to generateSelection. publicAPI.getSourceDataAsync = async (renderer, fx1, fy1, fx2, fy2) => {}; + // The backends call this before they render the selection. It checks + // that the view and the renderer are set, then gets the scene ready as + // vtkRenderWindow.render() does (each renderer gets a camera). + publicAPI.prepareCapture = (view, renderer) => { + if (!view || !renderer) { + vtkErrorMacro('Renderer and view must be set before calling Select.'); + return false; + } + view.getRenderable().preRender(); + return true; + }; + publicAPI.selectAsync = async (renderer, fx1, fy1, fx2, fy2) => { const srcData = await publicAPI.getSourceDataAsync( renderer, diff --git a/Sources/Rendering/Core/Mapper/index.js b/Sources/Rendering/Core/Mapper/index.js index 390220194a9..064b8d4646f 100644 --- a/Sources/Rendering/Core/Mapper/index.js +++ b/Sources/Rendering/Core/Mapper/index.js @@ -8,7 +8,7 @@ import Constants from 'vtk.js/Sources/Rendering/Core/Mapper/Constants'; import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; -import { PassTypes } from 'vtk.js/Sources/Rendering/OpenGL/HardwareSelector/Constants'; +import { PassTypes } from 'vtk.js/Sources/Rendering/Core/HardwareSelector/Constants'; const { FieldAssociations } = vtkDataSet; diff --git a/Sources/Rendering/OpenGL/HardwareSelector/Constants.d.ts b/Sources/Rendering/OpenGL/HardwareSelector/Constants.d.ts index a711b3885cf..67ef9577dd6 100644 --- a/Sources/Rendering/OpenGL/HardwareSelector/Constants.d.ts +++ b/Sources/Rendering/OpenGL/HardwareSelector/Constants.d.ts @@ -1,8 +1,7 @@ -export declare enum PassTypes { - MIN_KNOWN_PASS = 0, - ACTOR_PASS = 0, - COMPOSITE_INDEX_PASS = 1, - ID_LOW24 = 2, - ID_HIGH24 = 3, - MAX_KNOWN_PASS = 3, -} +import { PassTypes as CorePassTypes } from '../../Core/HardwareSelector/Constants'; + +/** + * @deprecated Import PassTypes from + * '@kitware/vtk.js/Rendering/Core/HardwareSelector/Constants'. + */ +export import PassTypes = CorePassTypes; diff --git a/Sources/Rendering/OpenGL/HardwareSelector/Constants.js b/Sources/Rendering/OpenGL/HardwareSelector/Constants.js index 8a63669a5c3..697a20d3894 100644 --- a/Sources/Rendering/OpenGL/HardwareSelector/Constants.js +++ b/Sources/Rendering/OpenGL/HardwareSelector/Constants.js @@ -1,12 +1,9 @@ -export const PassTypes = { - MIN_KNOWN_PASS: 0, - ACTOR_PASS: 0, - COMPOSITE_INDEX_PASS: 1, - ID_LOW24: 2, - ID_HIGH24: 3, - MAX_KNOWN_PASS: 3, -}; - -export default { +// @deprecated PassTypes is in @kitware/vtk.js/Rendering/Core/HardwareSelector/Constants. +// This module gives the same object to keep old imports working. +import CoreConstants, { PassTypes, -}; +} from 'vtk.js/Sources/Rendering/Core/HardwareSelector/Constants'; + +export { PassTypes }; + +export default CoreConstants; diff --git a/Sources/Rendering/OpenGL/HardwareSelector/index.d.ts b/Sources/Rendering/OpenGL/HardwareSelector/index.d.ts index 8d64ece42e8..c22b89e0894 100644 --- a/Sources/Rendering/OpenGL/HardwareSelector/index.d.ts +++ b/Sources/Rendering/OpenGL/HardwareSelector/index.d.ts @@ -9,7 +9,7 @@ import vtkOpenGLRenderWindow from '../../../Rendering/OpenGL/RenderWindow'; import { FieldAssociations } from '../../../Common/DataModel/DataSet/Constants'; import { EventHandler, vtkSubscription } from '../../../interfaces'; import { Nullable, Vector2, Vector3 } from '../../../types'; -import { PassTypes } from './Constants'; +import { PassTypes } from '../../Core/HardwareSelector/Constants'; type Area = [number, number, number, number]; @@ -46,7 +46,6 @@ export interface PixelInformation { attributeID?: number; } -// TODO extends vtkHardwareSelector export interface vtkOpenGLHardwareSelector extends vtkHardwareSelector { /** * Releases internal pixel buffer memory. diff --git a/Sources/Rendering/OpenGL/HardwareSelector/index.js b/Sources/Rendering/OpenGL/HardwareSelector/index.js index 8869481766a..307c96a67d4 100644 --- a/Sources/Rendering/OpenGL/HardwareSelector/index.js +++ b/Sources/Rendering/OpenGL/HardwareSelector/index.js @@ -1,21 +1,15 @@ import macro from 'vtk.js/Sources/macros'; -import Constants from 'vtk.js/Sources/Rendering/OpenGL/HardwareSelector/Constants'; +import Constants from 'vtk.js/Sources/Rendering/Core/HardwareSelector/Constants'; import vtkHardwareSelector from 'vtk.js/Sources/Rendering/Core/HardwareSelector'; import vtkOpenGLFramebuffer from 'vtk.js/Sources/Rendering/OpenGL/Framebuffer'; -import vtkSelectionNode from 'vtk.js/Sources/Common/DataModel/SelectionNode'; +import PixelSelectionHelper from 'vtk.js/Sources/Rendering/Core/HardwareSelector/PixelSelectionHelper'; import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; const { PassTypes } = Constants; -const { SelectionContent, SelectionField } = vtkSelectionNode; const { FieldAssociations } = vtkDataSet; -const { vtkErrorMacro, vtkDebugMacro } = macro; const idOffset = 1; -function getInfoHash(info) { - return `${info.propID} ${info.compositeID}`; -} - function getAlpha(xx, yy, pb, area) { if (!pb) { return 0; @@ -24,19 +18,16 @@ function getAlpha(xx, yy, pb, area) { return pb[offset + 3]; } -function convert(xx, yy, pb, area, caller = null) { +function convert(xx, yy, pb, area) { if (!pb) { return 0; } - // console.log(caller); - // console.log('convert', xx, yy, pb, area); const offset = (yy * (area[2] - area[0] + 1) + xx) * 4; const r = pb[offset]; const g = pb[offset + 1]; const b = pb[offset + 2]; - // console.log(b, g, r, (b * 256 + g) * 256 + r); return (b * 256 + g) * 256 + r; } @@ -49,287 +40,115 @@ function getID(low24, high8) { /* eslint-enable no-bitwise */ } -function getPixelInformationWithData( - buffdata, - inDisplayPosition, - maxDistance, - outSelectedPosition -) { - // Base case - const maxDist = maxDistance < 0 ? 0 : maxDistance; - if (maxDist === 0) { - outSelectedPosition[0] = inDisplayPosition[0]; - outSelectedPosition[1] = inDisplayPosition[1]; - if ( - inDisplayPosition[0] < buffdata.area[0] || - inDisplayPosition[0] > buffdata.area[2] || - inDisplayPosition[1] < buffdata.area[1] || - inDisplayPosition[1] > buffdata.area[3] - ) { - return null; - } - - // offset inDisplayPosition based on the lower-left-corner of the Area. - const displayPosition = [ - inDisplayPosition[0] - buffdata.area[0], - inDisplayPosition[1] - buffdata.area[1], - ]; - - const actorid = convert( - displayPosition[0], - displayPosition[1], - buffdata.pixBuffer[PassTypes.ACTOR_PASS], - buffdata.area, - 'getPixelInformationWithData1' - ); - if (actorid <= 0 || actorid - idOffset >= buffdata.props.length) { - // the pixel did not hit any actor. - return null; - } - - const info = {}; - info.valid = true; - - info.propID = actorid - idOffset; - info.prop = buffdata.props[info.propID]; - - let compositeID = convert( - displayPosition[0], - displayPosition[1], - buffdata.pixBuffer[PassTypes.COMPOSITE_INDEX_PASS], - buffdata.area, - 'getPixelInformationWithData2' - ); - if (compositeID < 0 || compositeID > 0xffffff) { - compositeID = 0; - } - info.compositeID = compositeID - idOffset; - if (buffdata.captureZValues) { - const offset = - (displayPosition[1] * (buffdata.area[2] - buffdata.area[0] + 1) + - displayPosition[0]) * - 4; - info.zValue = - (256 * buffdata.zBuffer[offset] + buffdata.zBuffer[offset + 1]) / - 65535.0; - info.displayPosition = inDisplayPosition; - } +// Decode one pixel of the pass buffers. buffdata has area, pixBuffer, props, +// captureZValues and zBuffer. +function readPixel(buffdata, inDisplayPosition) { + if ( + inDisplayPosition[0] < buffdata.area[0] || + inDisplayPosition[0] > buffdata.area[2] || + inDisplayPosition[1] < buffdata.area[1] || + inDisplayPosition[1] > buffdata.area[3] + ) { + return null; + } - if (buffdata.pixBuffer[PassTypes.ID_LOW24]) { - if ( - getAlpha( - displayPosition[0], - displayPosition[1], - buffdata.pixBuffer[PassTypes.ID_LOW24], - buffdata.area - ) === 0.0 - ) { - return info; - } - } + // offset inDisplayPosition based on the lower-left-corner of the Area. + const displayPosition = [ + inDisplayPosition[0] - buffdata.area[0], + inDisplayPosition[1] - buffdata.area[1], + ]; + + const actorid = convert( + displayPosition[0], + displayPosition[1], + buffdata.pixBuffer[PassTypes.ACTOR_PASS], + buffdata.area + ); + if (actorid <= 0 || actorid - idOffset >= buffdata.props.length) { + // the pixel did not hit any actor. + return null; + } - const low24 = convert( - displayPosition[0], - displayPosition[1], - buffdata.pixBuffer[PassTypes.ID_LOW24], - buffdata.area, - 'getPixelInformationWithData3' - ); - const high24 = convert( - displayPosition[0], - displayPosition[1], - buffdata.pixBuffer[PassTypes.ID_HIGH24], - buffdata.area, - 'getPixelInformationWithData4' - ); - info.attributeID = getID(low24, high24, 0); + const info = {}; + info.valid = true; - return info; - } + info.propID = actorid - idOffset; + info.prop = buffdata.props[info.propID]; - // Iterate over successively growing boxes. - // They recursively call the base case to handle single pixels. - const dispPos = [inDisplayPosition[0], inDisplayPosition[1]]; - const curPos = [0, 0]; - let info = getPixelInformationWithData( - buffdata, - inDisplayPosition, - 0, - outSelectedPosition + let compositeID = convert( + displayPosition[0], + displayPosition[1], + buffdata.pixBuffer[PassTypes.COMPOSITE_INDEX_PASS], + buffdata.area ); - if (info && info.valid) { - return info; + if (compositeID < 0 || compositeID > 0xffffff) { + compositeID = 0; } - for (let dist = 1; dist < maxDist; ++dist) { - // Vertical sides of box. - for ( - let y = dispPos[1] > dist ? dispPos[1] - dist : 0; - y <= dispPos[1] + dist; - ++y - ) { - curPos[1] = y; - if (dispPos[0] >= dist) { - curPos[0] = dispPos[0] - dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info && info.valid) { - return info; - } - } - curPos[0] = dispPos[0] + dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info && info.valid) { - return info; - } - } - // Horizontal sides of box. - for ( - let x = dispPos[0] >= dist ? dispPos[0] - (dist - 1) : 0; - x <= dispPos[0] + (dist - 1); - ++x - ) { - curPos[0] = x; - if (dispPos[1] >= dist) { - curPos[1] = dispPos[1] - dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info && info.valid) { - return info; - } - } - curPos[1] = dispPos[1] + dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info && info.valid) { - return info; - } - } + info.compositeID = compositeID - idOffset; + if (buffdata.captureZValues) { + const offset = + (displayPosition[1] * (buffdata.area[2] - buffdata.area[0] + 1) + + displayPosition[0]) * + 4; + info.zValue = + (256 * buffdata.zBuffer[offset] + buffdata.zBuffer[offset + 1]) / 65535.0; + info.displayPosition = [inDisplayPosition[0], inDisplayPosition[1]]; } - // nothing hit. - outSelectedPosition[0] = inDisplayPosition[0]; - outSelectedPosition[1] = inDisplayPosition[1]; - return null; -} - -//----------------------------------------------------------------------------- -function convertSelection( - fieldassociation, - dataMap, - captureZValues, - renderer, - openGLRenderWindow -) { - const sel = []; - - let count = 0; - dataMap.forEach((value, key) => { - const child = vtkSelectionNode.newInstance(); - child.setContentType(SelectionContent.INDICES); - switch (fieldassociation) { - case FieldAssociations.FIELD_ASSOCIATION_CELLS: - child.setFieldType(SelectionField.CELL); - break; - case FieldAssociations.FIELD_ASSOCIATION_POINTS: - child.setFieldType(SelectionField.POINT); - break; - default: - vtkErrorMacro('Unknown field association'); - } - child.getProperties().propID = value.info.propID; - child.getProperties().prop = value.info.prop; - child.getProperties().compositeID = value.info.compositeID; - child.getProperties().attributeID = value.info.attributeID; - child.getProperties().pixelCount = value.pixelCount; - if (captureZValues) { - child.getProperties().displayPosition = [ - value.info.displayPosition[0], - value.info.displayPosition[1], - value.info.zValue, - ]; - child.getProperties().worldPosition = openGLRenderWindow.displayToWorld( - value.info.displayPosition[0], - value.info.displayPosition[1], - value.info.zValue, - renderer - ); + // Skip attribute ids if alpha is zero (missed) + if (buffdata.pixBuffer[PassTypes.ID_LOW24]) { + if ( + getAlpha( + displayPosition[0], + displayPosition[1], + buffdata.pixBuffer[PassTypes.ID_LOW24], + buffdata.area + ) === 0.0 + ) { + return info; } + } - child.setSelectionList(value.attributeIDs); - sel[count] = child; - count++; - }); + const low24 = convert( + displayPosition[0], + displayPosition[1], + buffdata.pixBuffer[PassTypes.ID_LOW24], + buffdata.area + ); + const high24 = convert( + displayPosition[0], + displayPosition[1], + buffdata.pixBuffer[PassTypes.ID_HIGH24], + buffdata.area + ); + info.attributeID = getID(low24, high24); - return sel; + return info; } -//---------------------------------------------------------------------------- -function generateSelectionWithData(buffdata, fx1, fy1, fx2, fy2) { - const x1 = Math.floor(fx1); - const y1 = Math.floor(fy1); - const x2 = Math.floor(fx2); - const y2 = Math.floor(fy2); - - const dataMap = new Map(); - - const outSelectedPosition = [0, 0]; - - for (let yy = y1; yy <= y2; yy++) { - for (let xx = x1; xx <= x2; xx++) { - const pos = [xx, yy]; - const info = getPixelInformationWithData( - buffdata, - pos, - 0, - outSelectedPosition - ); - if (info && info.valid) { - const hash = getInfoHash(info); - if (!dataMap.has(hash)) { - dataMap.set(hash, { - info, - pixelCount: 1, - attributeIDs: [info.attributeID], - }); - } else { - const dmv = dataMap.get(hash); - dmv.pixelCount++; - if (buffdata.captureZValues) { - if (info.zValue < dmv.info.zValue) { - dmv.info = info; - } - } - if (dmv.attributeIDs.indexOf(info.attributeID) === -1) { - dmv.attributeIDs.push(info.attributeID); - } - } - } - } - } - return convertSelection( - buffdata.fieldAssociation, - dataMap, - buffdata.captureZValues, - buffdata.renderer, - buffdata.openGLRenderWindow - ); +// Give buffdata the functions that build selections from its pixels. +// buffdata also has fieldAssociation, renderer and openGLRenderWindow. +function addSelectionFunctions(buffdata) { + const read = (position) => readPixel(buffdata, position); + buffdata.getPixelInformation = ( + inDisplayPosition, + maxDistance, + outSelectedPosition + ) => + PixelSelectionHelper.getPixelInformation( + read, + inDisplayPosition, + maxDistance, + outSelectedPosition + ); + buffdata.generateSelection = (fx1, fy1, fx2, fy2) => + PixelSelectionHelper.generateSelection(read, fx1, fy1, fx2, fy2, { + fieldAssociation: buffdata.fieldAssociation, + captureZValues: buffdata.captureZValues, + displayToWorld: (x, y, z) => + buffdata.openGLRenderWindow.displayToWorld(x, y, z, buffdata.renderer), + }); + return buffdata; } // ---------------------------------------------------------------------------- @@ -462,15 +281,12 @@ function vtkOpenGLHardwareSelector(publicAPI, model) { renderer, openGLRenderWindow: model._openGLRenderWindow, }; - result.generateSelection = (...args) => - generateSelectionWithData(result, ...args); - return result; + return addSelectionFunctions(result); }; //---------------------------------------------------------------------------- publicAPI.captureBuffers = () => { - if (!model._renderer || !model._openGLRenderWindow) { - vtkErrorMacro('Renderer and view must be set before calling Select.'); + if (!publicAPI.prepareCapture(model._openGLRenderWindow, model._renderer)) { return false; } @@ -479,10 +295,6 @@ function vtkOpenGLHardwareSelector(publicAPI, model) { model._openGLRenderer ); - // todo revisit making selection part of core - // then we can do this in core - model._openGLRenderWindow.getRenderable().preRender(); - // int rgba[4]; // rwin.getColorBufferSizes(rgba); // if (rgba[0] < 8 || rgba[1] < 8 || rgba[2] < 8) { @@ -645,20 +457,12 @@ function vtkOpenGLHardwareSelector(publicAPI, model) { }; //---------------------------------------------------------------------------- - // TODO: make inline + // Keeps the largest attribute id given. A negative id is valid and is + // ignored: higher order cells add points to draw smooth surfaces. publicAPI.renderAttributeId = (attribid) => { - if (attribid < 0) { - // negative attribid is valid. It happens when rendering higher order - // elements where new points are added for rendering smooth surfaces. - return; + if (attribid >= 0) { + model.maxAttributeId = Math.max(model.maxAttributeId, attribid); } - - model.maxAttributeId = - attribid > model.maxAttributeId ? attribid : model.maxAttributeId; - - // if (model.currentPass < PassTypes.ID_LOW24) { - // return; // useless... - // } }; //---------------------------------------------------------------------------- @@ -678,224 +482,35 @@ function vtkOpenGLHardwareSelector(publicAPI, model) { publicAPI.delete ); - // info has - // valid - // propId - // prop - // compositeID - // attributeID - //---------------------------------------------------------------------------- + // The selection functions read the pass buffers of the last + // captureBuffers() call. info has valid, propID, prop, compositeID and + // attributeID. + const getCapturedData = () => + addSelectionFunctions({ + area: model.area, + pixBuffer: model.pixBuffer, + captureZValues: model.captureZValues, + zBuffer: model.zBuffer, + props: model.props, + fieldAssociation: model.fieldAssociation, + renderer: model._renderer, + openGLRenderWindow: model._openGLRenderWindow, + }); + publicAPI.getPixelInformation = ( inDisplayPosition, maxDistance, outSelectedPosition - ) => { - // Base case - const maxDist = maxDistance < 0 ? 0 : maxDistance; - vtkDebugMacro('getPixelInformation called', maxDist); - if (maxDist === 0) { - outSelectedPosition[0] = inDisplayPosition[0]; - outSelectedPosition[1] = inDisplayPosition[1]; - if ( - inDisplayPosition[0] < model.area[0] || - inDisplayPosition[0] > model.area[2] || - inDisplayPosition[1] < model.area[1] || - inDisplayPosition[1] > model.area[3] - ) { - return null; - } - - vtkDebugMacro(inDisplayPosition); - vtkDebugMacro(model.area); - // offset inDisplayPosition based on the lower-left-corner of the Area. - const displayPosition = [ - inDisplayPosition[0] - model.area[0], - inDisplayPosition[1] - model.area[1], - ]; - - vtkDebugMacro('adjusted displayPosition', displayPosition); - - const actorid = convert( - displayPosition[0], - displayPosition[1], - model.pixBuffer[PassTypes.ACTOR_PASS], - model.area, - 'getPixelInformation' - ); - - vtkDebugMacro('actorid', actorid); - if (actorid <= 0 || actorid - idOffset >= model.props.length) { - // the pixel did not hit any actor. - return null; - } - - const info = {}; - info.valid = true; - - info.propID = actorid - idOffset; - info.prop = model.props[info.propID]; - let compositeID = convert( - displayPosition[0], - displayPosition[1], - model.pixBuffer[PassTypes.COMPOSITE_INDEX_PASS], - model.area, - 'getPixelInformation2' - ); - if (compositeID < 0 || compositeID > 0xffffff) { - compositeID = 0; - } - info.compositeID = compositeID - idOffset; - if (model.captureZValues) { - const offset = - (displayPosition[1] * (model.area[2] - model.area[0] + 1) + - displayPosition[0]) * - 4; - info.zValue = - (256 * model.zBuffer[offset] + model.zBuffer[offset + 1]) / 65535.0; - info.displayPosition = inDisplayPosition; - } - - // Skip attribute ids if alpha is zero (missed) - if (model.pixBuffer[PassTypes.ID_LOW24]) { - if ( - getAlpha( - displayPosition[0], - displayPosition[1], - model.pixBuffer[PassTypes.ID_LOW24], - model.area - ) === 0.0 - ) { - return info; - } - } - - const low24 = convert( - displayPosition[0], - displayPosition[1], - model.pixBuffer[PassTypes.ID_LOW24], - model.area, - 'getPixelInformation3' - ); - const high24 = convert( - displayPosition[0], - displayPosition[1], - model.pixBuffer[PassTypes.ID_HIGH24], - model.area, - 'getPixelInformation4' - ); - info.attributeID = getID(low24, high24); - - return info; - } - - // Iterate over successively growing boxes. - // They recursively call the base case to handle single pixels. - const dispPos = [inDisplayPosition[0], inDisplayPosition[1]]; - const curPos = [0, 0]; - let info = publicAPI.getPixelInformation( + ) => + getCapturedData().getPixelInformation( inDisplayPosition, - 0, + maxDistance, outSelectedPosition ); - if (info && info.valid) { - return info; - } - for (let dist = 1; dist < maxDist; ++dist) { - // Vertical sides of box. - for ( - let y = dispPos[1] > dist ? dispPos[1] - dist : 0; - y <= dispPos[1] + dist; - ++y - ) { - curPos[1] = y; - if (dispPos[0] >= dist) { - curPos[0] = dispPos[0] - dist; - info = publicAPI.getPixelInformation(curPos, 0, outSelectedPosition); - if (info && info.valid) { - return info; - } - } - curPos[0] = dispPos[0] + dist; - info = publicAPI.getPixelInformation(curPos, 0, outSelectedPosition); - if (info && info.valid) { - return info; - } - } - // Horizontal sides of box. - for ( - let x = dispPos[0] >= dist ? dispPos[0] - (dist - 1) : 0; - x <= dispPos[0] + (dist - 1); - ++x - ) { - curPos[0] = x; - if (dispPos[1] >= dist) { - curPos[1] = dispPos[1] - dist; - info = publicAPI.getPixelInformation(curPos, 0, outSelectedPosition); - if (info && info.valid) { - return info; - } - } - curPos[1] = dispPos[1] + dist; - info = publicAPI.getPixelInformation(curPos, 0, outSelectedPosition); - if (info && info.valid) { - return info; - } - } - } - - // nothing hit. - outSelectedPosition[0] = inDisplayPosition[0]; - outSelectedPosition[1] = inDisplayPosition[1]; - return null; - }; - //---------------------------------------------------------------------------- - publicAPI.generateSelection = (fx1, fy1, fx2, fy2) => { - const x1 = Math.floor(fx1); - const y1 = Math.floor(fy1); - const x2 = Math.floor(fx2); - const y2 = Math.floor(fy2); - - const dataMap = new Map(); - - const outSelectedPosition = [0, 0]; - - for (let yy = y1; yy <= y2; yy++) { - for (let xx = x1; xx <= x2; xx++) { - const pos = [xx, yy]; - const info = publicAPI.getPixelInformation(pos, 0, outSelectedPosition); - if (info && info.valid) { - const hash = getInfoHash(info); - if (!dataMap.has(hash)) { - dataMap.set(hash, { - info, - pixelCount: 1, - attributeIDs: [info.attributeID], - }); - } else { - const dmv = dataMap.get(hash); - dmv.pixelCount++; - if (model.captureZValues) { - if (info.zValue < dmv.info.zValue) { - dmv.info = info; - } - } - if (dmv.attributeIDs.indexOf(info.attributeID) === -1) { - dmv.attributeIDs.push(info.attributeID); - } - } - } - } - } - return convertSelection( - model.fieldAssociation, - dataMap, - model.captureZValues, - model._renderer, - model._openGLRenderWindow - ); - }; + publicAPI.generateSelection = (fx1, fy1, fx2, fy2) => + getCapturedData().generateSelection(fx1, fy1, fx2, fy2); publicAPI.getRawPixelBuffer = (passNo) => model.rawPixBuffer[passNo]; publicAPI.getPixelBuffer = (passNo) => model.pixBuffer[passNo]; diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/index.js b/Sources/Rendering/OpenGL/PolyDataMapper/index.js index 2a28fe99a5d..dae2477031c 100755 --- a/Sources/Rendering/OpenGL/PolyDataMapper/index.js +++ b/Sources/Rendering/OpenGL/PolyDataMapper/index.js @@ -18,7 +18,7 @@ import vtkReplacementShaderMapper from 'vtk.js/Sources/Rendering/OpenGL/Replacem import { registerOverride } from 'vtk.js/Sources/Rendering/OpenGL/ViewNodeFactory'; -import { PassTypes } from 'vtk.js/Sources/Rendering/OpenGL/HardwareSelector/Constants'; +import { PassTypes } from 'vtk.js/Sources/Rendering/Core/HardwareSelector/Constants'; import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; import { Resolve } from 'vtk.js/Sources/Rendering/Core/Mapper/CoincidentTopologyHelper'; diff --git a/Sources/Rendering/WebGPU/HardwareSelector/index.d.ts b/Sources/Rendering/WebGPU/HardwareSelector/index.d.ts new file mode 100644 index 00000000000..ed942a0b3e7 --- /dev/null +++ b/Sources/Rendering/WebGPU/HardwareSelector/index.d.ts @@ -0,0 +1,146 @@ +import vtkSelectionNode from '../../../Common/DataModel/SelectionNode'; +import { + IHardwareSelectorInitialValues, + vtkHardwareSelector, +} from '../../../Rendering/Core/HardwareSelector'; +import vtkProp from '../../../Rendering/Core/Prop'; +import vtkRenderer from '../../../Rendering/Core/Renderer'; +import { FieldAssociations } from '../../../Common/DataModel/DataSet/Constants'; +import { Nullable, Vector2 } from '../../../types'; +import { PixelInformation } from '../../OpenGL/HardwareSelector'; + +type Area = [number, number, number, number]; + +/** + * The pixels that getSourceDataAsync read back, with the functions that use + * them. The color buffer holds only the area. The depth buffer holds all + * the window. + */ +export interface WebGPUSourceData { + area: Area; + width: number; + height: number; + colorValues: Uint32Array; + colorBufferWidth: number; + depthValues?: Float32Array; + zbufferBufferWidth?: number; + captureZValues: boolean; + fieldAssociation: FieldAssociations; + props: vtkProp[]; + renderer: vtkRenderer; + generateSelection( + fx1: number, + fy1: number, + fx2: number, + fy2: number + ): vtkSelectionNode[]; + getPixelInformation( + inDispPos: Vector2, + maxDistance: number, + outDispPos: Vector2 + ): Nullable; + isPropHit(propID: number): boolean; +} + +export interface vtkWebGPUHardwareSelector extends vtkHardwareSelector { + /** + * Attaches a render window and a renderer to this selector. + */ + attach(webGPURenderWindow: unknown, renderer: vtkRenderer): void; + + /** + * Renders the selection pass and reads back the pixels of the area. With + * no area, all the window is read. + */ + getSourceDataAsync( + renderer?: vtkRenderer, + fx1?: number, + fy1?: number, + fx2?: number, + fy2?: number + ): Promise; + + /** + * Reads the area of setArea() and keeps the data for getPixelInformation, + * generateSelection and isPropHit. WebGPU has no synchronous + * captureBuffers() or select(). + */ + captureBuffersAsync(): Promise; + + /** + * The data of the last captureBuffersAsync() call. + */ + getCapturedData(): Nullable; + + /** + * Gets the selection information for a pixel of the captured data. + */ + getPixelInformation( + inDispPos: Vector2, + maxDistance: number, + outDispPos: Vector2 + ): Nullable; + + /** + * Generates selections in an area of the captured data. + */ + generateSelection( + fx1: number, + fy1: number, + fx2: number, + fy2: number + ): vtkSelectionNode[]; + + /** + * True if the captured data has a pixel of the prop. + */ + isPropHit(propID: number): boolean; + + /** + * Removes the captured data. + */ + releasePixBuffers(): void; + + releaseGraphicsResources(): void; + + endSelection(): void; + + /** + * The area in display pixels, with y up. The values are rounded down. + */ + setArea(area: Area): boolean; + setArea(fx1: number, fy1: number, fx2: number, fy2: number): boolean; + getArea(): Area; + + /** + * Removes the area. Then captureBuffersAsync() reads all the window. + */ + clearArea(): void; +} + +export function newInstance( + initialValues?: IHardwareSelectorInitialValues +): vtkWebGPUHardwareSelector; + +export function extend( + publicAPI: object, + model: object, + initialValues?: IHardwareSelectorInitialValues +): void; + +/** + * Gives read back selection buffers the functions that use them. + */ +export function newSourceData(fields: object): WebGPUSourceData; + +/** + * The WebGPU hardware selector renders the prop, composite and attribute ids + * to one rgba32uint texture in one pass. + */ +export const vtkWebGPUHardwareSelector: { + newInstance: typeof newInstance; + extend: typeof extend; + newSourceData: typeof newSourceData; +}; + +export default vtkWebGPUHardwareSelector; diff --git a/Sources/Rendering/WebGPU/HardwareSelector/index.js b/Sources/Rendering/WebGPU/HardwareSelector/index.js index abd2c9012db..949949f28af 100644 --- a/Sources/Rendering/WebGPU/HardwareSelector/index.js +++ b/Sources/Rendering/WebGPU/HardwareSelector/index.js @@ -1,270 +1,129 @@ import macro from 'vtk.js/Sources/macros'; import vtkHardwareSelector from 'vtk.js/Sources/Rendering/Core/HardwareSelector'; +import PixelSelectionHelper from 'vtk.js/Sources/Rendering/Core/HardwareSelector/PixelSelectionHelper'; import vtkWebGPUBuffer from 'vtk.js/Sources/Rendering/WebGPU/Buffer'; import vtkWebGPUHardwareSelectionPass from 'vtk.js/Sources/Rendering/WebGPU/HardwareSelectionPass'; -import vtkSelectionNode from 'vtk.js/Sources/Common/DataModel/SelectionNode'; -import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; -const { SelectionContent, SelectionField } = vtkSelectionNode; -const { FieldAssociations } = vtkDataSet; const { vtkErrorMacro } = macro; -function getInfoHash(info) { - return `${info.propID} ${info.compositeID}`; +// The buffers hold only the captured area. Display y goes up and texture +// rows go down, so the first buffer row is the top row of the area. +function isInArea(xx, yy, buffdata) { + const area = buffdata.area; + return xx >= area[0] && xx <= area[2] && yy >= area[1] && yy <= area[3]; +} + +function getPixelIndex(xx, yy, buffdata, rowWidth) { + return (buffdata.area[3] - yy) * rowWidth + (xx - buffdata.area[0]); } function convert(xx, yy, buffdata, channel) { - const offset = - ((buffdata.height - yy - 1) * buffdata.colorBufferWidth + xx) * 4 + channel; - return buffdata.colorValues[offset]; + const index = getPixelIndex(xx, yy, buffdata, buffdata.colorBufferWidth); + return buffdata.colorValues[index * 4 + channel]; } -function getPixelInformationWithData( - buffdata, - inDisplayPosition, - maxDistance, - outSelectedPosition -) { - // Base case - const maxDist = maxDistance < 0 ? 0 : maxDistance; - if (maxDist === 0) { - outSelectedPosition[0] = inDisplayPosition[0]; - outSelectedPosition[1] = inDisplayPosition[1]; - if ( - inDisplayPosition[0] < 0 || - inDisplayPosition[0] >= buffdata.width || - inDisplayPosition[1] < 0 || - inDisplayPosition[1] >= buffdata.height - ) { - return null; - } +// Decode one pixel of the read back buffers. +function readPixel(buffdata, inDisplayPosition) { + if (!isInArea(inDisplayPosition[0], inDisplayPosition[1], buffdata)) { + return null; + } - const actorid = convert( - inDisplayPosition[0], - inDisplayPosition[1], - buffdata, - 0 - ); + const actorid = convert( + inDisplayPosition[0], + inDisplayPosition[1], + buffdata, + 0 + ); - if (actorid <= 0 || actorid - 1 >= (buffdata.props?.length ?? 0)) { - // the pixel did not hit any actor. - return null; - } + if (actorid <= 0 || actorid - 1 >= (buffdata.props?.length ?? 0)) { + // the pixel did not hit any actor. + return null; + } - const info = {}; + const info = { valid: true }; - info.propID = actorid - 1; - info.prop = buffdata.props?.[info.propID]; + info.propID = actorid - 1; + info.prop = buffdata.props?.[info.propID]; - let compositeID = convert( - inDisplayPosition[0], - inDisplayPosition[1], - buffdata, - 1 - ); - if (compositeID < 0 || compositeID > 0xffffff) { - compositeID = 0; - } - info.compositeID = compositeID - 1; - // attributeID is stored with a +1 offset in the selection buffer so that - // 0 means "no data" while 0 remains a valid decoded attribute index. - // A buffer value < 1 means no attribute was written, fall back to compositeID. - // After fallback, subtract 1 to recover the original 0 based index. - let attributeID = convert( - inDisplayPosition[0], - inDisplayPosition[1], - buffdata, - 2 - ); - if (attributeID < 1) { - attributeID = compositeID; - } - info.attributeID = attributeID - 1; - - if (buffdata.captureZValues) { - const offset = - (buffdata.height - inDisplayPosition[1] - 1) * - buffdata.zbufferBufferWidth + - inDisplayPosition[0]; - info.zValue = buffdata.depthValues[offset]; - info.zValue = buffdata.webGPURenderer.convertToOpenGLDepth(info.zValue); - info.displayPosition = inDisplayPosition; - } - return info; + let compositeID = convert( + inDisplayPosition[0], + inDisplayPosition[1], + buffdata, + 1 + ); + if (compositeID < 0 || compositeID > 0xffffff) { + compositeID = 0; } - - // Iterate over successively growing boxes. - // They recursively call the base case to handle single pixels. - const dispPos = [inDisplayPosition[0], inDisplayPosition[1]]; - const curPos = [0, 0]; - let info = getPixelInformationWithData( + info.compositeID = compositeID - 1; + // attributeID is stored with a +1 offset in the selection buffer so that + // 0 means "no data" while 0 remains a valid decoded attribute index. + // A buffer value < 1 means no attribute was written, fall back to compositeID. + // After fallback, subtract 1 to recover the original 0 based index. + let attributeID = convert( + inDisplayPosition[0], + inDisplayPosition[1], buffdata, - inDisplayPosition, - 0, - outSelectedPosition + 2 ); - if (info) { - return info; + if (attributeID < 1) { + attributeID = compositeID; } - for (let dist = 1; dist < maxDist; ++dist) { - // Vertical sides of box. - for ( - let y = dispPos[1] > dist ? dispPos[1] - dist : 0; - y <= dispPos[1] + dist; - ++y - ) { - curPos[1] = y; - if (dispPos[0] >= dist) { - curPos[0] = dispPos[0] - dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info) { - return info; - } - } - curPos[0] = dispPos[0] + dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info) { - return info; - } - } - // Horizontal sides of box. - for ( - let x = dispPos[0] >= dist ? dispPos[0] - (dist - 1) : 0; - x <= dispPos[0] + (dist - 1); - ++x - ) { - curPos[0] = x; - if (dispPos[1] >= dist) { - curPos[1] = dispPos[1] - dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info) { - return info; - } - } - curPos[1] = dispPos[1] + dist; - info = getPixelInformationWithData( - buffdata, - curPos, - 0, - outSelectedPosition - ); - if (info) { - return info; - } - } + info.attributeID = attributeID - 1; + + if (buffdata.captureZValues) { + // A copy from a depth texture must use the full texture, so the depth + // buffer holds all the window. + const offset = + (buffdata.height - inDisplayPosition[1] - 1) * + buffdata.zbufferBufferWidth + + inDisplayPosition[0]; + info.zValue = buffdata.depthValues[offset]; + info.zValue = buffdata.webGPURenderer.convertToOpenGLDepth(info.zValue); + info.displayPosition = [inDisplayPosition[0], inDisplayPosition[1]]; } - - // nothing hit. - outSelectedPosition[0] = inDisplayPosition[0]; - outSelectedPosition[1] = inDisplayPosition[1]; - return null; -} - -//----------------------------------------------------------------------------- -function convertSelection(fieldassociation, dataMap, buffdata) { - const sel = []; - - let count = 0; - dataMap.forEach((value, key) => { - const child = vtkSelectionNode.newInstance(); - child.setContentType(SelectionContent.INDICES); - switch (fieldassociation) { - case FieldAssociations.FIELD_ASSOCIATION_CELLS: - child.setFieldType(SelectionField.CELL); - break; - case FieldAssociations.FIELD_ASSOCIATION_POINTS: - child.setFieldType(SelectionField.POINT); - break; - default: - vtkErrorMacro('Unknown field association'); - } - child.getProperties().propID = value.info.propID; - child.getProperties().prop = value.info.prop; - child.getProperties().compositeID = value.info.compositeID; - child.getProperties().attributeID = value.info.attributeID; - child.getProperties().pixelCount = value.pixelCount; - if (buffdata.captureZValues) { - child.getProperties().displayPosition = [ - value.info.displayPosition[0], - value.info.displayPosition[1], - value.info.zValue, - ]; - child.getProperties().worldPosition = - buffdata.webGPURenderWindow.displayToWorld( - value.info.displayPosition[0], - value.info.displayPosition[1], - value.info.zValue, - buffdata.renderer - ); - } - - child.setSelectionList(value.attributeIDs); - sel[count] = child; - count++; - }); - - return sel; + return info; } //---------------------------------------------------------------------------- -function generateSelectionWithData(buffdata, fx1, fy1, fx2, fy2) { - const x1 = Math.floor(fx1); - const y1 = Math.floor(fy1); - const x2 = Math.floor(fx2); - const y2 = Math.floor(fy2); - - const dataMap = new Map(); - - const outSelectedPosition = [0, 0]; - - for (let yy = y1; yy <= y2; yy++) { - for (let xx = x1; xx <= x2; xx++) { - const pos = [xx, yy]; - const info = getPixelInformationWithData( - buffdata, - pos, - 0, - outSelectedPosition - ); - if (info) { - const hash = getInfoHash(info); - if (!dataMap.has(hash)) { - dataMap.set(hash, { - info, - pixelCount: 1, - attributeIDs: [info.attributeID], - }); - } else { - const dmv = dataMap.get(hash); - dmv.pixelCount++; - if (buffdata.captureZValues) { - if (info.zValue < dmv.info.zValue) { - dmv.info = info; - } - } - if (dmv.attributeIDs.indexOf(info.attributeID) === -1) { - dmv.attributeIDs.push(info.attributeID); - } +// Give the read back buffers the functions that use them. The fields are +// area, width, height, colorValues, colorBufferWidth, props, +// fieldAssociation and captureZValues. With captureZValues, also +// depthValues, zbufferBufferWidth, renderer, webGPURenderer and +// webGPURenderWindow. +export function newSourceData(fields) { + const result = { ...fields }; + const read = (position) => readPixel(result, position); + result.generateSelection = (fx1, fy1, fx2, fy2) => + PixelSelectionHelper.generateSelection(read, fx1, fy1, fx2, fy2, { + fieldAssociation: result.fieldAssociation, + captureZValues: result.captureZValues, + displayToWorld: (x, y, z) => + result.webGPURenderWindow.displayToWorld(x, y, z, result.renderer), + }); + result.getPixelInformation = ( + inDisplayPosition, + maxDistance, + outSelectedPosition + ) => + PixelSelectionHelper.getPixelInformation( + read, + inDisplayPosition, + maxDistance, + outSelectedPosition + ); + result.isPropHit = (propID) => { + if (!result.hitProps) { + result.hitProps = new Set(); + const values = result.colorValues; + for (let i = 0; i < values.length; i += 4) { + if (values[i] > 0) { + result.hitProps.add(values[i] - 1); } } } - } - return convertSelection(buffdata.fieldAssociation, dataMap, buffdata); + return result.hitProps.has(propID); + }; + return result; } // ---------------------------------------------------------------------------- @@ -293,23 +152,104 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { //---------------------------------------------------------------------------- publicAPI.endSelection = () => { - model.WebGPURenderer.setSelector(null); + model._WebGPURenderWindow + ?.getViewNodeFor(model._renderer) + ?.setSelector(null); }; //---------------------------------------------------------------------------- - // note we ignore the x,y arguments as WebGPU has to do buffer copies - // of the entire depth bufer. We could realloc hardware selection textures - // based on the passed in size etc but it gets messy so for now we always - // render the full size window and copy it to the buffers. - publicAPI.getSourceDataAsync = async (renderer = model._renderer) => { - if (!renderer || !model._WebGPURenderWindow) { - vtkErrorMacro('Renderer and view must be set before calling Select.'); - return false; + // The area is in display pixels, with y up. setArea rounds it down. + // The _areaSet flag records that an area was given. Thus an area of all + // zeros is the pixel at the origin and not all the window. + const superSetArea = publicAPI.setArea; + publicAPI.setArea = (...args) => { + const changed = superSetArea(...args); + model._areaSet = true; + for (let i = 0; i < 4; i++) { + model.area[i] = Math.floor(model.area[i]); + } + return changed; + }; + + // Removes the area. Then captureBuffersAsync() reads all the window. + publicAPI.clearArea = () => { + model._areaSet = false; + }; + + //---------------------------------------------------------------------------- + // WebGPU reads the GPU buffers back asynchronously, so there is no + // synchronous captureBuffers() or select(). captureBuffersAsync() reads + // the area of setArea() and keeps the data. Then getPixelInformation(), + // generateSelection() and isPropHit() read the kept data synchronously. + // With no area set, all the window is read. + publicAPI.captureBuffersAsync = async () => { + const area = model.area; + let data = null; + if (model._areaSet) { + data = await publicAPI.getSourceDataAsync( + model._renderer, + area[0], + area[1], + area[2], + area[3] + ); + } else { + data = await publicAPI.getSourceDataAsync(model._renderer); } + model._capturedData = data || null; + return !!data; + }; - // todo revisit making selection part of core - // then we can do this in core - model._WebGPURenderWindow.getRenderable().preRender(); + publicAPI.getCapturedData = () => model._capturedData; + + publicAPI.releasePixBuffers = () => { + model._capturedData = null; + }; + + publicAPI.releaseGraphicsResources = () => { + publicAPI.releasePixBuffers(); + }; + + publicAPI.getPixelInformation = ( + inDisplayPosition, + maxDistance, + outSelectedPosition + ) => { + if (!model._capturedData) { + vtkErrorMacro('Call captureBuffersAsync before getPixelInformation.'); + return null; + } + return model._capturedData.getPixelInformation( + inDisplayPosition, + maxDistance, + outSelectedPosition + ); + }; + + publicAPI.generateSelection = (fx1, fy1, fx2, fy2) => { + if (!model._capturedData) { + vtkErrorMacro('Call captureBuffersAsync before generateSelection.'); + return []; + } + return model._capturedData.generateSelection(fx1, fy1, fx2, fy2); + }; + + publicAPI.isPropHit = (propID) => !!model._capturedData?.isPropHit(propID); + + //---------------------------------------------------------------------------- + // The selection pass renders the full window. Only the pixels of the area + // fx1, fy1, fx2, fy2 (display pixels, y up) are copied back to the CPU. + // With no area, all the window is copied. + publicAPI.getSourceDataAsync = async ( + renderer = model._renderer, + fx1 = undefined, + fy1 = undefined, + fx2 = undefined, + fy2 = undefined + ) => { + if (!publicAPI.prepareCapture(model._WebGPURenderWindow, renderer)) { + return false; + } if (!model._WebGPURenderWindow.getInitialized()) { model._WebGPURenderWindow.initialize(); @@ -349,22 +289,38 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { // the class as multiple calls may start before resolving // so anything specific to this request gets put into the // result object (by value in most cases) + const width = texture.getWidth(); + const height = texture.getHeight(); + const area = [0, 0, width - 1, height - 1]; + if (fx1 !== undefined) { + area[0] = Math.max(0, Math.floor(Math.min(fx1, fx2))); + area[1] = Math.max(0, Math.floor(Math.min(fy1, fy2))); + area[2] = Math.min(width - 1, Math.floor(Math.max(fx1, fx2))); + area[3] = Math.min(height - 1, Math.floor(Math.max(fy1, fy2))); + } + if (area[2] < area[0] || area[3] < area[1]) { + return false; + } + const copyWidth = area[2] - area[0] + 1; + const copyHeight = area[3] - area[1] + 1; + const copyOrigin = { x: area[0], y: height - 1 - area[3] }; + const result = { - area: [0, 0, texture.getWidth() - 1, texture.getHeight() - 1], + area, captureZValues: model.captureZValues, fieldAssociation: model.fieldAssociation, props: [...model._selectionProps], renderer, webGPURenderer, webGPURenderWindow: model._WebGPURenderWindow, - width: texture.getWidth(), - height: texture.getHeight(), + width, + height, }; // must be a multiple of 256 bytes, so 16 texels with rgba32uint - result.colorBufferWidth = 16 * Math.floor((result.width + 15) / 16); + result.colorBufferWidth = 16 * Math.floor((copyWidth + 15) / 16); result.colorBufferSizeInBytes = - result.colorBufferWidth * result.height * 4 * 4; + result.colorBufferWidth * copyHeight * 4 * 4; const colorBuffer = vtkWebGPUBuffer.newInstance({ label: 'hardwareSelectColorBuffer', }); @@ -382,27 +338,28 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { cmdEnc.copyTextureToBuffer( { texture: texture.getHandle(), + origin: copyOrigin, }, { buffer: colorBuffer.getHandle(), bytesPerRow: 16 * result.colorBufferWidth, - rowsPerImage: result.height, + rowsPerImage: copyHeight, }, { - width: result.width, - height: result.height, + width: copyWidth, + height: copyHeight, depthOrArrayLayers: 1, } ); let zbuffer; if (model.captureZValues) { - result.zbufferBufferWidth = 64 * Math.floor((result.width + 63) / 64); + result.zbufferBufferWidth = 64 * Math.floor((width + 63) / 64); zbuffer = vtkWebGPUBuffer.newInstance({ label: 'hardwareSelectDepthBuffer', }); zbuffer.setDevice(device); - result.zbufferSizeInBytes = result.height * result.zbufferBufferWidth * 4; + result.zbufferSizeInBytes = height * result.zbufferBufferWidth * 4; /* eslint-disable no-bitwise */ /* eslint-disable no-undef */ zbuffer.create( @@ -420,11 +377,11 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { { buffer: zbuffer.getHandle(), bytesPerRow: 4 * result.zbufferBufferWidth, - rowsPerImage: result.height, + rowsPerImage: height, }, { - width: result.width, - height: result.height, + width, + height, depthOrArrayLayers: 1, } ); @@ -438,6 +395,7 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { await Promise.all([cLoad, zLoad]); result.depthValues = new Float32Array(zbuffer.getMappedRange().slice()); zbuffer.unmap(); + zbuffer.getHandle().destroy(); } else { await cLoad; } @@ -445,10 +403,10 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { result.colorValues = new Uint32Array(colorBuffer.getMappedRange().slice()); colorBuffer.unmap(); + // the read back buffers are used one time only + colorBuffer.getHandle().destroy(); - result.generateSelection = (fx1, fy1, fx2, fy2) => - generateSelectionWithData(result, fx1, fy1, fx2, fy2); - return result; + return newSourceData(result); }; } @@ -458,8 +416,11 @@ function vtkWebGPUHardwareSelector(publicAPI, model) { const DEFAULT_VALUES = { // WebGPURenderWindow: null, + area: undefined, + _areaSet: false, _selectionPropMap: null, _selectionProps: null, + _capturedData: null, }; // ---------------------------------------------------------------------------- @@ -473,7 +434,13 @@ export function extend(publicAPI, model, initialValues = {}) { model._selectionPass = vtkWebGPUHardwareSelectionPass.newInstance(); model._selectionPropMap = new Map(); model._selectionProps = []; + if (model.area) { + model._areaSet = true; + } else { + model.area = [0, 0, 0, 0]; + } + macro.setGetArray(publicAPI, model, ['area'], 4); macro.setGet(publicAPI, model, ['_WebGPURenderWindow']); macro.moveToProtected(publicAPI, model, ['WebGPURenderWindow']); @@ -490,4 +457,4 @@ export const newInstance = macro.newInstance( // ---------------------------------------------------------------------------- -export default { newInstance, extend }; +export default { newInstance, extend, newSourceData }; diff --git a/Sources/Rendering/WebGPU/HardwareSelector/test/testWebGPUHardwareSelector.js b/Sources/Rendering/WebGPU/HardwareSelector/test/testWebGPUHardwareSelector.js new file mode 100644 index 00000000000..89f0b4a9a90 --- /dev/null +++ b/Sources/Rendering/WebGPU/HardwareSelector/test/testWebGPUHardwareSelector.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import macro from 'vtk.js/Sources/macros'; +import vtkDataSet from 'vtk.js/Sources/Common/DataModel/DataSet'; +import vtkSelectionNode from 'vtk.js/Sources/Common/DataModel/SelectionNode'; +import vtkWebGPUHardwareSelector from 'vtk.js/Sources/Rendering/WebGPU/HardwareSelector'; + +const { FieldAssociations } = vtkDataSet; + +// An area of 4 x 3 pixels at display (10, 20). The pixel (11, 21) shows +// prop 0, with attribute 5 (written as 6). +function makeData() { + const area = [10, 20, 13, 22]; + const colorBufferWidth = 16; + const colorValues = new Uint32Array(colorBufferWidth * 3 * 4); + const index = (area[3] - 21) * colorBufferWidth + (11 - area[0]); + colorValues[index * 4] = 1; + colorValues[index * 4 + 2] = 6; + return vtkWebGPUHardwareSelector.newSourceData({ + area, + width: 100, + height: 100, + colorValues, + colorBufferWidth, + props: ['prop0'], + fieldAssociation: FieldAssociations.FIELD_ASSOCIATION_CELLS, + captureZValues: false, + }); +} + +// A selector that returns the given source data instead of rendering. +function newStubSelector(sourceData, calls) { + return macro.newInstance((publicAPI, model, initialValues) => { + vtkWebGPUHardwareSelector.extend(publicAPI, model, initialValues); + publicAPI.getSourceDataAsync = async (...args) => { + calls.push(args); + return sourceData; + }; + }, 'vtkStubSelector')(); +} + +describe('Test WebGPU HardwareSelector', () => { + it('reads pixels relative to the area', () => { + const data = makeData(); + const info = data.getPixelInformation([11, 21], 0, [0, 0]); + expect(info.valid).toBe(true); + expect(info.prop).toBe('prop0'); + expect(info.attributeID).toBe(5); + expect(data.getPixelInformation([12, 21], 0, [0, 0])).toBe(null); + expect(data.getPixelInformation([5, 21], 0, [0, 0])).toBe(null); + }); + + it('finds the nearest pixel within a distance', () => { + const out = [0, 0]; + const info = makeData().getPixelInformation([12, 22], 3, out); + expect(info.attributeID).toBe(5); + expect(out).toEqual([11, 21]); + }); + + it('generates a selection in the area', () => { + const sel = makeData().generateSelection(10, 20, 13, 22); + expect(sel.length).toBe(1); + expect(sel[0].getFieldType()).toBe(vtkSelectionNode.SelectionField.CELL); + expect(Array.from(sel[0].getSelectionList())).toEqual([5]); + expect(sel[0].getProperties().pixelCount).toBe(1); + }); + + it('tells which props are hit', () => { + const data = makeData(); + expect(data.isPropHit(0)).toBe(true); + expect(data.isPropHit(1)).toBe(false); + }); + + it('captures the area and keeps the data', async () => { + const calls = []; + const selector = newStubSelector(makeData(), calls); + expect(selector.getPixelInformation([11, 21], 0, [0, 0])).toBe(null); + selector.setArea(10.7, 20.2, 13.9, 22.5); + expect(selector.getArea()).toEqual([10, 20, 13, 22]); + expect(await selector.captureBuffersAsync()).toBe(true); + expect(calls[0].slice(1)).toEqual([10, 20, 13, 22]); + + expect(selector.getPixelInformation([11, 21], 0, [0, 0]).attributeID).toBe( + 5 + ); + expect(selector.generateSelection(10, 20, 13, 22).length).toBe(1); + expect(selector.isPropHit(0)).toBe(true); + + selector.releasePixBuffers(); + expect(selector.getCapturedData()).toBe(null); + expect(selector.isPropHit(0)).toBe(false); + }); + + it('reads all the window when no area is set', async () => { + const calls = []; + const selector = newStubSelector(false, calls); + expect(await selector.captureBuffersAsync()).toBe(false); + expect(calls[0].length).toBe(1); + }); + + it('reads one pixel at the origin when the area is all zeros', async () => { + const calls = []; + const selector = newStubSelector(false, calls); + selector.setArea(0, 0, 0, 0); + await selector.captureBuffersAsync(); + expect(calls[0].length).toBe(5); + selector.clearArea(); + await selector.captureBuffersAsync(); + expect(calls[1].length).toBe(1); + }); +});