【ThreeJs原理解析】第3期 | 射线检测Raycaster实现原理

前言

在Threejs中如果我们希望给一个mesh添加交互事件,例如click,hover应该如何解决呢?能否像dom一样绑定click事件呢?

目前主流方案就是使用Raycaster 进行射线检测,下方是官方文档(部分截取)的使用案例,那么射线检测的原理是什么呢?我们继续往下看。

js 复制代码
const raycaster = new THREE.Raycaster();
// 通过摄像机和鼠标位置更新射线 
raycaster.setFromCamera( pointer, camera ); 
// 计算物体和射线的焦点 
const intersects = raycaster.intersectObjects( scene.children ); 
for ( let i = 0; i < intersects.length; i ++ ) { 
    intersects[ i ].object.material.color.set( 0xff0000 ); 
}

归一化

1.为什么要归一化

在说Raycaster之前,我们先想一个问题,为什么Threejs中没有提供像dom一样的onclik事件,我只要点击这个mesh就触发click事件回调?(其实并不是他不想而是他不能),原因如下:

  • 点击事件是浏览器层面的,而three负责的是渲染层面
  • 原生dom中的click事件监听可以拿到用户当前的鼠标event对象,而three是无法直接获取的

最后引出本段内容的主题,我通过监听鼠标click事件拿到event对象,获取到当前鼠标的坐标(clientX,clientY)能否直接传给three直接用呢?

答案是不能的,因为浏览器坐标系和three(webgl)坐标系是不同的,需要进行转换,这个转换过程就叫归一化

2.归一化代码实现

归一化代码很简洁,寥寥几行,可能会有人对这个格公式由来有困惑,但是实际上就是简单的数学运算

js 复制代码
/**
* 获取鼠标在three.js 中归一化坐标
* */
const setPickPosition = (event) => {
    let pickPosition = { x: 0, y: 0 };
    pickPosition.x =
        (event.clientX / renderer.domElement.getBoundingClientRect().width) * 2 - 1;
    pickPosition.y =
        (event.clientY / renderer.domElement.getBoundingClientRect().height) * -2 + 1;
    return pickPosition;
}

3.归一化原理解析

原理主要是数学公式的推导:

已知:

  • 我通过鼠标event事件获取clientX和clientY,
  • canvas元素宽高为width和height,
  • 黑色为浏览器坐标系,绿色为webgl坐标系

求归一化后该点坐标(x,y) ps: 归一化后webgl坐标系x,y轴的取值范围均为[-1, 1]

该公式的核心就是相似 ,在两种坐标系下x/y坐标到最左侧轴长度 和 坐标系 x/y 总长度,注意应该在归一化后的webgl坐标系有负数,所以不能直接写成
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> c l i e n t X w i d t h = x 2 \frac{clientX}{width} = \frac{x}{2} </math>widthclientX=2x

正确写法

  • clientX:是鼠标event对象获取的x坐标
  • width:是canvas元素宽度(窗口坐标系下x轴总长度)
  • x + 1 : 当前x坐标加上x轴左侧部分长度
  • 2 : webgl坐标系下x轴总长度

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> c l i e n t X w i d t h = x + 1 2 \frac{clientX}{width} = \frac{x+1}{2} </math>widthclientX=2x+1

然后转化一下求出x
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x = 2 c l i e n t X w i d t h − 1 x = 2\frac{clientX}{width} - 1 </math>x=2widthclientX−1

然后再和上面的归一化代码对比一下, 发现结果是一致的,因此可得上述就是归一化原理。

ps:y坐标推导也同理,不再赘述,感兴趣的同学可以自己尝试推导一下🧐🧐🧐

js 复制代码
pickPosition.x = (event.clientX / renderer.domElement.getBoundingClientRect().width) * 2 - 1;

射线检测

下方是官方文档(部分截取)的使用案例, 可以看出关键方法就俩个setFromCameraintersectObjects,接下来我们看一下源码

  • pointer 就是上述归一化后下webgl坐标系下坐标
js 复制代码
const raycaster = new THREE.Raycaster();
// 通过摄像机和鼠标位置更新射线 
raycaster.setFromCamera( pointer, camera ); 
// 计算物体和射线的焦点 
const intersects = raycaster.intersectObjects( scene.children ); 
for ( let i = 0; i < intersects.length; i ++ ) { 
    intersects[ i ].object.material.color.set( 0xff0000 ); 
}

源码地址:Raycaster.js

1.Raycaster初始化constructor

可以看出构造函数有四个参数,可以初始化定义Raycaster

  • origin: 光线投射的原点向量。
  • direction: 向射线提供方向的方向向量,应当被标准化。
  • near: 返回的所有结果比near远。near不能为负值,其默认值为0。
  • far: 返回的所有结果都比far近。far不能小于near,其默认值为Infinity(正无穷。)
js 复制代码
constructor( origin, direction, near = 0, far = Infinity ) {
        this.ray = new Ray( origin, direction );

        this.near = near;
        this.far = far;
        this.camera = null;
        this.layers = new Layers();

        this.params = {
            Mesh: {},
            Line: { threshold: 1 },
            LOD: {},
            Points: { threshold: 1 },
            Sprite: {}
        };

}

