超級角色控制器

誕生

Unity自帶的是有一個CharacterController的,驅(qū)使我來研究這一超級角色控制器有很多原因:

  • CharacterController把移動和跳躍等封裝到了內(nèi)部不好修改
  • 大部分情況要結(jié)合Rigidbody來實現(xiàn)邏輯,但是官方建議這兩個組件不要一起使用,會有很多問題
  • 膠囊體碰撞,無法區(qū)分頭部、身體、腳
  • 使用Collider實現(xiàn)碰撞奏属,運算量大
    綜上,在螺旋爆炸游戲開發(fā)中潮峦,我們決定重寫角色控制器囱皿,也就是超級角色控制器

資料以及參考

大部分的研究資料來自http://jjyy.guru/super-character-controller-part1這篇文章勇婴,是一個博主早年翻譯的一篇外國文章,我在這篇文章的算法研究上做了修改

代碼解讀

算法的說明這篇文章已經(jīng)說的很清晰了嘱腥,為防止文章關(guān)閉耕渴,在此復(fù)制了原文

碰撞檢測


你會看到我已經(jīng)把坐標(biāo)標(biāo)記為z軸和y軸,而不是x軸和y軸齿兔。這是因為我將會從頂視圖來觀察這個三維世界橱脸。由于頂視圖的緣故,那個藍(lán)色圓形是膠囊體分苇。綠色矩形是一堵墻添诉。理想的狀況是,角色無法穿過墻壁医寿。因此當(dāng)角色與墻體交叉時栏赴,我希望能夠檢測出碰撞的發(fā)生,然后正確地進行處理靖秩。之所以我們自己處理碰撞檢測须眷,也就是看看圓形是否與矩形發(fā)生了交叉,有兩個原因沟突。一個是Unity有相當(dāng)多的資源(我們后面會講)來處理這個問題花颗,第二個就是這是一個講解碰撞檢測的好例子。我們將會讓碰撞體計算出正確的位置從而得到合理的行為惠拭。



上面展示了我們的角色嘗試去朝著墻面運動扩劝。為了處理這個問題,我們從角色位移的起點與位移的終點之間進行一次掃略體測試求橄。這次測試的結(jié)果是有一面墻在我們的前方今野,并且返回到墻面的距離葡公。那么我們就可以直接拿到這個距離罐农,將角色按照方向移動所得碰撞距離,從而移動到墻體之前催什。(PS:Unity有著很多內(nèi)建的功能可以做到這點涵亏,包括Rigidbody.SweepTest,Physics.SphereCast,Physics.CapsuleCast)。

但是蒲凶,這并不完全是我們想要的效果气筋。如果我們用了這種方法,角色在與物體稍稍碰撞之后旋圆,就再也沒有進行任何移動了宠默。問題在于它缺少了現(xiàn)實中的反彈與側(cè)滑。



這個效果就更加合理一些灵巧。最初的掃略測試是在移動方向上進行的搀矫。當(dāng)掃略測試接觸到墻體之后抹沪,角色就直接移動過去,就像剛才那樣瓤球。但是這次我們更進一步融欧,讓角色向上滑動來補足丟失的移動,這樣使得可以沿著表面滑動卦羡。這是一個理想中的角色控制器該有的行為噪馏,但這不是實現(xiàn)它的最佳方法。首先绿饵,這么做效率不是很高:每次你想移動角色欠肾,你就需要執(zhí)行這個函數(shù)。如果每幀只執(zhí)行一次還好蝴罪,但是假如因為某些原因這個函數(shù)要執(zhí)行多次董济,那就消耗很大。其次要门,碰撞處理是依賴于角色移動的方向和距離虏肾。如果角色因為某種神奇的原因進入了墻體內(nèi)部,角色是不會被推出的欢搜。實際上封豪,我發(fā)現(xiàn)這個問題很讓人頭疼。



