Cinemachine 无疑是 Unity 中最好用的一个插件之一,其提供的许多选项可以帮助开发者在不写一行代码的情况下实现非常优质的镜头效果。CinemachineConfiner 作为 Cinemachine 的一个插件,可以允许开发者使用一个边界来限制摄像机的位置。而在 2D 模式下使用正交摄像头时,CinemachineConfiner 更可以直接限制摄像机视角的范围,非常有用。但问题就来了,Unity 技术人员在编写 CinemachineConfiner 时可能没有考虑到摄像机的视角大于边界的情况,所以在这种情况下视角的约束行为变得非常迷惑。于是接下来的文章我讲先解读一下 2D 模式下 CinemachineConfiner 是如何约束视角范围的,然后针对错误的约束行为进行修复。

对于没有时间了解细节的朋友,我对视角的修复 PR 已经被 Unity Cinemachine 的维护人员接收,将在接下来的几个版本内被合并到主分支中。不着急的朋友可能等待下一个版本的更新,着急的朋友可以查看我的提交。

解读 CinemachineConfiner

PostPipelineStageCallback

首先 CinemachineConfiner 是继承于 CinemachineExtension 的,需要实现一个 PostPipelineStageCallback 方法。

1
protected abstract void PostPipelineStageCallback(CinemachineVirtualCameraBase vcam, CinemachineCore.Stage stage, ref CameraState state, float deltaTime)

