2.3 创建一个第一人称射击游戏(FPS)的相机:Quake风格的相机

问题

你想创建一个类似于第一人称射击游戏的相机。你想使用鼠标旋转相机,使用键盘移动相机。

解决方案

首先使用教程2-2中的方法,只要检测到用户输入就更新相机的位置和旋转。相机的旋转矩阵会根据鼠标的移动进行改变,按Up或Down键让相机前后移动,按Left或Right键左右移动。

工作原理

通常,在FPS游戏中,玩家可以自由地上下左右旋转观察,这些运动对应绕Up和Right轴的旋转,或更具体地说,绕(0,1,0)和(1,0,0)方向的旋转。

但是绕Forward向量的旋转是不被允许的,这会导致玩家向左或向右弯曲脖子,在第一人称游戏中不使用,除非玩家被击中倒在地板上。

在进一步学习之前,先看一下教程4-2中的“矩阵乘法的顺序”一节还是很有帮助的。矩阵的乘法顺序是重要的,因为当你组合两个旋转时,第二个旋转轴会被第一个旋转所改变。

第一个旋转影响第二个轴叫做万向节锁(Gimbal lock),有时这是个大麻烦,但有时就是你想要的结果。在FPS相机的例子中,你很幸运:你是首先沿Up轴旋转,然后绕Right向量旋转。第一个旋转也旋转了Right向量,你可以想象成这样一个情况:你站立伸开手臂,如果身体转向右边,手臂也会跟着一起旋转,现在你可以旋转手臂的方向,对应向上或向下转动。

简单地说,你需要存储两个变量:绕Up方向的旋转量和绕Right方向的旋转量。然后,当需要计算总的旋转量时,你使用“在绕Up旋转之后绕Right旋转”组合两个旋转。知道了总旋转,就可以将它传递到前一个教程的代码中去了。

现在你知道了FPS相机后面的原理,仍需要将用户输入和这两个变量联系起来。这个例子中给定鼠标输入,你要获取一个更新周期之内鼠标位置的改变并对应地改变这两个变量。所以,你需要下列变量:

float leftrightRot;
float updownRot;
Vector3 cameraPosition;
Matrix viewMatrix;
MouseState originalMouseState;

MouseState包含鼠标的位置和一些bits代表鼠标键是否被按下。在Initialize 方法中给这两个变量一个初始值:

leftrightRot = 0.0f;
updownRot = 0.0f;
cameraPosition = new Vector3(1,1,10);
UpdateViewMatrix();
Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);
originalMouseState = Mouse.GetState();

你首先将旋转值设为0,将相机放置在你选择的位置。你等会儿还要创建一个UpdateViewMatrix方法,这里这个方法用来初始化viewMatrix变量。

然后,你将光标设置在屏幕的中心。最后一行代码存储了MouseState,它包含了光标的位置,所以在下一个更新循环中,你可以检测这个状态和新状态的区别,表示鼠标是否移动。

看一下更新过程。如前所述,你首先检查新的MouseState看看它是否与存储在 originalMouseState中的MouseState有所不同。如果不同,旋转值会随之更新,光标会回到屏幕中央。

float rotationSpeed = 0.005f;
MouseState currentMouseState = Mouse.GetState();
if (currentMouseState != originalMouseState)
{
    float xDifference = currentMouseState.X - originalMouseState.X; 
    float yDifference = currentMouseState.Y - originalMouseState.Y; 
    leftrightRot -= rotationSpeed * xDifference;
    updownRot -= rotationSpeed * yDifference; 
    Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);
}
UpdateViewMatrix();

rotationSpeed变量定义鼠标的旋转速度。你获取鼠标坐标当前位置和前面位置的水平距离和竖直距离,并对应地调整绕Right和Up向量的旋转。

最后一行代码将光标重新放置到窗口中央。

注意:另一个方法是将currentMouseState保存到originalMouseState中,这样它就做好了与下一个更新循环中的新的currentMouseState作比较的准备。但是,如果光标移动到屏幕的边缘时这个方法会变得无用。例如,如果光标位于屏幕的右边缘,当用户将光标在向右移动时X坐标也不会不同,解决的方法是将光标重新定位到屏幕的中央。

最后调用UpdateViewMatrix方法,它会根据新的旋转值更新viewMatrix变量。下面是UpdateViewMatrix方法的代码,除了第一行代码,其余代码在前面的教程中都已经详细解释过了。基于一个存储相机旋转和位置的矩阵,这个方法计算Target和Up 向量,它们用来创建View矩阵:

Matrix cameraRotation = Matrix.CreateRotationX(updownRot) * Matrix.CreateRotationY(leftrightRot);

Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); 
Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
Vector3 cameraFinalUpVector = cameraPosition + cameraRotatedUpVector;

viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraFinalUpVector);

