遊戲開發日誌 #05


這個世界充滿了物理法則,遊戲世界亦一樣。
我們習慣了現實世界的物理環境和慣性,也會把同樣的感知力放進遊戲世界。
若果遊戲世界的物理環境和慣性跟現實世界有出入,就會產生違和感。

不過現實卻是。。。

「即使遊戲引擎使用現實世界的物理法則去運算和表現物理效果,人還是會產生違和感。」

到底是為什麼呢?

物理和感知力

對非進修物理學的人,要說物理現象的話,第一件會想到的相信是重力 Gravity。

我們活在地球的重力中,但地球的重力有多大?
不論重量如何,物件都會以 9.81 m/s2 的加速度向地球的中心移動。
物件沒有真的到達地球中心,全因有承受物件質量的物質存在,例如我們踏著的大地。

但生物的感知力並沒有被物理所規範,生物所感受到的現象有很多特點,而最日常和對遊戲世界最大的一個,就是:
  • 大的生物會覺得小的生物移動速度快,小的生物會覺得大的生物移動速度慢。
    相對大小越大,速度感差會越大。
我們會覺得蚊子或果蠅等昆蟲反應很快,而覺得大象和長頸鹿的反應很慢,即使是小貓小狗,我們也覺得他們的反應很快。
其實在蚊子或小貓小狗的眼中,我們人類的動作有如慢動作一樣。
我們會覺得細小的東西從桌子上掉到地面的速度很快很難捕捉,但其實跟我們從桌子上掉到地面的速度是一樣的。

這個感知力對遊戲世界有很大的影響。

當我們在現實世界看著遊戲世界時,即使遊戲世界做得跟現實世界一樣,我們控制的角色也是跟現實世界一樣的人類也好,我們也會有進入小人國的感知,也就是加速力的不同。

在遊戲的世界,角色的行走速度只要能配合場景和動作就沒有違和感,但如果是牽涉到重力的移動,例如跳動、下墜等動作,就會有角色飄浮而非下墜的感覺。

這也是很多遊戲開發者都說內建的物理引擎不行,效果不好,自己寫的最好,甚至像宗教一樣,硬要去推行自建物理,一有機會就說內建的物理引擎不行。
其實只要使用得宜,盡用內建物理引擎的功能,再加上自行擴建的物理修正,就可以把違和感消除。

這一篇,主要分享針對「重力」方面如何作出修正,也就是所謂的半物理系統。



Physics Engine 物理引擎


在 Unity 的物理管理員中,可以看到一些參數。
先把沒用的東西關掉,以降底物理運算。
  • World Bounds:在確保世界的中心是 0,0,0 後,Extent 就是世界的半徑了,把這個設置成遊戲世界的大小,再加大多 10 就可以。
  • Layer Collision Matrix:是剛體 Rigidbody 的碰撞偵測,把不須要碰撞的 Layer 關掉。
基本就是這兩項,重力 Gravity 不用更改。


在角色的中,把 Rigidbody 的 Use Gravity 關掉,我們不使用引擎的重力。
加入自己的 CustomPhysics.cs 來作重力修正。
把角色的 Collider 拉到 CustomPhysics 中的 Col。
其他的東西保留就可以。


Custom Physics 自定義物理

這次進行的物理修正,只針對以下幾項:
  • 重力:不需要重力時就沒有重力
  • 上坡移動:上坡角度限制、上坡重力修正
  • 意外離地重力修正
  • 下墜重力修正
由於只為本人的遊戲設計作考量,遊戲本身不是 Open World 開放世界,所以絕不是萬用,所以這次只是分享自己使用中的理論,因此也不分享檔案,只分享程序碼。


Sample Video 示範影片


影片中的場景中有三個上坡,角度從左到右是 60, 45 25。

在影片的前半,使用內建物理引擎的重力設定,會看到角色上坡時速度減慢,從高處墜下時也有飄浮感;當轉到自訂的重力修正就沒問題了。

在影片的後半,沒有重力修正時,角色是以物理的加速力來計算移動,所以中間的 45 度上坡也可以爬上去,但左邊的 60 度就不行;當使用自訂的重力修正後,由於有設置上坡限制,所以中間的上坡也爬不上去。


Coding 程序碼

*有一些小修正

<必要元件 Components>

[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(CapsuleCollider))]

首先,必要的 Components 是 Rigidbody 和 Capsule Collider 碰撞器
因為只對重力作修正,保留了內建的碰撞偵測,所以剛體是必要的,在半物理系統下使用 Rigidbody 移動也是最理想的。
需要 Collider 的原因,為了自動取得物件的半徑,也可以在今後做自訂碰撞偵測作準備,需然沒有這個打算。

