2.4 创建一个Freelancer风格的相机:使用四元数的3D旋转

问题

译者注:《Freelancer》应该是指Digital Anvil制作,微软发行的游戏,中文译名《自由枪骑兵》,好像曾获2003年E3大展最佳PC游戏奖,是一个太空冒险游戏。

你想创建一个可以朝任意方向旋转的相机,例如,一个空战游戏。你需要能绕着三根轴旋转才能实现这点,但是因为万向节锁(Gimbal lock)(一个旋转会对影响另一个旋转)的缘故,这非常难,但并不是不可能。

解决方案

绕着多个轴组合一些旋转会导致万向节锁的发生,这会导致不正确的结果。使用四元数(quaternion)存储相机的旋转可以避免这个问题。

工作原理

当你组合绕着两个不同轴的旋转时,会发生万向节锁。发生的原因是第一个旋转会旋转第二个轴,解释可参见教程4-2。在这个教程中,你想让相机绕着x,y和z轴旋转。这会导致第二个轴被第一个旋转影响,第三个轴被前两个旋转的组合影响,所以,最后的结果难以预料。

旋转的一个特点是总存在一个旋转,这个单一旋转是多个旋转的组合。所以,这里需要的技巧是只定义一个旋转轴。这可以是任意轴:不一定是x, y或z轴。这根轴和旋转量被存储到一个变量中,叫做四元数 (quaternion)。

四元数可以很好的储存一个旋转,因为它可以存储一个轴和一个数,这个数保存绕轴旋转的角度。

下一个问题是,如何计算这个轴?答案是你不需要计算这个轴。过程中会自动为你创建。你只需指定一个初始轴,当用户移动鼠标时更新这个轴,你会在下面的例子中看到。

比方说将相机的Up轴作为旋转轴,旋转角度为0,这样你将看向前方。然后,根据用户输入,你想让相机绕着Right向量旋转。Right向量首先用当前相机的旋转值进行旋转,但因为是0度,所以Right向量保持不变。这个Right向量成为当前的旋转轴,相机将绕着这个轴旋转,会向上看。

如果接下来用户想让相机绕着Forward旋转。这根轴首先绕着当前相机的旋转轴旋转,此时已经不为0了而是包含绕Right轴的旋转。组合这两个旋转,旋转轴和旋转量存储到了相机四元数中。现在这个旋转成为了当前相机的旋转。

对用户的每个输入,发生同样的过程:Forward/Right/Up/ Down/. . .向量被相机的当前旋转轴旋转,这个新旋转和当前相机的旋转组合起来在四元数中储存为一个新的相机旋转,之后相机就绕着这个新轴旋转。

幸运的是,整个过程可以转化为一些简单的代码。首先,你只允许绕着一根轴旋转 (绕着Right轴):

float updownRotation = 0.0f;
KeyboardState keys = Keyboard.GetState(); 
if (keys.IsKeyDown(Keys.Up))
    updownRotation = 1.5f;
if (keys.IsKeyDown(Keys.Down))
    updownRotation = -1.5f;

Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation); 
cameraRotation = cameraRotation * additionalRotation;

首先,用户输入决定相机向上还是向下旋转。然后,创建一个新的四元数保存这个轴和旋转量。最后,这个新旋转和当前相机的旋转组合在一起,将结果储存为一个新的相机旋转。

注意:四元数(quaternion)这个词通常会让程序员望而生畏,我也看到过很多文章和网站将四元数看成黑暗魔法,很难被形象理解。但这不是真的!一个四元数用来存储一个绕一根轴的旋转,所以它需要能够存储这个轴和旋转角度。轴由三个数字定义,旋转角度需要一个。所以,如果你存储了这样一个旋转,最简单的方法是存储四个数字。猜一下一个四元数实际上是什么?很简单,就是四个数字。没什么神秘的。但用到的数学知识非常复杂。

