代码改变世界

(转)从零实现3D图像引擎:(13)把宽高比、透视投影矩阵、屏幕变换矩阵说透

2011-04-25 15:45  CoolJie  阅读(922)  评论(0编辑  收藏  举报

1. 问题的引出

这个问题的引出又是因为《3D游戏编程大师技巧》这书里面有的问题没讲明白,有的东西又不对。

首先宽高比这个名词的出现是因为我们的PC屏幕不是正方形的,屏幕宽度 : 屏幕高度 就是宽高比。但是我们上次搭建的相机系统的视平面是正方形的,那么当很多物体投影到视平面上后,必然最后完成的是一幅正方形的画,而屏幕是长方形的,这时只有两种办法:

1) 把照片压扁,这样画上的所有物体都被压扁了。

2) 把照片上下多余的两条分别裁下来,只保留屏幕大小的画,这样物体不会走样变形,但是这幅画有一部分看不到了。

哪种是正确的?

以人眼为例,我们的眼睛不可能因为眼睛的外框不是正方形就把东西压扁吧,所以我们要做的是不要多余的上下两条边,而不是把物体压扁。

2. 两种方式

我们可以想到有两种方法来做这件事:

1) 如Hello3DWorld这样,在已经变换到了屏幕系的图像上动手脚,把图像的多余上下边舍弃。

2) 我们将yz平面的FOV不设置为与xz平面的FOV相同,而是使视平面的宽高比和屏幕的宽高比相同。这样,上下裁剪面的方程就会有所变化,直接会将原本会出现在多余的两边的东西裁掉,也就是在3D空间裁剪做完之后,再投影到视平面上的结果已经不是正方形了,而是和屏幕的比例相同,也就是在实质上对纵向的视野进行了操作。这样的好处是在物体剔除时可以剔除更多的物体,也不需要再进行2D图像裁剪了。所以我们应该使用这种方法。

如果修改了上下裁剪面方程,从透视投影到屏幕变换的新的过程将会是这样:

得到的已经是最后的屏幕坐标了。

3. 修改生成上下裁剪面的代码

还记得如何生成上下裁剪面吗?上一篇文章讲的很细了,这里不再重复了。现在所作出的改变就是取那特殊的两点的坐标发生了变化。比如上裁剪面,以前是(-1,1) (1,1),现在变成了(-1, 1/ar) (1, 1/ar)。

把新的坐标代进求叉乘,求新的上裁剪面法向量,得:<0, d, -1/ar>

下裁剪面法向量:<0, -d, -1/ar>

这是新的创建相机的函数代码:

void _CPPYIN_3DLib::CameraCreate(CAMERA_PTR cam, int type, POINT4D_PTR pos, VECTOR4D_PTR dir, POINT4D_PTR target, VECTOR4D_PTR v, int needtarget,
		double nearz, double farz, double fov, double screenWidth, double screenHeight) // 创建相机
{
	// 相机类型
	cam->Type = type;

	// 设置位置和朝向
	VectorCopy(&(cam->WorldPos), pos);
	VectorCopy(&(cam->Direction), dir);

	// 设置UVN相机的目标点
	if (target != NULL)
	{
		VectorCopy(&(cam->UVNTarget), target);
	}
	else
	{
		VectorCreate(&(cam->UVNTarget), 0, 0, 0);
	}

	if (v != NULL)
	{
		VectorCopy(&(cam->V), v);
	}

	cam->UVNTargetNeedCompute = needtarget;

	// 裁剪面和屏幕参数
	cam->NearZ = nearz;
	cam->FarZ = farz;
	cam->ScreenWidth = screenWidth;
	cam->ScreenHeight = screenHeight;
	cam->ScreenCenterX = screenWidth / 2 - 1;
	cam->ScreenCenterY = screenHeight / 2 - 1;
	cam->AspectRatio = (double)screenWidth / (double)screenHeight;
	cam->FOV = fov;
	cam->ViewPlaneWidth = 2.0;
	cam->ViewPlaneHeight = 2.0 / cam->AspectRatio;

	// 根据FOV和视平面大小计算d
	if (cam->FOV == 90)
	{
		cam->ViewDistance = 1;
	}
	else
	{
		cam->ViewDistance = (0.5) * (cam->ViewPlaneWidth) / tan(AngelToRadian(fov/2));
	}

	// 所有裁剪面都过原点
	POINT3D po;
	VectorCreate(&po, 0, 0, 0);

	// 先去视平面上四个角上在该平面上的两个角作为该裁剪面上的两个向量,然后求叉乘,即可
	// 下面的法向量vn直接使用了结果

	// 面法线
	VECTOR3D vn;

	// 右裁剪面
	VectorCreate(&vn, cam->ViewDistance, 0, -1);
	PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);

	// 左裁剪面
	VectorCreate(&vn, -cam->ViewDistance, 0, -1);
	PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);

	// 上裁剪面
	VectorCreate(&vn, 0, cam->ViewDistance, -1 / cam->AspectRatio);
	PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);

	// 下裁剪面
	VectorCreate(&vn, 0, -cam->ViewDistance, -1 / cam->AspectRatio);
	PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);
}

4. 透视投影矩阵的推导

透视投影的原理我们已经非常清楚了,上篇文章介绍了:

x' = x * d / z

y' = y * d / z

推导矩阵的原理苍井空老师也介绍过了。但这次比较特殊,因为我们无法通过变换矩阵让坐标值除以z,只有借助4D齐次坐标了。

我们要这么做:

1) 把x和y放大d

2) 把齐次坐标w的值设置为z,这样因为w != 1,所以整理齐次坐标时,x就变成了x*d/w = x*d/z,y同理。

所以矩阵应该如下:

[ d 0 0 0 ]

[ 0 d 0 0 ]

[ 0 0 1 1 ]

[ 0 0 0 0 ]

如果你也发现这个结果和《3D大师》里面说的完全不一样的话,说明你有认真去推导了,因为在那本书里讲的投影矩阵推导根本狗屁不通。

然后在执行完矩阵变换后,需要把所有的顶点坐标的x,y除以w,这可以非常重要的一步哦。

下面是使用的方式,这个物体投影变换函数可以选择是手动来算还是用矩阵来算:

void _CPPYIN_3DLib::ObjectProjectTransform(OBJECT_PTR obj, CAMERA_PTR camera, int transMethod) // 透视变换,将3D坐标透视为2D坐标,结果为x取值为(-1,1),Y取值为(-1,1)
{
	// 手动变换
	if (transMethod == TRANSFORM_METHOD_MANUAL)
	{
		for (int i = 0; i < obj->VertexCount; ++i)
		{
			obj->VertexListTrans[i].x = obj->VertexListTrans[i].x *  camera->ViewDistance / obj->VertexListTrans[i].z;
			obj->VertexListTrans[i].y = -obj->VertexListTrans[i].y * camera->ViewDistance  / obj->VertexListTrans[i].z;
		}
	}
	else if (transMethod == TRANSFORM_METHOD_MATRIX) // 矩阵变换
	{
		ObjectTransform(obj, &camera->MatrixProjection, RENDER_TRANSFORM_TRANS, 0);

		// 执行完变换后,坐标是4D齐次坐标,但w不为1,所以需要使x,y,z都除以w
		for (int i = 0; i < obj->VertexCount; ++i)
		{
			obj->VertexListTrans[i].x /= obj->VertexListTrans[i].w;
			obj->VertexListTrans[i].y /= obj->VertexListTrans[i].w;
			obj->VertexListTrans[i].z /= obj->VertexListTrans[i].w;
			obj->VertexListTrans[i].w = 1;
		}
	}
}

5. 屏幕变换矩阵的推导

上面那个大图很好的说明了屏幕变换应该怎么做:

1) 放大screen_width / 2。

2) X平移screen_width / 2。

3) Y平移screen_height / 2。

这个矩阵我们完全可以直接写出来了,不就是个缩放和平移的综合嘛:

cam->ScreenWidth / 2,            0,          0,         0,
  0,              cam->ScreenWidth / 2,       0,         0,
  0,                                            0,          1,         0,
  cam->ScreenWidth / 2, cam->ScreenHeight / 2,          0,         1

这个矩阵很直观吧,该缩放的缩放,该平移的平移。

使用的代码如下:

void _CPPYIN_3DLib::ObjectScreenTransform(OBJECT_PTR obj, CAMERA_PTR camera, int transMethod) // 视口变换,结果取值X:(0,SCREEN_WIDTH) Y:(0,SCREEN_HEIGHT)
{
	// 手动变换
	if (transMethod == TRANSFORM_METHOD_MANUAL)
	{
		for (int i = 0; i < obj->VertexCount; ++i)
		{
			obj->VertexListTrans[i].x *= camera->ScreenWidth / 2;
			obj->VertexListTrans[i].x += (camera->ScreenWidth / 2);
			obj->VertexListTrans[i].y *= camera->ScreenWidth / 2;
			obj->VertexListTrans[i].y += (camera->ScreenHeight / 2);
		}
	}
	else if (transMethod == TRANSFORM_METHOD_MATRIX) // 矩阵变换
	{
		ObjectTransform(obj, &camera->MatrixScreen, RENDER_TRANSFORM_TRANS, 0);

		// 执行完变换后,坐标是4D齐次坐标,但w不为1,所以需要使x,y,z都除以w
		for (int i = 0; i < obj->VertexCount; ++i)
		{
			obj->VertexListTrans[i].x /= obj->VertexListTrans[i].w;
			obj->VertexListTrans[i].y /= obj->VertexListTrans[i].w;
			obj->VertexListTrans[i].z /= obj->VertexListTrans[i].w;
			obj->VertexListTrans[i].w = 1;
		}
	}
}

6. 总结

屏幕的宽和高不一致影响的事情有两件,一:相机的宽视野和纵视野不一致。二:变换到屏幕坐标系时需要平移的位移不相同。

7. 代码下载

因为有了灵活的相机系统,有了方便的透视和视口变换矩阵,这次稍微改了一改DEMO,可以使用方向键来调整UVN相机的世界坐标。上下调整Z坐标,左右调整X坐标,注意不要让相机离物体太近,否则会超出相机造成出错,因为我们还没有写裁剪的代码不是吗。

截图:

完整项目源代码下载:>>点击进入下载页<<

转自:http://blog.csdn.net/cppyin/archive/2011/02/23/6203000.aspx