前言
在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;
射线检测
下方是官方文档(部分截取)的使用案例, 可以看出关键方法就俩个setFromCamera
和 intersectObjects
,接下来我们看一下源码
- 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: {}
};
}
origin
和direction
传入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屏幕上,通常涉及以下几个步骤:
- 世界坐标系 -> 视图坐标系 : 通过相机的视图矩阵(
viewMatrix
)进行变换。 - 视图坐标系 -> 裁剪坐标系 : 通过相机的投影矩阵(
projectionMatrix
)进行变换。 - 裁剪坐标系 -> 屏幕坐标系: 通过标准化设备坐标(NDC)变换到屏幕像素坐标。
unproject
方法执行的是这个过程的逆操作,即将屏幕坐标转换回世界坐标:- 屏幕坐标系 -> 裁剪坐标系: 将屏幕坐标(如鼠标位置)归一化到裁剪空间。
- 裁剪坐标系 -> 视图坐标系 : 使用投影矩阵的逆矩阵 (
projectionMatrixInverse
) 进行变换。 - 视图坐标系 -> 世界坐标系 : 使用世界矩阵 (
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 );
}
}
}
-
intersectObjects会循环传入的objects数组,并调用intersect方法
-
intersect方法参数
object
: 要检测的三维对象,可能是场景中的一个节点(如网格或组)。raycaster
: 射线投射器,用于检测射线与对象之间的交点。intersects
: 一个数组,用于存储交点信息。recursive
: 一个布尔值,表示是否递归地检查子对象。
-
intersect方法逻辑
-
初始化传播标志:
propagate
初始化为true
,表示是否继续向下传播射线检测。
-
检查对象层:
- 如果
object.layers.test(raycaster.layers)
返回true
,表示对象所在的层在射线投射器关注的层范围内,则进行射线检测。
- 如果
-
射线检测:
- 调用
object.raycast(raycaster, intersects)
进行具体的射线检测,并将交点信息存入intersects
数组中。 - 如果
object.raycast
返回false
,表示对象不希望射线继续穿过它,将propagate
设为false
。
- 调用
-
递归检测子对象:
- 如果
propagate
为true
且recursive
为true
,遍历object.children
,对每个子对象递归调用intersect
函数。
- 如果
-
-
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)相交。该方法通过一系列优化步骤来提高计算效率,具体步骤如下:
- 材质检查:首先检查网格是否有材质,如果没有材质,则直接返回,因为没有材质的网格不需要进行相交检测。
- 包围球检测:使用网格的包围球在世界空间中检测射线是否相交。如果射线的起点不在包围球内,并且射线与包围球也没有相交,则直接返回,从而节省后续的复杂计算。
- 射线转换:将射线从世界空间转换到网格的局部空间,以便与网格的局部几何数据进行比较。
- 包围盒检测:检查转换后的射线是否与网格的包围盒相交。如果不相交,则返回,避免进一步的计算。
- 详细几何检测 :如果通过了所有的包围体积测试,则调用
_computeIntersections
方法进行详细的几何相交检测,确定具体的交点信息,并将结果添加到intersects
数组中。
总结
本文涉及源码和数学计算逻辑(向量矩阵)较多,理解起来比较困难🧐,需要多花点时间,也可以结合本专栏之前的文章再理解一下。