要让相机可以任意旋转,你需要能够绕第二根轴旋转。这个旋转需要存储在additionalRotation变量中,要乘以绕着第二根轴的旋转和第一根轴的旋转的组合:

float updownRotation = 0.0f; 
float leftrightRotation = 0.0f; 
KeyboardState keys = Keyboard.GetState();

if (keys.IsKeyDown(Keys.Up))
    updownRotation = 0.05f;
if (keys.IsKeyDown(Keys.Down))
    updownRotation = -0.05f;
if (keys.IsKeyDown(Keys.Right))
    leftrightRotation = -0.05f; 
if (keys.IsKeyDown(Keys.Left))
    leftrightRotation = 0.05f;
Quaternion additionalRotation =Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation)* Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), leftrightRotation); 
cameraRotation = cameraRotation * additionalRotation;

注意:使用矩阵乘法时,乘以四元数的顺序很重要。要理解为什么这么重要,可参见教程4-2中的例子。但是对四元数来说,当两个旋转轴相互垂直时,规则有个例外,如在计算 additionalRotation 变量的一行代码中的情况。这意味着无论是先绕(1,0,0) Right向量旋转还是绕(0,1,0) Up向量旋转,或其他旋转,结果都是一样的。但这不是计算cameraRotation变量的情况,因为两个旋转轴并不互相垂直。所以,你写成cameraRotation*additionalRotatio是很重要的。

注意:在矩阵乘法中,“*”的意思是“在……之后”,但在四元数乘法中表示“之前”。所以,代码cameraRotation = cameraRotation * additionalRotation就是cameraRotation在additionalRotation之前, 意思是存储在additionalRotatio变量中的轴会首先绕着存储在cameraRotation 中的轴旋转。

找到了旋转矩阵,相机的View矩阵就可以创建了,可参见教程2-2:

private void UpdateViewMatrix()
{
    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);

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

例如,当用户按下箭头键时,移动相机可以通过首先通过相机旋转量旋转移动向量,然后将这个变换添加到当前相机位置上实现:

float moveSpeed = 0.5f;
Vector3 rotatedVector =Vector3.Transform(vectorToAdd, cameraRotation); 
cameraPosition += moveSpeed * rotatedVector; 
UpdateViewMatrix();
代码

因为这种相机通常使用在一个太空游戏中,如果你想创建一个太空射击游戏而且3D世界需要从飞船中的飞行员观察时,这个代码可以立即被使用。用户可以通过键盘或鼠标控制相机的旋转,而相机会自动向前移动。键盘的例子已经给出了,你可以在教程2-3中找到鼠标控制的代码。

要实现基于四元数的相机,只需要两个变量:相机的当前位置和旋转。

protected override void Initialize() 
{
    cCross = new CoordCross();

    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);

    cameraPosition = new Vector3(-1, 1, 10); 
    cameraRotation = Quaternion.Identity; 
    UpdateViewMatrix();
}

protected override void Update(GameTime gameTime) 
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back ButtonState.Pressed)
        this.Exit();

    float updownRotation = 0.0f; 
    float leftrightRotation = 0.0f; 
    KeyboardState keys = Keyboard.GetState();

    if (keys.IsKeyDown(Keys.Up))
        updownRotation = 0.05f;
    if (keys.IsKeyDown(Keys.Down))
        updownRotation = -0.05f;
    if (keys.IsKeyDown(Keys.Right))
        leftrightRotation = -0.05f; 
    if (keys.IsKeyDown(Keys.Left))
        leftrightRotation = 0.05f;

    Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation) * Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), leftrightRotation);
    cameraRotation = cameraRotation * additionalRotation;

    AddToCameraPosition(new Vector3(0, 0, -1));

    base.Update(gameTime); 
}

private void AddToCameraPosition(Vector3 vectorToAdd) 
{
    float moveSpeed = 0.05f;
    Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation);
    cameraPosition += moveSpeed * rotatedVector;
    UpdateViewMatrix();
}