<參數 Parameters>

   [Header("Settings")]
    public LayerMask groundMask;
    public float radius = 0f;
    public float colliderSkin = 0.05f;
    public float gravity = 50f;
    public float maxSlope = 45f;

    [Header("Components")]
    public CapsuleCollider col;
    private Rigidbody rb;

    [Header("Public Parameters")]
    public bool isGrounded;
    public float inAirHeight;

    // Others
    // You can public following variables for your use.
    private float currentGravity;
    private bool isFalling = false;
    private bool isClimbing = false;
    private bool IsMoving
    {
        get { return rb.position - oldPosition != Vector3.zero; }
    }
    private bool isTouchingGround;
    private float radius;
    private float slopeAngle;
    private float platformAngle;
    private Vector3 oldPosition;
    private List colGround;
    private RaycastHit hit;
    private Vector3 RayPos
    {
        get { return rb.position.y(col.bounds.min.y); }
    }

設定參數:
  • groundMask:Ground Layer
  • colliderSkin:物件碰撞器的皮膚厚度
  • gravity:自訂重力
  • maxSlope:上坡上限角度
  • col:碰撞器,由於用了萬用碰撞器,所以必須自己在 Inspector 中設置。
分享參數:
  • isGrounded:是否著地
  • inAirHeight:離地距離


<初始化 Initialization>

    private void Awake()
    {
        if (!rb)
            rb = GetComponent();
        if (!col)
            col = GetComponent();
        if (radius == 0)
            radius = col.bounds.extents.x;
    }

生成時,設好參數 rb、col 和 radius。
radius 是物件碰撞器的半徑。


<每物理循環 Every physics cycle>

    private void FixedUpdate()
    {
        // Detection
        CheckGround();
        CheckPlatform();
        CheckSlope();
      
        // Gravity Adjustment
        Climbing();
        Falling();

        // Record
        currentGravity = 
           Mathf.Clamp(-rb.velocity.y, 0, float.MaxValue).ToZero(0.0001f, false);
      
        // For next cycle
        oldPosition = rb.position;
    }

由於對 Rigidbody 進行修正,所以在 FixedUpdate 中執行。
這裡先做好偵測後,再進行重力的調整。

要執行的五件事情:

。CheckGround()
    private void CheckGround()
    {
        // Check grounded and height
        Vector3 pos = RayPos.y(RayPos.y + colliderSkin);
        Ray rayDown = new Ray(pos, Vector3.down);
        Physics.Raycast(rayDown, out hit, 10f, groundMask);
        inAirHeight = hit.transform ?
            hit.distance.ToZero(0.0001f, false) : 10f;

        // Check touching ground
        colGround = new List();
        pos = RayPos.y(RayPos.y + radius);
        foreach (Collider col in Physics.OverlapSphere(pos, radius + colliderSkin, groundMask))
        {
            if (!colGround.Contains(col.transform))
                colGround.Add(col.transform);
        }
        isTouchingGround = colGround.Count > 0;

        isGrounded = inAirHeight < 0.15f || isTouchingGround;
    }
檢查是否著地。(有修訂)
  • 在物件底部高一點(皮膚厚度)向下射出一條 10 長度 Ray,只偵察地面 layer,計算物件和打中地面的距離,套用在參數 (float)inAirHeight。
  • 用一個 OverlapSphere,只偵測地面 layer,位置在設在角色底部拉高角色碰撞器的半徑,而半徑則是角色碰䃥器的半徑加 colliderSkin,這樣角色的 Sphere Collider 的半圓底部就有皮膚厚度去偵測全角度的地面,無論是上坡下坡也可以,把結果套用在 (bool)isTouchingGround。
  • 如果 inAirHeight < 0.15f 或 isTouchingGround 的話,就設定 isGrounded 為 true 著地。


。CheckPlatform()
    private void CheckPlatform()
    {
        Vector3 pos = RayPos.y(RayPos.y + colliderSkin);
        Ray rayDown = new Ray(pos, Vector3.down);
        Physics.Raycast(rayDown, out hit, 0.20f, groundMask);
        platformAngle = hit.transform ?
            Vector3.Angle(hit.normal, Vector3.up) : 0;
    }
檢查腳下地面的角度。
  • 在物件底部高一點(皮膚厚度)向下射出一條 0.2f 長度 Ray,只偵察地面 layer,計算著地地面的角度,套用在參數 (float)platformAngle。

