• (转)从零实现3D图像引擎:(12)构建支持欧拉和UVN的相机系统


    1. 相机需要哪些参数

    先看下图了解一下上次我们建立的相机在3D世界中的意义:

    如Hello3DWorld中介绍的,在相机坐标系下,相机朝向正Z轴,相机有视野角度,因为视野角度,形成了上图的视锥体,视平面就是要被透视投影到的2D平面。

    在这幅图里增加了近裁剪面与远裁剪面。他们是做什么的呢?在现实中,我们会发现,离眼睛太远的东西我们就看不到了,离眼睛太近的东西又会挡住大部分景色。于是就定义了远裁剪面和近裁剪面——比近裁剪面还近的物体和比远裁剪面还远的物体,我们不把他们透视到视平面上。他们和视平面平行,也就和x-y平面平行,所以他们的平面方程就分别是z = nearz 和 z = farz。

    还有什么物体我们也不做透视投影呢?如图可见,在视锥体以外的东西我们也不要。所以除了远近裁剪面,又有了上、下、左、右裁剪面的定义。通过判断物体与这些裁剪面的关系,我们可以过滤掉大量不可能出现在屏幕中的物体,从而避免对他们进行一系列的3D流水线处理,这对于渲染3D世界中庞大的世界来说是必须的。

    现在总结一下Hello3DWorld中我们使用的相机参数,和现在我们新增的,有这些:相机的在世界中的位置WorldPos、相机在世界中的朝向Direction(通过一次绕X,Y,Z轴旋转的角度确定)、近裁剪面的nearz、远裁剪面的farz、视距d(相机到视平面的距离)、屏幕的宽度和高度、相机的视野角度。

    其中有几个参数,他们看起来很乱:视距d、屏幕的宽度和高度、相机的视野角度。他们是相互依赖的,那我们需要输入哪些参数,又需要算出哪些参数?我们一定是知道屏幕的宽度和高度的,这是个死值。对于相机的视野角度,可能会根据游戏的某些场景而做要求,而是视距d则不太直观,我们很难因为某个游戏场景而去直接调整视距d。所以我们应以屏幕的宽高和视野角度FOV来作为输入参数,算出这种情况下的视距d应为多少。

    有了这些参数我们就可以构建第一种相机模型了,叫做欧拉相机。因为相机的朝向是通过欧拉旋转角度(绕x,y,z轴分别转动的角度)来决定的。其实Hello3DWorld的示例就是一个欧拉相机了,但是我们是规定死了FOV为90度,而且也没有引入裁剪面的概念。

    2. UVN相机系统

    还有一种相机系统叫UVN,他和上面的欧拉相机只有一点区别,就是如何表示相机的朝向。它使用三个相互垂直的向量来表示相机的朝向:

    1) 相机注视的向量N

    2) 相机的上方向向量V

    3) 相机的右方向向量U

    如下图,是在世界坐标系下的UVN相机的向量表示:

    UVN来表示方向有什么好处呢?首先,我们如果想让相机跟踪某个点来拍摄的话,这种方式无疑要比每次去计算欧拉旋转角好很多。比如第三人称视角的游戏,一般都是以玩家为注视点。想象一下对于这种情况,能够很直观的想到去转多少欧拉角度吗。。。

    这里有几点非常值得注意:

    1) 如果我们有了注视点,通过用注视点坐标减去相机世界坐标,就可以求得N。(尚未归一化的)

    2) 如果我们已知上向量V,那么就可以通过N×V求得右向量U。

    3) 我们如果把上向量V通过直角三角形分解成在nv平面和uv平面上的两个分量的话,可以发现在nv平面上的分量没有意义,因为相机仰头或低头的方向已经由向量N来决定了。所以实质上向量V是决定相机是否倾斜的,所谓的倾斜是指让左耳靠向肩部的动作,而不是头部向左转头的动作。

    4) 而且即使给定的上向量V与向量N不垂直,也可以根据N×V求得U,因为N和V可以构成一个平面了,自然可以求其法向量。然后再通过U×N反求V,而这个V则是垂直于U和N的真正的V,其实也就是nv平面上的分量为0的V了。

    3. 定义相机结构

    typedef struct CAMERA_TYPE // 相机
    {
    	POINT4D WorldPos; // 相机在世界的坐标
    	VECTOR4D Direction; // 相机的朝向,相机默认的朝向
    	int Type; // 相机类型 CAMERA_TYPE_ELUER 或CAMERA_TYPE_UVN
    	VECTOR4D U, V, N; // UVN相机的u,v,n向量
    	POINT4D UVNTarget; // UVN相机的目标点
    	int UVNTargetNeedCompute; // UVN相机的目标点是否需要根据朝向计算,1-是, 0-否,UVNTarget已给定 
    	double ViewDistance; // 视距
    	double FOV; // 视野角度
    	double NearZ; // 近裁剪距离
    	double FarZ; // 远裁剪距离
    	PLANE3D  ClipPlaneLeft, ClipPlaneRight, ClipPlaneUp, ClipPlaneDown; // 上下左右裁剪平面
    	double ViewPlaneWidth, ViewPlaneHeight; // 透视平面的宽和高
    	double ScreenWidth, ScreenHeight; // 屏幕宽高
    	double ScreenCenterX, ScreenCenterY; // 屏幕中心坐标
    	double AspectRatio; // 宽高比
    
    	MATRIX4X4 MatrixCamera; // 相机变换矩阵
    	MATRIX4X4 MatrixProjection; // 透视投影变换矩阵
    	MATRIX4X4 MatrixScreen; // 屏幕变换矩阵
    
    } CAMERA, *CAMERA_PTR;

    都有注释,没啥好说的了,都是上面介绍的参数,最后有3个矩阵缓存变换矩阵。

    4. 创建相机并计算需要计算的相机参数

    创建相机的函数除了把参数赋值外,要计算下列东西:宽高比、视距、各裁剪面。

    1) 求宽高比

    就是屏幕宽除以高。。。

    cam->AspectRatio = (double)screenWidth / (double)screenHeight;

    2) 求视距

    求视距OA很简单,已知视平面宽度为2,所以AB=1。

    tan(FOV/2) = AB : AO

    所以,AO = AB / tan(FOV/2)

    3) 求各裁剪面

    我们知道可以用平面上一点和平面的法向量来表示平面,因为所有的裁剪面都过原点O,所以只要找到法向量即可。找法向量最简单的方法就是在平面上找到另外两个点,然后就这两个点的叉乘,就可以得到法向量。找哪两个点呢?看下图,是找右裁剪面上的另外两点,这两个点真爽:

    再复习一遍叉乘公式吧:u×v = <uy*vz - vy*uz , -ux*vz + vx*uz , ux*vy - vx*uy>

    把上面的两点分别作为u和v代入:<w*d / 2 + w*d / 2 , -w*d / 2 + w*d / 2 , -w*w / 4 - w*w / 4>

    计算一下:<w*d , 0 , - w*w>,我们可以把各分量缩放1/w,得:<d, 0, -w/2>

    呵呵,法向量就搞定了。

    下面的函数,负责填充Camera的参数,并且计算上面这三个参数:

    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 - 1) / 2;
    	cam->ScreenCenterY = (screenHeight - 1) / 2;
    	cam->AspectRatio = (double)screenWidth / (double)screenHeight;
    	cam->FOV = fov;
    	cam->ViewPlaneWidth = 2.0;
    	cam->ViewPlaneHeight = 2.0 / cam->AspectRatio;
    
    	// 根据FOV和视平面大小计算d
    	cam->ViewDistance = (0.5) * (cam->ViewPlaneWidth) / tan(AngelToRadian(fov/2));
    
    	// 所有裁剪面都过原点
    	POINT3D po;
    	VectorCreate(&po, 0, 0, 0);
    
    	// 面法线
    	VECTOR3D vn;
    
    	if (fov == 90.0)
    	{
    		// 右裁剪面
    		VectorCreate(&vn, 1, 0, -1);
    		PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);
    
    		// 左裁剪面
    		VectorCreate(&vn, -1, 0, -1);
    		PlaneCreate(&cam->ClipPlaneLeft, &po, &vn, 1);
    
    		// 上裁剪面
    		VectorCreate(&vn, 0, 1, -1);
    		PlaneCreate(&cam->ClipPlaneUp, &po, &vn, 1);
    
    		// 下裁剪面
    		VectorCreate(&vn, 0, -1, -1);
    		PlaneCreate(&cam->ClipPlaneDown, &po, &vn, 1);
    	}
    	else
    	{
    		// 如果视野不是90度,则在算某个裁剪面的法向量时,先去视平面上四个角上在该平面上的两个角作为该裁剪面上的两个向量,然后求叉乘,即可
    		// 下面的法向量vn直接使用了结果
    
    		// 右裁剪面
    		VectorCreate(&vn, cam->ViewDistance, 0, -cam->ViewPlaneWidth / 2.0);
    		PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);
    
    		// 左裁剪面
    		VectorCreate(&vn, -cam->ViewDistance, 0, -cam->ViewPlaneWidth / 2.0);
    		PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);
    
    		// 上裁剪面
    		VectorCreate(&vn, 0, cam->ViewDistance, -cam->ViewPlaneWidth / 2.0);
    		PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);
    
    		// 下裁剪面
    		VectorCreate(&vn, 0, -cam->ViewDistance, -cam->ViewPlaneWidth / 2.0);
    		PlaneCreate(&cam->ClipPlaneRight, &po, &vn, 1);
    	}
    }

    5. 计算相机变换矩阵并缓存

    欧拉相机的变换矩阵在Hello3DWorld已经说的够多了,就是平移+旋转,但是位移和角度都是和物体变换相反而已。下面介绍下UVN相机的变换矩阵。

    平移是肯定要做的,这个不再多说了,说说平移之后,我们的变换矩阵是什么呢?再看一次UVN相机的示意图:

    U、V、N互相垂直,这不就是个坐标系么?还记得上次苍井空教给咱们如何推出变换矩阵么,就是找X,Y,Z轴作为操作柄,如何变换成新的坐标柄。

    设基向量<1,0,0> <0,1,0> <0,0,1>为世界坐标系三个坐标轴向量,因为相机系的相机朝向为Z正方向,所以变换对应关系为:

    X轴->U

    Y轴->V

    Z轴->N

    所以变换矩阵的X分量为<Ux, Uy, Uz>。

    同理,Y分量<Vx, Vy, Vz>。Z分量<Nx, Ny, Nz>。

    所以UVN的变换矩阵为(不包括平移):

    [ Ux Uy Uz ]

    [ Vx Vy Vz ]

    [ Nx Ny Nz ]

    所以可以写出下面的相机变换矩阵更新函数:

    void _CPPYIN_3DLib::CameraUpdateMatrix(CAMERA_PTR cam) // 更新相机中缓存的变换矩阵
    {
    	VECTOR4D vmove;
    	VectorCreate(&vmove, -cam->WorldPos.x, -cam->WorldPos.y, -cam->WorldPos.z);
    	MATRIX4X4 mmove;
    	BuildMoveMatrix(&vmove, &mmove);
    
    	if (cam->Type == CAMERA_TYPE_ELUER)
    	{
    		MATRIX4X4 mrotation;
    		BuildRotateMatrix(-cam->Direction.x, -cam->Direction.y, -cam->Direction.z, &mrotation);
    		MatrixMul(&mmove, &mrotation, &cam->MatrixCamera);
    	}
    	else if (cam->Type == CAMERA_TYPE_UVN)
    	{
    		if (cam->UVNTargetNeedCompute)
    		{
    			// 方向角、仰角
    			double phi = cam->Direction.x;
    			double theta = cam->Direction.y;
    
    			double sin_phi = FastSin(phi);
    			double cos_phi = FastCos(phi);
    			double sin_theta = FastSin(theta);
    			double cos_theta = FastCos(theta);
    
    			cam->UVNTarget.x = -1 * sin_phi * sin_theta;
    			cam->UVNTarget.y = 1 * cos_phi;
    			cam->UVNTarget.z = 1 * sin_phi * cos_theta;
    		}
    
    		// 定义临时的UVN(未归一化)
    		VECTOR4D u, v, n;
    
    		// 求N
    		VectorSub(&cam->UVNTarget, &cam->WorldPos, &n);
    
    		// 设置V
    		VectorCopy(&v, &(cam->V));
    
    		// 应为N和V可以组成一个平面,所以可求法向量U
    		VectorCross(&v, &n, &u);
    
    		// 因为V和N可能不垂直,所以反求V,使得V和U、N都垂直
    		VectorCross(&n, &u, &v);
    
    		// UVN归一
    		VectorNormalize(&u, &cam->U);
    		VectorNormalize(&v, &cam->V);
    		VectorNormalize(&n, &cam->N);
    
    		// UVN变换矩阵
    		MATRIX4X4 muvn;
    		MatrixCreate(&muvn,
    			cam->U.x, cam->V.x, cam->N.x, 0,
    			cam->U.y, cam->V.y, cam->N.y, 0,
    			cam->U.z, cam->V.z, cam->N.z, 0,
    			0,        0,        0,        1);
    
    		MatrixMul(&mmove, &muvn, &cam->MatrixCamera);
    	}
    }

    6. 欧拉旋转角转UVN

    有一种非常常见的用法,就是相机只知道初始的朝向,并且是欧拉旋转角表示的。但我们还希望使用UVN系统的相机,那么就需要把欧拉相机朝向计算出UVN,这样相机就变成UVN相机了。

    要做这个转换,其实就是要求得目标注视点的坐标,这里要应用球面坐标系转笛卡尔坐标系的性质来完成了。我们先假设注视目标点在点P,我们用球面坐标系来表示这个点P(p, phi, theta):

    如果您对球面坐标系还有印象的话,可以发现其实球面坐标中的phi就是欧拉旋转角中的绕Y轴旋转的角度,theta就是绕Z轴旋转的角度。所以:

    phi = cam->Direction.y;
    theta = cam->Direction.z;

    我自认为这个图画的立体效果很强哈,而且重要的直角都标出来了。

    因为我们只关系朝向不关系长度,所以我们设这个球面坐标的p为1。

    所以可以非常容易推得:

    r = sin(phi)

    x = cos(theta) * r

    y = sin(theta) * r

    z = cos(phi)

    因为我们只关系朝向不关系长度,所以我们设这个球面坐标的p为1。

    好了,万事俱备,现在实现一个比较完善的更新缓存矩阵的函数:

    void _CPPYIN_3DLib::CameraUpdateMatrix(CAMERA_PTR cam) // 更新相机中缓存的变换矩阵
    {
    	VECTOR4D vmove;
    	VectorCreate(&vmove, -cam->WorldPos.x, -cam->WorldPos.y, -cam->WorldPos.z);
    	MATRIX4X4 mmove;
    	BuildMoveMatrix(&vmove, &mmove);
    
    	if (cam->Type == CAMERA_TYPE_ELUER)
    	{
    		MATRIX4X4 mrotation;
    		BuildRotateMatrix(-cam->Direction.x, -cam->Direction.y, -cam->Direction.z, &mrotation);
    		MatrixMul(&mmove, &mrotation, &cam->MatrixCamera);
    	}
    	else if (cam->Type == CAMERA_TYPE_UVN)
    	{
    		if (cam->UVNTargetNeedCompute)
    		{
    			// 欧拉角度求注视向量
    			double phi = cam->Direction.y;
    			double theta = cam->Direction.z;
    
    			double sin_phi = FastSin(phi);
    			double cos_phi = FastCos(phi);
    			double sin_theta = FastSin(theta);
    			double cos_theta = FastCos(theta);
    
    			double r = sin_phi;
    
    			cam->UVNTarget.x = cos_theta * r;
    			cam->UVNTarget.y = sin_theta * r;
    			cam->UVNTarget.z = cos_phi;
    		}
    
    		// 定义临时的UVN(未归一化)
    		VECTOR4D u, v, n;
    
    		// 求N
    		VectorSub(&cam->UVNTarget, &cam->WorldPos, &n);
    
    		// 设置V
    		VectorCopy(&v, &(cam->V));
    
    		// 应为N和V可以组成一个平面,所以可求法向量U
    		VectorCross(&v, &n, &u);
    
    		// 因为V和N可能不垂直,所以反求V,使得V和U、N都垂直
    		VectorCross(&n, &u, &v);
    
    		// UVN归一
    		VectorNormalize(&u, &cam->U);
    		VectorNormalize(&v, &cam->V);
    		VectorNormalize(&n, &cam->N);
    
    		// UVN变换矩阵
    		MATRIX4X4 muvn;
    		MatrixCreate(&muvn,
    			cam->U.x, cam->V.x, cam->N.x, 0,
    			cam->U.y, cam->V.y, cam->N.y, 0,
    			cam->U.z, cam->V.z, cam->N.z, 0,
    			0,        0,        0,        1);
    
    		MatrixMul(&mmove, &muvn, &cam->MatrixCamera);
    	}
    }

    通过type判断是缓存欧拉变换矩阵还是UVN变换矩阵,如果是UVN变换,还要判断UVNTargetNeedCompute的值,来决定是否将欧拉角度转变为注视向量,否则直接使用结构体存放的Target向量。别的就不用多说了。

    7. 代码下载

    这次没有多演示DEMO做什么更新,但是因为使用比较完善的相机系统,所以已经可以支持随意的操纵相机了。

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

    8. 补充说明

    这篇文章的知识用的比较多,我学习了好几本书,如《3D数学基础:图形与游戏开发》。结果发现有一本书中存不少的说法错误和问题,就是《3D游戏编程大师技巧》这本,有一些可能是翻译水平问题,还有一些光盘源码也有的问题,这里指出一下。

    1) 求视距,该书给出的推导公式和代码为 d = (0.5) * (cam->viewplane_width) * tan_fov_div2

    而实际推导的结果如果用这些变量来表示则应该为 d = (0.5) * (cam->viewplane_width) / tan_fov_div2。

    不知道为什么会犯这种错误,具体推导过程在上面的第4小节中。

    2) 给出UVN矩阵,作者说是用什么共线程度来推导,但又没给出推导过程,而实际原理就是利用变换矩阵的各向量的几何意义所做的坐标轴的转换。

    3) 求UVN变换矩阵,作者将V写死为<0,1,0>,而在实际应用中,可能要做倾斜相机的操作,所以不应该写死,而是允许调用者指定。否则我们的金字塔示例,如果把相机摆在x轴上面的话,金字塔永远都是倒的。而且也没有说明为什么要反求V,而原因就是传入的V可能与N不垂直。

    4) 上、下、左、右裁剪面的构建。作者说“首先计算表示裁剪面在平面X-Z和Y-Z上的2D投影向量……”。然而,首先这个投影就不一定是个线,比如对于右裁剪面,在Y-Z上的投影,就是Y-Z平面,根本无法使用。而实际只要找很特殊的两个点即可,就是上面4-3所说的那两个点。

    最后,《3D游戏编程大师技巧》这书思路很好,但也有很多推导有问题,所以学习此书的朋友一定要小心,一定要自己亲自推导一遍。比如上面的欧拉转UVN,他非要使用某个右手坐标系来推,而实际我在相机坐标系下直接推导,则更加直观。

    转自:http://blog.csdn.net/cppyin/archive/2011/02/21/6198669.aspx

  • 相关阅读:
    SQLite Java Wrapper/JDBC Driver(收集)
    JAVA 并行运行(收集)
    log4net使用方法(转载)
    WMI服务故障,VBS脚本无法运行错误
    ArcEngine中UID使用资料收集
    使用 ArcGIS Engine Runtime 制作安装包(转载)
    Eclipse安装WindowBuilder Pro(转载)
    C#操作SQL Server数据库
    自动化测试 (三) Web自动化测试原理
    HTTP协议 (六) 状态码详解
  • 原文地址:https://www.cnblogs.com/CoolJie/p/2027361.html
Copyright © 2020-2023  润新知