2.7 创建一个相机飞入效果

问题

你想平滑地将相机从一个位置移动到另一个位置,当然你也让相机的观察目标也能平滑地移动。

具体的的说,你想要一个功能让相机能沿着一条平滑的曲线从开始位置到结束位置。这个移动过程应该是平滑的,在过程结束后,你想让相机回到常规状态,将它作为一个第一人称相机。

解决方案

这个过程需要一个时间变量,当相机在开始位置时这个变量为0,到达结束位置时为1。

通过指定第三个位置作为相机移动的中点,你可以定义一个贝塞尔曲线(Bezier curve)。贝塞尔曲线是一条通过起点和终点的光滑曲线,非常靠近起点和终点之间指定的额外点(例如第三个点)。通过给一个贝塞尔曲线指定一个介于0和1之间的时间变量的当前值,它会返回对应时间的曲线上的位置。

观察目标的位置会在目标起点和终点间进行线性插值。结果是,相机会沿着起点指向终点的光滑曲线移动,同时观察目标也会沿着目标起点和中点间的直线运动。

要使用第一人称相机的代码,你不能简单地只改变相机的位置和观察目标,因为相机是基于目标位置和绕UP和Right轴的旋转来计算观察目标的。所以你需要调整相机的旋转值。

一切准备就绪后,这个过程的平滑初末位置就可以通过使用MathHelper. SmoothStep 方法获取。

注意:你可以使用包含在XNA框架中的Curve类编写复杂相机路径的脚本,但它还不够完善用以处理相机的飞入,因为它缺少了一些细小的功能。例如,这个类不支持3D点,需要你手动设置Curve类的关键点。总之,本教程可以用Curve功能替换的部分就是Bezier方法,如果你想使用XNA框架的Curve功能代替Bezier方法,本教程90%的部分还是有用的。

工作原理

对贝塞尔相机的飞入效果,你需要保存这些变量:

float bezTime = 1.0f;
Vector3 bezStartPosition; 
Vector3 bezMidPosition; 
Vector3 bezEndPosition; 
Vector3 bezStartTarget; 
Vector3 bezEndTarget;

变量bezTime保存飞入过程。当开始一个飞入行为时,将这个变量设为0。当运行时,在更新过程中将它增加到1,这个值超过1表示过程结束。

在飞入过程中,你需要保存一些位置,诸如起点,终点和观察目标,但你还要计算中点获取一个光滑曲线。这些变量都应该在飞入开始时指定。

在下面的代码中,你可以找到触发飞入行为的方法,这个方法需要相机的初始位置和最终位置、观察目标的初始位置和结束位置:

private void InitBezier(Vector3 startPosition, Vector3 startTarget, Vector3 endPosition, Vector3 endTarget)
{
    bezStartPosition = startPosition;
    bezEndPosition = endPosition;

    bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f; 
    Vector3 midShiftDirecton = new Vector3(1, 1, 0)*2;

    Vector3 cameraDirection = endPosition - startPosition; 
    Vector3 perpDirection = Vector3.Cross(upV, cameraDirection); 
    perpDirection.Normalize();

    Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection; 
    bezMidPosition += cameraDirection.Length() * midShiftDirecton;

    bezStartTarget = startTarget; 
    bezEndTarget = endTarget;

    bezTime = 0.0f;
}

startPosition, endPosition, startTarget和endTarget参数可以立即被存储到对应的变量中。

midPosition是插入到开始的和结束点之间的额外点,它需要被计算。你可以通过首先计算起始点和终点的中点,然后取两者的平均值做到。要从曲线偏离一点距离,你需要通过添加一个垂直于这条线上的方向将这个点偏离相机的起点和终点之间的直线。

你可以通过叉乘这两个方法获取垂直于这两个方向的方向。在本教程中,你想让这个方向垂直于指向相机的方向和Up向量。首先计算指向相机的方向,这和获取其他方向的做法一样:通过获取最终位置并减去初始位置。然后叉乘这个方向和Up方向,就可以获取垂直于这两个方向的方向。