第一行代码需要一些解释:这里的Right向量是(1,0,0),所以它指向x轴,这就是为什么使用CreateRotationX方法创建这个旋转的原因。这里的Up向量是(0,1,0),所以你使用CreateRotationY方法创建左右旋转。乘法的顺序:M1*M2表示“M1位于M2之后”,这里就是“上/下旋转在左/右旋转之后”。更多有关矩阵乘法顺序的知识,可参见教程4-2。

这会让相机随鼠标的移动发生旋转。然后,当按下Forward键时你想让相机向前移动。在更新过程中,你首先检测按键情况,并由此指定将哪个向量添加到相机位置中:

KeyboardState keyState = Keyboard.GetState();
if (keyState.IsKeyDown(Keys.Up))
   AddToCameraPosition(new Vector3(0, 0, -1)); 
if (keyState.IsKeyDown(Keys.Down))
    AddToCameraPosition(new Vector3(0, 0, 1)); 
if (keyState.IsKeyDown(Keys.Right))
    AddToCameraPosition(new Vector3(1, 0, 0)); 
if (keyState.IsKeyDown(Keys.Left))
    AddToCameraPosition(new Vector3(-1, 0, 0));

按下Up键导致将Forward向量添加到相机位置中,按下Down键导致从相机位置减去Forward向量等。但是,在将这些变量添加到相机位置之前,你仍需要通过相机旋转量旋转它们。更多的信息可参见前一个教程。这是由AddToCameraPosition方法处理的:

private void AddToCameraPosition(Vector3 vectorToAdd)
{
    float moveSpeed = 0.5f;
    Matrix cameraRotation = Matrix.CreateRotationX(updownRot) * Matrix.CreateRotationY(leftrightRot);
    Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation); 
    cameraPosition += moveSpeed * rotatedVector;
    UpdateViewMatrix();
}

首先,你计算相机旋转矩阵,如UpdateViewMatrix方法中所示。然后,根据这个旋转矩阵旋转指定向量,这个旋转过的向量会乘以一个变量,这个变量用来定义相机移动的速度,相机的Position发生改变。最后调用UpdateViewMatrix方法,这个方法根据新的相机位置创建一个新View矩阵。

代码

要创建一个第一人称相机,你需要保存四个变量:绕Up轴和Right轴的旋转量,相机位置和对应窗口中心的光标的MouseState。在更新过程中检测按键或鼠标移动,这会触发这些变量的改变,基于这些变量创建一个新View矩阵。

protected override void Initialize() 
{
    base.Initialize();

    float viewAngle = MathHelper.PiOver4;
    float aspectRatio = (float)this.Window.ClientBounds.Width / (float)this.Window.ClientBounds.Height;
    float nearPlane = 0.5f;
    float farPlane = 100.0f;

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

    leftrightRot = 0.0f;
    updownRot = 0.0f;
    cameraPosition = new Vector3(1,1,10);
    UpdateViewMatrix();
    Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);
    originalMouseState = Mouse.GetState();
}protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
        this.Exit();

    float rotationSpeed = 0.005f;
    MouseState currentMouseState = Mouse.GetState();
    if (currentMouseState != originalMouseState) 
    {
        float xDifference = currentMouseState.X - originalMouseState.X;
        float yDifference = currentMouseState.Y - originalMouseState.Y;
        leftrightRot -= rotationSpeed * xDifference; 
        updownRot -= rotationSpeed * yDifference; 
        Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);

        UpdateViewMatrix(); 
    }
    KeyboardState keyState = Keyboard.GetState();
    if (keyState.IsKeyDown(Keys.Up))
        AddToCameraPosition(new Vector3(0, 0, -1));
    if (keyState.IsKeyDown(Keys.Down)) 
        AddToCameraPosition(new Vector3(0, 0, 1));
    if (keyState.IsKeyDown(Keys.Right)) 
        AddToCameraPosition(new Vector3(1, 0, 0));
    if (keyState.IsKeyDown(Keys.Left)) 
        AddToCameraPosition(new Vector3(-1, 0, 0));
    base.Update(gameTime);
}private void AddToCameraPosition(Vector3 vectorToAdd)
{
    float moveSpeed = 0.5f;
    Matrix cameraRotation = Matrix.CreateRotationX(updownRot) Matrix.CreateRotationY(leftrightRot);
    Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation);
    cameraPosition += moveSpeed * rotatedVector;
    UpdateViewMatrix();
}private void UpdateViewMatrix()
{
    Matrix cameraRotation = Matrix.CreateRotationX(updownRot) Matrix.CreateRotationY(leftrightRot);

    Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); 
    Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

    Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); 
    Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

    Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation); 
    Vector3 cameraFinalUpVector = cameraPosition + cameraRotatedUpVector;

    viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector);
}

QuakeCamera类

因为第一人称相机会频繁使用,我将本章的代码分离出来写入一个类中。本书中的很多例子都用到了这个类,而且它也很容易整合到你的项目中去。