這是一個糟糕的情況炒瘟。我們可以看到我們的英雄現(xiàn)在已經(jīng)在墻體內(nèi)部了吹埠。這種情況下,我們不應(yīng)該再考慮怎么像之前一樣處理碰撞疮装,而是當(dāng)做一種獨立的情況來處理缘琅。我們不再關(guān)心玩家朝哪個方向移動或者移動了多遠(yuǎn)。而是我們應(yīng)該考慮的是廓推,這一刻他應(yīng)該在哪個位置刷袍,這個位置是否存在問題。在上圖中樊展,我們可以看到玩家正在與墻體交叉(在墻體內(nèi)部了)呻纹,因此他當(dāng)前位置存在問題,并且需要修正专缠。自從我們不再將處理碰撞檢測作為運動的反饋雷酪,我們是不知道之前的位置在哪或者移動了多遠(yuǎn)。而我們所知道的是涝婉,他目前卡在墻內(nèi)哥力,而我們需要將他從墻體內(nèi)挪出來。但是應(yīng)該挪到哪里呢墩弯?就像之前的例子一樣吩跋,我們應(yīng)該在剛接觸墻面的時候就將其推出蟀淮。這里我們有不少候選位置。

每個半透明黃色圓圈都指示了一個潛在的滿足推出的位置钞澳。但是我們應(yīng)該選擇哪個呢怠惶?很簡單,只要選擇距離角色最近的墻面的最近點即可轧粟。



這里我們計算得出距離角色最近點位于右邊策治。接著我們就可以將角色移動到該點,加上角色的半徑(紅色部分)兰吟。

代碼解讀

控制器主循環(huán)

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方法中并沒有執(zhí)行真正的邏輯操作通惫,而是只調(diào)用數(shù)次SingleUpdate函數(shù),調(diào)用的次數(shù)取決于定義的頻率混蔼,這樣可在一幀的時間內(nèi)多次執(zhí)行碰撞檢測函數(shù)履腋,目的在確保角色不會因為移動速度太快而穿過物體跳過檢測(實際上相當(dāng)于重寫了FixedUpdate函數(shù),添加了更為方便的控制方法)

SingleUpdate

實際邏輯的循環(huán)分為幾個部分:

檢測地面

該部分會檢測角色腳下的地面惭嚣,并給出地面信息遵湖,包括地面法線、游戲性信息等等

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

執(zhí)行三次的原因是因為后續(xù)會有移動角色的操作晚吞,而移動角色后角色可能移動到了另一種地面延旧,所以需要在每次移動操作后都檢測地面,1槽地,2迁沫,3的參數(shù)只是用于Debug

角色運動

這一模塊處理了角色運動

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

首先判斷角色是否是瞬移到了某個位置,由于這一瞬移是一次性的捌蚊,所以用一個bool信號量來標(biāo)記
對外的接口是這樣的:

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

如果調(diào)用了MoveToTarget函數(shù)集畅,角色則會瞬移

  transform.position += moveDirection * deltaTime;

這句話處理了角色的連續(xù)運動,moveDirection在常態(tài)下是零向量缅糟,會根據(jù)角色的運動狀態(tài)進行改變挺智,運動接口如下

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

參數(shù)分別代表方向,速度和加速度溺拱,函數(shù)作用是在角色的水平平面上按一定最大速度和加速度移動逃贝,實現(xiàn)方式很簡單谣辞,就是對當(dāng)前水平速度和目標(biāo)速度線性插值迫摔,而角色改變了速度,也就發(fā)生了位移

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

垂直方向的移動實現(xiàn)是相同的

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

旋轉(zhuǎn)的實現(xiàn)與平移不同泥从,首先我規(guī)定角色是不可以上下轉(zhuǎn)的(這樣看起來實在很蠢句占,如果讀者需要可自行修改),旋轉(zhuǎn)是有四元數(shù)定義的躯嫉,而四元數(shù)的線性插值并不是我們想象的那樣規(guī)律纱烘,所以旋轉(zhuǎn)的接口實現(xiàn)在控制器內(nèi)部是瞬間完成的,若需要完成持續(xù)旋轉(zhuǎn)請在控制器外部調(diào)用時使用對角度的線性插值

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

最后兩行代碼讀者可不必理解,這是通過FSM有限自動狀態(tài)機來維護角色狀態(tài)的改變和運動邏輯辨宠,我會在之后的文章介紹并實現(xiàn)

碰撞回推

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

這一部分就是之前介紹的碰撞檢測的實現(xiàn)才避,代碼如下:

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分別代表了不可穿越的碰撞和可穿越的觸發(fā)

坡度限制

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

實現(xiàn)原理同樣不難屋吨,計算角色與地面法線夾角,判斷是否超過最大角度

附著地面