private void UpdateViewMatrix()
{
    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);
    viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector);
}
扩展阅读
跟随一个物体的相机

你可以使用前面的代码创建一个太空游戏,这个游戏中的相机放置在飞船的座舱中。但是,如果你想把相机放置在飞船之后,那么需要其他代码。在这种情况中,你要根据飞船而不是相机存储位置和旋转。相机的位置是基于飞船的位置计算的。对飞船来说,你可以使用前面的代码,这可以让飞船绕任意方向旋转并向前飞行,你想让相机位于飞船之后。记住,要定义相机的View矩阵,需要三个向量:相机的Position,Target和Up向量,你可以参见教程2-1学习相关知识。如果你想将一个相机放置在飞船后面,你需要将相机看着飞船。所以,你已经知道了View矩阵的Target向量。相机的Up方法与飞船相同,这个向量可以取默认的(0,1,0)Up向量,使用飞船的旋转量绕它旋转。要解释的最后一个向量是相机的Position。你想让相机位于飞船后面,因为(0,0,-1)向量通常作为Forward向量,(0,0,1)表示Behind向量。首先,这个向量需要使用飞船的旋转量进行旋转,这样Behind向量变成了相对于飞船的Behind向量。然后,你需要将这个向量添加到飞船的位置中,这样当飞船的位置改变时相机的位置也会发生改变。下面是代码:

protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ~ ButtonState.Pressed)
        this.Exit();

    float updownRotation = 0.0f; 
    float leftrightRotation = 0.0f; 
    KeyboardState keys = Keyboard.GetState();

    if (keys.IsKeyDown(Keys.Up))
        updownRotation = 0.05f;
    if (keys.IsKeyDown(Keys.Down))
        updownRotation = -0.05f;
    if (keys.IsKeyDown(Keys.Right))
        leftrightRotation = -0.05f; 
    if (keys.IsKeyDown(Keys.Left))
        leftrightRotation = 0.05f;

    Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation) * Quaternion.CreateFromAxisAngle(new ~ Vector3(0, 1, 0), leftrightRotation);
    spacecraftRotation = spacecraftRotation * additionalRotation;

    AddToSpacecraftPosition(new Vector3(0, 0, -1));

    base.Update(gameTime); 
}

private void AddToSpacecraftPosition(Vector3 vectorToAdd)
{
    float moveSpeed = 0.05f;
    Vector3 rotatedVector = Vector3.Transform(vectorToAdd, spacecraftRotation); 
    spacecraftPosition += moveSpeed * rotatedVector;
    UpdateViewMatrix();
}

在UpdateViewMatrix方法中,相机位置作为spacecraftPosition的后面被计算:

private void UpdateViewMatrix()
{
    Vector3 cameraOriginalPosition = new Vector3(0, 0, 1);
    Vector3 cameraRotatedPosition = Vector3.Transform(cameraOriginalPosition, spacecraftRotation);
    Vector3 cameraFinalPosition = spacecraftPosition + cameraRotatedPosition;

    Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); 
    Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, spacecraftRotation);

    viewMatrix = Matrix.CreateLookAt(cameraFinalPosition, spacecraftPosition, cameraRotatedUpVector); 
}

在Draw过程中,你需要根据当前位置和旋转量平移和旋转飞船:

Matrix worldMatrix = Matrix.CreateScale(0.001f, 0.001f, 0.001f)*Matrix.CreateFromQuaternion(spacecraftRotation)* Matrix.CreateTranslation(spacecraftPosition); 
spacecraftModel.CopyAbsoluteBoneTransformsTo(modelTransforms); 
foreach (ModelMesh mesh in spacecraftModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.EnableDefaultLighting();
        effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; 
        effect.View = viewMatrix;
        effect.Projection = projectionMatrix;
    }
    mesh.Draw();
}

注意看模型的World矩阵是如何取决于存储在四元数的飞船旋转和位置的。你可以在第4章学习通过设置World矩阵在3D空间绘制和放置一个物体。