origindirection传入Ray,构造一个Ray的实例,那么这个Ray又是什么呢?

源码地址:Ray.js

很简单,就是构造一个向量,origin是原点向量,direction是方向向量

ps: 如果要传direction,应当被标准化,意思就是这个向量的模应该是1

js 复制代码
class Ray {
    constructor( origin = new Vector3(), direction = new Vector3( 0, 0, - 1 ) ) {
            this.origin = origin;
            this.direction = direction;
    }
    ........
}

2.setFromCamera

源码地址:Raycaster.js

js 复制代码
setFromCamera( coords, camera ) {
    if ( 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.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: ' + camera.type );
    }
}

这段代码可能初学者看着有点懵,因为里面涉及向量矩阵运算,不用急,我们一步一步来看

1. 参数

还记得我们上面是怎么使用这个api的吗?

js 复制代码
raycaster.setFromCamera( pointer, camera );
  • pointer: 上述归一化后下webgl坐标系下坐标
  • camera: 就是我们使用的相机(PerspectiveCamera/OrthographicCamera/....)

ps: 如果对相机原理不太理解的可以看我这个专栏之前写的一篇文章,地址:# 【ThreeJs原理解析】第1期 | 透视相机(PerspectiveCamera)

2. 以camera.isPerspectiveCamera为例

camera.isOrthographicCamera下的逻辑同理,不多赘述

js 复制代码
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;

这段代码核心点就俩个:设置ray的origin和direction,也就是光线投射的原点向量和方向向量

1.setFromMatrixPosition

this.ray.origin就是一个向量Vector3, setFromMatrixPosition也是Vector3的方法

源码地址:Vector3.js

js 复制代码
setFromMatrixPosition( m ) {
    const e = m.elements;

    this.x = e[ 12 ];
    this.y = e[ 13 ];
    this.z = e[ 14 ];

    return this;
}

这段代码作用是提取当前传入矩阵(即相机的世界矩阵)的平移矩阵 并将其设置为射线的原点。,这段话不懂的可以参考我之前写的文章# 【ThreeJs原理解析】第2期 | 旋转、平移、缩放实现原理

2.direction.set......

this.ray.direction也是一个Vector3对象,本质上这段代码就是向量运算

js 复制代码
this.ray.direction.set( coords.x, coords.y, 0.5 )
                  .unproject( camera )
                  .sub( this.ray.origin )
                  .normalize();

源码地址:Vector3.js

js 复制代码
class Vector3 {
    // 反投影
    unproject( camera ) {
        return this.applyMatrix4( camera.projectionMatrixInverse )
                   .applyMatrix4( camera.matrixWorld );
    }
    sub( v ) {
        this.x -= v.x;
        this.y -= v.y;
        this.z -= v.z;
        return this;
    }
        
}
  • set( coords.x, coords.y, 0.5 ): 将射线方向向量的初始值设置为(coords.x, coords.y, 0.5), coords是鼠标在屏幕上的坐标。

  • unproject(camera): 射线方向向量依次乘以相机的投影矩阵的逆矩阵世界矩阵

    • ps: 如果对相机原理不太理解的可以看我这个专栏之前写的一篇文章,地址:# 【ThreeJs原理解析】第1期 | 透视相机(PerspectiveCamera)

    • 投影变换的逆过程

      在计算机图形学中,投影变换用于将3D场景渲染到2D屏幕上,通常涉及以下几个步骤:

      1. 世界坐标系 -> 视图坐标系 : 通过相机的视图矩阵(viewMatrix)进行变换。
      2. 视图坐标系 -> 裁剪坐标系 : 通过相机的投影矩阵(projectionMatrix)进行变换。
      3. 裁剪坐标系 -> 屏幕坐标系: 通过标准化设备坐标(NDC)变换到屏幕像素坐标。

      unproject 方法执行的是这个过程的逆操作,即将屏幕坐标转换回世界坐标:

      1. 屏幕坐标系 -> 裁剪坐标系: 将屏幕坐标(如鼠标位置)归一化到裁剪空间。
      2. 裁剪坐标系 -> 视图坐标系 : 使用投影矩阵的逆矩阵 (projectionMatrixInverse) 进行变换。
      3. 视图坐标系 -> 世界坐标系 : 使用世界矩阵 (matrixWorld) 进行变换。
  • sub( this.ray.origin ): 向量减法操作,即计算出当前向量在this.ray.origin(原点向量)上的分量

  • normalize: 将向量归一化,使其模为1

3.小结

setFromCamera就是根据传入的coords和camera构造ray对象的原点向量origin和方向向量direction

3.intersectObjects

使用方法:

js 复制代码
intersects = raycaster.intersectObjects( scene.children )

源码地址:Raycaster.js

js 复制代码
intersectObjects( objects, recursive = true, intersects = [] ) {

    for ( let i = 0, l = objects.length; i < l; i ++ ) {
        intersect( objects[ i ], this, intersects, recursive );
    }

    intersects.sort( ascSort );
    return intersects;
}

