喵的Unity游戲開發(fā)之路 - 推球:游戲中的物理

????????很多童鞋沒有系統(tǒng)的Unity3D游戲開發(fā)基礎,也不知道從何開始學。為此我們精選了一套國外優(yōu)秀的Unity3D游戲開發(fā)教程,翻譯整理后放送給大家,教您從零開始一步一步掌握Unity3D游戲開發(fā)。?本文不是廣告,不是推廣,是免費的純干貨!本文全名:喵的Unity游戲開發(fā)之路 - 移動?-?推球:游戲中的物理


  • 控制剛體球體的速度。

  • 通過跳躍支持垂直運動。

  • 檢測地面及其角度。

  • 使用ProBuilder創(chuàng)建測試場景。

  • 沿斜坡移動。



  • 這是有關控制角色移動的教程系列的第二部分。這次,我們將使用物理引擎創(chuàng)建更逼真的運動并支持更復雜的環(huán)境。


    本教程使用Unity 2019.2.11f1制作。它還使用ProBuilder軟件包。

    最終效果之一




    在不公平的賽道上不受約束的球體。





    剛體



    在上一教程中,我們將球體約束為保留在矩形區(qū)域內(nèi)。顯式地編程這樣的限制很有意義,因為它很簡單。但是,如果我們希望球體在復雜的3D環(huán)境中移動,則必須支持與任意幾何圖形的交互。我們將使用Unity現(xiàn)有的物理引擎,即NVIDIA的PhysX,而不是自己實現(xiàn)。


    與物理引擎結(jié)合使用,有兩種通用的方法來控制角色。首先是剛體方法,即通過施加力或改變其速度,使角色的行為像常規(guī)物理對象一樣,而間接控制它。第二種是運動學方法,即在僅查詢物理引擎執(zhí)行自定義碰撞檢測的同時進行直接控制。




    剛體組件


    我們將使用第一種方法來控制球體,這意味著我們必須向其中添加一個Rigidbody組件。我們可以使用剛體的默認配置。



    添加該分量足以將我們的球體變成一個物理對象,只要它仍然具有其SphereCollider分量即可。從現(xiàn)在開始,我們推遲到物理引擎進行碰撞,因此從中刪除區(qū)號Update。

  •      Vector3 newPosition = transform.localPosition + displacement;    //if (newPosition.x < allowedArea.xMin) {    //  newPosition.x = allowedArea.xMin;    //  velocity.x = -velocity.x * bounciness;    //}    //…    transform.localPosition = newPosition;


    消除了我們自己的約束后,球體再次可以自由移動經(jīng)過平面的邊緣,在此點,球體由于重力而直線下降。發(fā)生這種情況是因為我們從不覆蓋球體的Y位置。



    我們不再需要允許區(qū)域的配置選項。我們的自定義跳動也不再需要。

  •   //[SerializeField, Range(0f, 1f)]  //float bounciness = 0.5f;
    //[SerializeField] //Rect allowedArea = new Rect(-5f, -5f, 10f, 10f);


    如果我們?nèi)匀幌爰s束球體保留在平面上,則可以通過添加其他對象來阻止其路徑來實現(xiàn)。例如,創(chuàng)建四個立方體,對其進行縮放和定位,以便它們圍繞平面形成一堵墻。這將防止球體掉落,盡管它在與墻壁碰撞時表現(xiàn)得很怪異。由于此時我們具有3D幾何形狀,因此再次啟用陰影以更好地了解深度也是一個好主意。


    物理怪異。


    當試圖移動到一個角落時,由于物理引擎和我們自己的代碼爭奪球形的位置,因此球形變得不穩(wěn)定。我們將其移入墻壁,然后PhysX通過將其向后推來解決碰撞。如果我們停止將其推入墻壁,則PhysX將使球由于動量而保持運動。





    控制剛體速度


    如果要使用物理引擎,則應讓它控制球體的位置。直接調(diào)整位置將有效地傳送,這不是我們想要的。相反,我們必須通過對球施加力或調(diào)整其速度來間接控制球。


    我們已經(jīng)對位置進行了間接控制,因為我們會影響速度。我們要做的就是更改代碼,使其覆蓋Rigidbody組件的速度,而不是自己調(diào)整位置。我們需要為此訪問組件,因此通過bodyAwake方法中初始化的字段來跟蹤它。

  •   Rigidbody body;
    void Awake () { body = GetComponent<Rigidbody>(); }


    從Update中刪除位移代碼,而是將我們的速度分配給body的速度。



  •     //Vector3 displacement = velocity * Time.deltaTime;    //Vector3 newPosition = transform.localPosition + displacement;    //transform.localPosition = newPosition;    body.velocity = velocity;


    但是物理碰撞等也會影響速度,因此請先將其從body中檢索出來,然后再對其進行調(diào)整以匹配所需的速度。

  •     velocity = body.velocity;    float maxSpeedChange = maxAcceleration * Time.deltaTime;    velocity.x =      Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange);    velocity.z =      Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);    body.velocity = velocity;


    控制body的速度。





    無摩擦運動


    現(xiàn)在,我們調(diào)整球體的速度,PhysX用來移動它。然后解決沖突,可以調(diào)整速度,然后再次調(diào)整速度,依此類推。盡管球體更加緩慢并且沒有達到其最大速度,但最終的運動看起來像我們以前的運動。那是因為PhysX會產(chǎn)生摩擦。盡管這更現(xiàn)實,但它使配置球體變得更加困難,因此讓我們消除摩擦和反彈。這是通過“ 資產(chǎn)/創(chuàng)建/物理材質(zhì)”創(chuàng)建新的物理材質(zhì)(是的,在菜單中拼寫為“ Physic”),然后將所有值設置為零,將“ 合并”模式設置為“ 最小”。



    將此物理材質(zhì)分配給球體的對撞機。


    現(xiàn)在,它不再受到任何摩擦或反彈。


    不建議不要直接調(diào)節(jié)速度嗎?

    這是基于速度瞬時變化是不現(xiàn)實的想法的通用建議。我們正在做的是有效地施加加速度,只是以一種受控的方式來達到目標速度。如果您知道自己在做什么,直接調(diào)整速度就可以了。



    無摩擦運動。


    與球體碰撞時,球體似乎仍會反彈一點。發(fā)生這種情況是因為PhysX不會阻止碰撞,而是會在碰撞發(fā)生后檢測到它們,然后移動剛體以使它們不再相交。在快速運動的情況下,這可能需要一個以上的物理模擬步驟,因此我們可以看到這種穿透現(xiàn)象的發(fā)生。


    如果運動確實非常快,那么球體可能最終會完全穿過壁或朝另一側(cè)穿透,這對于較薄的壁來說更可能發(fā)生。您可以通過更改的Rigidbody碰撞檢測模式來避免這種情況,但這通常僅在移動非??鞎r才需要。


    而且,球體現(xiàn)在可以滑動而不是滾動,因此我們也可以凍結(jié)其在所有尺寸上的旋轉(zhuǎn),這可以通過組件的“ 約束”復選框來完成Rigidbody






    固定更新


    物理引擎使用固定的時間步長,而不管幀速率如何。盡管我們已經(jīng)將球的控制權交給了PhysX,但我們?nèi)匀粫绊懫渌俣?。為了獲得最佳結(jié)果,我們應該以固定的時間步長同步調(diào)整速度。為此,我們將Update方法分為兩部分。我們檢查輸入并設置所需速度的部分可以保留在Update中,而速度的調(diào)整應移至新FixedUpdate方法。為了完成這項工作,我們必須將所需的速度存儲在一個場中。

  •   Vector3 velocity, desiredVelocity;

  •   void Update () {    Vector2 playerInput;    playerInput.x = Input.GetAxis("Horizontal");    playerInput.y = Input.GetAxis("Vertical");    playerInput = Vector2.ClampMagnitude(playerInput, 1f);
    //Vector3 desiredVelocity = desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed; }
    void FixedUpdate () { velocity = body.velocity; float maxSpeedChange = maxAcceleration * Time.deltaTime; velocity.x = Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange); velocity.z = Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange); body.velocity = velocity; }


    FixedUpdate在每個物理模擬步驟的開始都調(diào)用該方法。發(fā)生的頻率取決于時間步長,默認為0.02(每秒50次),但是您可以通過“ 時間”項目設置或通過更改時間步長Time.fixedDeltaTime


    根據(jù)您的幀速率FixedUpdate,每次調(diào)用可以調(diào)用0次,一次或多次Update。每個框架都會發(fā)生一系列FixedUpdate調(diào)用,然后Update被調(diào)用,然后呈現(xiàn)框架。當物理時間步長相對于幀時間太大時,這可以使物理仿真的離散性質(zhì)變得明顯。


    0.2物理時間步。


    您可以通過減少固定時間步長或啟用的Rigidbody插值模式來解決此問題。將其設置為Interpolate可使它在其最后位置和當前位置之間線性插值,因此根據(jù)PhysX,它會稍微落后于其實際位置。另一個選項是Extrapolate,它根據(jù)其速度插值到其猜測的位置,這僅對于速度基本恒定的對象才真正可接受。


    帶插值的0.2物理時間步長。


    請注意,增加時間步長意味著球體在每次物理更新時覆蓋的距離更大,這可能導致使用離散碰撞檢測時球體穿過壁隧穿。






    跳躍


    由于我們的球體現(xiàn)在可以在3D物理世界中導航,因此我們可以使其具有跳躍的能力。




    根據(jù)指令跳躍


    我們可以用Input.GetButtonDown("Jump")來檢測玩家是否按下了該幀的跳轉(zhuǎn)按鈕,默認情況下是空格鍵。我們在Update中這樣做,但是就像調(diào)整速度一樣,我們會將實際的跳躍延遲到FixedUpdate的下次調(diào)用。因此,請通過布爾字段desiredJump跟蹤是否需要跳轉(zhuǎn)。

  •   bool desiredJump;

    void Update () { desiredJump = Input.GetButtonDown("Jump"); }


    但是,我們可能最終不調(diào)用FixedUpdate下一幀,在這種情況下desiredJump將其調(diào)回false原定位置,而desiredJump 將被遺忘。我們可以通過布爾“或”運算或“或”分配將檢查與其先前的值相結(jié)合來防止這種情況。這樣,它將保持true啟用狀態(tài),直到我們將其顯式設置回false。

  •     desiredJump|=Input.GetButtonDown("Jump");


    在調(diào)整速度之后和在FixedUpdate中應用速度之前,請檢查是否需要跳躍。如果是這樣,請重置desiredJump并調(diào)用一個新Jump方法,該方法最初僅將5添加到速度的Y分量,以模擬突然的向上加速度。

  •   void FixedUpdate () {
    if (desiredJump) { desiredJump = false; Jump(); } body.velocity = velocity; } void Jump() { velocity.y += 5f; }


    這將使球體向上移動,直到由于重力不可避免地回落。


    跳。





    跳躍高度


    讓我們對其范圍進行配置是可配置的。我們可以通過直接控制跳躍速度來做到這一點,但這并不直觀,因為初始跳躍速度和跳躍高度之間的關系并不微不足道。直接控制跳躍高度更方便,所以讓我們開始吧。

  •   [SerializeField, Range(0f, 10f)]  float jumpHeight = 2f;



    跳躍需要克服重力,因此所需的垂直速度取決于重力。特別,v?=--2?G?Hv_y = sqrt(-2gh)?那里?GG 是重力, HH是所需的高度。負號在那里,因為GG假定為負。我們可以通過檢索它Physics.gravity.y,也可以通過Physics項目設置進行配置。我們正在使用默認的重力矢量,該矢量向下垂直為9.81,與地球的平均重力匹配。

  •   void Jump () {    velocity.y +=Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);  }

    如何得出所需的速度?

    我們從初始跳躍速度開始??,它會因重力而減小,直到達到零,然后我們開始回落。重力G是一個持續(xù)不斷的加速度,將我們拉倒,為此我們在此推導中使用正數(shù),因為這使我們免于編寫大量負號。所以在任何時候??因為跳躍的垂直速度是 v = jg t。什么時候v達到零,我們位于跳躍的頂部,因此正好位于所需的高度。這發(fā)生在jg t = 0,所以什么時候 j = gt。因此,當t = j /克。

    因為 G 恒定,任何時候的平均速度為 v_(av)= j-(gt)/ 2,因此隨時的高度為 h = v_(av)t = jt-(gt ^ 2)/ 2。這意味著在跳躍的頂端h = j(j / g)-(g(j / g)^ 2)/ 2,我們可以重寫為 h = j ^ 2 / g-(j ^ 2 / g)/ 2 = j ^ 2 / g-j ^ 2 /(2g)= j ^ 2 /(2g)

    現(xiàn)在我們知道 h = j ^ 2 /(2g)?在頂部,因此 j ^ 2 = 2gh 和 j = sqrt(2gh)。什么時候G 是負數(shù)而不是 j = sqrt(-2gh)。


    請注意,由于物理模擬的離散性,我們很可能無法達到所需的高度。在時間步長之間的某個地方將達到最大值。





    在地面的跳躍


    目前,我們可以隨時跳下,即使已經(jīng)在空中,也可以永遠保持空中飛行。僅當球體在地面上時才能啟動適當?shù)奶S。我們無法直接詢問Rigidbody它當前是否正在接觸地面,但是當它與某些物體碰撞時我們會得到通知,因此我們將使用它。


    如果MovingSphere有一個OnCollisionEnter方法,那么它將在PhysX檢測到新的碰撞后被調(diào)用。只要物體保持彼此接觸,碰撞就仍然存在。之后,OnCollisionExit將調(diào)用一個方法(如果存在)。將兩種方法都添加到MovingSphere中,將第一個?onGround?boolean字段設置為true,并將后者?boolean字段設置為false。

  •   bool onGround;

    void OnCollisionEnter () { onGround = true; }
    void OnCollisionExit () { onGround = false; }


    現(xiàn)在我們只能在地面上跳躍,現(xiàn)在我們假設在觸摸某物時就是這種情況。如果我們不接觸任何東西,則應忽略期望的跳躍。

  •   void Jump () {    if (onGround) {      velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);    }  }


    當球體僅接觸地面時,此方法有效,但如果它也短暫接觸墻,則跳躍將變得不可能。之所以發(fā)生這種情況,是因為OnCollisionExit在我們?nèi)耘c地面保持接觸的同時,它被作為墻壁使用。解決方案是不依賴OnCollisionExit而是添加一種OnCollisionStay方法,只要碰撞仍然存在,就可以在每個物理步驟中調(diào)用該方法。設置onGroundtrue在該方法中。

  •   //void OnCollisionExit () {  //  onGround = false;  //}
    void OnCollisionStay () { onGround = true; }


    每個物理步驟都從調(diào)用所有FixedUpdate方法開始,然后PhysX完成其工作,最后調(diào)用碰撞方法。因此,如果存在任何活動沖突,則在最后一步FixedUpdate期間將設置何時調(diào)用gets 。為了保持onGround有效,我們要做的就是在FixedUpdate末尾將其onGround設置為false。

  •   void FixedUpdate () {    onGround = false;  }


    現(xiàn)在,只要我們接觸到某物,我們就可以跳躍。





    無墻跳躍


    當觸摸任何東西時都允許跳躍意味著我們也可以在空中但觸摸墻壁而不是地面時跳躍。如果要防止這種情況,我們必須能夠區(qū)分地面和其他東西。


    將地面定義為主要是水平面是有意義的。我們可以通過檢查碰撞接觸點的法線向量來檢查我們所碰撞的物體是否滿足此條件。


    什么是法向量?

    它是指示方向的單位長度向量。通常是遠離某物的方向。因此,一個平面只有一個法向量,而球體上的每個點都有一個指向其中心的不同法線向量。



    一個簡單的碰撞只有兩個形狀接觸的單個點,例如,當我們的球體接觸地面時。通常,球體會稍微穿透平面,而PhysX通過將球體直接推離平面而解決了。推動的方向是接觸點的法線向量。因為我們使用的是球體,所以矢量始終從球體表面上的接觸點指向其中心。



    實際上,它可能比這更混亂,因為可能存在多個碰撞,并且穿透可能會持續(xù)一個以上的仿真步驟,但是我們現(xiàn)在不必真正擔心這一點。我們確實需要認識到的是,一次碰撞可以包含多個接觸。對于平面-球體碰撞,這是不可能的,但是當涉及到凹形網(wǎng)格對撞機時,這是可能的。


    我們可以通過向和Collision都添加一個參數(shù)來獲取碰撞信息。與其直接設置onGround 為true,我們不如將責任轉(zhuǎn)交給一種新方法EvaluateCollision ,并將數(shù)據(jù)給它。

  •   void OnCollisionEnter (Collision collision) {    //onGround = true;    EvaluateCollision(collision);  }
    void OnCollisionStay (Collision collision) { //onGround = true; EvaluateCollision(collision); } void EvaluateCollision (Collision collision) {}



    可以通過Collision的contactCount屬性找到接觸點的數(shù)量。我們可以使用它通過該GetContact方法遍歷所有點,并為其傳遞索引。然后,我們可以訪問該點的normal屬性。

  •   void EvaluateCollision (Collision collision) {    for (int i = 0; i < collision.contactCount; i++) {      Vector3 normal = collision.GetContact(i).normal;    }  }


    法線是球應被推動的方向,該方向直接遠離碰撞表面。假設它是一個平面,則矢量與平面的法向矢量匹配。如果平面是水平的,則其法線將指向垂直,因此其Y分量應正好為1。如果是這種情況,則我們正在接觸地面。但是,我們要寬容一些,接受0.9或更大的Y分量。

  •       Vector3 normal = collision.GetContact(i).normal;      onGround |= normal.y >= 0.9f;





    空中跳躍


    在這一點上,我們只能在地面上跳,但是游戲通常允許空中跳兩次甚至三跳。讓我們對此進行支持,并使其可配置為允許多少次空氣跳躍。

  •   [SerializeField, Range(0, 5)]  int maxAirJumps = 0;



    現(xiàn)在,我們必須跟蹤跳轉(zhuǎn)階段,以便知道是否允許再次跳轉(zhuǎn)。如果我們在地面上,我們可以通過在FixedUpdate開始時將其設置為零的整數(shù)字段來執(zhí)行此操作。但是,讓我們將代碼與速度檢索一起移動到單獨的UpdateState方法中,以保持FixedUpdate簡短。

  •   int jumpPhase;      void FixedUpdate () {    //velocity = body.velocity;    UpdateState();  }
    void UpdateState () { velocity = body.velocity; if (onGround) { jumpPhase = 0; } }


    從現(xiàn)在開始,每次跳躍時,我們都會增加跳躍階段。我們可以在地面上或尚未達到允許的最大空中跳躍時跳躍。

  •   void Jump () {    if (onGround|| jumpPhase < maxAirJumps) {      jumpPhase += 1;      velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);    }  }


    應該<= maxAirJumps不是嗎?

    跳轉(zhuǎn)后,跳轉(zhuǎn)階段立即設置回零。在下一個教程中,我們將找到原因。






    限制向上速度


    快速連續(xù)跳躍的空氣使向上的速度比單次跳躍的速度高得多。我們將進行更改,以使我們不能超過單跳即可達到所需高度的跳速。第一步是隔離計算出的跳躍速度Jump。

  •       jumpPhase += 1;      float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);      velocity.y +=jumpSpeed;


    如果我們已經(jīng)有向上的速度,則在將其添加到速度的Y分量之前,將其從跳躍速度中減去。這樣,我們將永遠不會超過跳躍速度。

  •       float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);      if (velocity.y > 0f) {        jumpSpeed = jumpSpeed - velocity.y;      }      velocity.y += jumpSpeed;


    但是,如果我們已經(jīng)快于跳躍速度,那么我們不希望跳躍使我們減速。我們可以通過確保修改后的跳轉(zhuǎn)速度永遠不會變?yōu)樨撝祦肀苊膺@種情況。通過采用修改后的最大跳躍速度和零來實現(xiàn)。

  •       if (velocity.y > 0f) {        jumpSpeed =Mathf.Max(jumpSpeed - velocity.y, 0f);      }





    空中運動


    目前,我們在控制球體時不在乎球體是在地面上還是在空中,但可以理解,空中球體更難控制??刂频臄?shù)量可以在完全控制和完全控制之間變化。這取決于游戲。因此,通過添加單獨的最大空氣加速度(默認設置為1),使它可配置。這樣可以大大減少空中控制,但不能完全將其刪除。

  •   [SerializeField, Range(0f, 100f)]  float maxAcceleration = 10f, maxAirAcceleration = 1f;



    現(xiàn)在,我們在FixedUpdate計算最大速度變化時使用哪種加速度取決于我們是否在地面上。

  •     float acceleration = onGround ? maxAcceleration : maxAirAcceleration;    float maxSpeedChange =acceleration* Time.deltaTime;






    連續(xù)下坡


    我們正在使用物理學在一個小的平面上移動球體,與墻碰撞并四處跳躍。一切都很好,因此是時候考慮更復雜的環(huán)境了。在本教程的其余部分中,我們將研究涉及坡度時的基本運動。




    ProBuilder測試場景


    您可以通過旋轉(zhuǎn)平面或立方體來創(chuàng)建坡度,但這是創(chuàng)建關卡的不便方法。因此,我們將導入ProBuilder程序包,并使用該程序包創(chuàng)建一些坡度。該ProGrids包也得心應手柵格捕捉,但如果你碰巧使用,它不是在統(tǒng)一2019.3需要。ProBuilder使用起來相當簡單,但是可能需要一些時間來適應。我不會解釋如何使用它,只是要記住,它主要是關于臉的,而邊緣和頂點是次要的。


    我從ProBuilder立方體開始創(chuàng)建了一個坡度,將其拉伸到10×5×3,在X維度上將其拉伸了10個單位,然后將X面折疊到其底部邊緣。這將產(chǎn)生一個三角形的雙斜面,其兩側(cè)的斜率長為10個單位,高為5個單位。



    我將其中十個放置在一個平面上,并將它們的高度從一單位更改為十個單位。包括平坦的地面在內(nèi),我們獲得的傾斜角度大約為0.0°,5.7°,11.3°,16.7°,21.8°,26.6°,31.0°,35.0°,38.7°,42.0°和45.0°。


    之后,我又放置了十個斜坡,這次是從45°版本開始,然后將筆尖向每個傾斜的角度向左拉一個單位,直到最后得到一面垂直墻。這給我們提供了大約48.0°,51.3°,55.0°,59.0°,63.4°,68.2°,73.3°,78.7°,84.3°和90.0°的角度。


    通過將球體變成預制件并添加21個實例(從每個水平到完全垂直),每個坡度一個實例,我完成了測試場景。



    如果您不想自己設計關卡,可以從本教程的資源庫中獲取它。

    資源庫(Repository)

    https://bitbucket.org/catlikecodingunitytutorials/movement-02-physics/





    斜率測試


    因為所有球體實例都響應用戶輸入,所以我們可以同時控制它們。這樣就可以立即測試與多個傾斜角度相互作用時球體的行為。對于大多數(shù)這些測試,我將進入播放模式,然后連續(xù)按向右鍵。


    斜率測試。


    使用默認球體配置,我們可以看到前五個球體以幾乎完全相同的水平速度移動,而與傾斜角無關。第六個幾乎沒有經(jīng)過,而其余的則回滾或被陡峭的斜坡完全擋住了。


    因為大多數(shù)球體都有效地結(jié)束了飛行,所以我們將最大空氣加速度設置為零。這樣,我們只有在考慮到基礎上才考慮加速。



    空氣加速與零空氣加速之間的差異并不重要,因為它們飛出了斜坡。但是第六球現(xiàn)在不再到達另一側(cè),其他球也由于重力而提前停止。發(fā)生這種情況是因為它們的坡度太陡而無法保持足夠的動力。在第六球的情況下,其空氣加速度足以將其推向上方。





    接地角


    目前,我們使用0.9作為閾值來將某物歸類為不歸類,但這是任意的。我們可以使用0–1范圍內(nèi)的任何閾值。嘗試兩個極端會產(chǎn)生非常不同的結(jié)果。



    讓我們通過控制最大地面角度使閾值可配置,因為最大地面角度比坡度法線向量的Y分量更直觀。讓我們使用25°作為默認值。

  •   [SerializeField, Range(0f, 90f)]  float maxGroundAngle = 25f;



    當表面水平時,其法線向量的Y分量為1。對于完全垂直的墻,Y分量為零。Y分量根據(jù)傾斜角度在這些極端之間變化:它是該角度的余弦。我們在這里處理單位圓,其中Y是垂直軸,水平軸位于XZ平面中的某個位置。另一種說法是,我們正在查看向上矢量和表面法線的點積。



    組態(tài)的角度定義了仍算作地面的最小結(jié)果。讓我們的門檻存儲在一個領域,并通過Mathf.Cos計算它的一個OnValidate方法。這樣,當我們在播放模式下通過檢查器更改角度時,它將保持與角度同步。同時Awake調(diào)用它,以便在構建中對其進行計算。

  •   float minGroundDotProduct;
    void OnValidate () { minGroundDotProduct = Mathf.Cos(maxGroundAngle); } void Awake () { body = GetComponent<Rigidbody>(); OnValidate(); }


    我們以度為單位指定角度,但Mathf.Cos希望將其表示為弧度。我們可以通過乘以Mathf.Deg2Rad將其轉(zhuǎn)換。


    
    

  •     minGroundDotProduct = Mathf.Cos(maxGroundAngle* Mathf.Deg2Rad);


    現(xiàn)在我們可以調(diào)整最大地面角度,看看它如何影響球體的運動。從現(xiàn)在開始,我將角度設置為40°。

  •   void EvaluateCollision (Collision collision) {    for (int i = 0; i < collision.contactCount; i++) {      Vector3 normal = collision.GetContact(i).normal;      onGround |= normal.y >=minGroundDotProduct;    }  }






    在斜坡上跳躍


    無論當前球面的角度如何,我們的球體始終會直線向上跳躍。



    另一種方法是沿法線向量的方向跳離地面。每個坡度測試車道都會產(chǎn)生不同的跳躍,所以讓我們這樣做。


    我們需要跟蹤一個領域中的當前接觸法線,并在遇到地面接觸EvaluateCollision時將其存儲起來。

  •   Vector3 contactNormal;

    void EvaluateCollision (Collision collision) { for (int i = 0; i < collision.contactCount; i++) { Vector3 normal = collision.GetContact(i).normal; //onGround |= normal.y >= minGroundDotProduct; if (normal.y >= minGroundDotProduct) { onGround = true; contactNormal = normal; } } }


    但是,我們最終可能沒有觸及地面。在這種情況下,我們將使用up向量作為接觸法線,因此空氣跳躍仍然會直線上升。如果需要,將其在UpdateState中設置。

  •   void UpdateState () {    velocity = body.velocity;    if (onGround) {      jumpPhase = 0;    }    else {      contactNormal = Vector3.up;    }  }


    現(xiàn)在,我們必須將按跳躍速度縮放的跳躍接觸法線添加到跳躍時的速度上,而不是始終僅增加Y分量。這意味著跳躍高度表示我們在平坦地面或僅在空中時跳躍的距離。在斜坡上跳躍不會達到很高,但會影響水平速度。

  •   void Jump () {    if (onGround || jumpPhase < maxAirJumps) {      //velocity.y += jumpSpeed;      velocity += contactNormal * jumpSpeed;    }  }


    但這意味著對垂直速度為正的檢查也不再正確。它必須成為檢查與接觸法線對齊速度的方法。我們可以通過將速度投影到接觸法線上并通過計算它們的點積Vector3.Dot來找到該速度。

  •       float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);      float alignedSpeed = Vector3.Dot(velocity, contactNormal);      if (alignedSpeed> 0f) {        jumpSpeed = Mathf.Max(jumpSpeed -alignedSpeed, 0f);      }      velocity += contactNormal * jumpSpeed;



    現(xiàn)在,這些跳躍與坡度對齊,我們的測試場景中的每個球體都具有唯一的跳躍軌跡。陡峭的斜坡上的球不再直接跳入其斜坡,而是隨著跳躍將球朝與運動相反的方向推動而變慢。您可以通過大幅降低最大速度來嘗試在所有斜坡上更清楚地看到這一點。






    沿著斜坡移動


    到目前為止,無論傾斜角度如何,我們始終在水平XZ平面中定義所需的速度。如果球體沿坡度上升,那是因為PhysX將球向上推以解決發(fā)生的碰撞,因為我們給它指定了指向坡度的水平速度。在上坡時,這可以很好地工作,但是在下坡時,球體會遠離地面移動,并且當它們的加速度足夠高時最終會掉落。結(jié)果是難以控制的彈性運動。在上坡時反轉(zhuǎn)方向時,尤其是在將最大加速度設置為較高值時,您可以清楚地看到這一點。


    失去接地;最大加速度100。


    我們可以通過將所需速度與地面對齊來避免這種情況。它的工作方式與我們在法線上投影速度以獲得跳躍速度的方式類似,只是現(xiàn)在我們必須在平面上投影速度才能獲取新速度。我們通過像以前一樣取向量和法線的點積,然后從原始速度向量中減去由該法線縮放的法線來做到這一點。讓我們?yōu)槭褂萌我馐噶繀?shù)的方法創(chuàng)建一個方法ProjectOnContactPlane。


  •   Vector3 ProjectOnContactPlane (Vector3 vector) {    return vector - contactNormal * Vector3.Dot(vector, contactNormal);  }
    
    

    為什么不使用Vector3.ProjectOnPlane?

    該方法執(zhí)行相同的操作,但不假定提供的法向向量具有單位長度。它將結(jié)果除以法線的平方長度(通常為1,因此不需要)。


    讓我們創(chuàng)建一個新方法AdjustVelocity來調(diào)整速度。首先通過在接觸平面上投影右向向量和向前向量來確定投影的X軸和Z軸。

  •   void AdjustVelocity () {    Vector3 xAxis = ProjectOnContactPlane(Vector3.right);    Vector3 zAxis = ProjectOnContactPlane(Vector3.forward);  }


    這使我們的向量與地面對齊,但是當?shù)孛嫱耆教箷r,它們只有單位長度。通常,我們必須對向量進行歸一化以獲得正確的方向。

  •     Vector3 xAxis = ProjectOnContactPlane(Vector3.right).normalized;    Vector3 zAxis = ProjectOnContactPlane(Vector3.forward).normalized;


    現(xiàn)在,我們可以將當前速度投影到兩個向量上,以獲得相對的X和Z速度。

  •     Vector3 xAxis = ProjectOnContactPlane(Vector3.right).normalized;    Vector3 zAxis = ProjectOnContactPlane(Vector3.forward).normalized;
    float currentX = Vector3.Dot(velocity, xAxis); float currentZ = Vector3.Dot(velocity, zAxis);


    我們可以像以前一樣使用它們來計算新的X和Z速度,但是現(xiàn)在相對于地面。

  •     float currentX = Vector3.Dot(velocity, xAxis);    float currentZ = Vector3.Dot(velocity, zAxis);
    float acceleration = onGround ? maxAcceleration : maxAirAcceleration; float maxSpeedChange = acceleration * Time.deltaTime;
    float newX = Mathf.MoveTowards(currentX, desiredVelocity.x, maxSpeedChange); float newZ = Mathf.MoveTowards(currentZ, desiredVelocity.z, maxSpeedChange);


    最后,通過沿相對軸添加新舊速度之間的差異來調(diào)整速度。

  •     float newX =      Mathf.MoveTowards(currentX, desiredVelocity.x, maxSpeedChange);    float newZ =      Mathf.MoveTowards(currentZ, desiredVelocity.z, maxSpeedChange);
    velocity += xAxis * (newX - currentX) + zAxis * (newZ - currentZ);


    FixedUpdate代替舊的速度調(diào)節(jié)代碼,調(diào)用此新方法。

  •   void FixedUpdate () {    UpdateState();    AdjustVelocity();    //float acceleration = onGround ? maxAcceleration : maxAirAcceleration;    //float maxSpeedChange = acceleration * Time.deltaTime;
    //velocity.x = // Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange); //velocity.z = // Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);
    if (desiredJump) { desiredJump = false; Jump(); } body.velocity = velocity; onGround = false; }


    與地面保持一致;最大加速度100。


    使用我們新的速度調(diào)整方法,當在斜坡上突然突然反轉(zhuǎn)方向時,球不再與地面失去接觸。除此之外,由于期望速度會調(diào)整其方向以匹配斜率,因此現(xiàn)在每個車道都會改變絕對期望水平速度。



    請注意,如果坡度未與X軸或Z軸對齊,則相對投影軸之間的角度將不為90°。除非斜坡非常陡峭,否則這并不是很明顯。您仍然可以在所有方向上移動,但是要精確地在某些方向上進行導航比在其他方向上更難。這在某種程度上模仿了試圖穿越但不與陡坡對齊的尷尬。





    多個地面法線


    當只有一個地面接觸點時,使用接觸法線來調(diào)整所需的速度和跳躍方向效果很好,但是當同時存在多個地面接觸時,行為可能會變得奇怪且不可預測。為了說明這一點,我創(chuàng)建了另一個測試場景,該測試場景的地面有些凹陷,一次最多可以有四個接觸點。



    跳躍時,球體會朝哪個方向前進?就我而言,擁有四個聯(lián)系人的人傾向于偏向一個方向,但最終會朝四個不同方向前進。同樣,具有兩個接觸的球體在兩個方向之間任意拾取。具有三個接觸的球始終以相同的方式跳躍,以匹配僅接觸單個坡度的附近球。



    出現(xiàn)這種現(xiàn)象的原因是,只要我們發(fā)現(xiàn)地面接觸點,便將法線設置為EvaluateCollision。因此,如果我們發(fā)現(xiàn)多個,則最后一個贏。由于移動的順序是任意的,或者由于PhysX計算碰撞的順序,順序總是相同的。


    哪個方向最好?沒有一個。將它們?nèi)拷M合成一個代表平均接地平面的法線是最有意義的。為此,我們必須累積法線向量。這就要求我們在FixedUpdate的末尾將接觸法線設置為零。讓我們將代碼與onGround重置一起放入新方法ClearState中。

  •   void FixedUpdate () {    body.velocity = velocity;    //onGround = false;    ClearState();  }
    void ClearState () { onGround = false; contactNormal = Vector3.zero; }


    現(xiàn)在在EvaluateCollision累積法線而不是覆蓋前一個法線。

  •   void EvaluateCollision (Collision collision) {    for (int i = 0; i < collision.contactCount; i++) {      Vector3 normal = collision.GetContact(i).normal;      if (normal.y >= minGroundDotProduct) {        onGround = true;        contactNormal+=normal;      }    }  }


    最后,將UpdateState中在地面上的接觸法線歸一化以使其成為適當?shù)姆ň€向量。

  •   void UpdateState () {    velocity = body.velocity;    if (onGround) {      jumpPhase = 0;      contactNormal.Normalize();    }    else {      contactNormal = Vector3.up;    }  }






    地面接觸點計算


    雖然不是必需的,但我們可以算出我們有多少個地面接觸點,而不僅僅是跟蹤是否至少有一個。我們通過將布爾字段替換為整數(shù)來做到這一點。然后,我們引入一個布爾型只讀屬性OnGround(注意大小寫),該屬性檢查計數(shù)是否大于零,并替換該onGround字段。



  •   //bool onGround;  int groundContactCount;
    bool OnGround => groundContactCount > 0;


    該代碼如何工作?

    這是定義單語句只讀屬性的一種簡便方法。與以下內(nèi)容相同:



    bool

    OnGround {



    ????get

    {



    ????????return

    groundContactCount > 0;


    ????}


    }


    ClearState 現(xiàn)在必須將計數(shù)設置為零。

  •   void ClearState () {    //onGround = false;    groundContactCount = 0;    contactNormal = Vector3.zero;  }


    并且UpdateState必須依靠屬性而不是字段。除此之外,我們還可以通過僅對接觸法線進行歸一化(如果是聚合的話)進行歸一化來進行一些優(yōu)化,否則它已經(jīng)是單位長度了。

  •   void UpdateState () {    velocity = body.velocity;    if (OnGround) {      jumpPhase = 0;      if (groundContactCount > 1) {        contactNormal.Normalize();      }    }  }


    還要在Evaluate適當?shù)臅r候增加計數(shù)。

  •   void EvaluateCollision (Collision collision) {    for (int i = 0; i < collision.contactCount; i++) {      Vector3 normal = collision.GetContact(i).normal;      if (normal.y >= minGroundDotProduct) {        //onGround = true;        groundContactCount += 1;        contactNormal += normal;      }    }  }


    最后,用OnGroundAdjustVelocityJump更換onGround。


    除了UpdateState中地面接觸數(shù)量的優(yōu)化,對調(diào)試也很有用。例如,您可以記錄計數(shù)或根據(jù)計數(shù)調(diào)整球體的顏色,以更好地了解其狀態(tài)。


    您是如何改變顏色的?

    我將以下代碼添加到Update:


    GetComponent<Renderer>().material.SetColor(
    ????????????????"_Color", Color.white * (groundContactCount * 0.25f)
    );

    假定球體的材質(zhì)具有_Color屬性,默認渲染管線的標準著色器就是這種情況。如果您使用的是Lightweight / Universal管道的默認著色器,則需要使用_BaseColor。

    下一個教程是表面接觸(Surface Contact)。

    資源庫(Repository)

    https://bitbucket.org/catlikecodingunitytutorials/movement-02-physics/


    往期精選

    Unity3D游戲開發(fā)中100+效果的實現(xiàn)和源碼大全 - 收藏起來肯定用得著

    Shader學習應該如何切入?

    UE4 開發(fā)從入門到入土


    聲明:發(fā)布此文是出于傳遞更多知識以供交流學習之目的。若有來源標注錯誤或侵犯了您的合法權益,請作者持權屬證明與我們聯(lián)系,我們將及時更正、刪除,謝謝。

    原作者:Jasper Flick

    原文:

    https://catlikecoding.com/unity/tutorials/movement/physics/

    翻譯、編輯、整理:MarsZhou


    More:【微信公眾號】?u3dnotes

  • ?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
    【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
    平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

    友情鏈接更多精彩內(nèi)容