这个方法将会在虚拟摄像机( Virtual Camera )在管道中成功实现了每个舞台( stage )的时候调用。我们可以先看看 CinemahineConfiner 中的该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// <summary>Callback to to the camera confining</summary>
protected override void PostPipelineStageCallback(
    CinemachineVirtualCameraBase vcam,
    CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
{
    if (IsValid)
    {
        // Move the body before the Aim is calculated
        if (stage == CinemachineCore.Stage.Body)
        {
            Vector3 displacement;
            if (m_ConfineScreenEdges && state.Lens.Orthographic)
                displacement = ConfineScreenEdges(vcam, ref state);
            else
                displacement = ConfinePoint(state.CorrectedPosition);

            VcamExtraState extra = GetExtraState<VcamExtraState>(vcam);
            if (m_Damping > 0 && deltaTime >= 0 && VirtualCamera.PreviousStateIsValid)
            {
                Vector3 delta = displacement - extra.m_previousDisplacement;
                delta = Damper.Damp(delta, m_Damping, deltaTime);
                displacement = extra.m_previousDisplacement + delta;
            }
            extra.m_previousDisplacement = displacement;
            state.PositionCorrection += displacement;
            extra.confinerDisplacement = displacement.magnitude;
        }
    }
}

前面两个 if 语句中,第一个是为了检测该拓展是否有正确的输入,例如是否输入了一个边界。第二个是为了让约束在整个流程的第二个 Stage : Body 舞台被计算,也就是在 Aim Stage 前被计算。Body Stage 是为了将摄像机摆放在空间中正确的位置,而 Aim Stage 是为了将摄像机朝向目标。接下来就是约束行为的计算了。

首先我们创建了一个 displacement ,这个变量的作用是记录摄像机的目前位置与约束后的位置之间的差距。也就是说摄像机当前的位置加上 displacement 就是约束后的位置。然后我们分情况处理,对于 2D 下的正交摄像机,我们约束的是摄像机的视角范围,而对于其他情况我们约束的是摄像机的位置。ConfineScreenEdgesConfinePoint 方法都是返回一个 displacement ,告诉我们摄像机的位置与目标位置差多少。我们暂时不管这两个方法的实现,在下文将会讲解。

接下来是对 Damping 的处理,我们也暂时不管,最后我们把摄像机的当前位置 state.PositionCorrection 加上 displacement 返回,就是约束后的摄像机位置。

ConfinePoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private Vector3 ConfinePoint(Vector3 camPos)
{
#if CINEMACHINE_PHYSICS
    // 3D version
    #if CINEMACHINE_PHYSICS_2D
        if (m_ConfineMode == Mode.Confine3D)
    #endif
    return m_BoundingVolume.ClosestPoint(camPos) - camPos;
#endif

#if CINEMACHINE_PHYSICS_2D
    // 2D version
    Vector2 p = camPos;
    Vector2 closest = p;
    if (m_BoundingShape2D.OverlapPoint(camPos))
        return Vector3.zero;
    // Find the nearest point on the shape's boundary
    if (!ValidatePathCache())
        return Vector3.zero;

    float bestDistance = float.MaxValue;
    for (int i = 0; i < m_pathCache.Count; ++i)
    {
        int numPoints = m_pathCache[i].Count;
        if (numPoints > 0)
        {
            Vector2 v0 = m_BoundingShape2D.transform.TransformPoint(m_pathCache[i][numPoints-1]);
            for (int j = 0; j < numPoints; ++j)
            {
                Vector2 v = m_BoundingShape2D.transform.TransformPoint(m_pathCache[i][j]);
                Vector2 c = Vector2.Lerp(v0, v, p.ClosestPointOnSegment(v0, v));
                float d = Vector2.SqrMagnitude(p - c);
                if (d < bestDistance)
                {
                    bestDistance = d;
                    closest = c;
                }    
                v0 = v;
            }
        }
    }
    return closest - p;
#endif
}

这个函数的主要功能就是范围点 camPosCollider 的最小距离 ( Vector3 )。

当在 3D 模式下时,使用 ClosestPoint(Vector3) 直接返回 Collider 与摄像机位置 camPos 最近的点离 camPos 的距离。

当在 2D 模式下时,先使用 OverlapPoint 检测 camPos 是否在 Collider2D 上,若在上面则返回 Vector3.zero。若没有在 Collider2D 上,则先检测缓存是否有效,若无效则返回 Vector3.zero。若有效,则通过 Lerp 函数插值 Collider2D 的每两个节点,在一定精度下找到离 camPos 最近的节点,然后返回这个 camPosCollider 的最小距离( Vector3 )。

ConfineScreenEdges

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Camera must be orthographic
private Vector3 ConfineScreenEdges(CinemachineVirtualCameraBase vcam, ref CameraState state)
{
    Quaternion rot = Quaternion.Inverse(state.CorrectedOrientation);
    float dy = state.Lens.OrthographicSize;
    float dx = dy * state.Lens.Aspect;
    Vector3 vx = (rot * Vector3.right) * dx;
    Vector3 vy = (rot * Vector3.up) * dy;

    Vector3 displacement = Vector3.zero;
    Vector3 camPos = state.CorrectedPosition;
    const int kMaxIter = 12;
    for (int i = 0; i < kMaxIter; ++i)
    {
        Vector3 d = ConfinePoint((camPos - vy) - vx);
        if (d.AlmostZero())
            d = ConfinePoint((camPos - vy) + vx);
        if (d.AlmostZero())
            d = ConfinePoint((camPos + vy) - vx);
        if (d.AlmostZero())
            d = ConfinePoint((camPos + vy) + vx);
        if (d.AlmostZero())
            break;
        displacement += d;
        camPos += d;
    }
    return displacement;
}

这个方法是在返回为了使视角约束在范围内,摄像机的位置的偏移量。基本理论就是对摄像机边界的 4 个点做 ConfinePoint,每次结果累加的到最终偏移量。

函数首先计算视角 4 个点的位置,vx 是 4 个点对摄像机位置的水平偏移,vy 是 4 个点对摄像机位置的垂直偏移。

所以摄像机的四个点就可以算出来了:

  • 左下点:(camPos - vy) - vx
  • 右下点:(camPos - vy) + vx
  • 左上点:(camPos + vy) - vx
  • 右上点:(camPos + vy) + vx

然后按照上述列表的顺序,对每个点做 ConfinePoint,若得到的 dVector3.zero,就说明这个点已经在约束范围内了,我们就可以跳过这个点的约束,约束下一个点。若四个点的 ConfinePoint 结果都等于 Vector3.zero,就说明 4 个点都在范围内了。约束结束,返回 displacement

可视化视角约束行为

通过上面的解读,相信读者也对约束行为有了一点理解。接下来我们通过例子可视化一次约束行为。

假设黑色边框是我们的约束范围,红点是摄像机想要跟随的对象。那在没有约束的情况下,用黄色边框代表摄像机视角,摄像机应该是这样的:

现在我们开始走 ConfineScreenEdges 流程。

首先计算视角 4 个点的位置:

第一个循环: 计算左下点与约束边界的距离,因为 d 不为 0,所以接下来的 3 个点的计算在这个循环里面都被取消了。我们把摄像机朝 d 移动过去。

第二个循环:由左下角开始,每个点的 ConfinePoint 值都为 0 ,所以镜头已经到了目标位置,退出循环,将 displacement 返回。

可视化不正确的约束行为

在摄像机的大小小于边界大小的情况下约束行为是非常正确的,那如果摄像机的大小大于边界大小呢?

摄像机的高大于边界的高

第一次循环还是左下角会计算出一个偏移量,然后把摄像机朝这个方向移动。

第二次循环的时候左上点会得到一个朝下的偏移量,并把摄像机朝这个方向移动。

第三次循环的时候左下点会得到一个朝上的偏移量,并把摄像机朝这个方向移动。最后摄像机到了第二次循环的初始位置。于是可以预知的第四次循环会重复第二次循环的操作,第五次循环会重复第三次循环的操作,直到循环次数耗尽,而摄像机依旧没有到达一个正确的位置。

如果我们把摄像机的中心点抽象出来,我们会得到这样的中心点移动图:

摄像机的宽大于边界的宽

同样,我们会得到一个这样子的示例图。但不同的是,当摄像机的宽大于边界的宽的时候,所有摄像机的调整行为都将局限在左下点和右下点中进行。这就意味着 CinemachineConfiner 将无法对高度进行限制,所以此时视角被允许移出上边界线。

修复

通过可视化我们可以非常容易的看出,当 CinemachineConfiner 面临循环调整的问题时,有一个非常明显的特征就是重复的位置调整。所以要鉴别出问题现象的发生,我们只需要引入一个新的变量 lastD 来记录上一次的偏移量。若这一次的偏移量加上上一次的偏移量等于 0 ,就说明摄像机在做 “往返运动”,我们就可以终止循环,并将 d 取为 d / 2 ,使约束区域居中。

而由于摄像机宽过长导致无法调整高度的问题也非常好解决。我们只需要改变 4 个点的运算顺序,让左下点和右上点提前计算就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Camera must be orthographic
private Vector3 ConfineScreenEdges(CinemachineVirtualCameraBase vcam, ref CameraState state)
{
    Quaternion rot = Quaternion.Inverse(state.CorrectedOrientation);
    float dy = state.Lens.OrthographicSize;
    float dx = dy * state.Lens.Aspect;
    Vector3 vx = (rot * Vector3.right) * dx;
    Vector3 vy = (rot * Vector3.up) * dy;

    Vector3 displacement = Vector3.zero;
    Vector3 camPos = state.CorrectedPosition;
+   Vector3 lastD = Vector3.zero;
    const int kMaxIter = 12;
    for (int i = 0; i < kMaxIter; ++i)
    {
        Vector3 d = ConfinePoint((camPos - vy) - vx);
+       if (d.AlmostZero())
+           d = ConfinePoint((camPos + vy) + vx);
        if (d.AlmostZero())
            d = ConfinePoint((camPos - vy) + vx);
        if (d.AlmostZero())
            d = ConfinePoint((camPos + vy) - vx);
        if (d.AlmostZero())
            break;
-       if (d.AlmostZero())
-           d = ConfinePoint((camPos + vy) + vx);
+       if (d == (-1) * lastD)
+       {
+           d *= 0.5f;
+           displacement += d;
+           break;
+       }
        displacement += d;
        camPos += d;
+       lastD = d;
    }
    return displacement;
}


发现存在错别字或者事实错误?请麻烦您点击 这里 汇报。谢谢您!