Three.js 项目器和射线对象

43

我一直在尝试使用Projector和Ray类来进行一些碰撞检测演示。起初,我只是尝试使用鼠标选择对象或拖动它们。我查看了使用这些对象的示例,但似乎没有一个解释Projector和Ray方法确切作用的注释。我有几个问题,希望有人能轻松回答。

Projector.projectVector()和Projector.unprojectVector()到底发生了什么以及它们之间的区别是什么? 我注意到,在使用两个投影仪和射线对象的所有示例中,都会在创建射线之前调用unproject方法。 什么时候会使用projectVector?

我在此演示中使用以下代码来拖动鼠标时旋转立方体。能否用简单的语言解释当我使用mouse3D和camera进行unproject并创建射线时到底发生了什么?射线是否依赖于对unprojectVector()的调用?

/** Event fired when the mouse button is pressed down */
function onDocumentMouseDown(event) {
    event.preventDefault();
    mouseDown = true;
    mouse3D.x = mouse2D.x = mouseDown2D.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse3D.y = mouse2D.y = mouseDown2D.y = -(event.clientY / window.innerHeight) * 2 + 1;
    mouse3D.z = 0.5;

    /** Project from camera through the mouse and create a ray */
    projector.unprojectVector(mouse3D, camera);
    var ray = new THREE.Ray(camera.position, mouse3D.subSelf(camera.position).normalize());
    var intersects = ray.intersectObject(crateMesh); // store intersecting objects

    if (intersects.length > 0) {
        SELECTED = intersects[0].object;
        var intersects = ray.intersectObject(plane);
    }

}

/** This event handler is only fired after the mouse down event and
    before the mouse up event and only when the mouse moves */
function onDocumentMouseMove(event) {
    event.preventDefault();

    mouse3D.x = mouse2D.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse3D.y = mouse2D.y = -(event.clientY / window.innerHeight) * 2 + 1;
    mouse3D.z = 0.5;
    projector.unprojectVector(mouse3D, camera);

    var ray = new THREE.Ray(camera.position, mouse3D.subSelf(camera.position).normalize());

    if (SELECTED) {
        var intersects = ray.intersectObject(plane);
        dragVector.sub(mouse2D, mouseDown2D);
        return;
    }

    var intersects = ray.intersectObject(crateMesh);

    if (intersects.length > 0) {
        if (INTERSECTED != intersects[0].object) {
            INTERSECTED = intersects[0].object;
        }
    }
    else {
        INTERSECTED = null;
    }
}

/** Removes event listeners when the mouse button is let go */
function onDocumentMouseUp(event) {
    event.preventDefault();

    /** Update mouse position */
    mouse3D.x = mouse2D.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse3D.y = mouse2D.y = -(event.clientY / window.innerHeight) * 2 + 1;
    mouse3D.z = 0.5;

    if (INTERSECTED) {
        SELECTED = null;
    }

    mouseDown = false;
    dragVector.set(0, 0);
}

/** Removes event listeners if the mouse runs off the renderer */
function onDocumentMouseOut(event) {
    event.preventDefault();

    if (INTERSECTED) {
        plane.position.copy(INTERSECTED.position);
        SELECTED = null;
    }
    mouseDown = false;
    dragVector.set(0, 0);
}

2
ThreeJS r69不再有Projector对象。请改用vector.unproject( camera );代替! - circuitBurn
https://github.com/mrdoob/three.js/issues/5587 - Ivan Bacher
4个回答

85

我发现需要深入了解样例代码的背后,才能在其作用域之外进行工作(例如拥有一个不占据整个屏幕或者拥有额外效果的画布)。 我写了一篇关于此的博客文章 在这里。 这是一个简短的版本,但应该涵盖了我发现的几乎所有内容。

如何做到

以下代码(类似于@mrdoob已经提供的代码)将在单击时更改立方体的颜色:

    var mouse3D = new THREE.Vector3( ( event.clientX / window.innerWidth ) * 2 - 1,   //x
                                    -( event.clientY / window.innerHeight ) * 2 + 1,  //y
                                    0.5 );                                            //z
    projector.unprojectVector( mouse3D, camera );   
    mouse3D.sub( camera.position );                
    mouse3D.normalize();
    var raycaster = new THREE.Raycaster( camera.position, mouse3D );
    var intersects = raycaster.intersectObjects( objects );
    // Change color if hit block
    if ( intersects.length > 0 ) {
        intersects[ 0 ].object.material.color.setHex( Math.random() * 0xffffff );
    }

