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);
}
/**