超级角色控制器

诞生

Unity自带的是有一个CharacterController的,驱使我来研究这一超级角色控制器有很多原因:

  • CharacterController把移动和跳跃等封装到了内部不好修改
  • 大部分情况要结合Rigidbody来实现逻辑,但是官方建议这两个组件不要一起使用,会有很多问题
  • 胶囊体碰撞,无法区分头部、身体、脚
  • 使用Collider实现碰撞,运算量大
    综上,在螺旋爆炸游戏开发中,我们决定重写角色控制器,也就是超级角色控制器

资料以及参考

大部分的研究资料来自http://jjyy.guru/super-character-controller-part1这篇文章,是一个博主早年翻译的一篇外国文章,我在这篇文章的算法研究上做了修改

代码解读

算法的说明这篇文章已经说的很清晰了,为防止文章关闭,在此复制了原文

碰撞检测


你会看到我已经把坐标标记为z轴和y轴,而不是x轴和y轴。这是因为我将会从顶视图来观察这个三维世界。由于顶视图的缘故,那个蓝色圆形是胶囊体。绿色矩形是一堵墙。理想的状况是,角色无法穿过墙壁。因此当角色与墙体交叉时,我希望能够检测出碰撞的发生,然后正确地进行处理。之所以我们自己处理碰撞检测,也就是看看圆形是否与矩形发生了交叉,有两个原因。一个是Unity有相当多的资源(我们后面会讲)来处理这个问题,第二个就是这是一个讲解碰撞检测的好例子。我们将会让碰撞体计算出正确的位置从而得到合理的行为。



上面展示了我们的角色尝试去朝着墙面运动。为了处理这个问题,我们从角色位移的起点与位移的终点之间进行一次扫略体测试。这次测试的结果是有一面墙在我们的前方,并且返回到墙面的距离。那么我们就可以直接拿到这个距离,将角色按照方向移动所得碰撞距离,从而移动到墙体之前。(PS:Unity有着很多内建的功能可以做到这点,包括Rigidbody.SweepTest,Physics.SphereCast,Physics.CapsuleCast)。

但是,这并不完全是我们想要的效果。如果我们用了这种方法,角色在与物体稍稍碰撞之后,就再也没有进行任何移动了。问题在于它缺少了现实中的反弹与侧滑。



这个效果就更加合理一些。最初的扫略测试是在移动方向上进行的。当扫略测试接触到墙体之后,角色就直接移动过去,就像刚才那样。但是这次我们更进一步,让角色向上滑动来补足丢失的移动,这样使得可以沿着表面滑动。这是一个理想中的角色控制器该有的行为,但这不是实现它的最佳方法。首先,这么做效率不是很高:每次你想移动角色,你就需要执行这个函数。如果每帧只执行一次还好,但是假如因为某些原因这个函数要执行多次,那就消耗很大。其次,碰撞处理是依赖于角色移动的方向和距离。如果角色因为某种神奇的原因进入了墙体内部,角色是不会被推出的。实际上,我发现这个问题很让人头疼。



这是一个糟糕的情况。我们可以看到我们的英雄现在已经在墙体内部了。这种情况下,我们不应该再考虑怎么像之前一样处理碰撞,而是当做一种独立的情况来处理。我们不再关心玩家朝哪个方向移动或者移动了多远。而是我们应该考虑的是,这一刻他应该在哪个位置,这个位置是否存在问题。在上图中,我们可以看到玩家正在与墙体交叉(在墙体内部了),因此他当前位置存在问题,并且需要修正。自从我们不再将处理碰撞检测作为运动的反馈,我们是不知道之前的位置在哪或者移动了多远。而我们所知道的是,他目前卡在墙内,而我们需要将他从墙体内挪出来。但是应该挪到哪里呢?就像之前的例子一样,我们应该在刚接触墙面的时候就将其推出。这里我们有不少候选位置。

每个半透明黄色圆圈都指示了一个潜在的满足推出的位置。但是我们应该选择哪个呢?很简单,只要选择距离角色最近的墙面的最近点即可。



这里我们计算得出距离角色最近点位于右边。接着我们就可以将角色移动到该点,加上角色的半径(红色部分)。

代码解读

控制器主循环

void Update()
{
    // If we are using a fixed timestep, ensure we run the main update loop
    // a sufficient number of times based on the Time.deltaTime
    if (manualUpdateOnly)
        return;

    if (!fixedTimeStep)
    {
        deltaTime = Time.deltaTime;

        SingleUpdate();
        return;
    }
    else
    {
        float delta = Time.deltaTime;

        while (delta > fixedDeltaTime)
        {
            deltaTime = fixedDeltaTime;

            SingleUpdate();

            delta -= fixedDeltaTime;
        }

        if (delta > 0f)
        {
            deltaTime = delta;

            SingleUpdate();
        }
    }
}

在Unity自带的Update方法中并没有执行真正的逻辑操作,而是只调用数次SingleUpdate函数,调用的次数取决于定义的频率,这样可在一帧的时间内多次执行碰撞检测函数,目的在确保角色不会因为移动速度太快而穿过物体跳过检测(实际上相当于重写了FixedUpdate函数,添加了更为方便的控制方法)

