【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坐标系有负数,所以不能直接写成
c l i e n t X w i d t h = x 2 \frac{clientX}{width} = \frac{x}{2} widthclientX=2x

正确写法

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

c l i e n t X w i d t h = x + 1 2 \frac{clientX}{width} = \frac{x+1}{2} widthclientX=2x+1

然后转化一下求出x
x = 2 c l i e n t X w i d t h − 1 x = 2\frac{clientX}{width} - 1 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数组中。

总结

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

相关推荐
weixin_3823952318 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__19 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.19 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~20 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华21 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子1 天前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅1 天前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni1 天前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学1 天前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端