。CheckSlope()
    private void CheckSlope()
    {
        Ray rayFront = new Ray(RayPos, rb.velocity.y(0));
        Physics.Raycast(rayFront, out hit, radius, groundMask);
        slopeAngle = hit.transform ?
            Vector3.Angle(hit.normal, Vector3.up) : 0;
    }
檢查前方地面的角度。
  • 在物件底部高一點(皮膚厚度)向前射出一條比半徑長的 Ray,只偵察地面 layer,計算前方地面的角度,套用在參數 (float)slopeAngle。

。Climbing()
    private void Climbing()
    {
        float moveDistance = Vector3.Distance(rb.velocity.y(0), Vector3.zero);
        if (slopeAngle > 0)
        {
            if (slopeAngle < maxSlope)
            {
                isClimbing = isTouchingGround;
                if (isTouchingGround)
                    rb.velocity = rb.velocity.y(
                        Mathf.Sin(slopeAngle * Mathf.Deg2Rad) * moveDistance);
            }
            else
            {
                isClimbing = false;
                rb.position = oldPosition;
                rb.velocity -= -transform.forward * moveDistance;
            }
        }
        else
        {
            isClimbing = IsMoving ? false : platformAngle > 0;
        }
    }
上坡重力修正。(有修訂)
角色的移動是水平的,因為碰撞到上坡的地面,才被擠壓到更高的位置,這樣會產生很大的阻力,讓角色的加速力大減,角色的移動速度就會大減。
  • 當 slopeAngle 大於 0 時,以上坡的角度和前進的距離來計算向上的逆重力,讓角色可以在上坡時的速度變得正常。
    角色並不是什麼上坡都可以爬上去,總是有一個限度,所以上坡大過 maxSlope 的話,就不會進行動力修正,會把 rigidbody 回到上一偵的位置。
  • 以 (bool)isClimbing 記錄是否上坡中。由於 CheckSlope() 在角色靜止時沒法偵測前方,slopeAngle 就會等於 0,所以當 slopeAngle 等於 0 時,就要用 (bool)IsMoving 檢查角色是否移動中,false 的話 isClimbing 就要看 platformAngle 是否太於 0。
  • 由於 CheckSlope() 只會檢查前方地面,所以下坡時 slopeAngle 就會等於 0,isClimbing 就會記錄正確狀態。
  • 由於往前偵測上坡時,角色和上坡有一點距離,所以角色會提早被升高。解決辦法是利用 isTouchGround 來決定是否提升和設定 isClimbing,當人物被提升但還沒有到達上坡時,下墜修正就會正常運作,人物就自然會貼地。

。Falling()
    private void Falling()
    {
        if (!isTouchingGround)
        {
            isFalling = !isGrounded;
            rb.velocity += Vector3.up * -gravity * Time.fixedDeltaTime;
        }
        else
        {
            isFalling = false;
        }
    }
下墜重力修正。(有修訂)
  • 只要不是 isTouchingGround 的話,就有必要提供重力讓角色下墜,否則角色就會懸浮在半空中。
  • 使用 (bool)isFalling 記錄是否墜下中,但不能把角色的意外懸浮當成是墜下,所以在需要重力修正時,由 isGrounded 來決定是墜下中還是意外懸浮的修正,因為 isGrounded 在 inAirHeight 少於 0.15f 都會是 true。

<重力的數值計算>

在這個重力修正中,有一個參數是 Gravity,我們必須自己設定,那要如何計算應該的重力呢?

有個比較簡單的方法去計算,就是利用跳躍,這裡需要兩個數值:
  • Jump Height (m) 跳躍高度 (米)
  • Time to Apex (s) 到頂點所需時間 (秒)
假設跳躍高度是 1 米,所需時間是 0.2 秒的話。。。
  • Gravity = (2 * Jump Height) / Time to Apex2
  • G = (2 * 1) / 0.22
  • G = 2 / 0.04
  • G = 50

<System Class>

在程序中需要用到本人的 system class 內的 method,請在下面下載並放到閣下的 project 中。

Conclusion 總結

使用重力修正,有助遊戲中物件的物理運動效果變得更自然,也有助降低物理引擎的運算量。
這次的分享,也可以再補正到其他遊戲中,但先好好了解內建物理引擎的運作,否則只會增加物理運算量,對遊戲的整體性能有很大的影響。

留言

此網誌的熱門文章

[教學]一起來開發遊戲吧 - Unity C# 基礎

[瀬戸のテーマ] HTC Desire 最強體驗(S)預告