如果你将中点移动到这个方向,如果起点和终点靠得很近时就会获得一个漂亮的曲线。但是如果两点间的距离较大,曲线会非常平。你可以通过通过起点和终点间的距离乘以偏移量解决这个问题,这个距离可以通过找到起点和终点间的矢量长度获取。

最后将bezTime变量设置为0,表示开始飞入行为。

初始化飞入之后,你就可以调用UpdateBezier方法在每帧进行更新:

private void UpdateBezier() 
{
    bezTime += 0.01f;
    if (bezTime > 1.0f)
        return;

    Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, bezTime);
    Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, bezTime);

    float updownRot; 
    float leftrightRot;

    AnglesFromDirection(newCamTarget - newCamPos, out updownRot, out leftrightRot);

    fpsCam.UpDownRot = updownRot;
    fpsCam.LeftRightRot = leftrightRot; 
    fpsCam.Position = newCamPos;
}

这个方法首先将bezTime增加一点点。如果这个值大于1,则过程结束返回return。

否则,执行下一行代码。首先,相机的下一个位置通过传递bezTime变量从贝塞尔曲线获取,这个变量表示飞入行为的进度。然后,通过在bezStartTarget和bezEndTarget之间进行插值获取当前目标,可以在教程5-9中学习更多插值的知识。

如果你想和第一人称相机的代码组合在一起,需要计算最终的updownRot和 leftrightRot值,这可以通过调用AnglesFromDirection方法做到。这个方法有三个参数:相机的朝向(如果从目标位置减去相机位置,你就可以获取观察方向)和两个角度。通过作为“out”参数传递这两个角度,这个方法可以改变这两个值,这两个值会被存储到对应的变量中。最后,更新相机的新位置和旋转。

贝塞尔曲线

第一行代码调用Bezier方法,这个方法基于bezTime变量返回曲线上的位置,bezTime变量通常介于0和1之间。这个函数需要三个点定义一条曲线,你已经在InitBezier方法中计算过这些变量了。

对于一条由三个点定义的贝塞尔曲线,你可以使用下列函数计算任意给定时间时的位置:

P(t)=Pstart*(1-t)2+2*Pmid* (1-t)*t+Pfinal*t2

看起来很难,实际上不是。让我们看一下开始t=0时的结果,使用函数 P(0)=Pstart*12+2*Pmid*1*0+Pfinal*02,结果是Pstart!在过程结束时,t=1,使用函数P(1)=Pstart*02+2*Pmid*0*1+Pfinal*12,结果是Pfinal。对所以介于0和1之间的值,结果介于开始位置和结束位置之间,并将这个点稍微拉向中点。在本教程的情况中,t就是bezTime。

下面的代码计算前面的公式:

private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time)
{
    float invTime = 1.0f - time;
    float timePow = (float)Math.Pow(time, 2);
    float invTimePow = (float)Math.Pow(invTime, 2);

    Vector3 result = startPoint * invTimePow; 
    result += 2 * midPoint * time * invTime; 
    result += endPoint * timePow;

    return result; 
}

invTime就是公式中的(1-t),所以invTimePow就是(1-t)2。结果变量就是函数的最终输出结果,这个位置会返回到调用代码。

获取旋转

当你看一下UpdateBezier方法的最后一行代码时,你会发现相机的下一个位置使用了 Bezier 方法进行了计算,而下一个目标使用了简单的插值。

这用于最简单的飞入行为,因为你可以立即从这个位置和观察目标创建一个View矩阵(见教程2-1)。但是当飞入结束后,你想获取相机的运动情况。相机的位置会自动存储,但旋转变量不会。所以你需要获取刚才计算的相机位置和观察目标位置,找到对应的 leftrightRotation和updownRotation,存储这两个变量,这样就可以用于第一人称相机了。而且,第一人称相机还可以获取飞入结束后的相机的信息,不会出现错误。

这就是在AnglesFromDirection方法中进行的操作。这个方法以相机的朝向为参数,计算updownRot和leftrightRot的值。这两个值都作为这个方法的“out”参数,这意味着它们可以返回到调用代码,所以这个方法做出的改变会存储在调用方法的变量中。

