THREE射线拾取、缓冲区拾取原理

3,550 阅读7分钟

射线拾取

顾名思义,射线拾取就是从相机发射一条无限远的射线,然后判断射线和那些物体相交。如下图

QQ截图20210722170452.png

官方案例

下面直接来看Three官方文档的射线拾取代码,这段代码是每帧都会在相机发射一条到鼠标位置的射线并返回射线击中的物体,最后修改材质颜色。

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseMove( event ) {
	// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
	mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
	mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
}
function render() {
	// 通过摄像机和鼠标位置更新射线
	raycaster.setFromCamera( mouse, camera );
	// 计算物体和射线的焦点(这里的scene.children可以只传需要被射线检测的物体,不然物体太多会卡顿)
	const intersects = raycaster.intersectObjects( scene.children );
	for ( let i = 0; i < intersects.length; i ++ ) {
		intersects[ i ].object.material.color.set( 0xff0000 );
	}
	renderer.render( scene, camera );
}
window.addEventListener( 'mousemove', onMouseMove, false );
window.requestAnimationFrame(render);

THREE射线拾取步骤

  1. 先把鼠标点击的(X,Y)从屏幕坐标系转成webgl坐标系。
  2. 然后再把webgl坐标系通过投影逆变换转成投影坐标系,得到(X,Y)在投影坐标系下的值(Xw,Yw,Zw)。
  3. 把(Xw,Yw,Zw)减去相机的坐标得到射线的方向向量(起点是相机的坐标,知道起点和方向就可以得到一条无限长的射线)。
  4. 射线先和检测物体的包围和求交。
  5. 把上一步检测相交的物体遍历每一个面,检测是否相交。
  6. 把相交的物体按照深度(Z)排序,并返回。

第1步,屏幕坐标系转webgl坐标系,具体计算方法。blog.csdn.net/u011332271/…

mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;

第2、3步,计算射线的起点和方向。

raycaster.setFromCamera( mouse, camera );

可以查看raycaster.setFromCamera()方法的代码,其中.unproject(camera)就是。

setFromCamera(coords, camera) {
    if (camera && camera.isPerspectiveCamera) {
          // 起点设置为相机的位置
          this.ray.origin.setFromMatrixPosition(camera.matrixWorld);
          // 屏幕坐标转成世界坐标,然后减去射线的起点就可以得到起点到点击的点的方向向量,最后归一化
          this.ray.direction.set(coords.x, coords.y, 0.5).unproject(camera).sub(this.ray.origin).normalize();
          this.camera = camera;
    } else if (camera && camera.isOrthographicCamera) {
          this.ray.origin.set(coords.x, coords.y, (camera.near + camera.far) / (camera.near - camera.far)).unproject(camera); // set origin in plane of camera
	  // 正交相机的没有近大远小,所以射线方向都是相机方向平行的
	  this.ray.direction.set(0, 0, -1).transformDirection(camera.matrixWorld);
	  this.camera = camera;
    } else {
	  console.error('THREE.Raycaster: Unsupported camera type.');
    }
 },

第4步,射线先和检测物体的包围和求交,先和包围体求交可以排除那先必定不相交的物体,减少后面的计算量。

const intersects = raycaster.intersectObjects( scene.children );

节选代码

// Checking boundingSphere distance to ray
if (geometry.boundingSphere === null) geometry.computeBoundingSphere();
_sphere.copy(geometry.boundingSphere);
_sphere.applyMatrix4(matrixWorld);
if (raycaster.ray.intersectsSphere(_sphere) === false) return;
// 这里用射线乘模型矩阵得逆矩阵得到-ray相当于射线逆着模型变换,保证和模形的相对位置,后面检测射线和模型的面相交的情况的时候就可以直接用模型未经过变换的点来计算
// 不然的话要先计算变换后的模型,在和ray(未变换的)进行相交检测,而且后面还需要和模型的面求交,每一个面都需要变换,计算量大
_inverseMatrix.copy(matrixWorld).invert();
_ray.copy(raycaster.ray).applyMatrix4(_inverseMatrix); 
// Check boundingBox before continuing
if (geometry.boundingBox !== null) {
   if (_ray.intersectsBox(geometry.boundingBox) === false) return;
}

_ray的计算具体可以看下图,计算_ray可以不旋转模型就保持和模型的相对位置。

Snipaste_2021-07-23_00-25-57.png

第5、6步,通过包围盒检测的模型继续遍历模型的面在判断相交(具体代码可以查看Mesh对象的raycast()方法,这里就不贴出),最后再把相交物体按照深度排序返回。

intersects.sort(ascSort);
return intersects;

以上就是THREE里射线拾取的原理的,可以看出当场景的模型数量大的时候,每一个模型都要检测一次的计算量是非常大的。但是这个也是有办法优化的,一个是通过八叉树把场景的模型管理起来,每一次只检测一小部分(这里先不展开),另外一个就是通过缓冲区拾取。

缓冲区拾取

