three.js内置了Raycaster类实现鼠标的碰撞检测,用它可以实现3D物体的鼠标点击,移入移出,触屏检测一类的业务功能。
该功能虽然强大,但同事们普遍反映不是那么好用,因为它不像其它配套了可视编辑的3D引擎一样,直接把这些交互事件挂载到3D物体上。
不好用没关系,只要研发团队有懂three.js的人,或者有人把写好的实现代码封装一下给到业务开发去用就可以了。
蛋疼的是,这个东西还有一些小bug,比如它不认相机裁剪。以下为测试用例代码
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>three_cameraNear</title><style>body {margin: 0;overflow: hidden;}</style><script src="three/build/three.js"></script><script src="three/examples/js/controls/OrbitControls.js"></script><script src="three/examples/js/libs/dat.gui.min.js"></script>
</head><body><script>var scene = new THREE.Scene();var geometry = new THREE.SphereGeometry(50, 100, 100);var srcColor = 0xFF6600;var material = new THREE.MeshLambertMaterial({color: srcColor});var mesh = new THREE.Mesh(geometry, material);scene.add(mesh);var light = new THREE.DirectionalLight({color: 0xFFFFFF, intensity: 0.5});light.position.set(-500, 500, 500);scene.add(light);var ambLight = new THREE.AmbientLight({color: 0xFFFFFF, intensity: 0.3});scene.add(ambLight);var width = window.innerWidth; var height = window.innerHeight; var camera = new THREE.PerspectiveCamera(60, width / height, 100, 20000);camera.position.set(0, 0, 150); var renderer = new THREE.WebGLRenderer();renderer.setSize(width, height);renderer.setClearColor(0x000000, 1); document.body.appendChild(renderer.domElement); var gui = new dat.GUI(),folderCamera = gui.addFolder("相机"),propsCamera = {get '裁剪'() {return camera.near;},set '裁剪'( v ) {camera.near = v;camera.updateProjectionMatrix();},};folderCamera.add( propsCamera, '裁剪', 100, 150 ); folderCamera.open();function render() {renderer.render(scene, camera);requestAnimationFrame(render);}render();var controls = new THREE.OrbitControls(camera,renderer.domElement);controls.addEventListener('change', render);var raycaster = new THREE.Raycaster(); function onMouseMove(e){ //这里是屏幕坐标到ndc的转换,不懂的可以自行上webgl中文网学习var x = ((e.clientX - width * 0.5) / width * 2);var y = (-(e.clientY - height * 0.5) / height * 2);raycaster.setFromCamera(new THREE.Vector2(x, y), camera);var intersects = raycaster.intersectObject(scene, true);material.color = new THREE.Color().set(srcColor);for(let intersect of intersects){intersect.object.material.color = new THREE.Color().set(0x999900);}}window.addEventListener("mousemove", onMouseMove);</script>
</body>
</html>
场景上的球体在场景不被裁剪的时候工作得非常好,但是一旦用上了裁剪(设置camera的near/far,就会发现,Raycaster完全无视这一属性。
解决问题的时候,笔者还是先尝试去找现成的api,看是不是有设置项。果不其然,Raycaster就自带了near和far属性。
//在onMouseMove方法的intersects调用前加上这两行,同步相机的裁剪属性
raycaster.near = camera.near;
raycaster.far = camera.far;
这不是脱裤子放屁嘛,为什么就不直接在Raycaster内部去读取camera的这两条属性呢,非要业务层多此一举?
先不纠结这问题,我们看看这样写是否就能把问题解决掉。
有了显著改善,但是边缘处并不是那么准确,并且鼠标位置离画布中心越远,误差就越大。
笔者在解决这个问题之前,有大致了解过射线检测的原理,所以结合源码里的实现逻辑,盲猜到把相机改成正交就会立马变得非常准确。
var k = width / height;
var s = 100;
var camera = new THREE.OrthographicCamera(-s * k, s * k, s, -s, 100, 20000);
也就是说,near和far的同步适用于正交相机,透视相机用它不靠谱,这大概也就可以解释为什么three.js不直接在底层同步这两项属性了。
现在笔者就来跟大家解释下,为什么两种相机得到的结果会有所差异。
透视相机的特征是近大远小(实际上玩得花的可以搞成近小远大),其可视区域是下图中近裁剪面和远裁剪面及其顶点连线所围成的一个棱台,称作视锥体。
做基于射线检测的鼠标碰撞时,人眼的直观感受是从鼠标位置发射一条垂直于屏幕向里的射线,最先跟啥相交就算碰到谁。近裁剪面和远裁剪面在屏幕上将会被缩放到相同的大小从而形成近大远小的效果,那么垂直于屏幕向里的射线也将据此进行的倾斜变换(如上图的红绿蓝3条射线),图中P,A和A1在画面上是重叠的,P,蓝点和蓝箭头的头部也一样,可以看到,上图的3条射线跟近裁剪面交点和相机的距离不一样,绿点(屏幕中心)最近,红点次之,蓝点最远。
但是射线检测源码用的却是相机位置到交点的距离,因此对于透视相机而言,离屏幕中心越远,误差就越大,它用球面代替了平面来判断。
而正交相机则不存在正大远小的说法,它的视锥体是一个长方体,所以不管从哪里发出来的射线都是两两平行的。
下面给出解决方案,代码很简单,只有两行。
const proj = _intersectionPointWorld.clone().project(raycaster.camera);
if(proj.z > 1 || proj.z < -1) return null;
这段代码加到Mesh.js上
每个显示对象都有自己的raycast属性,其它对象的修改方法类似,不再赘述。
虽然只有两行,但这里的学问大着呢。
无论是OpenGL(WebGL是其子集)还是DirectX,这些GPU渲染底层使用的都是标准设备坐标系(Normal Device Coordinate,NDC),其xyz3轴的范围均为-1到1(不懂的小伙伴可以搜索NDC进一步学习)。上述代码中的方法,就是实现从屏幕像素坐标到NDC坐标的转换,它在three.js内已经封装好了。
NDC超出-1到1范围的物体将不予显示,因此该做法在视觉上是最准确的。但有同事不建议笔者这样改,他们认为能不动源码就不动,后续想要更新引擎也方便。但笔者当时坚持修改源码,因为这就是引擎的一个bug。
写本文的时候,笔者重新审视了一下,改源码也有它的不合理之处。因为它并不适用于所有的业务场景。比如线框材质,它的背面也是可见的,这时候,笔者的这一修改就反倒不正确。
由此可见,three.js不直接在raycaster里读取camera的near和far也是有他的道理。再者,如果哪天有人突发奇想,搞个哇哈哈效果那样的曲面相机,那机制又可能不是这么一回事了。
这也就使得three.js比其它引擎更加灵活,同时也变得没那么好用了。如果读者们有注意到笔者上面的滑块源码就会发现,在camera的near修改了之后,笔者还加了句updateProjectionMatrix触发刷新,这些都是灵活性高封装性不强的表现,对于萌新们来说,上手和查问题都会变得困难。这时候,笔者这一专栏的价值就体现出来了。
废话说完,来小结一下:
1 默认情况下,射线检测无视相机裁剪
2 正交相机,非线框材质的情况下,把camera的near和far属性同步到raycaster能完美解决问题
3 透视相机,非线框材质的情况下,用THREE.Vector3的project方法算出来的结果比用near和far要准确得多,同时此法也适用于正交相机
4 如果要纠结线框材质,那么可以通过修改Mesh.js源码来实现兼容
本文重点是project方法,事实上这个方法也有坑,后面还会提到,敬请期待!