diff --git a/examples/src/examples/gaussian-splatting-legacy/picking.example.mjs b/examples/src/examples/gaussian-splatting-legacy/picking.example.mjs index 3cd921beb47..dca965d6639 100644 --- a/examples/src/examples/gaussian-splatting-legacy/picking.example.mjs +++ b/examples/src/examples/gaussian-splatting-legacy/picking.example.mjs @@ -169,15 +169,20 @@ assetListLoader.load(() => { const pickerScale = 0.25; picker.resize(canvas.clientWidth * pickerScale, canvas.clientHeight * pickerScale); - // render the ID texture + // render the ID texture — scissor to a single pixel around the click so only that + // fragment is rasterized into the pick buffer const worldLayer = app.scene.layers.getLayerByName('World'); - picker.prepare(camera.camera, app.scene, [worldLayer]); + const px = x * pickerScale; + const py = y * pickerScale; + picker.prepare(camera.camera, app.scene, [worldLayer], { + x: px, y: py, width: 1, height: 1 + }); // get the world position at the clicked point - picker.getWorldPointAsync(x * pickerScale, y * pickerScale).then((worldPoint) => { + picker.getWorldPointAsync(px, py).then((worldPoint) => { if (worldPoint) { // get the meshInstance of the picked object - picker.getSelectionAsync(x * pickerScale, y * pickerScale, 1, 1).then((meshInstances) => { + picker.getSelectionAsync(px, py, 1, 1).then((meshInstances) => { if (meshInstances.length > 0) { const meshInstance = meshInstances[0]; diff --git a/examples/src/examples/gaussian-splatting/paint.example.mjs b/examples/src/examples/gaussian-splatting/paint.example.mjs index 45c5bad26e8..414caf6fe1f 100644 --- a/examples/src/examples/gaussian-splatting/paint.example.mjs +++ b/examples/src/examples/gaussian-splatting/paint.example.mjs @@ -243,9 +243,6 @@ assetListLoader.load(() => { // Paint state let isPainting = false; - // Track if picker needs re-preparation (after camera moves) - let pickerDirty = true; - // Disable context menu for RMB app.mouse.disableContextMenu(); @@ -270,13 +267,13 @@ assetListLoader.load(() => { const picker = new pc.Picker(app, 1, 1, true); const worldLayer = app.scene.layers.getLayerByName('World'); - // Prepare picker (re-prepare when camera moves) - const preparePicker = () => { - if (pickerDirty) { - picker.resize(canvas.clientWidth, canvas.clientHeight); - picker.prepare(camera.camera, app.scene, [worldLayer]); - pickerDirty = false; - } + // Prepare picker for a single pixel at the brush position. Scissoring to 1x1 keeps the + // pick render trivially small even when called every mousemove during a paint drag. + const preparePicker = (x, y) => { + picker.resize(canvas.clientWidth, canvas.clientHeight); + picker.prepare(camera.camera, app.scene, [worldLayer], { + x: x, y: y, width: 1, height: 1 + }); }; // Pending paint requests - processed in update loop for consistent frame timing @@ -310,8 +307,8 @@ assetListLoader.load(() => { // Request paint at a specific screen position - queues for processing in update loop const paintAt = (x, y) => { - // Prepare picker if needed (after camera moved) - preparePicker(); + // Re-prepare each call so the 1x1 scissor follows the brush + preparePicker(x, y); // Get world position for the paint brush picker.getWorldPointAsync(x, y).then((worldPoint) => { @@ -328,7 +325,6 @@ assetListLoader.load(() => { app.mouse.on(pc.EVENT_MOUSEDOWN, (e) => { if (e.button === pc.MOUSEBUTTON_RIGHT) { isPainting = true; - pickerDirty = true; orbitInput.enabled = false; orbitInput.panButtonDown = false; // Cancel pan that orbit-camera started paintAt(e.x, e.y); diff --git a/examples/src/examples/gaussian-splatting/picking.example.mjs b/examples/src/examples/gaussian-splatting/picking.example.mjs index a8659d07a9a..a221804d428 100644 --- a/examples/src/examples/gaussian-splatting/picking.example.mjs +++ b/examples/src/examples/gaussian-splatting/picking.example.mjs @@ -184,15 +184,20 @@ assetListLoader.load(() => { const pickerScale = 0.25; picker.resize(canvas.clientWidth * pickerScale, canvas.clientHeight * pickerScale); - // render the ID texture + // render the ID texture — scissor to a single pixel around the click so only that + // fragment is rasterized into the pick buffer const worldLayer = app.scene.layers.getLayerByName('World'); - picker.prepare(camera.camera, app.scene, [worldLayer]); + const px = x * pickerScale; + const py = y * pickerScale; + picker.prepare(camera.camera, app.scene, [worldLayer], { + x: px, y: py, width: 1, height: 1 + }); // get the world position at the clicked point - picker.getWorldPointAsync(x * pickerScale, y * pickerScale).then((worldPoint) => { + picker.getWorldPointAsync(px, py).then((worldPoint) => { if (worldPoint) { // get the meshInstance of the picked object - picker.getSelectionAsync(x * pickerScale, y * pickerScale, 1, 1).then((meshInstances) => { + picker.getSelectionAsync(px, py, 1, 1).then((meshInstances) => { if (meshInstances.length > 0) { // Unified mode: picker returns the GSplatComponent directly diff --git a/examples/src/examples/graphics/area-picker.example.mjs b/examples/src/examples/graphics/area-picker.example.mjs index 8cab0f6460c..3a426aa814f 100644 --- a/examples/src/examples/graphics/area-picker.example.mjs +++ b/examples/src/examples/graphics/area-picker.example.mjs @@ -220,12 +220,6 @@ assetListLoader.load(() => { camera.setLocalPosition(40 * Math.sin(time), 0, 40 * Math.cos(time)); camera.lookAt(pc.Vec3.ZERO); - // Make sure the picker is the right size, and prepare it, which renders meshes into its render target - if (picker) { - picker.resize(canvas.clientWidth * pickerScale, canvas.clientHeight * pickerScale); - picker.prepare(camera.camera, app.scene, pickerLayers); - } - // areas we want to sample - two larger rectangles, one small square, and one pixel at a mouse position // assign them different highlight colors as well const areas = [ @@ -252,6 +246,39 @@ assetListLoader.load(() => { } ]; + // compute the union bounding rect of all query areas (in canvas pixels) and then scale it + // to pick-buffer pixels for the picker scissor so the GPU only rasterizes fragments that + // will actually be read. Include any pending click point so getWorldPointAsync can still + // read a valid pixel. + for (let a = 0; a < areas.length; a++) { + const ap = areas[a].pos; + const asz = areas[a].size; + minX = Math.min(minX, ap.x); + minY = Math.min(minY, ap.y); + maxX = Math.max(maxX, ap.x + asz.x); + maxY = Math.max(maxY, ap.y + asz.y); + } + if (pendingPickRequest) { + const cx = pendingPickRequest.x / pickerScale; + const cy = pendingPickRequest.y / pickerScale; + minX = Math.min(minX, cx); + minY = Math.min(minY, cy); + maxX = Math.max(maxX, cx + 1); + maxY = Math.max(maxY, cy + 1); + } + const scissor = { + x: minX * pickerScale, + y: minY * pickerScale, + width: (maxX - minX) * pickerScale, + height: (maxY - minY) * pickerScale + }; + + // Make sure the picker is the right size, and prepare it, which renders meshes into its render target + if (picker) { + picker.resize(canvas.clientWidth * pickerScale, canvas.clientHeight * pickerScale); + picker.prepare(camera.camera, app.scene, pickerLayers, scissor); + } + // process all areas every frame const promises = []; for (let a = 0; a < areas.length; a++) { diff --git a/examples/src/examples/misc/editor.controls.jsx b/examples/src/examples/misc/editor.controls.jsx index fc9e8c590c2..74a2161b3f1 100644 --- a/examples/src/examples/misc/editor.controls.jsx +++ b/examples/src/examples/misc/editor.controls.jsx @@ -1,5 +1,6 @@ import { BindingTwoWay, + BooleanInput, LabelGroup, Panel, SliderInput, @@ -191,6 +192,15 @@ export function Controls({ observer }) { /> + + + + + ); } diff --git a/examples/src/examples/misc/editor.example.mjs b/examples/src/examples/misc/editor.example.mjs index d3793aee557..6e562a71a00 100644 --- a/examples/src/examples/misc/editor.example.mjs +++ b/examples/src/examples/misc/editor.example.mjs @@ -184,6 +184,9 @@ setGizmoControls(); // view cube const viewCube = new pc.ViewCube(new pc.Vec4(0, 1, 1, 0)); viewCube.dom.style.margin = '20px'; +data.set('picking', { + showAxes: false +}); data.set('viewCube', { colorX: Object.values(viewCube.colorX), colorY: Object.values(viewCube.colorY), @@ -207,6 +210,21 @@ app.on('prerender', () => { viewCube.update(camera.getWorldTransform()); }); +// pick hit visualization — three orthogonal axes anchored at the clicked surface point. +// PlayCanvas is Y-up, so the derived surface normal is the local +Y (green) axis. +// Tangent and bitangent become local +X (red) and local +Z (blue) respectively. +/** @type {{ point: pc.Vec3 | null, normal: pc.Vec3 }} */ +const pickHit = { + point: null, + normal: new pc.Vec3() +}; +const pickTangent = new pc.Vec3(); +const pickBitangent = new pc.Vec3(); +const pickTip = new pc.Vec3(); +const worldUp = new pc.Vec3(0, 1, 0); +const worldRight = new pc.Vec3(1, 0, 0); +const AXIS_LEN = 1.0; + // selector const layers = app.scene.layers; const selector = new Selector(app, camera.camera, [layers.getLayerByName('World')]); @@ -225,6 +243,26 @@ selector.on('deselect', () => { } gizmoHandler.clear(); outlineRenderer.removeAllEntities(); + pickHit.point = null; +}); +selector.on('pick', (/** @type {pc.Vec3} */ point, /** @type {pc.Vec3} */ normal) => { + pickHit.point = (pickHit.point ?? new pc.Vec3()).copy(point); + pickHit.normal.copy(normal); +}); +app.on('prerender', () => { + if (!pickHit.point || !data.get('picking.showAxes')) return; + const p = pickHit.point; + const n = pickHit.normal; + + // choose a reference vector not (anti)parallel to the normal, then build a right-handed + // orthonormal basis with X = tangent, Y = normal, Z = bitangent = cross(X, Y) + const ref = Math.abs(n.y) < 0.99 ? worldUp : worldRight; + pickTangent.cross(ref, n).normalize(); + pickBitangent.cross(pickTangent, n).normalize(); + + app.drawLine(p, pickTip.copy(pickTangent).mulScalar(AXIS_LEN).add(p), pc.Color.RED); + app.drawLine(p, pickTip.copy(n).mulScalar(AXIS_LEN).add(p), pc.Color.GREEN); + app.drawLine(p, pickTip.copy(pickBitangent).mulScalar(AXIS_LEN).add(p), pc.Color.BLUE); }); // ensure canvas is resized when window changes size + keep gizmo size consistent to canvas size diff --git a/examples/src/examples/misc/editor.selector.mjs b/examples/src/examples/misc/editor.selector.mjs index 0281934921e..384106b02f7 100644 --- a/examples/src/examples/misc/editor.selector.mjs +++ b/examples/src/examples/misc/editor.selector.mjs @@ -42,7 +42,8 @@ class Selector extends pc.EventHandler { this._camera = camera; this._scene = app.scene; const device = app.graphicsDevice; - this._picker = new pc.Picker(app, device.canvas.width, device.canvas.height); + // depth enabled so we can also recover the world point + surface normal at the click + this._picker = new pc.Picker(app, device.canvas.width, device.canvas.height, true, true); this._layers = layers; this._onPointerDown = this._onPointerDown.bind(this); @@ -70,16 +71,30 @@ class Selector extends pc.EventHandler { const device = this._picker.device; this._picker.resize(device.canvas.clientWidth, device.canvas.clientHeight); - this._picker.prepare(this._camera, this._scene, this._layers); - const selection = await this._picker.getSelectionAsync(e.clientX - 1, e.clientY - 1, 2, 2); + // scissor the render to a 2x2 rect around the click so only those fragments rasterize + const px = e.clientX - 1; + const py = e.clientY - 1; + this._picker.prepare(this._camera, this._scene, this._layers, { + x: px, y: py, width: 2, height: 2 + }); + + // run selection and normal queries in parallel — both read the same prepared pick buffer + const [selection, hit] = await Promise.all([ + this._picker.getSelectionAsync(px, py, 2, 2), + this._picker.getWorldPointAndNormalAsync(px, py) + ]); if (!selection[0]) { this.fire('deselect'); return; } - this.fire('select', selection[0].node, !e.ctrlKey && !e.metaKey); + const clear = !e.ctrlKey && !e.metaKey; + this.fire('select', selection[0].node, clear); + if (hit) { + this.fire('pick', hit.point, hit.normal, selection[0].node, clear); + } } bind() { diff --git a/src/framework/graphics/picker.js b/src/framework/graphics/picker.js index 0d4292936b3..6781727239d 100644 --- a/src/framework/graphics/picker.js +++ b/src/framework/graphics/picker.js @@ -126,6 +126,23 @@ class Picker { */ renderTargetDepth = null; + /** + * Optional buffer holding the world-space surface normal (RGBA8, encoded as n*0.5+0.5) for + * normal picking. + * + * @type {Texture|null} + * @private + */ + normalBuffer = null; + + /** + * Internal render target for reading the normal buffer. + * + * @type {RenderTarget|null} + * @private + */ + renderTargetNormal = null; + /** * Mapping table from ids to MeshInstances or GSplatComponents. * @@ -149,8 +166,12 @@ class Picker { * @param {number} height - The height of the pick buffer in pixels. * @param {boolean} [depth] - Whether to enable depth picking. When enabled, depth * information is captured alongside mesh IDs using MRT. Defaults to false. + * @param {boolean} [normals] - Whether to enable normal picking. When enabled, the world-space + * surface normal is captured alongside mesh IDs and depth using MRT, letting + * {@link Picker#getWorldPointAndNormalAsync} read the normal directly instead of + * reconstructing it from neighboring depths. Implies depth. Defaults to false. */ - constructor(app, width, height, depth = false) { + constructor(app, width, height, depth = false, normals = false) { // Note: The only reason this class needs the app is to access the renderer. Ideally we remove this dependency and move // the Picker from framework to the scene level, or even the extras. Debug.assert(app); @@ -158,7 +179,9 @@ class Picker { this.renderPass = new RenderPassPicker(this.device, app.renderer); - this.depth = depth; + this.normals = normals; + // normal picking captures depth too (needed to reconstruct the world point) + this.depth = depth || normals; this.width = 0; this.height = 0; this.resize(width, height); @@ -263,6 +286,11 @@ class Picker { * @private */ _readTexture(texture, x, y, width, height, renderTarget) { + // Floor before the WebGL2 Y-flip so the flipped row matches prepare()'s scissor, which + // floors (via sanitizeRect) before flipping. With fractional pick coordinates, flipping + // first and flooring after lands the read one row off the rasterized pixel. + x = Math.floor(x); + y = Math.floor(y); if (this.device?.isWebGL2) { y = renderTarget.height - (y + height); } @@ -294,38 +322,107 @@ class Picker { * }); */ async getWorldPointAsync(x, y) { - // get the camera from the render pass - const camera = this.renderPass.camera; - if (!camera) { + const state = this._captureCameraState(); + if (!state) { return null; } - // capture camera state synchronously before awaiting - const viewProjMat = new Mat4().mul2(camera.camera.projectionMatrix, camera.camera.viewMatrix); - const invViewProj = viewProjMat.invert(); - const near = camera.nearClip; - const far = camera.farClip; - const isOrtho = camera.projection === PROJECTION_ORTHOGRAPHIC; - const linearDepth = await this.getPointDepthAsync(x, y); if (linearDepth === null) { return null; } - // convert linear normalized depth [0,1] to NDC depth [0,1] for unprojection - const ndcDepth = isOrtho ? linearDepth : (far * linearDepth / (linearDepth * (far - near) + near)); + return this._unprojectDepth(x, y, linearDepth, state); + } - // unproject to world space using the captured matrix - const deviceCoord = new Vec4( + /** + * Return the world position and surface normal at the specified screen coordinates. Requires + * the picker to have been created with `normals: true`: the world-space surface normal is read + * directly from its dedicated MRT attachment (a single-pixel read), and the world point is + * reconstructed from the depth attachment at the same pixel. + * + * @param {number} x - The x coordinate of the pixel to pick. + * @param {number} y - The y coordinate of the pixel to pick. + * @returns {Promise<{point: Vec3, normal: Vec3}|null>} Promise resolving to the world point and + * a unit world-space normal, or null if normal picking is not enabled or nothing was picked. + * @example + * picker.prepare(camera, scene, layers, { x: px, y: py, width: 1, height: 1 }); + * picker.getWorldPointAndNormalAsync(px, py).then((hit) => { + * if (hit) { + * console.log('point', hit.point.toString(), 'normal', hit.normal.toString()); + * } + * }); + */ + async getWorldPointAndNormalAsync(x, y) { + if (!this.normalBuffer) { + return null; + } + const state = this._captureCameraState(); + if (!state) { + return null; + } + + // depth gates the hit: the normal buffer is cleared to white, which would otherwise decode + // to a valid-looking normal, so a null depth means "nothing picked here". + const depth = await this.getPointDepthAsync(x, y); + if (depth === null) { + return null; + } + + const normal = await this._readNormalAt(x, y); + if (!normal) { + return null; + } + + return { point: this._unprojectDepth(x, y, depth, state), normal }; + } + + /** + * Capture camera state needed to unproject pick-buffer depths into world space. Returns null + * if no camera was supplied to the last prepare() call. + * + * @returns {{invViewProj: Mat4, near: number, far: number, isOrtho: boolean, cameraPos: Vec3}|null} + * Captured camera state, or null if no camera is bound. + * @private + */ + _captureCameraState() { + const camera = this.renderPass.camera; + if (!camera) { + return null; + } + const viewProjMat = new Mat4().mul2(camera.camera.projectionMatrix, camera.camera.viewMatrix); + return { + invViewProj: viewProjMat.invert(), + near: camera.nearClip, + far: camera.farClip, + isOrtho: camera.projection === PROJECTION_ORTHOGRAPHIC, + cameraPos: camera.entity.getPosition().clone() + }; + } + + /** + * Unproject a pick-buffer pixel + linear depth back into world space. + * + * @param {number} x - Pick-buffer x in pixel coords, top-left origin. + * @param {number} y - Pick-buffer y in pixel coords, top-left origin. + * @param {number} linearDepth - Linear normalized depth [0,1]. + * @param {{invViewProj: Mat4, near: number, far: number, isOrtho: boolean}} state - Camera state + * captured by {@link Picker#_captureCameraState}. + * @returns {Vec3} World position. + * @private + */ + _unprojectDepth(x, y, linearDepth, state) { + const { invViewProj, near, far, isOrtho } = state; + const ndcDepth = isOrtho ? linearDepth : (far * linearDepth / (linearDepth * (far - near) + near)); + const dc = new Vec4( (x / this.width) * 2 - 1, (1 - y / this.height) * 2 - 1, ndcDepth * 2 - 1, 1.0 ); - invViewProj.transformVec4(deviceCoord, deviceCoord); - deviceCoord.mulScalar(1.0 / deviceCoord.w); - - return new Vec3(deviceCoord.x, deviceCoord.y, deviceCoord.z); + invViewProj.transformVec4(dc, dc); + dc.mulScalar(1.0 / dc.w); + return new Vec3(dc.x, dc.y, dc.z); } /** @@ -339,6 +436,7 @@ class Picker { * @ignore */ async getPointDepthAsync(x, y) { + if (!this.depthBuffer) { return null; } @@ -358,6 +456,39 @@ class Picker { return _floatView[0]; } + /** + * Read and decode the world-space surface normal at the given pick-buffer pixel from the + * normal attachment (RGBA8, encoded as n*0.5+0.5). Callers should gate on a valid depth at the + * same pixel, since the buffer is cleared to white and a cleared pixel decodes to a non-null + * (but meaningless) normal. + * + * @param {number} x - Pick-buffer x in pixels, top-left origin. + * @param {number} y - Pick-buffer y in pixels, top-left origin. + * @returns {Promise} The unit world-space normal, or null if normal picking is not + * enabled or the sample is degenerate. + * @private + */ + async _readNormalAt(x, y) { + if (!this.normalBuffer) { + return null; + } + + const pixels = await this._readTexture(this.normalBuffer, x, y, 1, 1, /** @type {RenderTarget} */ (this.renderTargetNormal)); + + // decode RGBA8 (n*0.5+0.5) back to a world-space vector in [-1, 1] + const normal = new Vec3( + (pixels[0] / 255) * 2 - 1, + (pixels[1] / 255) * 2 - 1, + (pixels[2] / 255) * 2 - 1 + ); + + const len = normal.length(); + if (len < 1e-4) { + return null; + } + return normal.mulScalar(1 / len); + } + // sanitize the rectangle to make sure it's inside the texture and does not use fractions sanitizeRect(x, y, width, height) { const maxWidth = this.renderTarget.width; @@ -427,6 +558,18 @@ class Picker { }); } + if (this.normals) { + // create normal buffer for MRT (attachment 2, matches pcFragColor2) + this.normalBuffer = this.createTexture('pick-normal'); + colorBuffers.push(this.normalBuffer); + + // create a render target for reading the normal buffer + this.renderTargetNormal = new RenderTarget({ + colorBuffer: this.normalBuffer, + depth: false + }); + } + this.renderTarget = new RenderTarget({ colorBuffers: colorBuffers, depth: true @@ -441,8 +584,12 @@ class Picker { this.renderTargetDepth?.destroy(); this.renderTargetDepth = null; + this.renderTargetNormal?.destroy(); + this.renderTargetNormal = null; + this.colorBuffer = null; this.depthBuffer = null; + this.normalBuffer = null; } /** @@ -455,8 +602,13 @@ class Picker { * @param {Scene} scene - The scene containing the pickable mesh instances. * @param {Layer[]} [layers] - Layers from which objects will be picked. If not supplied, all * layers of the specified camera will be used. + * @param {object} [options] - Optional rendering controls. + * @param {number} [options.x] - Scissor rect left edge in pick-buffer pixels, top-left origin. + * @param {number} [options.y] - Scissor rect top edge in pick-buffer pixels, top-left origin. + * @param {number} [options.width] - Scissor rect width in pick-buffer pixels. + * @param {number} [options.height] - Scissor rect height in pick-buffer pixels. */ - prepare(camera, scene, layers) { + prepare(camera, scene, layers, options) { if (layers instanceof Layer) { layers = [layers]; @@ -465,6 +617,7 @@ class Picker { // make the render target the right size this.renderTarget?.resize(this.width, this.height); this.renderTargetDepth?.resize(this.width, this.height); + this.renderTargetNormal?.resize(this.width, this.height); // clear registered meshes mapping this.mapping.clear(); @@ -476,8 +629,23 @@ class Picker { renderPass.setClearColor(Color.WHITE); renderPass.depthStencilOps.clearDepth = true; + // optional scissor rect — sanitize and flip top-left → bottom-left for device.setScissor + let scissorRect = null; + if (options && this.renderTarget) { + const w = options.width ?? 0; + const h = options.height ?? 0; + if (w > 0 && h > 0) { + const r = this.sanitizeRect(options.x ?? 0, options.y ?? 0, w, h); + const flippedY = this.renderTarget.height - (r.y + r.w); + scissorRect = new Vec4(r.x, flippedY, r.z, r.w); + } + } + + const writeDepth = this.depth; + const writeNormals = this.normals; + // render the pass to update the render target - renderPass.update(camera, scene, layers, this.mapping, this.depth); + renderPass.update(camera, scene, layers, this.mapping, writeDepth, writeNormals, scissorRect); renderPass.render(); } diff --git a/src/framework/graphics/render-pass-picker.js b/src/framework/graphics/render-pass-picker.js index 142501276c1..3186d2f1c58 100644 --- a/src/framework/graphics/render-pass-picker.js +++ b/src/framework/graphics/render-pass-picker.js @@ -1,7 +1,7 @@ import { DebugGraphics } from '../../platform/graphics/debug-graphics.js'; import { BlendState } from '../../platform/graphics/blend-state.js'; import { RenderPass } from '../../platform/graphics/render-pass.js'; -import { SHADER_PICK, SHADER_DEPTH_PICK } from '../../scene/constants.js'; +import { SHADER_PICK, SHADER_DEPTH_PICK, SHADER_NORMAL_PICK } from '../../scene/constants.js'; /** * @import { BindGroup } from '../../platform/graphics/bind-group.js' @@ -10,6 +10,7 @@ import { SHADER_PICK, SHADER_DEPTH_PICK } from '../../scene/constants.js'; * @import { Layer } from '../../scene/layer.js' * @import { MeshInstance } from '../../scene/mesh-instance.js' * @import { GSplatComponent } from '../components/gsplat/component.js' + * @import { Vec4 } from '../../core/math/vec4.js' */ const tempMeshInstances = []; @@ -43,6 +44,12 @@ class RenderPassPicker extends RenderPass { /** @type {boolean} */ depth; + /** @type {boolean} */ + normals; + + /** @type {Vec4|null} Optional scissor rect in render-target pixel coords, bottom-left origin. */ + scissorRect = null; + /** @type {number[]} */ _qualifiedLayerIndices = []; @@ -68,13 +75,19 @@ class RenderPassPicker extends RenderPass { * @param {Layer[]} layers - The layers to pick from. * @param {Map} mapping - Map to store ID to object mappings. * @param {boolean} depth - Whether to render depth information. + * @param {boolean} normals - Whether to render the world-space surface normal (implies depth). + * @param {Vec4|null} [scissorRect] - Optional scissor rect in render-target pixel coords, + * bottom-left origin (x, y, w, h). If set, only fragments inside the rect are rasterized; + * outside the rect the cleared "no selection" value (white) remains. */ - update(camera, scene, layers, mapping, depth) { + update(camera, scene, layers, mapping, depth, normals, scissorRect = null) { this.camera = camera; this.scene = scene; this.layers = layers; this.mapping = mapping; this.depth = depth; + this.normals = normals; + this.scissorRect = scissorRect; if (scene.clusteredLightingEnabled) { this.emptyWorldClusters = this.renderer.worldClustersAllocator.empty; @@ -118,10 +131,18 @@ class RenderPassPicker extends RenderPass { execute() { const device = this.device; - const { renderer, camera, scene, mapping, renderTarget } = this; + const { renderer, camera, scene, mapping, renderTarget, scissorRect } = this; const srcLayers = scene.layers.layerList; const isTransparent = scene.layers.subLayerList; + // narrow rasterization to a sub-rect of the pick buffer. + // pass start has already cleared the full RT to white = "no selection", + // so fragments outside the scissor stay cleared. + // no need to restore — every render pass start resets viewport + scissor to full RT size. + if (scissorRect) { + device.setScissor(scissorRect.x, scissorRect.y, scissorRect.z, scissorRect.w); + } + for (const i of this._qualifiedLayerIndices) { const srcLayer = srcLayers[i]; const transparent = isTransparent[i]; @@ -191,7 +212,7 @@ class RenderPassPicker extends RenderPass { renderer.setupViewUniformBuffers(this.viewBindGroups, renderer.viewUniformFormat, renderer.viewBindGroupFormat, null); } - const shaderPass = this.depth ? SHADER_DEPTH_PICK : SHADER_PICK; + const shaderPass = this.normals ? SHADER_NORMAL_PICK : (this.depth ? SHADER_DEPTH_PICK : SHADER_PICK); renderer.renderForward(camera.camera, renderTarget, tempMeshInstances, lights, shaderPass, (meshInstance) => { device.setBlendState(this.blendState); }); diff --git a/src/framework/script/script-create.js b/src/framework/script/script-create.js index 8f9bbee2dc0..a4a5d2dc89b 100644 --- a/src/framework/script/script-create.js +++ b/src/framework/script/script-create.js @@ -59,7 +59,11 @@ function createScript(name, app) { if (reservedScriptNames.has(name)) { throw new Error(`Script name '${name}' is reserved, please rename the script`); } - + /** + * @preserve + * @type Class + * @param {*} args - Arguments passed to the script constructor. + */ const scriptType = function (args) { EventHandler.prototype.initEventHandler.call(this); ScriptType.prototype.initScriptType.call(this, args); diff --git a/src/scene/constants.js b/src/scene/constants.js index c55554d9003..8edcfce8ed5 100644 --- a/src/scene/constants.js +++ b/src/scene/constants.js @@ -815,6 +815,9 @@ export const SHADER_PICK = 3; // shader pass used by the Picker class to render mesh ID and depth export const SHADER_DEPTH_PICK = 4; +// shader pass used by the Picker class to render mesh ID, depth and world-space surface normal +export const SHADER_NORMAL_PICK = 5; + /** * Shader that performs forward rendering. * diff --git a/src/scene/shader-lib/glsl/chunks/common/frag/pick.js b/src/scene/shader-lib/glsl/chunks/common/frag/pick.js index 1b4bc1bd8bd..fae18634ee5 100644 --- a/src/scene/shader-lib/glsl/chunks/common/frag/pick.js +++ b/src/scene/shader-lib/glsl/chunks/common/frag/pick.js @@ -31,4 +31,13 @@ vec4 encodePickOutput(uint id) { return float2uint(linearDepth); } #endif + +#ifdef NORMAL_PICK_PASS + // Encode a world-space surface normal ([-1, 1]) into RGBA8 ([0, 1]). Alpha is set to 1 as a + // hit marker, though readback should gate on the depth attachment (the buffer is cleared to + // white, so alpha alone can't distinguish a hit from the cleared background). + vec4 getPickNormal(vec3 worldNormal) { + return vec4(normalize(worldNormal) * 0.5 + 0.5, 1.0); + } +#endif `; diff --git a/src/scene/shader-lib/glsl/chunks/lit/frag/pass-other/litOtherMain.js b/src/scene/shader-lib/glsl/chunks/lit/frag/pass-other/litOtherMain.js index 151a918be8a..261d8d4765e 100644 --- a/src/scene/shader-lib/glsl/chunks/lit/frag/pass-other/litOtherMain.js +++ b/src/scene/shader-lib/glsl/chunks/lit/frag/pass-other/litOtherMain.js @@ -20,6 +20,13 @@ void main(void) { #ifdef DEPTH_PICK_PASS pcFragColor1 = getPickDepth(); #endif + #ifdef NORMAL_PICK_PASS + // world-space interpolated vertex normal. The normal-pick pass forces needsNormal, + // so the NORMALS varying (vNormalW) is always generated. getPickNormal normalizes, + // so the raw interpolated value is fine. Note: this is the geometric surface normal, + // not the normal-mapped one - sufficient for pick-point orientation. + pcFragColor2 = getPickNormal(vNormalW); + #endif #endif #ifdef PREPASS_PASS diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js index 559b7435db1..5c820a94f27 100644 --- a/src/scene/shader-lib/programs/lit-shader.js +++ b/src/scene/shader-lib/programs/lit-shader.js @@ -9,7 +9,7 @@ import { import { LIGHTSHAPE_PUNCTUAL, LIGHTTYPE_DIRECTIONAL, LIGHTTYPE_OMNI, LIGHTTYPE_SPOT, - SHADER_PICK, + SHADER_PICK, SHADER_NORMAL_PICK, SPRITE_RENDERMODE_SLICED, SPRITE_RENDERMODE_TILED, shadowTypeInfo, SHADER_PREPASS, lightTypeNames, lightShapeNames, spriteRenderModeNames, fresnelNames, blendNames, lightFalloffNames, cubemaProjectionNames, specularOcclusionNames, reflectionSrcNames, ambientSrcNames, @@ -166,6 +166,9 @@ class LitShader { (options.clusteredLightingEnabled && !this.shadowPass) || options.useClearCoatNormals; this.needsNormal = this.needsNormal && !this.shadowPass; + // the normal-pick pass writes the world-space surface normal (dNormalW) to an MRT target, + // so the frontend must compute it even though this pass does no lighting + this.needsNormal = this.needsNormal || options.pass === SHADER_NORMAL_PICK; this.needsSceneColor = options.useDynamicRefraction; this.needsScreenSize = options.useDynamicRefraction; this.needsTransforms = options.useDynamicRefraction; diff --git a/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js b/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js index 219e3ed245b..f31d6f61460 100644 --- a/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js +++ b/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js @@ -31,4 +31,13 @@ fn encodePickOutput(id: u32) -> vec4f { return float2uint(linearDepth); } #endif + +#ifdef NORMAL_PICK_PASS + // Encode a world-space surface normal ([-1, 1]) into RGBA8 ([0, 1]). Alpha is set to 1 as a + // hit marker, though readback should gate on the depth attachment (the buffer is cleared to + // white, so alpha alone can't distinguish a hit from the cleared background). + fn getPickNormal(worldNormal: vec3f) -> vec4f { + return vec4f(normalize(worldNormal) * 0.5 + 0.5, 1.0); + } +#endif `; diff --git a/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-other/litOtherMain.js b/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-other/litOtherMain.js index 9d7adbd37b4..687c76ae5a1 100644 --- a/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-other/litOtherMain.js +++ b/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-other/litOtherMain.js @@ -23,6 +23,13 @@ fn fragmentMain(input: FragmentInput) -> FragmentOutput { #ifdef DEPTH_PICK_PASS output.color1 = getPickDepth(); #endif + #ifdef NORMAL_PICK_PASS + // world-space interpolated vertex normal. The normal-pick pass forces needsNormal, + // so the NORMALS varying (vNormalW) is always generated. getPickNormal normalizes, + // so the raw interpolated value is fine. Note: this is the geometric surface normal, + // not the normal-mapped one - sufficient for pick-point orientation. + output.color2 = getPickNormal(vNormalW); + #endif #endif #ifdef PREPASS_PASS diff --git a/src/scene/shader-pass.js b/src/scene/shader-pass.js index f4914387a49..c15220de811 100644 --- a/src/scene/shader-pass.js +++ b/src/scene/shader-pass.js @@ -1,7 +1,7 @@ import { Debug } from '../core/debug.js'; import { DeviceCache } from '../platform/graphics/device-cache.js'; import { - SHADER_FORWARD, SHADER_PICK, SHADER_SHADOW, SHADER_PREPASS, SHADER_DEPTH_PICK + SHADER_FORWARD, SHADER_PICK, SHADER_SHADOW, SHADER_PREPASS, SHADER_DEPTH_PICK, SHADER_NORMAL_PICK } from './constants.js'; /** @@ -64,6 +64,12 @@ class ShaderPassInfo { // depth pick generates both PICK_PASS and DEPTH_PICK_PASS defines keyword = 'PICK'; this.defines.set('DEPTH_PICK_PASS', ''); + } else if (this.index === SHADER_NORMAL_PICK) { + // normal pick generates PICK_PASS, DEPTH_PICK_PASS (for the world point) and + // NORMAL_PICK_PASS defines + keyword = 'PICK'; + this.defines.set('DEPTH_PICK_PASS', ''); + this.defines.set('NORMAL_PICK_PASS', ''); } this.defines.set(`${keyword}_PASS`, ''); @@ -107,6 +113,7 @@ class ShaderPass { add('shadow', SHADER_SHADOW); add('pick', SHADER_PICK); add('depth_pick', SHADER_DEPTH_PICK); + add('normal_pick', SHADER_NORMAL_PICK); } /**