缓冲区拾取就是利用FBO(帧缓冲区)渲染另外一份顶点数据一样,但是顶点颜色是按照ID位计算成RGB值,这样就保证了每一个的物体的颜色之都不一样,这样点击的时候获取到点击位置的RGB值,再位换算回ID值就可以知道点击到那个物体了。其实说白了缓冲区拾取就是用空间(多一份数据)换时间(拾取快),另外由于缓冲区拾取不需要遍历模型,所以模型是可以做合批的。 微信截图_20210723115358.png

THREE缓冲区拾取步骤

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

下面来一起看一下THREE的例子webgl_interactive_cubes_gpu

Go to file


第1步,准备两份数据。

for ( let i = 0; i < 5000; i ++ ) {
    let geometry = new THREE.BoxBufferGeometry();
    // 生成随机模型矩阵		
    const position = new THREE.Vector3();
    position.x = Math.random() * 10000 - 5000;
    position.y = Math.random() * 6000 - 3000;
    position.z = Math.random() * 8000 - 4000;
    const rotation = new THREE.Euler();
    rotation.x = Math.random() * 2 * Math.PI;
    rotation.y = Math.random() * 2 * Math.PI;
    rotation.z = Math.random() * 2 * Math.PI;
    const scale = new THREE.Vector3();
    scale.x = Math.random() * 200 + 100;
    scale.y = Math.random() * 200 + 100;
    scale.z = Math.random() * 200 + 100;
    quaternion.setFromEuler( rotation );
    matrix.compose( position, quaternion, scale );
    geometry.applyMatrix4( matrix );

    // 给BOX随机生成颜色
    applyVertexColors( geometry, color.setHex( Math.random() * 0xffffff ) );
    // push到数组。第一份数据准备完成
    geometriesDrawn.push( geometry );

    // 复制一份新的数据
    geometry = geometry.clone();
    // 通过i位换算设置颜色,这样每一个的颜色都是唯一的
    applyVertexColors( geometry, color.setHex( i ) );
    // push到数组,第二份数据准备完成
    geometriesPicking.push( geometry );
    // 保存每一个BOX的模型矩阵,后面用于生成HeightLightBox
    pickingData[ i ] = {
          position: position,
          rotation: rotation,
	  scale: scale
    };
}
// 把两份数组都合批,加载到各自的场景
scene.add( new THREE.Mesh( BufferGeometryUtils.mergeBufferGeometries( geometriesDrawn ), defaultMaterial ) );
pickingScene.add( new THREE.Mesh( BufferGeometryUtils.mergeBufferGeometries( geometriesPicking ), pickingMaterial ) );

第2、3、4、5步

pickingTexture = new THREE.WebGLRenderTarget( 1, 1 );

这里设置为(1,1)是因为只需要拿一个像素的颜色值就行了,设大了反而增加计算量。

function pick() {
    // 渲染FBO( pickingTexture)
    // 将视图偏移设置为仅代表鼠标下方的单个像素
    camera.setViewOffset( renderer.domElement.width, renderer.domElement.height, mouse.x * window.devicePixelRatio | 0, mouse.y * window.devicePixelRatio | 0, 1, 1 );
    // 渲染pickingTexture
    renderer.setRenderTarget( pickingTexture );
    renderer.render( pickingScene, camera );
    // 把相机恢复到正常状态
    camera.clearViewOffset();
    const pixelBuffer = new Uint8Array( 4 );
    // 获取pickingTexture的颜色值
    renderer.readRenderTargetPixels( pickingTexture, 0, 0, 1, 1, pixelBuffer );
    // 位换算回ID值
    const id = ( pixelBuffer[ 0 ] << 16 ) | ( pixelBuffer[ 1 ] << 8 ) | ( pixelBuffer[ 2 ] );
    // 获取到点击的物体的矩阵信息
    const data = pickingData[ id ];
    if ( data ) {
        // 把highlightBox变换到对应的位置
        if ( data.position && data.rotation && data.scale ) {
        highlightBox.position.copy( data.position );
        highlightBox.rotation.copy( data.rotation );
        highlightBox.scale.copy( data.scale ).add( offset );
        highlightBox.visible = true;
        }
    } 
    // 否则就是没选中,隐藏hignlightBox
    else {
        highlightBox.visible = false;
    }
}

上面代码大概就是缓冲区拾取的原理,当然不一定是要生成highlightBox来表示高亮,也可以去修改对应geometry里面的VertexColors。

function render() {
    controls.update();
    // 渲染FBO并获取到鼠标点击到的物体
    pick();
    // 切换回颜色缓冲区,正常渲染输出到屏幕
    renderer.setRenderTarget( null );
    renderer.render( scene, camera );
}

切换回颜色缓冲区,正常渲染输出到屏幕

射线拾取和缓冲区拾取优缺点

通过上面的解释我们不难看出,射线拾取可以拿到所有被射线击中的物体,并且可以拿到击中了模型具体的哪一个面和UV等信息。这些信息在一个射击游戏里面就显得很重要了,比如击中墙后面的人、在墙上留下弹孔等等。缺点就是场景物体多了就行卡,而且不能合批模型。相反,缓冲区拾取就是模型多也不会拾取卡,但是就拿不到详细的信息了,所以缓冲区拾取就比较适用在BIM模型的拾取上。

好了,上面就是作者对THREE射线拾取和缓冲区拾取的理解了,有哪些不对的地方希望各有可以指出。