附著地面則是Unity角色控制器所不具備的能力山宾,而且相當(dāng)重要至扰。當(dāng)水平走過不平的路面時,控制器將不會緊貼著地面资锰。在真實世界當(dāng)中敢课,我們通過雙腿每次的輕微上下來保持平衡。但是在游戲世界中绷杜,我們需要特殊處理直秆。與真實世界不同的是,重力不是總是作用在控制器身上鞭盟。當(dāng)我們沒有站在平面上時切厘,會添加向下的重力加速度。當(dāng)我們在平面上時懊缺,我們設(shè)置垂直速度為0疫稿,表示平面的作用力。由于我們站在平面上的垂直速度為0鹃两,當(dāng)我們走出平面時遗座,需要時間來產(chǎn)生向下的速度。對于走出懸崖來說俊扳,這么做沒問題途蒋,但是當(dāng)我們在斜坡或者不平滑的路面行走時,會產(chǎn)生不真實的反彈效果馋记。為了避免有視覺問題号坡,在地面與非地面之間的振幅會構(gòu)成邏輯問題,特別是在地面上與掉落時的差別梯醒。


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

實現(xiàn)即是將角色向下移動緊貼地面宽堆,由于檢測地面的原因,這步已經(jīng)不需要很多工作量了茸习。

尾聲

至此基本的代碼原理都已講清畜隶,至于具體使用時會有一些方便的接口,我將和超級FSM狀態(tài)機一同介紹,感謝

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末籽慢,一起剝皮案震驚了整個濱河市浸遗,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌箱亿,老刑警劉巖跛锌,帶你破解...
    沈念sama閱讀 211,265評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異届惋,居然都是意外死亡察净,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,078評論 2 385
  • 文/潘曉璐 我一進店門盼樟,熙熙樓的掌柜王于貴愁眉苦臉地迎上來氢卡,“玉大人,你說我怎么就攤上這事晨缴∫肭兀” “怎么了?”我有些...
    開封第一講書人閱讀 156,852評論 0 347
  • 文/不壞的土叔 我叫張陵击碗,是天一觀的道長筑悴。 經(jīng)常有香客問我,道長稍途,這世上最難降的妖魔是什么阁吝? 我笑而不...
    開封第一講書人閱讀 56,408評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮械拍,結(jié)果婚禮上突勇,老公的妹妹穿的比我還像新娘。我一直安慰自己坷虑,他們只是感情好甲馋,可當(dāng)我...
    茶點故事閱讀 65,445評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著迄损,像睡著了一般定躏。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上芹敌,一...
    開封第一講書人閱讀 49,772評論 1 290
  • 那天痊远,我揣著相機與錄音,去河邊找鬼氏捞。 笑死碧聪,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的幌衣。 我是一名探鬼主播矾削,決...
    沈念sama閱讀 38,921評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼豁护!你這毒婦竟也來了哼凯?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,688評論 0 266
  • 序言:老撾萬榮一對情侶失蹤楚里,失蹤者是張志新(化名)和其女友劉穎断部,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體班缎,經(jīng)...
    沈念sama閱讀 44,130評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡蝴光,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,467評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了达址。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蔑祟。...
    茶點故事閱讀 38,617評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖沉唠,靈堂內(nèi)的尸體忽然破棺而出疆虚,到底是詐尸還是另有隱情,我是刑警寧澤满葛,帶...
    沈念sama閱讀 34,276評論 4 329
  • 正文 年R本政府宣布径簿,位于F島的核電站,受9級特大地震影響嘀韧,放射性物質(zhì)發(fā)生泄漏篇亭。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,882評論 3 312
  • 文/蒙蒙 一锄贷、第九天 我趴在偏房一處隱蔽的房頂上張望译蒂。 院中可真熱鬧,春花似錦谊却、人聲如沸蹂随。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,740評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽岳锁。三九已至,卻和暖如春蹦魔,著一層夾襖步出監(jiān)牢的瞬間激率,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,967評論 1 265
  • 我被黑心中介騙來泰國打工勿决, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留乒躺,地道東北人。 一個月前我還...
    沈念sama閱讀 46,315評論 2 360
  • 正文 我出身青樓低缩,卻偏偏與公主長得像嘉冒,于是被迫代替她去往敵國和親曹货。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,486評論 2 348

推薦閱讀更多精彩內(nèi)容