在最近的three.js版本中(大约是r55及以后),您可以使用pickingRay,这进一步简化了事情,使其变得更加容易:

    var mouse3D = new THREE.Vector3( ( event.clientX / window.innerWidth ) * 2 - 1,   //x
                                    -( event.clientY / window.innerHeight ) * 2 + 1,  //y
                                    0.5 );                                            //z
    var raycaster = projector.pickingRay( mouse3D.clone(), camera );
    var intersects = raycaster.intersectObjects( objects );
    // Change color if hit block
    if ( intersects.length > 0 ) {
        intersects[ 0 ].object.material.color.setHex( Math.random() * 0xffffff );
    }

让我们坚持旧的方法,因为它更深入地了解内部发生的情况。您可以在这里看到这个工作,只需点击立方体即可改变其颜色。

发生了什么?

    var mouse3D = new THREE.Vector3( ( event.clientX / window.innerWidth ) * 2 - 1,   //x
                                    -( event.clientY / window.innerHeight ) * 2 + 1,  //y
                                    0.5 );                                            //z

event.clientX 是点击位置的 x 坐标。除以 window.innerWidth 可以得到点击位置相对于整个窗口宽度的比例。基本上,这是将从左上角 (0,0) 开始的屏幕坐标转换为笛卡尔坐标系,中心点为 (0,0),范围从 (-1,-1) 到 (1,1),如下所示:

translation from web page coordinates

请注意,z的值为0.5。在这一点上,我不会详细讨论z值,只是简单地说这是离相机投影到三维空间沿z轴的点的深度。稍后会有更多关于此的解释。
接下来:
    projector.unprojectVector( mouse3D, camera );

如果您查看three.js的代码,您会发现这实际上是将投影矩阵从3D世界转换为相机的倒置。请记住,为了从3D世界坐标到屏幕投影,需要将3D世界投影到相机的2D表面上(这就是您在屏幕上看到的内容)。我们基本上在进行反转。
请注意,mouse3D现在将包含此未投影值。这是我们感兴趣的光线/轨迹上的3D空间中的点的位置。确切的点取决于z值(稍后会看到这一点)。
此时,查看以下图片可能会有用:

Camera, unprojected value and ray

我们刚刚计算出来的点(mouse3D)用绿色圆点表示。请注意,这些点的大小纯粹是为了说明,它们与相机或mouse3D点的大小无关。我们更关心的是点的中心坐标。

现在,我们不仅想要一个三维空间中的单个点,而是想要一条射线/轨迹(由黑色点表示),以便确定物体是否位于此射线/轨迹上。请注意,沿着射线显示的点只是任意点,射线是从相机出发的方向,而不是一组点

幸运的是,因为我们有沿着射线的一个点,并且我们知道轨迹必须从相机通过这个点,所以我们可以确定射线的方向。因此,下一步是将相机位置从mouse3D位置中减去,这将给出一个方向向量,而不仅仅是一个单独的点:

    mouse3D.sub( camera.position );                
    mouse3D.normalize();

现在我们已经得到了从相机到三维空间中这一点的方向(mouse3D 现在包含此方向)。然后通过对其进行归一化,将其转换为单位向量。

下一步是创建一个射线(Raycaster),从相机位置开始使用方向(mouse3D)来投射射线:

    var raycaster = new THREE.Raycaster( camera.position, mouse3D );

代码的其余部分确定了3D空间中的对象是否被射线相交。令人高兴的是,使用intersectsObjects在幕后为我们处理了所有这些。

演示

好的,让我们来看一下我的网站这里上展示的一个演示,展示了这些射线在3D空间中的投射。当您单击任何位置时,摄像机会围绕对象旋转,以显示射线的投射方式。请注意,当摄像机返回到其原始位置时,您只会看到一个点。这是因为所有其他点都沿着投影线,并且被前面的点挡住了视线。这类似于当您沿着指向远离您的箭头的线看去时 - 您所看到的只有底部。当然,当您沿着直接朝向您行进的箭头的线看去时(您只能看到箭头的头),这通常是一个糟糕的情况。

z坐标

让我们再来看看那个 z 坐标。在阅读本节内容并尝试不同的 z 值时,请参考这个演示

好的,让我们再来看看这个函数:

    var mouse3D = new THREE.Vector3( ( event.clientX / window.innerWidth ) * 2 - 1,   //x
                                    -( event.clientY / window.innerHeight ) * 2 + 1,  //y
                                    0.5 );                                            //z  

我们选择了0.5作为值。前面我提到过,z坐标决定了在3D中的投影深度。因此,让我们来看看不同的z值对效果的影响。为此,我在相机位置放置了一个蓝色的点,并在相机到未投影位置之间放置了一条绿色的点线。然后,在计算出交点之后,我将相机向后移动并向侧面移动,以显示射线。最好用几个例子来看。

首先是z值为0.5:

z value of 0.5