private void AnglesFromDirection(Vector3 direction, out float updownAngle, out float leftrightAngle)
{
    Vector3 floorProjection = new Vector3(direction.X, 0, direction.Z); 
    float directionLength = floorProjection.Length();

    updownAngle = (float)Math.Atan2(direction.Y, directionLength); 
    leftrightAngle = -(float)Math.Atan2(direction.X, -direction.Z);
}

教程4-17具体解释了如何对应一个方向获取旋转角度。本教程中,你必须获取两个角度,因为这个方向是在3D空间中的。首先找到leftrightRot角度。图2-5展示的是包含相机和观察目标的XZ平面。虚线是相机的朝向,线段X和Z是这个向量的X和Z分量。在直角三角形中,如果你想获取顶角,你要做的就是计算对边的反正切值,除以邻边。在本教程中,就是X除以Z。Atan2函数让你可以指定两个值而不是它们的商, 这可以避免出现两个结果。 这就是获取leftrightRot角度的方法。

2-5

图2-5 获取leftrightRot角度

要找到updownAngle,你可以使用图2-6。虚线表相机观察的方向。你想获取向上的Y方向和这个方向在XZ平面上的投影之间的角度。所以,将这两个方向传递到Atan2方法中就可以获取updownAngle。

2-6

图2-6 获取updownAngle角度

使用方法

确保在Update方法中调用UpdateBezier方法:

UpdateBezier(); 

现在要开始一个飞越效果,你要做的就是调用InitBezier方法!

if (bezTime > 1.0f)
    InitBezier(new Vector3(0,10,0), new Vector3(0,0,0), new Vector3(0, 0, -20), new Vector3(0, 0, -10));

当你想将飞越效果和相机结合起来时,你可以从相机的位置和观察目标开始:

if (bezTime > 1.0f)
    InitBezier(fpsCam.Position, fpsCam.Position + fpsCam.Forward * 10.0f, new Vector3(0, 0, -20), new Vector3(0, 0, -10));

你可以看到,我将开始的观察目标放置在开始位置前方10个单位,这可以给出一个平滑的结果,否则当跟随目标移动时相机会出现一个急速的移动。

平滑开始和加速

通过一个将bezTime均匀地从0增加到1,相机沿曲线的移动速度是一个常量。这会导致一个很不舒服的开始和结束,你真正想要的是让bezTime首先从0慢慢地增加到0.2,然后很快增加到0.8,在最后的0.8到1的部分又慢慢增加。这可以通过MathHelper. SmoothStep方法实现:给定一个介于0和1之间的持续增加的值,这个方法会返回一个具有光滑开始和结束的介于0和1之间的值!

这一步在UpdateBezier方法中进行,所以使用这个代码替换中间的两行代码:

float smoothValue = MathHelper.SmoothStep(0, 1, bezTime);
Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, smoothValue);
Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, smoothValue);

smoothValue变量保存这个平滑过的介于0和1之间的值,用来代替均匀增加的bezTime变量传递到方法中。

解决飞入过程结束时的不正确的相机旋转

本教程的这部分要解决在某些情况中会出现的问题。这种情况之一显示在图2-7的左图中。开始时,相机看向左方直到两个曲线相交,在这个交点上,相机会突然看向右方。

你可以通过将曲线的中点切换到左边解决这个问题,通过这种方式,你获得了如右图所示的曲线,开始时相机会看向右方而无需切换到左方。

2-7

图2-7 飞入曲线的不正确和正确的情况

你如何知道中点该移向哪个方向?参考一下图2-8的左上图和右上图,它们显示了会导致问题的两种情况。虚线显示了目标路径,实线表示相机的直线路径。

2-8

图2-8 判断如何切换中点

