Three.js Projector 和 Ray 对象

IT技术 javascript 3d three.js
2021-03-07 23:12:40

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

到底发生了什么,Projector.projectVector() 和 Projector.unprojectVector() 有什么区别?我注意到在所有同时使用投影仪和光线对象的示例中,似乎在创建光线之前调用了 unproject 方法。你什么时候会使用 projectVector?

我在这个演示中使用以下代码在用鼠标拖动时旋转立方体。有人可以用简单的术语解释当我用 mouse3D 和相机取消投影然后创建 Ray 时到底发生了什么。射线是否取决于对 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);
}
4个回答

我发现我需要在表面下更深入一点才能在示例代码的范围之外工作(例如有一个不填满屏幕的画布或有额外的效果)。在这里写了一篇关于它的博客文章这是一个缩短的版本,但应该涵盖我发现的几乎所有内容。

怎么做

以下代码(类似于@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 及更高版本),您可以使用 pickRay 进一步简化事情,使其变为:

    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) 开始到右下角( window.innerWidth, window.innerHeight) 的屏幕坐标转换为中心为 (0,0) 的笛卡尔坐标,范围从 (-1,-1 ) 到 (1,1) 如下图:

从网页坐标翻译

请注意,z 的值为 0.5。在这一点上,我不会详细介绍 z 值,只是说这是我们沿 z 轴投影到 3D 空间的远离相机的点的深度。稍后会详细介绍。

下一个:

    projector.unprojectVector( mouse3D, camera );

如果您查看three.js 代码,您会发现这实际上是从3D 世界到相机的投影矩阵的反转。请记住,为了从 3D 世界坐标到屏幕上的投影,3D 世界需要投影到相机的 2D 表面(这就是您在屏幕上看到的)。我们基本上是在做相反的事情。

请注意, mouse3D 现在将包含此未投影值。这是沿着我们感兴趣的光线/轨迹的 3D 空间中的点的位置。确切的点取决于 z 值(我们稍后会看到)。

此时,查看下图可能会有所帮助:

相机、未投影值和射线

我们刚刚计算的点 (mouse3D) 由绿点显示。请注意,点的大小纯粹是说明性的,它们与相机或 mouse3D 点的大小无关。我们对点中心的坐标更感兴趣。

现在,我们不只是想要 3D 空间中的单个点,而是想要一条光线/轨迹(由黑点显示),以便我们可以确定对象是否沿着这条光线/轨迹定位。请注意,沿射线显示的点只是任意点,射线是来自相机的方向,而不是一组点

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

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

我们现在有一个从相机到 3D 空间中这个点的方向(mouse3D 现在包含这个方向)。然后通过归一化将其转换为单位向量。

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

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

其余代码确定 3D 空间中的对象是否与光线相交。令人高兴的是,这一切都在幕后使用intersectsObjects.

演示

好了,让我们来看看一个演示从我的网站在这里显示了这些光线被浇铸在三维空间。当您单击任意位置时,相机会围绕对象旋转以显示光线是如何投射的。请注意,当相机返回其原始位置时,您只能看到一个点。这是因为所有其他点都沿着投影线,因此被前面的点挡住了视线。这类似于当您向下看直接指向远离您的箭头线时 - 您看到的只是底部。当然,当向下看直接朝向您的箭头线(您只能看到头部)时,这同样适用,这通常是一种糟糕的情况。

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 的不同值,看看它有什么影响。为此,我在相机所在的位置放置了一个蓝点,并在相机与未投影位置之间放置了一条绿点。然后,在计算出交点后,我将相机向后和向一侧移动以显示光线。最好看几个例子。

一、az值为0.5:

z 值为 0.5

请注意从相机(蓝点)到未投影值(3D 空间中的坐标)的绿色点线。这就像枪管一样,指向它们应该投射的方向。绿线基本上代表在归一化之前计算的方向。

好的,让我们试试 0.9 的值:

z 值为 0.9

如您所见,绿线现已进一步延伸至 3D 空间。0.99 进一步延伸。

我不知道 z 的值有多大。似乎更大的值会更精确(就像更长的枪管),但由于我们正在计算方向,即使是短距离也应该非常准确。我见过的示例使用 0.5,因此除非另有说明,否则我将坚持使用 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),类似于画布填充窗口时。

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

