The Design of XNA Camera
仅供个人学习使用,请勿转载,勿用于任何商业用途。
在编写任何3D程序之前,建立一个camara是非常有必要的。有多少次当你花了很长时间写完程序,运行时却只看到空空的背景颜色?你检查了模型数据,程序逻辑,shader,最后发现原来是变换矩阵计算错了。这大概是编写3D程序时最常见,也最低级的错误之一。
那么,什么是Camera呢?这看起来似乎是个很愚蠢的问题。但是如果你常常游荡于各大游戏开发论坛,就会发现几乎每隔几天就有人问如何实现Camara。对于这样的帖子我总是觉得很奇怪,其实Camera本身的实现是非常简单的,只要清楚基本的3D变换原理就够了。问题在于提问的人根本就不清楚什么是camera,自然就无法实现了。在我看来,camera的职责只有一个:为渲染系统提供观察者(或者说视点)信息。说白了,任何能提供view matrix, projection matrix和bounding view frustum的类都可以看作是camera,如果你连裁减都不想做,那么bounding view frustum也不是必须的。也许有人会说不同游戏里的camera是不一样的,比如大部分fps游戏与rpg游戏的摄像机控制方式就非常不一样。但只要花30秒思考一下就会发现,其实所有游戏中的摄像机都是相同的,把摄像机放在人物头部,就成了fps camera,而放在人物斜上方,就成了rpg风格的camera。
不同的游戏有不同的摄像机移动行为,千万不要把这些行为和摄像机本身混为一谈,任何与摄像机移动相关的行为都不应该属于camera。为了和接下来的讨论不产生混淆,先区分2个概念:摄像机控制方式和摄像机移动行为。这里控制方式指如何描述观察者信息,比如,是通过当前camera的位置和观察点的位置来确定view matrix,还是把标准view matrix旋转某个角度来获得当前view matrix;当旋转camera时,是以世界坐标旋转,还是以camera当前坐标旋转等等。而移动行为则指以什么方式改变摄像机数据,比如把观察点从a改变到b,是让视线直接跳转,还是有一个缓慢“旋转”的差值过程。不同的控制方也许特别适合某些特定的摄像机行为。对于游戏引擎来说,camera只需要提供观察信息和基本的控制方式就可以了。至于移动行为,每个游戏都可能不相同,不应该属于游戏引擎的一部分,你需要为每个游戏定制自己的移动行为。
说了那么多,接下来则是关于实现细节,以下是我的camera:
前面说过,可能有多种摄像机控制方式,所以Camera是一个抽象类,定义了最基本的观察者信息,渲染系统只需要知道这个接口,就可以完成整个渲染。同时也留给了开发者最大的灵活性实现自己的算法。为了简便,省略了关于bounding box的数据。Camaera的大部分属性都是只读的(显然,你不希望外部代码破坏这些值),包括:ViewMatrix, InverseViewMatrix, ViewProjMatrix, Up, Forward, Right。后三个属性代表了摄像机local space的三条坐标轴方向,你并不需要额外的成员变量来保存这3个值,因为viewMatix矩阵的前3行就分别代表了local space中x,y,z三条轴的方向。Camera并没有直接提供projecton matrix,而是包含了一个projector对象。Projector可以提供2中投影方式:透视投影或者正交投影。这只是一个非常简单的类,把它和Camera合在一起也是可以的,不过考虑到重用,我选择了分开。
当实现Camera时,你需要考虑什么时候计算观察信息(view matrix)。比如,你分别通过属性X和Y改变了Camera的位置,那么是在设置X的时候计算一次view matrix,设置Y的时候再计算一次,还是X和Y都改变了之后再计算一次呢?显然,游戏中Camera将会频繁改变各种属性,第一种方法显然效率会很低,但第二种也不够好,需要用户在更改完各种属性之后显式调用Update(). 我选择了延迟计算的方法:用一个状态变量记录属性是否需要重新计算矩阵,当访问ViewMatrix或相关属性时,检查状态变量,进行相应的更新:
- public virtual Matrix ViewMatrix
- {
- get
- {
- if (requireUpdate)
- UpdateMatrix();
- return viewMatrix;
- }
- }
这样的实现还有一个潜在的问题,你希望客户如何使用Camera呢:
Model.Draw(camera.viewMatrix, ……….)
你的客户很有可能就是这样来调用代码。但通过属性来访问矩阵是很慢的,因为矩阵是值类型,是按值传递的,所以每次进行这样的参数传递都会发生大量数据的拷贝。为了提高性能,更好的方式也许是:
Model.Draw(ref camera.viewMatrix, ……….)
但由于这里的ViewMatrix是属性,上面的代码完全不能通过编译,不能用ref修饰属性。因此,如何更好的传递变量,是在设计时应该考虑的。可以尝试直接把view matrix公共成员,或者用用一个临时变量保存view matrix,然后再接下来的方法中直接引用这个值:
- Matrix tempView = camera.viewMatrix;
- Model1.Draw(ref tempView, ………);
- Model2.Draw(ref tempView, ………);
- Model3.Draw(ref tempView, ………);
- .........
Fps Camera:通过当前的位置和旋转角度来定义camera。Translate*()方法把camera沿特定坐标轴移动一段距离,这里的坐标轴可以是世界坐标下的,也可以是摄像机自身坐标空间下的轴。和公共属性X,Y,Z相比,translate实现的是一种累加的移动,类似,x += vale; 而属性则是x = value。Pitch,Roll和Yaw则分别是对坐标轴旋转,同样也可以是世界坐标或者局部坐标。公共属性Rotation是一个Quaternion,表示当前的累积旋转角度。使用Quaternion的优点是在对个条轴进行旋转时没有顺序要求,也不会遇到万向锁的问题。这样的控制方式非常适合于实现FPS游戏中的摄像机。最后,你并不必总要使用Matrix.CreateLookAt()创建view matrix,对于这种类型的camera来说,这样更方便:
- Vector3 camOrigin;
- Quaternion inverseRotation;
- Matrix translateMat;
- Matrix rotationMat;
- Vector3.Negate(ref position,out camOrigin);
- Quaternion.Negate(ref combinedRotation, out inverseRotation);
- Matrix.CreateTranslation(ref camOrigin, out translateMat);
- Matrix.CreateFromQuaternion(ref combinedRotation, out rotationMat);
- Matrix.Multiply(ref translateMat, ref rotationMat, out viewMatrix);
LookAtCamera:通过当前点和观察点的位置定义camera。这是一个非常简单的camera,不过非常适合当作指定路径动画的摄像机。
OrbitCamera:通过中心点,半径,以及2个旋转角度定义的camera,rpg游戏中非常常见的围绕人物中心旋转的摄像机,同时也是三种摄像机里实现起来较为复杂的一个。对于OrbitCamera来说,你需要选择如何实现Translate*()方法,如果translate仅仅改变摄像机的位置,而中心点不变,则显然会让视线不在穿过中心位置,同时,半径,旋转角度也必须重新计算。当然,对OrbitCamera来说,视线不穿过中心位置也是允许的,参考wow中的camera,当摄像机旋转到某些极限角度时,便允许实现不穿过中心位置。另外,还需要考虑当半径为0时的行为。
上面三种camera来说,其实都是可以互换的,你可以用FpsCamera实现一个rpg游戏中的轨道式摄像机,也可以用OrbitCamera实现fps摄像机,或者把某几个组合起来使用。唯一的区别只在于参数的不同以及控制起来的难易程度。毕竟,通过观察点就能对计算旋转角度,反之亦然,就看各位数学的好坏了。
最后的争议是是否应该把3种Camera的控制行为提取到抽象类Camera中,毕竟,三种camera都是可以有Translate*和Roll,Yall,Pitch方法的。目前的实现实际上是屏蔽了某些camera的控制功能。这样做的原因是对于以上三种camera来说,同一种变换应用于特定摄像机的结果差异太大。多态的目的是通过不同的实现,完成类似的任务,统一接口有可能让引擎的使用者对非预期效果非常迷惑。显式分开有助于提醒使用者更清楚每个方法之间是有差别的。目前我更倾向于让使用者编写CameraController达到预期行为,当然,如果某天发现统一接口更方便再做修改也是有可能的。