SingleUpdate

实际逻辑的循环分为几个部分:

检测地面

该部分会检测角色脚下的地面,并给出地面信息,包括地面法线、游戏性信息等等

  private class GroundHit
    {
        public Vector3 point { get; private set; }
        public Vector3 normal { get; private set; }
        public float distance { get; private set; }

        public GroundHit(Vector3 point, Vector3 normal, float distance)
        {
            this.point = point;
            this.normal = normal;
            this.distance = distance;
        }
    }

信息由GroundHit类定义

  ****
  ProbeGround(1);  
  ****
  ProbeGround(2);
  ****
  ProbeGround(3);
  ****

执行三次的原因是因为后续会有移动角色的操作,而移动角色后角色可能移动到了另一种地面,所以需要在每次移动操作后都检测地面,1,2,3的参数只是用于Debug

角色运动

这一模块处理了角色运动

  if (isMoveToTarget)
    {
        transform.position = targetPosition;
        isMoveToTarget = false;
    }
    transform.position += moveDirection * deltaTime;
    //gameObject.SendMessage("SuperUpdate", SendMessageOptions.DontRequireReceiver);
    fsm.CurrentState.Reason();
    fsm.CurrentState.Act();

首先判断角色是否是瞬移到了某个位置,由于这一瞬移是一次性的,所以用一个bool信号量来标记
对外的接口是这样的:

public void MoveToTarget(Vector3 target)
{
    targetPosition = target;
    isMoveToTarget = true;
}

如果调用了MoveToTarget函数,角色则会瞬移

  transform.position += moveDirection * deltaTime;

这句话处理了角色的连续运动,moveDirection在常态下是零向量,会根据角色的运动状态进行改变,运动接口如下

public void MoveHorizontal(Vector2 direction,float speed,float WalkAcceleration)
{
    direction = direction.normalized;
    Vector3 ver = Math3d.ProjectVectorOnPlane(up, moveDirection);
    Vector3 hor = moveDirection - ver;
    ver = Vector3.MoveTowards(ver, (GetRight()*direction.x+GetForword()*direction.y) * speed, WalkAcceleration * deltaTime);
    moveDirection = ver + hor;
}

参数分别代表方向,速度和加速度,函数作用是在角色的水平平面上按一定最大速度和加速度移动,实现方式很简单,就是对当前水平速度和目标速度线性插值,而角色改变了速度,也就发生了位移

public void MoveVertical(float Acceleration,float finalSpeed)
{
    Vector3 ver = Math3d.ProjectVectorOnPlane(up, moveDirection);
    Vector3 hor = moveDirection - ver;
    hor = Vector3.MoveTowards(hor, up * finalSpeed, Acceleration * deltaTime);
    moveDirection = ver + hor;
    //moveDirection = Vector3.MoveTowards(moveDirection, new Vector3(direction.x, 0, direction.y) * speed, WalkAcceleration * deltaTime);
}

垂直方向的移动实现是相同的

public void Ronate(float angle)
{
    lookDirection = Quaternion.AngleAxis(angle, up) * lookDirection;

    // Rotate our mesh to face where we are "looking"
    AnimatedMesh.rotation = Quaternion.LookRotation(lookDirection, up);
}

旋转的实现与平移不同,首先我规定角色是不可以上下转的(这样看起来实在很蠢,如果读者需要可自行修改),旋转是有四元数定义的,而四元数的线性插值并不是我们想象的那样规律,所以旋转的接口实现在控制器内部是瞬间完成的,若需要完成持续旋转请在控制器外部调用时使用对角度的线性插值

fsm.CurrentState.Reason();
fsm.CurrentState.Act();

最后两行代码读者可不必理解,这是通过FSM有限自动状态机来维护角色状态的改变和运动逻辑,我会在之后的文章介绍并实现

碰撞回推

collisionData.Clear();
RecursivePushback(0, MaxPushbackIterations);

这一部分就是之前介绍的碰撞检测的实现,代码如下:

