Threejs入门03 拾取篇

606 阅读5分钟

一、3D场景下拾取的思路:

思路几何思路渲染思路
介绍通过连接摄像机和屏幕坐标生成射线,然后与场景中的物体做相交判断对渲染的每个物体给与编号,将编号转换成颜色,然后通过拾取framebuffer的颜色来判断拾取到了哪个物体
优点使用较为简单,无需额外渲染开销1.充分利用GPU运算
2.由于拾取颜色,自动可以把深度过滤,只取最前面的
缺点1.需要给每一个需要检测的物体配置碰撞体,有些场景多余,特别是编辑器里面,场景对象有些是不需要碰撞的
2.使用cpu计算相交性,如果场景较大CPU开销较高,会卡顿(需要额外的八叉树、BVH树优化拾取)
3.解决不了精度问题:当物体特别靠近摄像机的时候;当物体的中心在另一个物体内部时;当两个物体相互穿过,且有较大部分重合时
1.增加了渲染调度的复杂度
2.增加了渲染的开销

二、射线追踪法Raycaster

2.1原理

从鼠标处发射一条射线,穿透场景的视锥体,通过计算,找出与射线相交的对象。

2.2 坐标变换

与图形流水线反过来,窗口坐标系 ——> NDC坐标系 ———> 世界坐标系

2.3 常用方法

  • .setFromCamera(coords,camera)更新点坐标和相机视椎。coords点坐标(归一化),camera相机。
  • .intersectObject(scenes,recursive,optionalTarget)检查射线与场景对象和其子集是否有交集。scenes 要检查的场景对象数组格式。recursive为true检查所有后代。optionalTarget返回交集结果,可选。

2.4 代码示例

// 实现效果:选中的网络模型变为半透明效果
function ray() {
  const Sx = event.clientX;//鼠标单击位置横坐标
  const Sy = event.clientY;//鼠标单击位置纵坐标
  //屏幕坐标转标准设备坐标
  const x = ( Sx / window.innerWidth ) * 2 - 1;//标准设备横坐标
  const y = -( Sy / window.innerHeight ) * 2 + 1;//标准设备纵坐标
  const standardVector  = new THREE.Vector3(x, y, 0.5);//标准设备坐标
  //标准设备坐标转世界坐标
  const worldVector = standardVector.unproject(camera);
  //射线投射方向单位向量(worldVector坐标减相机位置坐标)
  const ray = worldVector.sub(camera.position).normalize();   // 相机位置 指向 鼠标位置 的射线的 方向向量
  //创建射线投射器对象
  const raycaster = new THREE.Raycaster(camera.position, ray);
  //返回射线选中的对象
  const intersects = raycaster.intersectObjects([boxMesh,sphereMesh,cylinderMesh]);
  if (intersects.length > 0) {
      intersects[0].object.material.transparent = true;
      intersects[0].object.material.opacity = 0.6;
  }
}

三、颜色/GPU拾取Framebuffer Picker

3.1 原理

OpenGL 提供了一个 glReadPixels 函数,利用颜色的6位16进制表示,以颜色作为ID,在后台渲染出纹理后,检查鼠标位置关联的像素的颜色,通过颜色来确认相交的对象。该技术用空间(多一份数据)换时间(拾取快),对象拾取仅在用户单击鼠标按钮时完成,因此性能损失仅在该时间发生。

3.2 步骤

  • 准备好两份数据,一份渲染输出到屏幕,一份渲染到FBO帧缓存对象,同时把每个物体的信息存起来
  • 创建一个webglRenderTarget 离屏渲染()(FBO,不直接输出到屏幕)。
  • 渲染FBO,通过获取到的颜色位换算回ID值,判断点击了那个物体。
  • 通过ID值获取到点击的物体的信息,在生成一个正方体套在点击物体外面,表示高亮。
  • 最后正常渲染场景,输出到颜色缓冲区(屏幕)

3.3 代码实现

参考文档

全局变量

除了基本的,增加多了几个 缓存对象相关的 变量:

let pickingTexture, pickingScene;
let highlightBox;

const pickingData = [];

const pointer = new THREE.Vector2();
const offset = new THREE.Vector3( 10, 10, 10 );
const clearColor = new THREE.Color();

初始化

