誕生
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狀態機一同介紹,感謝