2.1 创建一个相机:Position,Target和View Frustum

问题

在将3D世界绘制到屏幕之前,你需要相机的View和Projection矩阵。

解决方案

你可以在一个矩阵中保存相机位置和方向,这个矩阵叫做View矩阵(视矩阵,观察矩阵)。要创建View矩阵,XNA需要知道相机的Position,Target和Up矢量。

你也可以保存视锥体(view frustum),它是3D世界中实际可见的部分,在另一个叫做Projection的矩阵中。

工作原理

View矩阵保存相机位置和观察方向的定义。你可以通过调用Matrix. CreateLookAt方法创建这个矩阵:

viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector); 

这个方法需要三个参数:相机的Position, Target和Up向量。Position向量容易理解,它表示在3D空间中的哪个位置放置相机。然后,需要指定另一个点表示相机观察的目标。这已经可以定义一个相机了,但Up向量用来干什么?

看一下这个例子:你的头(事实上是你的眼睛)就是相机。你试着定义一个与头有相同位置和方向的相机。第一个向量很容易找到:Position向量就是头在3D场景中的位置。然后, Target向量也不是很难;假设你看着图2-1中的X,在这种情况中,X的位置就是相机的Target 向量。但有其他方式可以让在同一位置的头看着X!

1

图2-1 相机的观察目标

只定义了Position和Target向量 ,你也可以绕着双眼之间的点旋转头部,例如,上下颠倒看。如果你这样做,头部的位置和观察目标仍保持不变,但因为所有东西都进行了旋转,观察到的图像会完全不同。你就是为什么需要定义相机的Up向量的原因。

知道了相机的位置,观察目标和相机的up方向,相机就唯一确定了。View矩阵由这三个向量决定,可以使用Matrix. CreateLookAt方法创建一个相机:

Matrix viewMatrix; 
Vector3 camPosition = new Vector3(10, 0, 0); 
Vector3 camTarget = new Vector3(0, 0, 0); 
Vector3 camUpVector = new Vector3(0, 1, 0); 
viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector); 

注意:相机的Position和Target向量指向3D空间中的真实位置,Up向量表示相机向上方向。例如,一个相机位于点(300,0,0)观察点(200,0,0)。如果相机的Up向量只是简单地向上,只需指定(0,1,0)Up向量,这不是指在3D空间中的点,这个例子中这个3D点为(300,1,0)。

注意:XNA为最常用的向量提供了一个快捷方式,Vector3. Up表示(0,1,0),Vector3. Forward表示(0,0,-1),Vector3. Right表示(1,0,0)。为了帮你理解3D向量,本章第一个教程都使用完整的写法。

XNA还需要Projection矩阵。你可以将这个矩阵看成可以映射从3D空间到2D窗口的所有点的一个东西,但我更希望你把它看成包含相机镜头信息的矩阵。

让我们看一下图2-2,左图显示了一个在相机视野中的3D场景,你可以看到它像一个金字塔。右图中你可以看到金字塔的一个2D切面。

2

图2-2 相机的视锥体

图片左边的切除顶部的金字塔叫做视锥体(view frustum)。只有在视锥体内部的物体才会被绘制到屏幕上。

XNA可以为你创建这样一个视锥体,它存储在Projection矩阵中。你可以调用Matrix. CreatePerspectiveFieldOfView创建这个矩阵:

projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane);

Matrix. CreatePerspectiveFieldOfView方法的第一个参数是观察角度。它对应金字塔顶角的一半,如图2-2右图所示。如果你想知道自己的观察角度,可以将手放在眼睛前面,你会发现这个角度约为90度。因为弧度PI等于180度,90等于PI/2。因为你需要指定观察角度的一半,所以这个参数为PI/4。

注意:通常你想使用一个对应人的视角的视角,但是在某些场景中你可能会指定其他的视角。通常发生在你想将场景绘制到一张纹理的情况中,例如,从光线的视角看来。在光线的情况中,更大的视角表示更大的光照范围。对应的例子可参见教程3-13。

你需要指定的另一个参数与“source,”无关,即与视锥体无关,而和“destination,”有关,即与屏幕有关。它是2D屏幕的长宽比,它实际上对应后备缓冲的长宽比,你可以使用以下代码获取它:

float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; 

当使用一个长和宽相同的正方形窗口时,这个比为1。但是绘制全屏800 × 600窗口时这个比大于1,当绘制到宽屏笔记本或HDTV更大。如果你用错误地用1代替800/600作为800*600窗口的长宽比,图像会水平拉伸。

最后两个参数与视锥体有关。想象一下一个物体非常靠近相机,这个物体会占据整个视野,窗口会被一个单独的颜色占满。要避免这个情况的发生,XNA让你可以定义一个靠近金字塔顶部的平面。位于金字塔顶部和这个平面之间的物体不会被绘制,这个平面叫做近裁平面(near clipping plane),你可以指定相机到这个近裁平面的距离作为CreatePerspectiveFieldOfView方法的第三个参数。

注意:剪裁过程用来表示有些物体无需被绘制以提高程序帧频率。

同理也可以处理离相机非常远的物体;这些物体看起来很小,但仍占用显卡的处理时间。所以,远于第二个平面的物体也会被剪裁。第二个平面叫做远裁平面(far clipping plane),它是视锥体的最远边界。你可以指定相机到这个远裁平面的距离作为CreatePerspectiveFieldOfView方法的最后一个参数。

当心:即使绘制的是一个及其简单的3D场景,也不要把远裁平面设置地过大。例如将远裁平面的距离设置为比较疯狂的100000会导致一些视觉错误。带有16-bit深度缓冲的显卡(可参见本教程的“Z-Buffer (或Depth Buffer)”一节)有2^16 = 65535个深度值。如果两个物体使用同一个像素,而且之间的距离小于100k/65535 = 1.53个单位时,显卡就无法判断哪个物体更加靠近相机。

