小窥探 3D 中射线投射(raycast)

这个代码片段是在 Unity 中编写 FPS 小游戏,判断玩家射击是否击中目标。首先获取从摄像机到屏幕中心的射线,然后查看射线与哪个 GameObject 相交。虽然就短短几行代码,却隐藏了一个问题:如何在三维世界中判断在屏幕上点击是否触碰了某片区域。下面就在 OpengGL 中具体使用 raycast(射线投射)。

void Update() {
	if (Input.GetMouseButtonDown(0)) {
		Vector3 point = new Vector3(_camera.pixelWidth/2, _camera.pixelHeight/2, 0);
		Ray ray = _camera.ScreenPointToRay(point);
		RaycastHit hit;
		if (Physics.Raycast(ray, out hit)) {
			GameObject hitObject = hit.transform.gameObject;
			// do something
		}
	}
}

3D 世界中点击某个物体

在屏幕上点击可以得到一个屏幕坐标,这就是我们的点击点。现在要判断这一点是否与 3D 世界中某个物体相交需要如下步骤。

  • 首先把 2D 屏幕坐标转换到 3D 坐标空间中。要做到这点,我们要把点击点投射到一条射线上。小学生都知道两点决定一条直线。现在有了屏幕上的点击点,还需要一个起点来决定这条射线,而这一点就是摄像机的位置。在 OpengGL 进行坐标变换时,可选择把 World Space 中的点变换到 View Space (也叫 Camera Space) 中。如下图,摄像机观察角度是向前方俯视。
  • 根据摄像机位置和屏幕坐标表示射线。这里统一在 World Space 中进行处理。因此需要把屏幕坐标逆转换到 World Space 中。
  • 最后,检查射线是否与物体相交。

raycast

3D 世界中物体的移动

经过上面分析,我们可以判断是否点击到了物体。如何想进一步拖动物体进行移动,该如何处理呢。这里我们选择让物体在 3D 中指定平面进行移动。鼠标拖动物体时,同判断是否点击到物体一样,这里我们判断移动鼠标时,鼠标的屏幕坐标与该平面的交点。然后移动物体到此交点。

具体的实现

example

如图,一共绘制两个四边形,红色的是 _obj 物体,蓝色的是平面 _plane 。只有点击到 _obj 时才绘制 _plane 。然后拖动 _obj_plane 中移动。完整的代码实现在 blogspinnet/opengl/raycast 目录中

  • 绘制。类 color_shader 是绘制用到的 shader 。类 quad 是被绘制的几何体,负责产生顶点坐标。绘制时由于 _obj 会与 _plane 重叠造成颜色闪烁,所以代码中用深度值解决这个问题。
_colorshader.useprogram();
    
// 先绘制背景,并不写入深度缓冲中,现在缓冲中的值仍都是最小值 1.0f ,这样之后的绘制就会覆盖背景,防止重叠造成闪烁。
glDepthMask(GL_FALSE);
if (_params.ishint) {
	_colorshader.setuniform(mvp, 0.0f, 0.0f, 1.0f);
	_plane.draw();
}
glDepthMask(GL_TRUE);

_colorshader.setuniform(mvp, 1.0f, 0.0f, 0.0f);
_obj.draw();
  • 产生从摄像机到屏幕点的射线。先把屏幕坐标转换到 NDC 坐标空间,然后计算 View Space 和 Perspective Projection 的逆矩阵将 NDC 转换到 World Space 中。
// 将屏幕像素坐标转换成 NDC 坐标。屏幕坐标的原点是屏幕左上角。而 NDC 坐标原点是屏幕中心,y 轴指向屏幕上方。
static void
turn_screencoord_to_ndc(int width, int height, int x, int y, float *nx, float *ny) {
	  *nx = (float)x/(float)width * 2 - 1;
	  *ny = -((float)y/(float)height * 2 - 1); // reverte y axis
}

// 要想知道在窗口上点击时,是否点击到三维空间中的某个物体,需要先把窗口上点击时的二维坐标转换到三维空间,
// 具体会被转换成三维空间中的一条直线,然后判断该直线是否与物体相交。下面就是具体的实现。
// 把一个 2d ndc 转换成世界坐标空间中的一条射线。
static void
normalized2d_to_ray(float nx, float ny, glm::vec3 &raypos, glm::vec3 &raydir) {
	// 世界坐标 - 视图矩阵 - 透视矩阵 - 透视除法 - ndc
	// 要得到反转得到世界坐标,先需要视图矩阵和透视矩阵的反转矩阵

	// ndc 坐标系是左手坐标系,所以近平面的 z 坐标为远平面的 z 坐标要小
	glm::vec4 nearpoint_ndc(nx, ny, -1, 1);
	glm::vec4 nearpoint_world = _params.inverse_mvp * nearpoint_ndc;

	// 消除矩阵反转后,透视除法的影响
	nearpoint_world /= nearpoint_world.w;
	
	raypos = glm::vec3(nearpoint_world);
	raydir = raypos - _params.camerapos;
	raydir = glm::normalize(raydir);
}
  • 判断相交。类 quad 提供了相交判断函数。这里采用 GLM 数学库提供的函数,简单处理了相交判断。成员函数 isintersect 判断射线是否与 quad 相交。quad 就是由两个三角形绘制,因此判断是否与两个三角形相交。成员函数 intersect 判断射线与 quad 所在平面的交点。
bool
isintersect(const glm::vec3 &raypos, const glm::vec3 &raydir) {
	glm::vec3 barypos;
	if (glm::intersectRayTriangle(raypos, raydir, _vertices[0], _vertices[1], _vertices[2], barypos))
		return true;
	if (glm::intersectRayTriangle(raypos, raydir, _vertices[1], _vertices[3], _vertices[2], barypos))
		return true;
	return false;
}

bool
intersect(const glm::vec3 &raypos, const glm::vec3 &raydir, glm::vec3 &barypos) {
	float dist;
	glm::vec3 v1(_vertices[2] - _vertices[1]);
	glm::vec3 v2(_vertices[0] - _vertices[1]);
	glm::vec3 normal = glm::normalize(glm::cross(v1, v2));
	if (glm::intersectRayPlane(raypos, raydir, _center, normal, dist)) {
		barypos =  raypos + raydir * dist;
		return true;
	} else {
		return false;
	}
}

最后

之前写过一篇关于 OpenGL 坐标变换 的文章。本篇文章在现实中具体应用了坐标变换。如下坐标变换代码,透视投影时 frustum(视锥体)的中心点是位于原点,这和 View Space 中的摄像机位置是两码事情,View Space 中的坐标原点就是摄像机所在位置。因此选取射线的起点时应选择摄像机作为起点。

glm::mat4 viewmat = glm::lookAt(_params.camerapos, glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::mat4 projmat = glm::perspective(45.0f, (float)w / (float)h, 1.0f, 100.0f);
glm::mat4 mvp = projmat * viewmat;

Unity 中的摄像机位置又是 frustum 的中心点,这点肯定是内部多了一层处理。所以不要把 Unity 中 frustum 的中心点与上面这个代码片段 frustum 的中心点搞混了。

转载于:https://my.oschina.net/iirecord/blog/867822

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值