Skip to content

Commit 3331186

Browse files
refactor normal picking to use MRT instead, removed multiple sampling normal derivatives.
1 parent bf1905e commit 3331186

10 files changed

Lines changed: 153 additions & 69 deletions

File tree

examples/src/examples/misc/editor.selector.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Selector extends pc.EventHandler {
4343
this._scene = app.scene;
4444
const device = app.graphicsDevice;
4545
// depth enabled so we can also recover the world point + surface normal at the click
46-
this._picker = new pc.Picker(app, device.canvas.width, device.canvas.height, true);
46+
this._picker = new pc.Picker(app, device.canvas.width, device.canvas.height, true, true);
4747
this._layers = layers;
4848

4949
this._onPointerDown = this._onPointerDown.bind(this);

src/framework/graphics/picker.js

Lines changed: 97 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ class Picker {
126126
*/
127127
renderTargetDepth = null;
128128

129+
/**
130+
* Optional buffer holding the world-space surface normal (RGBA8, encoded as n*0.5+0.5) for
131+
* normal picking.
132+
*
133+
* @type {Texture|null}
134+
* @private
135+
*/
136+
normalBuffer = null;
137+
138+
/**
139+
* Internal render target for reading the normal buffer.
140+
*
141+
* @type {RenderTarget|null}
142+
* @private
143+
*/
144+
renderTargetNormal = null;
145+
129146
/**
130147
* Mapping table from ids to MeshInstances or GSplatComponents.
131148
*
@@ -149,16 +166,22 @@ class Picker {
149166
* @param {number} height - The height of the pick buffer in pixels.
150167
* @param {boolean} [depth] - Whether to enable depth picking. When enabled, depth
151168
* information is captured alongside mesh IDs using MRT. Defaults to false.
169+
* @param {boolean} [normals] - Whether to enable normal picking. When enabled, the world-space
170+
* surface normal is captured alongside mesh IDs and depth using MRT, letting
171+
* {@link Picker#getWorldPointAndNormalAsync} read the normal directly instead of
172+
* reconstructing it from neighboring depths. Implies depth. Defaults to false.
152173
*/
153-
constructor(app, width, height, depth = false) {
174+
constructor(app, width, height, depth = false, normals = false) {
154175
// Note: The only reason this class needs the app is to access the renderer. Ideally we remove this dependency and move
155176
// the Picker from framework to the scene level, or even the extras.
156177
Debug.assert(app);
157178
this.device = app.graphicsDevice;
158179

159180
this.renderPass = new RenderPassPicker(this.device, app.renderer);
160181

161-
this.depth = depth;
182+
this.normals = normals;
183+
// normal picking captures depth too (needed to reconstruct the world point)
184+
this.depth = depth || normals;
162185
this.width = 0;
163186
this.height = 0;
164187
this.resize(width, height);
@@ -308,18 +331,15 @@ class Picker {
308331
}
309332

310333
/**
311-
* Return the world position and a derived surface normal at the specified screen coordinates.
312-
* The normal is computed from depth-buffer neighbors via a cross product of two screen-aligned
313-
* world-space tangent vectors — it is a faceted (per-pixel) normal, not an interpolated vertex
314-
* normal. Internally a single 2x2 depth readback is used and {@link Picker#prepare} auto-pads
315-
* any supplied scissor rect by 1 pixel so the neighbor samples are inside the rasterized
316-
* region, so callers do not need to widen the scissor themselves.
334+
* Return the world position and surface normal at the specified screen coordinates. Requires
335+
* the picker to have been created with `normals: true`: the world-space surface normal is read
336+
* directly from its dedicated MRT attachment (a single-pixel read), and the world point is
337+
* reconstructed from the depth attachment at the same pixel.
317338
*
318339
* @param {number} x - The x coordinate of the pixel to pick.
319340
* @param {number} y - The y coordinate of the pixel to pick.
320-
* @returns {Promise<{point: Vec3, normal: Vec3}|null>} Promise resolving to the world point
321-
* and a unit normal facing the camera, or null if depth picking is not enabled or any of the
322-
* required neighbor pixels did not hit an object.
341+
* @returns {Promise<{point: Vec3, normal: Vec3}|null>} Promise resolving to the world point and
342+
* a unit world-space normal, or null if normal picking is not enabled or nothing was picked.
323343
* @example
324344
* picker.prepare(camera, scene, layers, { x: px, y: py, width: 1, height: 1 });
325345
* picker.getWorldPointAndNormalAsync(px, py).then((hit) => {
@@ -329,65 +349,27 @@ class Picker {
329349
* });
330350
*/
331351
async getWorldPointAndNormalAsync(x, y) {
332-
if (!this.depthBuffer) {
352+
if (!this.normalBuffer) {
333353
return null;
334354
}
335355
const state = this._captureCameraState();
336356
if (!state) {
337357
return null;
338358
}
339359

340-
// one 2x2 readback covering (x,y), (x+1,y), (x,y+1), (x+1,y+1)
341-
const pixels = await this._readTexture(this.depthBuffer, x, y, 2, 2, /** @type {RenderTarget} */ (this.renderTargetDepth));
342-
343-
// decode 4 RGBA8 pixels → 4 linear depths (null if cleared)
344-
/** @type {(number|null)[]} */
345-
const depths = [null, null, null, null];
346-
for (let i = 0; i < 4; i++) {
347-
const o = i * 4;
348-
const intBits = ((pixels[o] << 24) | (pixels[o + 1] << 16) | (pixels[o + 2] << 8) | pixels[o + 3]) >>> 0;
349-
if (intBits !== 0xFFFFFFFF) {
350-
_int32View[0] = intBits;
351-
depths[i] = _floatView[0];
352-
}
353-
}
354-
355-
// map array indices to screen positions. WebGPU returns rows top-down; WebGL2 returns
356-
// them bottom-up of the (already Y-flipped) read region — which means row 0 corresponds
357-
// to the BOTTOM screen row of the original (top-left origin) request.
358-
let dCenter, dRight, dDown;
359-
if (this.device.isWebGPU) {
360-
// pixels[0]=(x,y), [1]=(x+1,y), [2]=(x,y+1), [3]=(x+1,y+1)
361-
dCenter = depths[0];
362-
dRight = depths[1];
363-
dDown = depths[2];
364-
} else {
365-
// pixels[0]=(x,y+1), [1]=(x+1,y+1), [2]=(x,y), [3]=(x+1,y)
366-
dDown = depths[0];
367-
dCenter = depths[2];
368-
dRight = depths[3];
369-
}
370-
if (dCenter === null || dRight === null || dDown === null) {
360+
// depth gates the hit: the normal buffer is cleared to white, which would otherwise decode
361+
// to a valid-looking normal, so a null depth means "nothing picked here".
362+
const depth = await this.getPointDepthAsync(x, y);
363+
if (depth === null) {
371364
return null;
372365
}
373366

374-
const pCenter = this._unprojectDepth(x, y, dCenter, state);
375-
const pRight = this._unprojectDepth(x + 1, y, dRight, state);
376-
const pDown = this._unprojectDepth(x, y + 1, dDown, state);
377-
378-
// tangent vectors along screen +x and screen +y, then face normal via cross product
379-
const vRight = new Vec3().sub2(pRight, pCenter);
380-
const vDown = new Vec3().sub2(pDown, pCenter);
381-
const normal = new Vec3().cross(vRight, vDown).normalize();
382-
383-
// ensure the normal points back toward the camera
384-
const camPos = state.cameraPos;
385-
const toCam = new Vec3().sub2(camPos, pCenter);
386-
if (normal.dot(toCam) < 0) {
387-
normal.mulScalar(-1);
367+
const normal = await this._readNormalAt(x, y);
368+
if (!normal) {
369+
return null;
388370
}
389371

390-
return { point: pCenter, normal: normal };
372+
return { point: this._unprojectDepth(x, y, depth, state), normal };
391373
}
392374

393375
/**
@@ -449,6 +431,7 @@ class Picker {
449431
* @ignore
450432
*/
451433
async getPointDepthAsync(x, y) {
434+
452435
if (!this.depthBuffer) {
453436
return null;
454437
}
@@ -468,6 +451,39 @@ class Picker {
468451
return _floatView[0];
469452
}
470453

454+
/**
455+
* Read and decode the world-space surface normal at the given pick-buffer pixel from the
456+
* normal attachment (RGBA8, encoded as n*0.5+0.5). Callers should gate on a valid depth at the
457+
* same pixel, since the buffer is cleared to white and a cleared pixel decodes to a non-null
458+
* (but meaningless) normal.
459+
*
460+
* @param {number} x - Pick-buffer x in pixels, top-left origin.
461+
* @param {number} y - Pick-buffer y in pixels, top-left origin.
462+
* @returns {Promise<Vec3|null>} The unit world-space normal, or null if normal picking is not
463+
* enabled or the sample is degenerate.
464+
* @private
465+
*/
466+
async _readNormalAt(x, y) {
467+
if (!this.normalBuffer) {
468+
return null;
469+
}
470+
471+
const pixels = await this._readTexture(this.normalBuffer, x, y, 1, 1, /** @type {RenderTarget} */ (this.renderTargetNormal));
472+
473+
// decode RGBA8 (n*0.5+0.5) back to a world-space vector in [-1, 1]
474+
const normal = new Vec3(
475+
(pixels[0] / 255) * 2 - 1,
476+
(pixels[1] / 255) * 2 - 1,
477+
(pixels[2] / 255) * 2 - 1
478+
);
479+
480+
const len = normal.length();
481+
if (len < 1e-4) {
482+
return null;
483+
}
484+
return normal.mulScalar(1 / len);
485+
}
486+
471487
// sanitize the rectangle to make sure it's inside the texture and does not use fractions
472488
sanitizeRect(x, y, width, height) {
473489
const maxWidth = this.renderTarget.width;
@@ -537,6 +553,18 @@ class Picker {
537553
});
538554
}
539555

556+
if (this.normals) {
557+
// create normal buffer for MRT (attachment 2, matches pcFragColor2)
558+
this.normalBuffer = this.createTexture('pick-normal');
559+
colorBuffers.push(this.normalBuffer);
560+
561+
// create a render target for reading the normal buffer
562+
this.renderTargetNormal = new RenderTarget({
563+
colorBuffer: this.normalBuffer,
564+
depth: false
565+
});
566+
}
567+
540568
this.renderTarget = new RenderTarget({
541569
colorBuffers: colorBuffers,
542570
depth: true
@@ -551,8 +579,12 @@ class Picker {
551579
this.renderTargetDepth?.destroy();
552580
this.renderTargetDepth = null;
553581

582+
this.renderTargetNormal?.destroy();
583+
this.renderTargetNormal = null;
584+
554585
this.colorBuffer = null;
555586
this.depthBuffer = null;
587+
this.normalBuffer = null;
556588
}
557589

558590
/**
@@ -580,6 +612,7 @@ class Picker {
580612
// make the render target the right size
581613
this.renderTarget?.resize(this.width, this.height);
582614
this.renderTargetDepth?.resize(this.width, this.height);
615+
this.renderTargetNormal?.resize(this.width, this.height);
583616

584617
// clear registered meshes mapping
585618
this.mapping.clear();
@@ -591,22 +624,23 @@ class Picker {
591624
renderPass.setClearColor(Color.WHITE);
592625
renderPass.depthStencilOps.clearDepth = true;
593626

594-
// optional scissor rect — sanitize and flip top-left → bottom-left for device.setScissor.
595-
// Pad width/height by 1 so neighbor pixels needed by getWorldPointAndNormalAsync
596-
// (which reads a 2x2 depth block) stay inside the rasterized region.
627+
// optional scissor rect — sanitize and flip top-left → bottom-left for device.setScissor
597628
let scissorRect = null;
598629
if (options && this.renderTarget) {
599630
const w = options.width ?? 0;
600631
const h = options.height ?? 0;
601632
if (w > 0 && h > 0) {
602-
const r = this.sanitizeRect(options.x ?? 0, options.y ?? 0, w + 1, h + 1);
633+
const r = this.sanitizeRect(options.x ?? 0, options.y ?? 0, w, h);
603634
const flippedY = this.renderTarget.height - (r.y + r.w);
604635
scissorRect = new Vec4(r.x, flippedY, r.z, r.w);
605636
}
606637
}
607638

639+
const writeDepth = this.depth;
640+
const writeNormals = this.normals;
641+
608642
// render the pass to update the render target
609-
renderPass.update(camera, scene, layers, this.mapping, this.depth, scissorRect);
643+
renderPass.update(camera, scene, layers, this.mapping, writeDepth, writeNormals, scissorRect);
610644
renderPass.render();
611645
}
612646

src/framework/graphics/render-pass-picker.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
22
import { BlendState } from '../../platform/graphics/blend-state.js';
33
import { RenderPass } from '../../platform/graphics/render-pass.js';
4-
import { SHADER_PICK, SHADER_DEPTH_PICK } from '../../scene/constants.js';
4+
import { SHADER_PICK, SHADER_DEPTH_PICK, SHADER_NORMAL_PICK } from '../../scene/constants.js';
55

66
/**
77
* @import { BindGroup } from '../../platform/graphics/bind-group.js'
@@ -44,6 +44,9 @@ class RenderPassPicker extends RenderPass {
4444
/** @type {boolean} */
4545
depth;
4646

47+
/** @type {boolean} */
48+
normals;
49+
4750
/** @type {Vec4|null} Optional scissor rect in render-target pixel coords, bottom-left origin. */
4851
scissorRect = null;
4952

@@ -72,16 +75,18 @@ class RenderPassPicker extends RenderPass {
7275
* @param {Layer[]} layers - The layers to pick from.
7376
* @param {Map<number, MeshInstance | GSplatComponent>} mapping - Map to store ID to object mappings.
7477
* @param {boolean} depth - Whether to render depth information.
78+
* @param {boolean} normals - Whether to render the world-space surface normal (implies depth).
7579
* @param {Vec4|null} [scissorRect] - Optional scissor rect in render-target pixel coords,
7680
* bottom-left origin (x, y, w, h). If set, only fragments inside the rect are rasterized;
7781
* outside the rect the cleared "no selection" value (white) remains.
7882
*/
79-
update(camera, scene, layers, mapping, depth, scissorRect = null) {
83+
update(camera, scene, layers, mapping, depth, normals, scissorRect = null) {
8084
this.camera = camera;
8185
this.scene = scene;
8286
this.layers = layers;
8387
this.mapping = mapping;
8488
this.depth = depth;
89+
this.normals = normals;
8590
this.scissorRect = scissorRect;
8691

8792
if (scene.clusteredLightingEnabled) {
@@ -207,7 +212,7 @@ class RenderPassPicker extends RenderPass {
207212
renderer.setupViewUniformBuffers(this.viewBindGroups, renderer.viewUniformFormat, renderer.viewBindGroupFormat, null);
208213
}
209214

210-
const shaderPass = this.depth ? SHADER_DEPTH_PICK : SHADER_PICK;
215+
const shaderPass = this.normals ? SHADER_NORMAL_PICK : (this.depth ? SHADER_DEPTH_PICK : SHADER_PICK);
211216
renderer.renderForward(camera.camera, renderTarget, tempMeshInstances, lights, shaderPass, (meshInstance) => {
212217
device.setBlendState(this.blendState);
213218
});

src/scene/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,9 @@ export const SHADER_PICK = 3;
815815
// shader pass used by the Picker class to render mesh ID and depth
816816
export const SHADER_DEPTH_PICK = 4;
817817

818+
// shader pass used by the Picker class to render mesh ID, depth and world-space surface normal
819+
export const SHADER_NORMAL_PICK = 5;
820+
818821
/**
819822
* Shader that performs forward rendering.
820823
*

src/scene/shader-lib/glsl/chunks/common/frag/pick.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,13 @@ vec4 encodePickOutput(uint id) {
3131
return float2uint(linearDepth);
3232
}
3333
#endif
34+
35+
#ifdef NORMAL_PICK_PASS
36+
// Encode a world-space surface normal ([-1, 1]) into RGBA8 ([0, 1]). Alpha is set to 1 as a
37+
// hit marker, though readback should gate on the depth attachment (the buffer is cleared to
38+
// white, so alpha alone can't distinguish a hit from the cleared background).
39+
vec4 getPickNormal(vec3 worldNormal) {
40+
return vec4(normalize(worldNormal) * 0.5 + 0.5, 1.0);
41+
}
42+
#endif
3443
`;

src/scene/shader-lib/glsl/chunks/lit/frag/pass-other/litOtherMain.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ void main(void) {
2020
#ifdef DEPTH_PICK_PASS
2121
pcFragColor1 = getPickDepth();
2222
#endif
23+
#ifdef NORMAL_PICK_PASS
24+
// world-space interpolated vertex normal. The normal-pick pass forces needsNormal,
25+
// so the NORMALS varying (vNormalW) is always generated. getPickNormal normalizes,
26+
// so the raw interpolated value is fine. Note: this is the geometric surface normal,
27+
// not the normal-mapped one - sufficient for pick-point orientation.
28+
pcFragColor2 = getPickNormal(vNormalW);
29+
#endif
2330
#endif
2431
2532
#ifdef PREPASS_PASS

src/scene/shader-lib/programs/lit-shader.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import {
1010
LIGHTSHAPE_PUNCTUAL,
1111
LIGHTTYPE_DIRECTIONAL, LIGHTTYPE_OMNI, LIGHTTYPE_SPOT,
12-
SHADER_PICK,
12+
SHADER_PICK, SHADER_NORMAL_PICK,
1313
SPRITE_RENDERMODE_SLICED, SPRITE_RENDERMODE_TILED, shadowTypeInfo, SHADER_PREPASS,
1414
lightTypeNames, lightShapeNames, spriteRenderModeNames, fresnelNames, blendNames, lightFalloffNames,
1515
cubemaProjectionNames, specularOcclusionNames, reflectionSrcNames, ambientSrcNames,
@@ -166,6 +166,9 @@ class LitShader {
166166
(options.clusteredLightingEnabled && !this.shadowPass) ||
167167
options.useClearCoatNormals;
168168
this.needsNormal = this.needsNormal && !this.shadowPass;
169+
// the normal-pick pass writes the world-space surface normal (dNormalW) to an MRT target,
170+
// so the frontend must compute it even though this pass does no lighting
171+
this.needsNormal = this.needsNormal || options.pass === SHADER_NORMAL_PICK;
169172
this.needsSceneColor = options.useDynamicRefraction;
170173
this.needsScreenSize = options.useDynamicRefraction;
171174
this.needsTransforms = options.useDynamicRefraction;

src/scene/shader-lib/wgsl/chunks/common/frag/pick.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,13 @@ fn encodePickOutput(id: u32) -> vec4f {
3131
return float2uint(linearDepth);
3232
}
3333
#endif
34+
35+
#ifdef NORMAL_PICK_PASS
36+
// Encode a world-space surface normal ([-1, 1]) into RGBA8 ([0, 1]). Alpha is set to 1 as a
37+
// hit marker, though readback should gate on the depth attachment (the buffer is cleared to
38+
// white, so alpha alone can't distinguish a hit from the cleared background).
39+
fn getPickNormal(worldNormal: vec3f) -> vec4f {
40+
return vec4f(normalize(worldNormal) * 0.5 + 0.5, 1.0);
41+
}
42+
#endif
3443
`;

0 commit comments

Comments
 (0)