真的很棒的答案,如果可以的话,+10。
2021-05-05 23:12:40
如果我有更多的赞成票,你就会拥有它们。我花了两天的时间努力理解矢量投影是如何工作的,这完全让我觉得很有趣。
2021-05-06 23:12:40
还有很棒的演示。这比接受的答案帮助我理解得多
2021-05-10 23:12:40
点赞。图形对我们视觉人士来说非常棒。顺便说一下,我认为这个答案要彻底得多。
2021-05-12 23:12:40
@AmericanUmlaut - 很高兴它有所帮助
2021-05-14 23:12:40

基本上,您需要从 3D 世界空间和 2D 屏幕空间进行投影。

渲染器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);

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

var ray = new THREE.Ray(camera.position, mouse3D.subSelf(camera.position).normalize());
var intersects = ray.intersectObject(plane);
此外,由于 API 的创建者非常好,可以在这里回答问题。从库中删除的碰撞代码是否会被修改,并为边界框添加类似的东西,比如物体的碰撞。或者这超出了您使用 Three.js 的范围?如果我实现边界框碰撞,我认为最好使用 Ray 对象。
2021-04-20 23:12:40
需要 0.5。有时它也需要为 1。
2021-04-27 23:12:40
不确定碰撞代码。它被删除是因为它重复了做事的方式并且没有维护。你认为 Ray & co 不能做的事情是什么?
2021-04-28 23:12:40
@mrdoob 谢谢。您能否解释为什么 z 需要为 0.5,而何时需要为 1?ps我猜p95上的“WebGL:启动和运行”书是错误的,然后当它说视口坐标范围从-0.5到+0.5时......
2021-05-07 23:12:40
” 老实说,我对 z = 0.5 的逻辑从来不太确定。“ 是的,我实际上看到了一些不设置 z 的演示,但我看到了很多总是将 z 设置为 0.5 的演示。所以它不需要?
2021-05-14 23:12:40

从版本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给出鼠标指针被点击的对象上的点。

Projector.unprojectVector() 将 vec3 视为一个位置。在这个过程中,向量被翻译,因此我们在它上面使用.sub(camera.position)另外,我们需要在此操作后对其进行标准化。

我将在这篇文章中添加一些图形,但现在我可以描述操作的几何形状。

我们可以将相机视为几何方面的金字塔。事实上,我们用 6 个窗格来定义它——左、右、上、下、近和远(近是最靠近尖端的平面)。

如果我们站在某个 3d 中并观察这些操作,我们会在任意位置看到这个金字塔,在空间中任意旋转。假设这个金字塔的原点在它的顶端,它的负 z 轴朝底部延伸。

如果我们应用正确的矩阵变换序列,最终包含在这 6 个平面中的任何内容都将最终呈现在我们的屏幕上。我 opengl 是这样的:

NDC_or_homogenous_coordinates = projectionMatrix * viewMatrix * modelMatrix * position.xyzw; 

这将我们的网格从它的对象空间带到世界空间,进入相机空间,最后投影它执行透视投影矩阵,该矩阵基本上将所有内容放入一个小立方体(NDC,范围从 -1 到 1)。

对象空间可以是一组整洁的 xyz 坐标,您可以在其中程序生成一些东西,或者说,一个 3d 模型,艺术家使用对称性建模,因此整齐地与坐标空间对齐,而不是像这样获得的建筑模型REVIT 或 AutoCAD。

objectMatrix 可能发生在模型矩阵和视图矩阵之间,但这通常会提前处理。比如说,翻转 y 和 z,或者将远离原点的模型带入边界,转换单位等。

如果我们将平面 2d 屏幕视为具有深度,则可以将其描述为与 NDC 立方体相同的方式,尽管略微失真。这就是我们为相机提供纵横比的原因。如果我们想象一个正方形的屏幕高度,余数就是我们需要缩放 x 坐标的纵横比。

现在回到 3d 空间。

我们站在一个 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 * 远距离。

(一开始是-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,someZpositive) 的方向

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

但是,在 NDC 空间中,该点被拉伸到无穷大,并且这条线最终与左、上、右、下平面平行。

使用上述方法取消投影基本上将其转换为位置/点。远平面刚刚映射到世界空间,所以我们的点位于 z=-1 的某个位置,在 X 上的 -camera 方面和 + cameraAspect 之间以及 y 上的 -1 和 1 之间。

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