请注意从相机(蓝点)到未投影值(3D空间中的坐标)的绿色点线。这就像一支枪的枪管,指向应该发射光线的方向。绿色线实际上代表了在归一化之前计算出来的方向。
好的,让我们试试0.9的值:

z value of 0.9

正如您所看到的,绿线现在已经延伸到了三维空间中。0.99甚至延伸得更远。

我不知道z值有多大的重要性。似乎一个更大的值会更精确(就像一根更长的枪管),但由于我们正在计算方向,即使是短距离也应该相当准确。我看到的例子使用0.5,所以除非另有说明,否则我将坚持使用这个值。

画布不是全屏时的投影

现在我们知道了更多的情况,我们可以弄清楚当画布没有填满窗口并且位于页面上时,值应该是什么。例如:

  • 包含three.js画布的div从屏幕左侧偏移offsetX,从顶部偏移offsetY。
  • 画布的宽度等于viewWidth,高度等于viewHeight。

代码将是:

    var mouse3D = new THREE.Vector3( ( event.clientX - offsetX ) / viewWidth * 2 - 1,
                                    -( event.clientY - offsetY ) / viewHeight * 2 + 1,
                                    0.5 );

基本上,我们正在计算鼠标点击相对于画布的位置(对于x:event.clientX - offsetX)。然后,我们按比例确定单击发生的位置(对于x:/viewWidth),类似于画布填充窗口时。

就是这样,希望能有所帮助。


3
点赞。对于我们这些视觉人来说,图形非常精彩。顺便说一句,我认为这个答案更加详尽。 - winduptoy
2
还有很棒的演示。这帮助我比被接受的答案更好地理解了。 - winduptoy
3
非常棒的回答,如果可以的话我会给+10分。 - Aerik
如果我有更多的赞可以给,你们都会得到。我花了两天时间努力理解向量投影是如何工作的,这个完全让我恍然大悟。 - AmericanUmlaut
@AmericanUmlaut - 很高兴它有帮助 - acarlon
你的插图非常有用,帮助我解决了我的问题这里 - Avner Moshkovitz

53

基本上,你需要从三维世界空间和二维屏幕空间进行投影。

渲染器使用 projectVector 将 3D 点转换为 2D 屏幕。unprojectVector 基本上是进行反向操作,将 2D 点还原成 3D 世界空间。在这两种方法中,你需要传递场景所用的摄像机。

因此,在此代码中,你正在创建一个 2D 空间中的归一化向量。说实话,我从来没有完全理解 z = 0.5 的逻辑。

mouse3D.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse3D.y = -(event.clientY / window.innerHeight) * 2 + 1;
mouse3D.z = 0.5;

然后,这段代码使用相机投影矩阵将其转换为我们的 3D 世界空间。

projector.unprojectVector(mouse3D, camera);

将鼠标3D点转换为3D空间后,我们现在可以用它来获取方向,然后使用相机位置投射一条光线。

var ray = new THREE.Ray(camera.position, mouse3D.subSelf(camera.position).normalize());
var intersects = ray.intersectObject(plane);

1
另外,由于API的创建者很好心地在这里回答问题。被移除的碰撞代码是否会被修订,并添加类似于边界框碰撞对象的功能?或者这已经超出了您使用three.js的范围?如果我实现边界框碰撞,我认为最好使用Ray对象。 - Cory Gross
5
需要0.5。有时也需要变成1。 - mrdoob
2
不确定碰撞代码。它被删除了,因为它重复了做事情的方式,并且无法维护。你认为 Ray & Co 不能做什么? - mrdoob
4
@mrdoob谢谢。您能解释一下为什么z需要是0.5,以及什么情况下它需要是1吗?附注:我猜“WebGL: Up and Running”一书在第95页上的观点是错误的,当它说视口坐标范围从-0.5到+0.5时... - poshaughnessy
@mrdoob 我不知道这对于更高级的用户是否显而易见,但我在使用“TrackballControls”移动相机时遇到了一些困难,因为坐标转换“滞后”。问题是世界和投影矩阵直到渲染时才会更新,所以我在计算中使用了旧矩阵。调用“updateMatrixWorld()”和“updateProjectionMatrix()”解决了这个问题。 - dwn
显示剩余8条评论

21

从发布版本r70开始,Projector.unprojectVectorProjector.pickingRay已被弃用。现在我们有了raycaster.setFromCamera,它可以更轻松地找到鼠标指针下的物体。

var mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; 

var raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
var intersects = raycaster.intersectObjects(scene.children);

intersects[0].object 返回鼠标指针下的对象,intersects[0].point 返回鼠标指针点击时所在对象上的点。