事实上,这会导致更坏的结果,因为scale is quadratic(?),会导致整个场景的最后三个 quarters(?)看起来离开相机的距离相同。近裁平面和远裁平面间的距离最好小于几百,如果显卡的深度缓冲小于16-bit,这个距离应该更小。

这个问题的典型错误就是你看到的所有对象都有锯齿边缘。

使用方法

你想在程序的更新过程中更新View矩阵,因为相机的位置和方向是基于用户输入的。Projection矩阵只需在窗口的长宽比发生变化时才需要更新,例如,当将窗口切换到全屏模式时。

计算好View和Projection矩阵之后,你需要将它们传递到绘制物体的effect中,可在下面的Draw方法中看到对应代码。这可以让显卡上的shader将所有的顶点转换为窗口的对应像素。

代码

下面的例子显示了如何创建一个View矩阵和一个Projection矩阵。比方说你有一个物体位于(0,0,0),你想将相机放置在x轴上+10个单位的地方,正y轴作为Up向量。 而且,你想在800 × 600窗口中绘制场景,使所有与相机的距离小于0.5f大于100.0f的三角形被剪裁。下面是代码:

using System; 
using System.Collections.Generic; 
using Microsoft.Xna.Framework; 
using Microsoft.Xna.Framework.Audio; 
using Microsoft.Xna.Framework.Content; 
using Microsoft.Xna.Framework.Graphics; 
using Microsoft.Xna.Framework.Input; 
using Microsoft.Xna.Framework.Storage; 

namespace BookCode 
{
    public class Game1 : Microsoft.Xna.Framework.Game 
    { 
        GraphicsDeviceManager graphics; 
        ContentManager content; 
        BasicEffect basicEffect; 
        GraphicsDevice device; 
        
        CoordCross cCross; 
        Matrix viewMatrix; 
        Matrix projectionMatrix; 
        
        public Game1() 
        {
            graphics = new GraphicsDeviceManager(this); 
            content = new ContentManager(Services); 
        } 

只有在窗口的长宽比发生变化时才需要更新Projection矩阵。你只需要定义一次Projection矩阵,所以放在程序的初始化过程中。

protected override void Initialize() 
{
    base.Initialize(); 
    float viewAngle = MathHelper.PiOver4; 
    float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; 
    float nearPlane = 0.5f; 
    float farPlane = 100.0f; 
    
    projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane,farPlane); 
}

protected override void LoadContent() 
{
    device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null); 
    
    cCross = new CoordCross(device); 
}

protected override void UnLoadContent() 
{
} 

你需要改变View矩阵让用户输入移动相机,因此将它放在程序的更新过程中。

protected override void Update(GameTime gameTime) 
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) 
        this.Exit(); 
    
    Vector3 camPosition = new Vector3(10, 10, -10); 
    Vector3 camTarget = new Vector3(0, 0, 0); 
    Vector3 camUpVector = new Vector3(0, 1, 0); 
    viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector); 
    
    base.Update(gameTime); 
} 

然后将Projection和View矩阵传递到Effect绘制场景:

protected override void Draw(GameTime gameTime) 
{ 
    graphics.GraphicsDevice.Clear(Color.CornflowerBlue); 
    
    basicEffect.World = Matrix.Identity; 
    basicEffect.View = viewMatrix;
    basicEffect.Projection = projectionMatrix;
    
    basicEffect.Begin(); 
    foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) 
    {
        pass.Begin(); 
        cCross.DrawUsingPresetEffect(); 
        pass.End(); 
    }
    basicEffect.End(); 
    
    base.Draw(gameTime); 
} 
扩展阅读

只需要用到前面两个矩阵XNA就可以将3D场景绘制到2D屏幕中。从3D转换到2D是个挑战,但XNA已经帮你做到了。但是,要创建和调试更大的3D程序需要深入理解这个操作背后到底发生了什么事。 Z-Buffer (或Depth Buffer) 第一个挑战是指定哪个物体会占据最终图像上的像素。当从3D空间转换到2D屏幕时,有可能多个物体都显示在同一个像素上,如图2-3所示。2D屏幕上的一个像素对应3D空间中的一条射线,解释可参见教程4-14。对一个像素,这条射线如图2-3中的虚线所示,它与两个物体相交。这种情况中,这个像素的颜色会取自物体A,因为A比B更靠近相机。

3

图2-3 多个物体占据同一个像素

但是,如果首先绘制物体B,frame buffer中对应像素会首先被指定为B的颜色。然后物体A被绘制,显卡需要判断像素是否需要用物体A的颜色覆盖。

解决方法是,在显卡中还储存了第二张图像,它的大小与窗口大小一样。当给frame buffer中的一个像素指定一个颜色时,这个物体和相机间的距离会保存在第二个图像中。这个距离介于0和1之间,0对应近裁平面与相机间的距离,1对应远裁平面与相机间的距离。所以第二个图像叫做depth buffer或z-buffer。

那么如何解决这个问题?当绘制物体B时会检查z-buffe,因为B首先绘制,z-buffer是空的。结果是frame buffer中对应像素的颜色就是B的颜色,在z-buffer的相同像素中获得一个值,对应B物体与相机间的距离。

然后绘制物体A,对应物体A的每个像素,首先检查z-buffer。z-buffer已经包含了物体B的值,但储存在z-buffer中的距离大于物体A与相机间的距离,所以显卡知道需要用物体A的颜色覆盖这个像素!