function intersect( object, raycaster, intersects, recursive ) {
    let propagate = true;

    if ( object.layers.test( raycaster.layers ) ) {
        const result = object.raycast( raycaster, intersects );
        if ( result === false ) propagate = false;
    }

    if ( propagate === true && recursive === true ) {
        const children = object.children;
        for ( let i = 0, l = children.length; i < l; i ++ ) {
            intersect( children[ i ], raycaster, intersects, true );
        }
    }
}
  1. intersectObjects会循环传入的objects数组,并调用intersect方法

  2. intersect方法参数

    • object: 要检测的三维对象,可能是场景中的一个节点(如网格或组)。
    • raycaster: 射线投射器,用于检测射线与对象之间的交点。
    • intersects: 一个数组,用于存储交点信息。
    • recursive: 一个布尔值,表示是否递归地检查子对象。
  3. intersect方法逻辑

    1. 初始化传播标志

      • propagate 初始化为 true,表示是否继续向下传播射线检测。
    2. 检查对象层

      • 如果 object.layers.test(raycaster.layers) 返回 true,表示对象所在的层在射线投射器关注的层范围内,则进行射线检测。
    3. 射线检测

      • 调用 object.raycast(raycaster, intersects) 进行具体的射线检测,并将交点信息存入 intersects 数组中。
      • 如果 object.raycast 返回 false,表示对象不希望射线继续穿过它,将 propagate 设为 false
    4. 递归检测子对象

      • 如果 propagatetruerecursivetrue,遍历 object.children,对每个子对象递归调用 intersect 函数。
  4. raycast方法

    源码地址:Mesh.js

js 复制代码
raycast( raycaster, intersects ) {

    const geometry = this.geometry;
    const material = this.material;
    const matrixWorld = this.matrixWorld;

    if ( material === undefined ) return;

    // test with bounding sphere in world space

    if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();

    _sphere.copy( geometry.boundingSphere );
    _sphere.applyMatrix4( matrixWorld );

    // check distance from ray origin to bounding sphere

    _ray.copy( raycaster.ray ).recast( raycaster.near );

    if ( _sphere.containsPoint( _ray.origin ) === false ) {

            if ( _ray.intersectSphere( _sphere, _sphereHitAt ) === null ) return;

            if ( _ray.origin.distanceToSquared( _sphereHitAt ) > ( raycaster.far - raycaster.near ) ** 2 ) return;

    }

    // convert ray to local space of mesh

    _inverseMatrix.copy( matrixWorld ).invert();
    _ray.copy( raycaster.ray ).applyMatrix4( _inverseMatrix );

    // test with bounding box in local space

    if ( geometry.boundingBox !== null ) {

            if ( _ray.intersectsBox( geometry.boundingBox ) === false ) return;

    }

    // test for intersections with geometry

    this._computeIntersections( raycaster, intersects, _ray );
}

raycast方法用于检测射线是否与网格(Mesh)相交。该方法通过一系列优化步骤来提高计算效率,具体步骤如下:

  1. 材质检查:首先检查网格是否有材质,如果没有材质,则直接返回,因为没有材质的网格不需要进行相交检测。
  2. 包围球检测:使用网格的包围球在世界空间中检测射线是否相交。如果射线的起点不在包围球内,并且射线与包围球也没有相交,则直接返回,从而节省后续的复杂计算。
  3. 射线转换:将射线从世界空间转换到网格的局部空间,以便与网格的局部几何数据进行比较。
  4. 包围盒检测:检查转换后的射线是否与网格的包围盒相交。如果不相交,则返回,避免进一步的计算。
  5. 详细几何检测 :如果通过了所有的包围体积测试,则调用_computeIntersections方法进行详细的几何相交检测,确定具体的交点信息,并将结果添加到intersects数组中。

总结

本文涉及源码和数学计算逻辑(向量矩阵)较多,理解起来比较困难🧐,需要多花点时间,也可以结合本专栏之前的文章再理解一下。

相关推荐
m0_748239334 分钟前
前端(Ajax)
前端·javascript·ajax
Fighting_p8 分钟前
【记录】列表自动滚动轮播功能实现
前端·javascript·vue.js
前端Hardy9 分钟前
HTML&CSS:超炫丝滑的卡片水波纹效果
前端·javascript·css·3d·html
技术思考者13 分钟前
HTML速查
前端·css·html
缺少动力的火车13 分钟前
Java前端基础—HTML
java·前端·html
Domain-zhuo26 分钟前
Git和SVN有什么区别?
前端·javascript·vue.js·git·svn·webpack·node.js
雪球不会消失了31 分钟前
SpringMVC中的拦截器
java·开发语言·前端
李云龙I42 分钟前
解锁高效布局:Tab组件最佳实践指南
前端
m0_748237051 小时前
Monorepo pnpm 模式管理多个 web 项目
大数据·前端·elasticsearch
JinSoooo1 小时前
pnpm monorepo 联调方案
前端·pnpm·monorepo