void RecursivePushback(int depth, int maxDepth)
{
    PushIgnoredColliders();

    bool contact = false;

    foreach (var sphere in spheres)
    {
         foreach(Collider col in Physics.OverlapSphere((SpherePosition(sphere)), radius, Triggerable,                triggerInteraction))
        {
            triggerData.Add(col);
        }
        foreach (Collider col in Physics.OverlapSphere((SpherePosition(sphere)), radius, Walkable, triggerInteraction))
        {
            Vector3 position = SpherePosition(sphere);
            Vector3 contactPoint;
            bool contactPointSuccess = SuperCollider.ClosestPointOnSurface(col, position, radius, out contactPoint);
            
            if (!contactPointSuccess)
            {
                return;
            }
                                        
            if (debugPushbackMesssages)
                DebugDraw.DrawMarker(contactPoint, 2.0f, Color.cyan, 0.0f, false);
                
            Vector3 v = contactPoint - position;
            if (v != Vector3.zero)
            {
                // Cache the collider's layer so that we can cast against it
                int layer = col.gameObject.layer;

                col.gameObject.layer = TemporaryLayerIndex;

                // Check which side of the normal we are on
                bool facingNormal = Physics.SphereCast(new Ray(position, v.normalized), TinyTolerance, v.magnitude + TinyTolerance, 1 << TemporaryLayerIndex);

                col.gameObject.layer = layer;

                // Orient and scale our vector based on which side of the normal we are situated
                if (facingNormal)
                {
                    if (Vector3.Distance(position, contactPoint) < radius)
                    {
                        v = v.normalized * (radius - v.magnitude) * -1;
                    }
                    else
                    {
                        // A previously resolved collision has had a side effect that moved us outside this collider
                        continue;
                    }
                }
                else
                {
                    v = v.normalized * (radius + v.magnitude);
                }

                contact = true;

                transform.position += v;

                col.gameObject.layer = TemporaryLayerIndex;

                // Retrieve the surface normal of the collided point
                RaycastHit normalHit;

                Physics.SphereCast(new Ray(position + v, contactPoint - (position + v)), TinyTolerance, out normalHit, 1 << TemporaryLayerIndex);

                col.gameObject.layer = layer;

                SuperCollisionType superColType = col.gameObject.GetComponent<SuperCollisionType>();

                if (superColType == null)
                    superColType = defaultCollisionType;

                // Our collision affected the collider; add it to the collision data
                var collision = new SuperCollision()
                {
                    collisionSphere = sphere,
                    superCollisionType = superColType,
                    gameObject = col.gameObject,
                    point = contactPoint,
                    normal = normalHit.normal
                };

                collisionData.Add(collision);
            }
        }            
    }

    PopIgnoredColliders();

    if (depth < maxDepth && contact)
    {
        RecursivePushback(depth + 1, maxDepth);
    }
}

需要注意的是这里添加了IgnoreCollider,保证我们在游戏中可以忽略一些物体的碰撞,返回的collisionData和triggerData分别代表了不可穿越的碰撞和可穿越的触发

坡度限制

if (slopeLimiting)
        SlopeLimit();

坡度限制限制了角色能爬上的最陡的坡度

bool SlopeLimit()
{
    Vector3 n = currentGround.PrimaryNormal();
    float a = Vector3.Angle(n, up);

    if (a > currentGround.superCollisionType.SlopeLimit)
    {
        Vector3 absoluteMoveDirection = Math3d.ProjectVectorOnPlane(n, transform.position - initialPosition);

        // Retrieve a vector pointing down the slope
        Vector3 r = Vector3.Cross(n, down);
        Vector3 v = Vector3.Cross(r, n);

        float angle = Vector3.Angle(absoluteMoveDirection, v);

        if (angle <= 90.0f)
            return false;

        // Calculate where to place the controller on the slope, or at the bottom, based on the desired movement distance
        Vector3 resolvedPosition = Math3d.ProjectPointOnLine(initialPosition, r, transform.position);
        Vector3 direction = Math3d.ProjectVectorOnPlane(n, resolvedPosition - transform.position);

        RaycastHit hit;

        // Check if our path to our resolved position is blocked by any colliders
        if (Physics.CapsuleCast(SpherePosition(feet), SpherePosition(head), radius, direction.normalized, out hit, direction.magnitude, Walkable, triggerInteraction))
        {
            transform.position += v.normalized * hit.distance;
        }
        else
        {
            transform.position += direction;
        }

        return true;
    }

    return false;
}

实现原理同样不难,计算角色与地面法线夹角,判断是否超过最大角度

附着地面

附着地面则是Unity角色控制器所不具备的能力,而且相当重要。当水平走过不平的路面时,控制器将不会紧贴着地面。在真实世界当中,我们通过双腿每次的轻微上下来保持平衡。但是在游戏世界中,我们需要特殊处理。与真实世界不同的是,重力不是总是作用在控制器身上。当我们没有站在平面上时,会添加向下的重力加速度。当我们在平面上时,我们设置垂直速度为0,表示平面的作用力。由于我们站在平面上的垂直速度为0,当我们走出平面时,需要时间来产生向下的速度。对于走出悬崖来说,这么做没问题,但是当我们在斜坡或者不平滑的路面行走时,会产生不真实的反弹效果。为了避免有视觉问题,在地面与非地面之间的振幅会构成逻辑问题,特别是在地面上与掉落时的差别。


if (clamping)
        ClampToGround();

    isClamping = clamping || currentlyClampedTo != null;
    clampedTo = currentlyClampedTo != null ? currentlyClampedTo : currentGround.transform;

    if (isClamping)
        lastGroundPosition = clampedTo.position;

    if (debugGrounding)
        currentGround.DebugGround(true, true, true, true, true);

 void ClampToGround()
{
    float d = currentGround.Distance();
    transform.position -= up * d;
}

实现即是将角色向下移动紧贴地面,由于检测地面的原因,这步已经不需要很多工作量了。

尾声

至此基本的代码原理都已讲清,至于具体使用时会有一些方便的接口,我将和超级FSM状态机一同介绍,感谢

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容