在这两种情况中,中点需要切换到另一个方向。这可以通过叉乘这两个方向进行检测。在这两种情况中,叉乘的结果是垂直于平面的向量,但是一种情况中是向上,另一种情况中是向下。假设向上称为upVector。接下来,如果将这个upVector与相机方向叉乘,你会获得一个垂直于相机方向和upVector的向量。最终向量的方向取决于upVector朝上还是朝下,而朝上还是朝下取决于位置和目标路径相交的情况。因为它垂直于位置路径和upVector,可以用来切换中点。

所以,在InitBezier方法中使用下列代码获取曲线正确的中点:

bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f; 
Vector3 cameraDirection = endPosition - startPosition;
Vector3 targDirection = endTarget - startTarget;
Vector3 upVector = Vector3.Cross(new Vector3(targDirection.X, 0, targDirection.Z), new Vector3(cameraDirection.X, 0, cameraDirection.Z));
Vector3 perpDirection = Vector3.Cross(upVector, cameraDirection); 
perpDirection.Normalize();

upVector可以通过叉乘位置路径和目标路径获取,这两个路径都通过将Y分量设置为0投影到XZ平面上(通过这种方式,你获得了如图所示的直线)。通过将upVector叉乘位置路径获取垂直于upVector的方向。

当你找到这个垂直方向时,你可以将中点切换到这个方向:

Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection; 
bezMidPosition += cameraDirection.Length() * midShiftDirecton;

但是有一种情况无法正确处理,如果targDirection和cameraDirection平行,或upVector和cameraDirection平行,叉乘方法会出现问题,导致perpDirection变量为(0,0,0),当归一化这个矢量时会出错。所以,你要检测这种情况,通过设置一个任意值解决这个问题:

if (perpDirection == new Vector3()) 
    perpDirection = new Vector3(0, 1, 0);

将这个代码放在归一化perpDirection 代码行之前。

代码

下面的方法初始化变量开始一个新的飞入行为:

private void InitBezier(Vector3 startPosition, Vector3 startTarget, Vector3 endPosition, Vector3 endTarget) 
{
    bezStartPosition = startPosition;
    bezEndPosition = endPosition;

    bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f; 
    Vector3 cameraDirection = endPosition - startPosition; 
    Vector3 targDirection = endTarget - startTarget;
    Vector3 upVector = Vector3.Cross(new Vector3(targDirection.X, 0,targDirection.Z), new Vector3(cameraDirection.X, 0, ~ cameraDirection.Z));
    Vector3 perpDirection = Vector3.Cross(upVector, cameraDirection);

    if (perpDirection == new Vector3()) 
        perpDirection = new Vector3(0, 1, 0); 

    perpDirection.Normalize();

    Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection; 
    bezMidPosition += cameraDirection.Length() * midShiftDirecton;

    bezStartTarget = startTarget; 
    bezEndTarget = endTarget;

    bezTime = 0.0f;
}

当运行时,每帧都要调用UpdateBezier方法计算相机的新位置和旋转:

private void UpdateBezier()
{
    bezTime += 0.01f;
    if (bezTime > 1.0f) 
        return;

    float smoothValue = MathHelper.SmoothStep(0, 1, bezTime);
    Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, smoothValue);
    Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, smoothValue);

    float updownRot; 
    float leftrightRot;
    AnglesFromDirection(newCamTarget - newCamPos, out updownRot, out leftrightRot);

    fpsCam.UpDownRot = updownRot;
    fpsCam.LeftRightRot = leftrightRot; 
    fpsCam.Position = newCamPos;
}

位置是由Bezier方法计算的:

private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time)
{
    float invTime = 1.0f - time;
    float timePow = (float)Math.Pow(time, 2);
    float invTimePow = (float)Math.Pow(invTime, 2);

    Vector3 result = startPoint * invTimePow; result += 2 * midPoint * time * invTime; 
    result += endPoint * timePow;

    return result;
}

旋转由下面的方法获取:

private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time)
{
    float invTime = 1.0f - time;
    float timePow = (float)Math.Pow(time, 2);
    float invTimePow = (float)Math.Pow(invTime, 2);

    Vector3 result = startPoint * invTimePow;
    result += 2 * midPoint * time * invTime; 
    result += endPoint * timePow;

    return result; 
}

1