1. 程式人生 > 實用技巧 >喵的Unity遊戲開發之路 - 推球:遊戲中的物理

喵的Unity遊戲開發之路 - 推球:遊戲中的物理

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

  • 控制剛體球體的速度。

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

  • 檢測地面及其角度。

  • 使用ProBuilder建立測試場景。

  • 沿斜坡移動。

這是有關控制角色移動的教程系列的第二部分。這次,我們將使用物理引擎建立更逼真的運動並支援更復雜的環境。

本教程使用Unity 2019.2.11f1製作。它還使用ProBuilder軟體包。

最終效果之一

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

剛體

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

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

剛體元件

我們將使用第一種方法來控制球體,這意味著我們必須向其中新增一個Rigidbody

元件。我們可以使用剛體的預設配置。

新增該分量足以將我們的球體變成一個物理物件,只要它仍然具有其SphereCollider分量即可。從現在開始,我們推遲到物理引擎進行碰撞,因此從中刪除區號Update

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

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

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

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

      如果我們仍然想約束球體保留在平面上,則可以通過新增其他物件來阻止其路徑來實現。例如,建立四個立方體,對其進行縮放和定位,以便它們圍繞平面形成一堵牆。這將防止球體掉落,儘管它在與牆壁碰撞時表現得很怪異。由於此時我們具有3D幾何形狀,因此再次啟用陰影以更好地瞭解深度也是一個好主意。

      物理怪異。

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

      控制剛體速度

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

      我們已經對位置進行了間接控制,因為我們會影響速度。我們要做的就是更改程式碼,使其覆蓋Rigidbody元件的速度,而不是自己調整位置。我們需要為此訪問元件,因此通過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中檢索出來,然後再對其進行調整以匹配所需的速度。

    •     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的速度。

      無摩擦運動

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

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

      現在,它不再受到任何摩擦或反彈。

      不建議不要直接調節速度嗎?

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

      無摩擦運動。

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

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

      而且,球體現在可以滑動而不是滾動,因此我們也可以凍結其在所有尺寸上的旋轉,這可以通過元件的“約束”複選框來完成Rigidbody

      固定更新

      物理引擎使用固定的時間步長,而不管幀速率如何。儘管我們已經將球的控制權交給了PhysX,但我們仍然會影響其速度。為了獲得最佳結果,我們應該以固定的時間步長同步調整速度。為此,我們將Update方法分為兩部分。我們檢查輸入並設定所需速度的部分可以保留在Update中,而速度的調整應移至新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在每個物理模擬步驟的開始都呼叫該方法。發生的頻率取決於時間步長,預設為0.02(每秒50次),但是您可以通過“時間”專案設定或通過更改時間步長Time.fixedDeltaTime

      根據您的幀速率FixedUpdate,每次呼叫可以呼叫0次,一次或多次Update。每個框架都會發生一系列FixedUpdate呼叫,然後Update被呼叫,然後呈現框架。當物理時間步長相對於幀時間太大時,這可以使物理模擬的離散性質變得明顯。

      0.2物理時間步。

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

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

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

      跳躍

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

      根據指令跳躍

      我們可以用Input.GetButtonDown("Jump")來檢測玩家是否按下了該幀的跳轉按鈕,預設情況下是空格鍵。我們在Update中這樣做,但是就像調整速度一樣,我們會將實際的跳躍延遲到FixedUpdate的下次呼叫。因此,請通過布林欄位desiredJump跟蹤是否需要跳轉。

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

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

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

      在調整速度之後和在FixedUpdate中應用速度之前,請檢查是否需要跳躍。如果是這樣,請重置desiredJump並呼叫一個新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是一個持續不斷的加速度,將我們拉倒,為此我們在此推導中使用正數,因為這使我們免於編寫大量負號。所以在任何時候Ť因為跳躍的垂直速度是 v = jg t。什麼時候v達到零,我們位於跳躍的頂部,因此正好位於所需的高度。這發生在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)

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

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

      在地面的跳躍

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

      如果MovingSphere有一個OnCollisionEnter方法,那麼它將在PhysX檢測到新的碰撞後被呼叫。只要物體保持彼此接觸,碰撞就仍然存在。之後,OnCollisionExit將呼叫一個方法(如果存在)。將兩種方法都新增到MovingSphere中,將第一個onGroundboolean欄位設定為true,並將後者boolean欄位設定為false

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

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

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

      當球體僅接觸地面時,此方法有效,但如果它也短暫接觸牆,則跳躍將變得不可能。之所以發生這種情況,是因為OnCollisionExit在我們仍與地面保持接觸的同時,它被作為牆壁使用。解決方案是不依賴OnCollisionExit而是新增一種OnCollisionStay方法,只要碰撞仍然存在,就可以在每個物理步驟中呼叫該方法。設定onGroundtrue在該方法中。

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

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

    •   void FixedUpdate () {    onGround = false;  }

      現在,只要我們接觸到某物,我們就可以跳躍。

      無牆跳躍

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

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

      什麼是法向量?

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

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

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

      我們可以通過向和Collision都新增一個引數來獲取碰撞資訊。與其直接設定onGround 為true,我們不如將責任轉交給一種新方法EvaluateCollision ,並將資料給它。

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

      可以通過Collision的contactCount屬性找到接觸點的數量。我們可以使用它通過該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;
      
      

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

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

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

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

      應該<= maxAirJumps不是嗎?

      跳轉後,跳轉階段立即設定回零。在下一個教程中,我們將找到原因。

      限制向上速度

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

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

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

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

      但是,如果我們已經快於跳躍速度,那麼我們不希望跳躍使我們減速。我們可以通過確保修改後的跳轉速度永遠不會變為負值來避免這種情況。通過採用修改後的最大跳躍速度和零來實現。

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

      空中運動

      目前,我們在控制球體時不在乎球體是在地面上還是在空中,但可以理解,空中球體更難控制。控制的數量可以在完全控制和完全控制之間變化。這取決於遊戲。因此,通過新增單獨的最大空氣加速度(預設設定為1),使它可配置。這樣可以大大減少空中控制,但不能完全將其刪除。

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

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

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

      連續下坡

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

      ProBuilder測試場景

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

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

      我將其中十個放置在一個平面上,並將它們的高度從一單位更改為十個單位。包括平坦的地面在內,我們獲得的傾斜角度大約為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/

      斜率測試

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

      斜率測試。

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

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

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

      接地角

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

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

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

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

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

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

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

      
      

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

      現在我們可以調整最大地面角度,看看它如何影響球體的運動。從現在開始,我將角度設定為40°。

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

      在斜坡上跳躍

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

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

      我們需要跟蹤一個領域中的當前接觸法線,並在遇到地面接觸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;    }  }

      現在,我們必須將按跳躍速度縮放的跳躍接觸法線新增到跳躍時的速度上,而不是始終僅增加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;

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

      沿著斜坡移動

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

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

      我們可以通過將所需速度與地面對齊來避免這種情況。它的工作方式與我們在法線上投影速度以獲得跳躍速度的方式類似,只是現在我們必須在平面上投影速度才能獲取新速度。我們通過像以前一樣取向量和法線的點積,然後從原始速度向量中減去由該法線縮放的法線來做到這一點。讓我們為使用任意向量引數的方法建立一個方法ProjectOnContactPlane。

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

      為什麼不使用Vector3.ProjectOnPlane?

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

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

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

      這使我們的向量與地面對齊,但是當地面完全平坦時,它們只有單位長度。通常,我們必須對向量進行歸一化以獲得正確的方向。

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

      現在,我們可以將當前速度投影到兩個向量上,以獲得相對的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速度,但是現在相對於地面。

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

      最後,通過沿相對軸新增新舊速度之間的差異來調整速度。

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

      FixedUpdate代替舊的速度調節程式碼,呼叫此新方法。

    •   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。

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

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

      多個地面法線

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

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

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

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

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

      現在在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中在地面上的接觸法線歸一化以使其成為適當的法線向量。

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

      地面接觸點計算

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

      
      

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

      該程式碼如何工作?

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

      bool

      OnGround {

      get

      {

      return

      groundContactCount > 0;

      }

      }

      ClearState現在必須將計數設定為零。

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

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

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

      還要在Evaluate適當的時候增加計數。

    •   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中地面接觸數量的優化,對除錯也很有用。例如,您可以記錄計數或根據計數調整球體的顏色,以更好地瞭解其狀態。

      您是如何改變顏色的?

      我將以下程式碼新增到Update:

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

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

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

      資源庫(Repository)

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


      往期精選

      Unity3D遊戲開發中100+效果的實現和原始碼大全 - 收藏起來肯定用得著

      Shader學習應該如何切入?

      UE4 開發從入門到入土


      宣告:釋出此文是出於傳遞更多知識以供交流學習之目的。若有來源標註錯誤或侵犯了您的合法權益,請作者持權屬證明與我們聯絡,我們將及時更正、刪除,謝謝。

      原作者:Jasper Flick

      原文:

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

      翻譯、編輯、整理:MarsZhou


      More:【微信公眾號】u3dnotes