在 Unity 用途廣泛的數值彈簧 - Numeric Springing

今天來聊聊一個很數學、很物理的東西:彈簧。

做遊戲難免會遇到的優化問題,就是玩家操控腳色的流暢性,一個腳色在玩家的操控下走走停停、跑跑跳跳,光是如何讓物件的 position 可以流暢且自然,就是個大課題。或者在外部操控器的訊號跟物件的移動之間的協調,如何讓物件的移動有著 “起承轉合” 般的真實動感,在 VR 遊戲上會大幅影響玩家的舒適度,以及對遊戲的接受度。

有個方式可以處理上述的議題,就是套用 “數值彈簧” (numeric springing) 在物體的移動上,產生起步的加速、停留的減速,減少物體在移動時讓然覺得卡頓或者突兀的感覺。“數值彈簧” 是個我一直想要理解與實作的東西,如今終於動工了,這篇文章分享的是我的作法,以及 數值彈簧 的廣泛用途。

數值彈簧簡介

數值彈簧 (numeric springing) 本身的概念並不複雜,就只是個在數學上進行模擬的彈簧系統,不過利用彈簧系統的特性,可以使得物件的移動非常接近實際物理(啟動時加速、停止時減速),而且不需要使用複雜的物理引擎,沒有力道、質量、加速度等概念需要去調整,便可以達到相當好的效果。有許多數據及研究指出,使用等速的移動方式來進行移動動畫,會讓觀看者產生輕微的不適感(如 3D 暈),有著真實物理的加減速,能延長觀看者沉浸在數位內容中的時間,這方面 數值彈簧 可以在很高程度上產生需要的效果。

數值彈簧的使用範圍很廣泛,這邊我實作的是 阻尼系統 (damping),用 “拖拉” 的方式來移動物件,讓物件的動畫可以更加自然,同時可以排除掉玩家操作上或操控器訊號中的 “震盪”。用個實際例子來比喻,就是類似用彈簧秤拖著一個砝碼的效果,只是這邊的彈簧秤是數據模擬而不可視的,而且彈簧秤的長度大小為 0,物件 (砝碼) 會確實地被拖動到指定位置。

除了用於腳色或物件的移動動畫上,這個阻尼式的彈簧系統還能用於很多地方。例如:

  • 游標的移動:使用搖桿進行遊戲,如果有使用游標的需要時
  • 旋轉動畫的優化:物件旋轉角度的加減速,也可以讓動畫看起來更自然
  • 放大與縮小:透過彈簧的參數設定,也能產生放大過頭再回縮的效果,讓放大縮小的動畫顯得更 “軟Q”
  • Tweening 的優化:google 的 design guideline 中強調,有著加減速的 UI 特效會優於等速移動的特效

阻尼系統的數學原理

主要的實作部分與參考連結中的內容大同小異,不過我還是用自己的方式研究了一下相關的物理及數學。首先,實作數值彈簧主要依賴的便是彈簧的運動方程式,其中 xt 是彈簧系統的原點:

阻尼方程式1

然後為了在 Unity 中使用,將方程式改寫成這樣,其中 x 相當於物件的目前位置,v 是暫存的移動變化量(速度),xt 是希望移動到的位置,Δt 則是 Update 的間隔時間:

阻尼方程式2

數學部分就點到為止,利用上面兩條式子,先解出 Δv 後便可以得到 Δx,以及下一禎畫面該將物件移動到何處了 (x + Δx)

除了物件的當前狀況(位置及速度),求出來的下一禎位置還跟兩個參數有關,分別是彈簧頻率 (ω)以及阻尼係數 (ζ),兩者的數值通常會在這個阻尼系統宣告時便設定好,後續會影響物件是用怎樣的移動曲線來到達指定的目標位置。彈簧頻率與彈簧強度正相關,數值越高則代表物件移動到指定位置的時間越短,但是對於震盪的敏感度也會提高;阻尼係數是一個避免彈簧來回震動的阻力,通常設為 1 是最通用的,依照需要也可改成其他數值,影響如下列及附圖:

  • 阻尼係數 = 1:最通用的設定,又稱為臨界阻尼,物件可以平穩的移動到指定位置。
  • 阻尼係數 > 1:阻力偏大,物件會花上較多時間移動。
  • 阻尼係數 < 1:阻力偏小,不只會快速移動到指定位置,還會移動過頭而來回震盪,再漸漸停止。
  • 阻尼係數 = 0:無阻力,會變成簡諧運動,不會停止。

阻尼類型

由I, Sandycx,創用CC 姓名標示-相同方式分享 3.0,https://commons.wikimedia.org/w/index.php?curid=2325619

