@@ -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
0 commit comments