由于浏览器是一个2d
视口,而在里面显示three.js
的内容是3d
场景,场景交互就是在二维平面控制三维场景的模型,问题就是如何将2d
视口的x
和y
坐标转换成three.js
场景中的3d
坐标。好在three.js
已经有了解决相关问题的方案,那就是THREE.Raycaster
射线,用于鼠标拾取(计算出鼠标移过的三维空间中的对象)等等。我们看一张图片:
我们一般都会设置三维场景的显示区域,如果,指明当前显示的2d
坐标给THREE.Raycaster
的话,它将生成一条从显示的起点到终点的一条射线。也就是说,我们再屏幕上点击了一个点,在three.js
里面获取的则是一条直线。
THREE.Raycaster构造函数和对象方法
实例化
new Raycaster( origin, direction, near, far );
origin
- 光线投射的原点矢量。
direction
- 光线投射的方向矢量,应该是被归一化的。
near
- 投射近点,用来限定返回比near要远的结果。near不能为负数。缺省为0。
far
- 投射远点,用来限定返回比far要近的结果。far不能比near要小。缺省为无穷大。
属性
far
当前射线的最远距离,射线的终点。此值不应该为负值,而且比near
值要大。
near
当前射线的最近距离,射线的起点。此值不应为负值,比far
值要小。
linePrecision
射线和线相交的精度,浮点数类型的值。
方法
.set()
此方法可以重新设置射线的原点和方向
.set(origin,direction)
origin
- 射线的新的原点矢量位置
direction
- 基于原点位置的射线的方向矢量
.setFromCamera ()
使用当前相机和界面的2d坐标设置射线的位置和方向。
.setFromCamera ( coords, camera )
coords
- 鼠标的二维坐标,在归一化的设备坐标(NDC)中,也就是X 和 Y 分量应该介于 -1 和 1 之间。
camera
- 射线起点处的相机,即把射线起点设置在该相机位置处。
.intersectObject ()和 .intersectObjects ()
检查射线和物体之间的所有交叉点(包含或不包含后代)。交叉点返回按距离排序,最接近的为第一个。 返回一个交叉点对象数组。
.intersectObject ( object, recursive, optionalTarget)
object
- 用来检测和射线相交的物体。
recursive
- 如果为true,它还检查所有后代。否则只检查该对象本身。缺省值为false。
optionalTarget
- 可选参数,用于设置放置结果的数组。如果没有则将实例化一个新数组并将获取到的数据放入,。
.intersectObjects ( array, recursive, optionalTarget)
intersectObject
和intersectObjects
的区别就是,intersectObject
第一个参数需要传入一个3d对象,而intersectObjects
需要传入一个3d对象组成的数组。
返回数组每一个对象的内容
如果射线与场景内的模型没有相交,将返回一个空数组,否则,将返回从近到远的顺序生成的一个对象数组。
[ { distance, point, face, faceIndex, indices, object }, ... ]
distance
– 射线的起点到相交点的距离
point
– 在世界坐标中的交叉点
face
– 相交的面
faceIndex
– 相交的面的索引
indices
– 组成相交面的顶点索引
object
– 相交的对象
当一个网孔(Mesh)对象和一个缓存几何模型(BufferGeometry)相交时,faceIndex 将是 undefined,并且 indices 将被设置; 而当一个网孔(Mesh)对象和一个几何模型(Geometry)相交时,indices 将是 undefined。
当计算这个对象是否和射线相交时,Raycaster 把传递的对象委托给 raycast 方法。 这允许 mesh 对于光线投射的反应可以不同于 lines 和 pointclouds(mesh,lines,pointclouds都是不同的计算相交的方法)。
注意,对于网格,面(faces)必须朝向射线原点,这样才能被检测到;通过背面的射线的交叉点将不被检测到。 为了光线投射一个对象的正反两面,你得设置 material 的 side 属性为 THREE.DoubleSide。
实现一个模型的点击事件
上面讲解了射线的相关内容,接下来,我们来看一下,如何使用射线实现一个普通的点击事件
- 首先,我们通过点击事件回调的
event
获取到点击的位置:
mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
默认没有经过矩阵转换过的显示区域的宽和高分别是2,即中心点也是webgl
场景的坐标原点,左上角的坐标是(-1.0, 1.0, 0.0)
, 右下角的坐标轴是(1.0, -1.0, 0.0)
。我们可以通过点击点的位置计算出当前点击的点在场景中,没有矩阵转换过的平面坐标。如果webgl
的渲染区域不是占满窗口状态,我们还需要获取到显示区域距离窗口左上角的偏移量,再计算位置:
//通过dom的getBoundingClientRect方法获得当前显示区域距离左上角的偏移量
var left = renderer.domElement.getBoundingClientRect().left;
var top = renderer.domElement.getBoundingClientRect().top;
//根据浏览器的设备类型来获取到当前点击的位置
var clientX = dop.browserRedirect() === "pc" ? event.clientX - left : event.touches[0].clientX - left;
var clientY = dop.browserRedirect() === "pc" ? event.clientY - top : event.touches[0].clientY - top;
//计算出场景内的原始坐标
mouse.x = (clientX / renderer.domElement.offsetWidth) * 2 - 1;
mouse.y = -(clientY / renderer.domElement.offsetHeight) * 2 + 1;
- 获取到坐标以后,我们需要使用射线的
setFromCamera()
方法配合场景坐标和相机更新射线的位置:
raycaster.setFromCamera( mouse, camera );
- 使用
intersectObjects()
方法获取射线和所有模型相交的数组集合
var intersects = raycaster.intersectObjects( scene.children );
这里在提醒一句,很多小伙伴有时候发现点击了以后射线无法获取到相交的物体,那是因为为了节约性能,我们需要设置第二个参数为true
,让Three.js
遍历模型所有的子类去判断是否相交。
- 最后,如果有与射线相交的模型,返回的
intersects
数组长度将不为零:
if(intersects.length > 0){
alert("有相交的模型");
}
下面是一个点击案例,点中物体后,模型颜色将会变色:点击这里 案例源码地址:点击这里
实现一个简单的框选案例
最近小伙伴都想实现一个简单的框选案例,那我在这一篇文章里面添加上,本框选案例是通过模型的位置进行判断实现的框选。相对于其它实现方式,这种实现节约性能,简单易懂,能够应付大部分场景。
接下来,我讲解一下这个框选的实现思路:
- 在鼠标按下时,记录鼠标按下时的场景坐标:
//获取到显示区域距离窗口左上角的偏移量
domClient.x = renderer.domElement.getBoundingClientRect().left;
domClient.y = renderer.domElement.getBoundingClientRect().top;
//计算出当前鼠标距离显示区域左上角的距离
down.x = e.clientX - domClient.x;
down.y = e.clientY - domClient.y;
- 使用之前学习到的
box
对象方法来计算出模型的包围盒中心位置,这样对多个复杂模型比较管用,如果简单的几何体的话,可以直接使用mesh
的位置来计算。通过相机将世界坐标的位置转换为平面坐标,并将模型放到一个数组内以便后期使用:
for (let i = 0; i < group.children.length; i++) {
let box = new THREE.Box3();
box.expandByObject(group.children[i]);
//获取到平面的坐标
let vec3 = new THREE.Vector3();
box.getCenter(vec3);
let vec = vec3.project(camera);
modelsList.push(
{
component: group.children[i],
position: {
x: vec.x * half.width + half.width,
y: -vec.y * half.height + half.height
},
normalMaterial: group.children[i].material
}
)
}
- 绑定
document
的mousemove
事件和mouseup
事件,鼠标移动事件是为了判断每个模型是否处于框内,鼠标抬起事件将绑定的事件清除。
//绑定鼠标按下移动事件和抬起事件
document.addEventListener("mousemove", movefun, false);
document.addEventListener("mouseup", upfun, false);
- 在鼠标移动事件中,我们计算出当前四个边的位置,并且循环判断哪些模型的位置处于框内,处于框内的模型的材质将被修改为框选材质:
for (let i = 0; i < modelsList.length; i++) {
let position = modelsList[i].position;
//判断当前位置是否处于框内
if (position.x > min.x && position.x < max.x && position.y > min.y && position.y < max.y) {
modelsList[i].component.material = material;
}
else{
modelsList[i].component.material = modelsList[i].normalMaterial;
}
}
- 在最后的鼠标抬起事件内,将框选框隐藏,并将所有材质修改为默认材质:
function upfun(e) {
//清除事件
document.body.removeChild(div);
document.removeEventListener("mousemove", movefun, false);
document.removeEventListener("mouseup", upfun, false);
//将所有的模型修改为当前默认的材质
for (let i = 0; i < modelsList.length; i++) {
modelsList[i].component.material = modelsList[i].normalMaterial;
}
}