現在我們了解了角色控制器的基本碰撞處理,接下來馬上就演示如何在Unity中實現上一章所展示的效果。
在開始以前,先確保你的Unity是否已經完成下載安裝。這篇文章中所使用的版本是Unity 4.3.4f1。(檢查Unity版本的方法是Help->About Unity)打開一個現有的工程或者創建一個新的來開始這篇教程。創建一個新的場景(Scene),然後創建一個立方體(Cube)和一個球體(Sphere)。雖然我們最終會用膠囊體作為我們的控制器形狀,但是剛開始還是讓事情保持簡單一些。將球體命名為Player,立方體命名為Wall。改變牆體每個軸的縮放到6。為了更加形象,我還給Player加了藍色的材質,給Wall加了綠色的材質。將Player上的Sphere Collider組件移除掉。
創建新的C#腳本,然後命名為SuperCharacterController.cs。為了表示我們的角色,拷貝和粘貼一下腳本,然後掛到Player身上:
using UnityEngine;
using System;
using System.Collections.Generic;
public class SuperCharacterController : MonoBehaviour {
[SerializeField]
float radius = 0.5f;
private bool contact;
// Update is called once per frame void Update () {
contact = false;
foreach (Collider col in Physics.OverlapSphere(transform.position, radius))
{
Vector3 contactPoint = col.ClosestPointOnBounds(transform.position);
Vector3 v = transform.position - contactPoint;
transform.position += Vector3.ClampMagnitude(v, Mathf.Clamp(radius - v.magnitude, 0, radius));
contact = true;
}
}
void OnDrawGizmos()
{
Gizmos.color = contact ? Color.cyan : Color.yellow;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
然後就完了。運行項目並且打開場景。將Player往牆體的邊緣慢慢拖過去。你可以看到牆體在反推,讓Player總是停留在邊緣。那麼這裡做了什麼呢?
Physics.OverlapSphere返回了與球體發生碰撞的一組Collider。這是個很好用的函數,參數很簡單。只需要傳入球心與半徑就可以了。
一旦檢測到任何碰撞,我們就會開始處理。為了找到box collider上的最近點,我們用了ClosestPointOnBounds函數。緊接著我們就可以通過contactPoint得到我們的位置。contactPoint的長度就是我們所需要推出去的距離。
你可能會注意到,我實現了OnDrawGizmos函數,這樣OverlapSphere的碰撞就一清二楚了。
兩幀演示了碰撞被檢測,而後被處理。
相當簡單。但是我們至今為止的勝利可能只是個開始。創建一個DebugDraw.cs的類,然後添加如下代碼。
using UnityEngine;
using System.Collections;
public static class DebugDraw {
public static void DrawMarker(Vector3 position, float size, Color color, float duration, bool depthTest = true)
{
Vector3 line1PosA = position + Vector3.up * size * 0.5f
Vector3 line1PosB = position - Vector3.up * size * 0.5f;
Vector3 line2PosA = position + Vector3.right * size * 0.5f;
Vector3 line2PosB = position - Vector3.right * size * 0.5f;
Vector3 line3PosA = position + Vector3.forward * size * 0.5f;
Vector3 line3PosB = position - Vector3.forward * size * 0.5f;
Debug.DrawLine(line1PosA, line1PosB, color, duration, depthTest);
Debug.DrawLine(line2PosA, line2PosB, color, duration, depthTest);
Debug.DrawLine(line3PosA, line3PosB, color, duration, depthTest);
}
}
這是一個我寫的挺有用的幫助函數,它可以讓我們在任何地方繪製在編輯器中(與此相對的,我們只能在OnDrawGizmos函數中繪製)。修改foreach循環如下。
foreach (Collider col in Physics.OverlapSphere(transform.position, radius))
{
Vector3 contactPoint = col.ClosestPointOnBounds(transform.position);
DebugDraw.DrawMarker(contactPoint, 2.0f, Color.red, 0.0f, false);
Vector3 v = transform.position - contactPoint;
transform.position += Vector3.ClampMagnitude(v, Mathf.Clamp(radius - v.magnitude, 0, radius));
contact = true;
}
運行代碼,你會注意到當碰撞發生的時候,會在位置上繪製一個紅色十字標記。現在,拖動player到牆體內,就能看到標記跟隨者player。這對於ClosestPointOnBounds函數來說也不完全是個錯誤,但是如果要對應上上回提到的退回策略,我們真的希望有一個ClosestPointOnSurfaceOfBoundsOrSomething函數。
問題就在於當我們的角色在碰撞體內部的時候,隨著返回最近點函數失效,沒法正確地處理碰撞。現在,我們就來處理這個問題。
將我們的牆體在y軸上旋轉大概20度,然後運行場景。你回發現一切都不正常了。這是因為ClosestPointOnBounds函數返回的最近點實在軸對齊包圍盒(AABB)上,而不是朝向包圍盒(OBB)上。
你可能已經在想如果這個問題擴展一下,不僅僅是盒子會是怎樣。由於函數只能返回軸對齊包圍盒的最近點,哪怕是其他類型的碰撞,它也是肯定沒法得到表面的最近點的。因此這個問題是沒有銀彈的(也許是我沒發現),我們只能每個碰撞類型自己實現。
先讓我們從最簡單的開始:球體碰撞。在場景中創建一個新的球體遊戲對象。找到表面上的最近點需要好幾步,每一步都比較簡單。要知道哪個方向推出玩家,我們計算從我們為之到球體中心的方向。由於球體表面每個點距離球心都一樣,我們只要正規化我們的向量,然後乘以半徑以及local scale因子即可。
下面是代碼實現。你可以看到新的方法中,多加了一個檢測當前OverlapSphere的碰撞類型。
using UnityEngine;
using System;
using System.Collections.Generic;
public class SuperCharacterController : MonoBehaviour {
[SerializeField]
float radius = 0.5f;
private bool contact;
// Update is called once per frame void Update () {
contact = false;
foreach (Collider col in Physics.OverlapSphere(transform.position, radius))
{
Vector3 contactPoint = Vector3.zero;
if (col is BoxCollider)
{
contactPoint = col.ClosestPointOnBounds(transform.position);
}
else if (col is SphereCollider)
{
contactPoint = ClosestPointOn((SphereCollider)col, transform.position);
}
DebugDraw.DrawMarker(contactPoint, 2.0f, Color.red, 0.0f, false);
Vector3 v = transform.position - contactPoint;
transform.position += Vector3.ClampMagnitude(v, Mathf.Clamp(radius - v.magnitude, 0, radius));
contact = true;
}
}
Vector3 ClosestPointOn(SphereCollider collider, Vector3 to)
{
Vector3 p;
p = to - collider.transform.position;
p.Normalize();
p *= collider.radius * collider.transform.localScale.x;
p += collider.transform.position;
return p;
}
void OnDrawGizmos()
{
Gizmos.color = contact ? Color.cyan : Color.yellow;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
機智的讀者可能會發現ClosestPointOn實際上返回的是球體表面的最近點。不像ClosestPointOnBounds返回的是包圍盒的最近點。這很簡單,但是在使用之前還有一些問題要解決。現在來看看第二種(也是今天的最後一種)碰撞類型的實現:朝向包圍盒。
圖像演示了如何通過球心與控制器位置之間的向量推算出最近點。
我們的一般做法是獲取輸入,然後clamp到box內部。這樣的效果與內建的ClosestPointOnBounds是一樣的,除了即使box帶旋轉也能處理之外。
Box Collider的擴展定義了局部大小x、y和z。為了將我們的點clamp到Box Collider內部,我們需要將作為從世界坐標系轉換到Box Collider的局部坐標系。在完成之後,我們對位置clamp到包圍盒內即可。最後,我們再將改點轉換回世界坐標系。代碼如下。
using UnityEngine;
using System;
using System.Collections.Generic;
public class SuperCharacterController : MonoBehaviour {
[SerializeField]
float radius = 0.5f;
private bool contact;
// Update is called once per frame void Update () {
contact = false;
foreach (Collider col in Physics.OverlapSphere(transform.position, radius))
{
Vector3 contactPoint = Vector3.zero;
if (col is BoxCollider)
{
contactPoint = ClosestPointOn((BoxCollider)col, transform.position);
}
else if (col is SphereCollider)
{
contactPoint = ClosestPointOn((SphereCollider)col, transform.position);
}
DebugDraw.DrawMarker(contactPoint, 2.0f, Color.red, 0.0f, false);
Vector3 v = transform.position - contactPoint;
transform.position += Vector3.ClampMagnitude(v, Mathf.Clamp(radius - v.magnitude, 0, radius));
contact = true;
}
}
Vector3 ClosestPointOn(BoxCollider collider, Vector3 to)
{
if (collider.transform.rotation == Quaternion.identity)
{
return collider.ClosestPointOnBounds(to);
}
return closestPointOnOBB(collider, to);
}
Vector3 ClosestPointOn(SphereCollider collider, Vector3 to)
{
Vector3 p;
p = to - collider.transform.position;
p.Normalize();
p *= collider.radius * collider.transform.localScale.x;
p += collider.transform.position;
return p;
}
Vector3 closestPointOnOBB(BoxCollider collider, Vector3 to)
{
// Cache the collider transform var ct = collider.transform;
// Firstly, transform the point into the space of the collider var local = ct.InverseTransformPoint(to);
// Now, shift it to be in the center of the box local -= collider.center;
// Inverse scale it by the colliders scale var localNorm =
new Vector3(
Mathf.Clamp(local.x, -collider.size.x * 0.5f, collider.size.x * 0.5f),
Mathf.Clamp(local.y, -collider.size.y * 0.5f, collider.size.y * 0.5f),
Mathf.Clamp(local.z, -collider.size.z * 0.5f, collider.size.z * 0.5f)
);
// Now we undo our transformations localNorm += collider.center;
// Return resulting point return ct.TransformPoint(localNorm);
}
void OnDrawGizmos()
{
Gizmos.color = contact ? Color.cyan : Color.yellow;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
你可能會注意到在主碰撞循環中做了一些修改,使得我們不管是軸對齊還是朝向的用ClosesPointOn就可以了。這裡的大部分實現都參考自fholm的RPGController package。
到這裡我們第一部分的實現就結束了。在後面的文章中,我會講講Unity物理API會遇到的一些問題。然後開始為實現理想中的角色控制器開發一些組件。
這篇文章主要的代碼參考自fholm的RPGController package。其中的推出來自RPGMotor.cs,最近點來自RPGCollisions.cs。