1
Projector.unprojectVector()将vec3视为位置。在过程中,向量会被平移,因此我们在其上使用.sub(camera.position)。此操作后我们需要对它进行归一化。
我将在此帖子中添加一些图形,但现在我可以描述该操作的几何形状。
从几何学角度来看,我们可以将相机视为一个金字塔。实际上,我们用6个面板定义它——左、右、上、下、近和远(近为最靠近尖端的平面)。
如果我们站在某个3D空间中观察这些操作,我们会看到这个金字塔处于任意位置,并以任意旋转方式存在于空间中。假设这个金字塔的原点位于其顶部,且其负z轴指向底部。
无论什么东西最终包含在这6个平面内,如果我们应用正确的矩阵变换序列,就会在屏幕上呈现出来。在OpenGL中,这大致如下:
NDC_or_homogenous_coordinates = projectionMatrix * viewMatrix * modelMatrix * position.xyzw; 

这将我们的网格从对象空间转换到世界空间,再到相机空间,最后进行透视投影矩阵,将所有内容放入一个范围从-1到1的小立方体(NDC)中。
对象空间可以是一组整齐的xyz坐标,在其中您可以生成某些过程或者三维模型,艺术家使用对称性建模,因此与坐标空间对齐,而不是从REVIT或AutoCAD等软件中获得的建筑模型。
一个objectMatrix可能会在模型矩阵和视图矩阵之间发生,但这通常是提前处理好的。例如,翻转y和z,或将远离原点的模型带入边界,转换单位等。
如果我们将平面2D屏幕想象成具有深度,它可以用与NDC立方体相同的方式描述,尽管略微扭曲。这就是为什么我们向相机提供纵横比的原因。如果我们想象一个大小与屏幕高度相同的正方形,则余下部分是我们需要缩放x坐标的纵横比。
现在回到3D空间。
我们站在一个三维场景中,看到一个金字塔。如果我们剪掉金字塔周围的一切,然后将金字塔连同其中包含的部分取出,并将其顶点放在0,0,0处,底部指向-z轴,我们最终会得到这个结果:
viewMatrix * modelMatrix * position.xyzw

将其乘以投影矩阵,就好像我们取出顶部,并开始在x和y轴上拉伸它,从一个点创建一个正方形,并将金字塔变成一个盒子一样。在这个过程中,盒子被缩放到-1到1之间,我们得到了透视投影,并最终到达此处:
projectionMatrix * viewMatrix * modelMatrix * position.xyzw; 

在这个空间中,我们可以控制一个二维的鼠标事件。由于它在我们的屏幕上,我们知道它是二维的,并且它位于 NDC 立方体内的某个位置。如果它是二维的,我们可以说我们知道 X 和 Y,但不知道 Z,因此需要进行射线投射。
当我们投射一条射线时,基本上是将一条直线通过立方体,垂直于其中一侧。
现在我们需要弄清楚这条射线是否在场景中击中了某些东西,为此我们需要将射线从该立方体转换为适合计算的空间。我们想要在世界空间中的射线。
射线是空间中的无限线。它与向量不同,因为它有一个方向,并且必须穿过空间中的一个点。这就是 Raycaster 接受其参数的方式。
因此,如果我们将箱子的顶部和线一起挤压回金字塔中,那么该线将起源于顶端并向下运行,并在金字塔底部之间的某个位置相交-- mouse.x * farRange 和 -mouse.y * farRange。
(起初是-1和1,但视图空间按世界比例缩放,只是旋转和移动)

由于这是相机的默认位置(即对象空间),如果我们将相机自己的世界矩阵应用于射线,我们将会与相机一起转换它。

由于射线通过0,0,0,我们只有它的方向,而THREE.Vector3有一个用于变换方向的方法:

THREE.Vector3.transformDirection()

该过程还会对向量进行归一化处理。

上述方法中的Z坐标

这个方法基本上适用于任何值,因为NDC立方体的工作方式相同。 近平面和远平面被投影到-1和1上。

所以当你说,射出一条射线:

[ mouse.x | mouse.y | someZpositive ]

你发送一条线,通过一个点(mouse.x,mouse.y,1),方向为(0,0,某个正Z)

如果你将此与盒子/金字塔示例相关联,则此点位于底部,并且由于该线源自相机,因此它也通过该点。

但是,在NDC空间中,此点被拉伸到无限远,因此该线最终与左、上、右、下平面平行。

使用上述方法进行反投影本质上将其转换为位置/点。远平面只是映射到世界空间,因此我们的点位于z=-1之间,在X上为-camera aspect和+cameraAspect,在y上为-1和1之间。

由于它是一个点,应用相机的世界矩阵不仅会旋转它,还会将其平移。因此需要通过减去相机的位置将其带回原点。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接