除了基本的初始化,增加scenecameralight等,init函数还做了其他处理:

  1. 生成5000个BoxGeometry,位置、颜色随机,并合成 mergedGeometry,add进scene
  2. 为每一个几何体赋值id=索引值i,同时将它的位置、旋转和缩放信息缓存在pickingData数组中
  3. 离屏渲染(缓存中)也备份一份数据
    • pickingScene:备份的场景对象
    • pickingTextureTHREE.WebGLRenderTarget实例,缓冲,用于在一个图像显示在屏幕上之前先做一些处理
    • pickingMaterial
    • highlightBox:为鼠标经过时做高亮效果预备,叠加绘制在ViewOffset区域
    • pickingData:存储5000个几何体的位置、缩放、旋转信息等
function init () {

  // 1、增加scene、camera、light ....
  
  for ( let i = 0; i < 5000; i ++ ) {

  // 2、生成5000个BoxGeometry,位置、颜色随机,并合成 mergedGeometry,add进scene中...

  // 为每一个几何体赋值id=索引值i,同时将它的位置、旋转和缩放信息缓存在pickingData数组中
    applyId( geometry, i );

  // 3、 离屏渲染(缓存中)也备份一份数据
    pickingData[ i ] = {
      position: position,
      rotation: rotation,
      scale: scale
    };
  }

  // set up the picking texture to use a 32 bit integer so we can write and read integer ids from it
  pickingScene = new THREE.Scene();
  pickingTexture = new THREE.WebGLRenderTarget( 1, 1, {

    type: THREE.IntType, // 纹理的type属性,THREE.IntType与格式THREE.RGIntegerFormat对应
    format: THREE.RGBAIntegerFormat,
    internalFormat: 'RGBA32I',

  } );
  const pickingMaterial = new THREE.ShaderMaterial( {

    glslVersion: THREE.GLSL3,

    vertexShader: /* glsl */`
      attribute int id;
      flat varying int vid;
      void main() {

        vid = id;
        gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );

      }
    `,

    fragmentShader: /* glsl */`
      layout(location = 0) out int out_id;
      flat varying int vid;

      void main() {

        out_id = vid;

      }
    `,
  } );

  pickingScene.add( new THREE.Mesh( mergedGeometry, pickingMaterial ) );

  highlightBox = new THREE.Mesh(
    new THREE.BoxGeometry(),
    new THREE.MeshLambertMaterial( { color: 0xffff00 } )
  );
  scene.add( highlightBox );
}

拾取

拾取函数考虑到性能优化,只对局部做了重新擦除、绘制处理,

  1. 先对针对鼠标位置下的区域:camera.setViewOffset(...)
  2. 清除:camera.clearViewOffset()
  3. 绘制:highlightBox.visible = true
function pick() {

  // render the picking scene off-screen
  // set the view offset to represent just a single pixel under the mouse  
  // 官方api .setViewOffset(多视图的全宽设置, 多视图的全高设置, 副摄像机的水平偏移, 副摄像机的垂直偏移,  副摄像机的宽度,  副摄像机的高度)
  // 针对鼠标下的设置相机的有效显示部分,即,只针对子视图进行处理:清除、渲染
  const dpr = window.devicePixelRatio;
  camera.setViewOffset(
    renderer.domElement.width, renderer.domElement.height,
    Math.floor( pointer.x * dpr ), 
    Math.floor( pointer.y * dpr ),
    1, 1
  );

  // render the scene
  renderer.setRenderTarget( pickingTexture );

  // clear the background to - 1 meaning no item was hit
  clearColor.setRGB( - 1, - 1, - 1 );
  renderer.setClearColor( clearColor );
  renderer.render( pickingScene, camera );

  // clear the view offset so rendering returns to normal
  camera.clearViewOffset();

  // create buffer for reading single pixel
  const pixelBuffer = new Int32Array( 4 );

  // read the pixel 
  // 将renderTarget的像素数据读取到传入的缓冲区中
  renderer.readRenderTargetPixels( pickingTexture, 0, 0, 1, 1, pixelBuffer );

  const id = pixelBuffer[ 0 ];
  if ( id !== - 1 ) {

    // move our highlightBox so that it surrounds the picked object
    const data = pickingData[ id ];
    highlightBox.position.copy( data.position );
    highlightBox.rotation.copy( data.rotation );
    highlightBox.scale.copy( data.scale ).add( offset );
    highlightBox.visible = true;

  } else {

    highlightBox.visible = false;

  }
}

为几何体赋予id

function applyId( geometry, id ) {

  const position = geometry.attributes.position;
  const array = new Int16Array( position.count );
  array.fill( id );

  const bufferAttribute = new THREE.Int16BufferAttribute( array, 1, false );
  bufferAttribute.gpuType = THREE.IntType;
  geometry.setAttribute( 'id', bufferAttribute );

}