Numeric Spring 程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using UnityEngine;
using System.Collections;
public class NumericSpring3D
{
private Vector3 m_TargetValue;
private Vector3 m_CurrentValue;
private Vector3 m_CurrentChange;

private float m_fDumpingRatio;
private float m_fFrequency;
private float m_fSensitivity;

public NumericSpring3D (Vector3 oriValue, float fFrequency, float fDumpingRatio = 1.0f, float fSensitivity = 0.01f) {
m_TargetValue = oriValue;
m_CurrentValue = oriValue;
m_CurrentChange = new Vector3();

m_fFrequency = fFrequency;
m_fDumpingRatio = fDumpingRatio;
m_fSensitivity = fSensitivity;
}

public void SetTargetValue(Vector3 TargetValue) {
m_TargetValue = TargetValue;
}

public Vector3 DumpingUpdate(float fDeltaTime) {
float ww = m_fFrequency * m_fFrequency;
float wwt = ww * fDeltaTime;
float wwtt = wwt * fDeltaTime;
float f = 1.0f + 2.0f * fDeltaTime * m_fDumpingRatio * m_fFrequency; float detInv = 1.0f / (f + wwtt);

m_CurrentValue = (m_CurrentValue * f + wwtt * m_TargetValue + fDeltaTime * m_CurrentChange) * detInv;
m_CurrentChange = (m_CurrentChange + wwt * (m_TargetValue - m_CurrentValue)) * detInv;

if (Vector3.Magnitude(m_CurrentValue - m_TargetValue) > m_fSensitivity) {
m_CurrentValue = m_TargetValue;
}
return m_CurrentValue;
}
}
  • Line 13:宣告 NumericSpring3D 這個類別時需要給予 物件的初始位置,並設定 彈簧頻率、**阻尼係數 **及 靈敏度
  • 彈簧頻率 (m_fFrequency) 的值越大,物件移動到目標位置的速度就越快,但也越容易受到震動影響。
  • 阻尼係數 (m_fDumpingRatio) 的值預設為 1,通常不用改變,調整的效果於前段描述過了。
  • 靈敏度 (m_fSensitivity) 用於避免無謂的細微運算,當物件距離目標位置小於靈敏度的時候,便結束阻尼運作。
  • Line 24:設定要移動的目標位置,可以隨時隨地設定目標讓阻尼系統跟上動作,不必等到阻尼系統結束動作。
  • Line 28:通常在 Update() 裡呼叫,取得物件應該顯示的位置,達到平滑移動的效果。

Numeric Spring 使用範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;
using System.Collections;

public class MoveWithSpring : MonoBehaviour {
private Vector3 m_TargetPos;
private NumericSpring3D m_Spring;

void Start () {
m_TargetPos = transform.localPosition;
m_Spring = new NumericSpring3D (m_TargetPos, 15);
}

void Update () {
// Do something to change m_TargetPos, such as player input.
// m_TargetPos = ...

m_Spring.SetTargetValue(m_TargetPos);
transform.localPosition = m_Spring.DumpingUpdate(Time.deltaTime);
}
}
  • Line 9~10:宣告類別,同時設定初始位置以及彈簧頻率,如果發現移動好像慢一拍便可以提高頻率試試。
  • Line 17:設定最新的移動目標,不論彈簧運作到何處,是否已經停止都可以設定新的目標。
  • Line 18:取得計算後的顯示位置,並將物件移至該處,得到彈簧系統的移動效果。

後話

  • 在我的專案上,套入數值彈簧後,腳色物件的移動轉瞬間平滑了起來,讓我相當開心沒有看走眼,果然數值彈簧是相當重要的一個部分。不過要在移動速度與震盪之間取得一個良好的平衡,需要反覆測試調整彈簧頻率,甚至要加上其他措施。
  • 未來我應該還會繼續研究數值彈簧在各處上的應用與優化,也會有不同的彈簧系統被實做出來,到時候會有更進一步的分享。
  • Unity 的物理引擎中有個 Spring Joint 的功能,在某些部分與本文範例的目的是相似的,有興趣的人可以自行研究看看。我之所以另外實作一個類別,是因為我想將這個類別運用在各處,不只是移動物件,旋轉或縮放物件都可以使用它。
  • 另外還有 Vector3.SmoothDamp() 這個函式也有類似的功能。

參考

附錄:float 版本的 Numeric Spring

上面的 NumericSpring3D 是使用 Vector3 作為核心,同時也可用於 Vector2 也沒問題,不過要用於單一數值就有點過頭了,所以有了下方的一維版本,以 float 作為核心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
using UnityEngine;
using System.Collections;

public class NumericSpring
{
private float m_TargetValue;
private float m_CurrentValue;
private float m_CurrentChange;

private float m_fDumpingRatio;
private float m_fFrequency;
private float m_fSensitivity;

public NumericSpring (float oriValue, float fFrequency, float fDumpingRatio = 1.0f, float fSensitivity = 0.01f) {
m_TargetValue = oriValue;
m_CurrentValue = oriValue;
m_CurrentChange = 0;

m_fFrequency = fFrequency;
m_fDumpingRatio = fDumpingRatio;
m_fSensitivity = fSensitivity;
}


public void SetTargetValue(float TargetValue) {
m_TargetValue = TargetValue;
}

public float DumpingUpdate(float fDeltaTime) {
float ww = m_fFrequency * m_fFrequency;
float wwt = ww * fDeltaTime;
float wwtt = wwt * fDeltaTime;
float f = 1.0f + 2.0f * fDeltaTime * m_fDumpingRatio * m_fFrequency;
float detInv = 1.0f / (f + wwtt);

m_CurrentValue = (m_CurrentValue * f + wwtt * m_TargetValue + fDeltaTime * m_CurrentChange) * detInv;
m_CurrentChange = (m_CurrentChange + wwt * (m_TargetValue - m_CurrentValue)) * detInv;

if (Mathf.Abs(m_CurrentValue - m_TargetValue) > m_fSensitivity) {
m_CurrentValue = m_TargetValue;
}
return m_CurrentValue;
}