”功能開發篇“系列文章記錄了我在平時工作中遇到的問題以及一些和遊戲功能相關的項目經驗。
目錄
1. 插件介紹
2. 核心類功能拆解
3. Timeline深入
- 修改Rigidbody的運作速度
- 修改NavMeshAgent的運作速度
- 修改AudioSource的運作速度
- 修改Animator的運作速度
- 修改Animation的運作速度
- 修改ParticleSystem的運作速度
插件介紹
時間系統,是幾乎每個遊戲都會有的一個功能。水雞今天帶大家學習的是 Chronos這個時間控制插件【下載下傳位址】。它包含時間加速、減速、暫停、回溯四大功能,并且有 全局對象控制、單組對象控制、單體對象控制、區域對象控制 四種控制方式。能夠滿足遊戲裡的各種關于時間的複雜需求。
這篇文章會介紹插件裡的功能,但是不會面面俱到,隻是單純講插件怎麼用對個人能力的提升并沒有用,是以本文還會花相當的篇幅分析這個插件是如何實作這些複雜功能的。最終目的是改造這個插件或者根據自己的項目需求做一套時間控制系統。其實這個插件功能有點過于複雜,如果項目沒有用到這麼複雜的功能,把整個插件放進去反而過于累贅。
打開插件裡的案例場景是這樣子的,Root滑動條影響全局對象,每個顔色的方框是影響一組對象的,兩個移動的半球也會影響範圍内的對象。拖動滑動條可以調整速度。
核心類功能拆解
接下來介紹Chronos插件裡幾個重要的類,分别是:
-
Timeline,繼承Monobehavior,所有受這套時間系統控制的對象身上會挂一個這個元件。内部會修改unity自帶元件Rigidbody、Animator、Animation等元件的速度。至于如何修改後面會講。
Timeline上可以配置一個string表示它是屬于哪個GlobalClock。或者是LocalClock。
- Clock,時鐘,抽象類,提供了deltaTime和timescale等屬性,Timeline上會存儲一個時鐘。根據時鐘提供的timeScale和deltaTime來修改其它元件的速度。而Clock類還有幾個子類,分别是:
- GlobalClock,全局時鐘,内部有一個string作為key,區分不同的時鐘。當GlobalClock的timeScale發生改變時,所有存儲該時鐘的Timeline都會受影響。應用場合舉例:敵人、炮彈等。
- LocalClock,局部時鐘,會挂在單個對象上,該時鐘隻影響它挂的那個GameObject。應用場合舉例:玩家。
- AreaClock,是一個包含泛型的抽象類。需要指定使用哪種Trigger,以及2D還是3D。作用是影響範圍内的Timeline對象。插件内部實作了AreaClock3D和AreaClock2D兩種類型。并且使用了OnTriggerEnter這個untiy自帶的方法來檢測進入的對象。如果你的項目不是使用這種方法來通知碰撞檢測的消息的話,這部分的代碼需要進行修改。應用場合舉例:減速炸彈。
public abstract class AreaClock<TCollider, TVector> : Clock, IAreaClock
- TimeKeeper,單例類,使用了字典來存儲了所有的全局時鐘GlobalClock,key就是GlobalClock身上那個string的key。
經過上面的介紹,我們知道了這個插件在設計思路上可以借鑒的點即:抽象出了Clock時鐘這個類并且用連結清單的結構來管理。
Timeline深入
接下來看Timeline類是如何通過一個timeScale來實作修改unity各個元件的參數。
首先先來看Timeline的結構。Timeline存儲了多個IComponentline,每種Componentline都是對Component的封裝。同時由于有時間回溯功能,一些Componentline需要儲存讀取,是以有了RecorderTimeline。
修改Rigidbody的運作速度
首先對rigidbody進行了封裝成了一個新的類RigidbodyTimeline。外部需要修改rigidbody時隻能通過這個類進行修改。這樣的目的是為了外部修改rigidbody的參數時能夠不考慮timeScale。
比如我設定剛體速度是10,我希望的結果是在1倍速情況下剛體速度是10,在0.5倍速下剛體能自動調整速度為0.5。反過來也一樣,我擷取速度的時候也不需要考慮目前倍速是多少。
要想實作這樣的效果就需要對rigidbody進行封裝,并且約定修改剛體速度時不能直接通過rigidbody進行修改。這就是代理模式的應用。(以後面試如果問起設計模式在遊戲中的具體應用場景,就可以答這個。)
//Snapshot用于元件時間回溯,具體後面會講
public class RigidbodyTimeline3D : RigidbodyTimeline<Rigidbody, RigidbodyTimeline3D.Snapshot>
{
...
protected override Vector3 bodyVelocity
{
get { return component.velocity; }
set { component.velocity = value; }
}
protected override Vector3 bodyAngularVelocity
{
get { return component.angularVelocity; }
set { component.angularVelocity = value; }
}
protected override float bodyDrag
{
get { return component.drag; }
set { component.drag = value; }
}
protected override float bodyAngularDrag
{
get { return component.angularDrag; }
set { component.angularDrag = value; }
}
public Vector3 velocity
{
get { return bodyVelocity / timeline.timeScale; }
set { bodyVelocity = value * timeline.timeScale; }
}
public Vector3 angularVelocity
{
get { return bodyAngularVelocity / timeline.timeScale; }
set { bodyAngularVelocity = value * timeline.timeScale; }
}
...
}
修改力和修改速度同理:
protected virtual float AdjustForce(float force)
{
return force * timeline.timeScale;
}
那麼如何實作timeScale變化時剛體自動變化速度呢?這個插件的做法是在記錄上一幀的timeScale并且實時進行比較。
如果發現目前timeScale和上一幀的timeScale不同,則把上一幀的速度 x(目前timeScale / 上一幀的timeScale)得到目前的速度。
比如上一幀速度是10,上一幀的timeScale是2,現在突然timeScale變成0.5了,那速度就要乘以四分之一。代碼如下:
public void Update()
{
...
if (timeScale > 0 && timeScale != lastTimeScale && !bodyIsKinematic)
{
float modifier = timeScale / lastPositiveTimeScale;
bodyVelocity *= modifier;
bodyAngularVelocity *= modifier;
bodyDrag *= modifier;
bodyAngularDrag *= modifier;
}
...
}
跟速度相關的參數全部使用上面這種方法,接下來講重力gravity。
首先把剛體的重力關掉:
然後再由我們自己管理重力,并且在FixedUpdated根據重力方向來對速度進行修正:
public bool useGravity { get; set; }
public override void FixedUpdate()
{
if (useGravity && !component.isKinematic && timeline.timeScale > 0)
{
velocity += (Physics.gravity * timeline.fixedDeltaTime);
}
}
關于rigidbody的回放,代碼比較複雜就不全部貼出來了,這裡說下思路:
要想實作回放,需要在rigidbody正常運作的時候以一定的時間間隔進行快照,也就是記錄資料,并且将這些資料統統存儲起來。然後倒放的時候,記錄倒放那一時刻的資料,并找到離目前時間點最近的快照,進行插值進而得到目前的狀态。
假設從0秒開始正常運作,每隔1秒進行快照,在第4.5秒進行倒放,首先需要記錄下第4.5秒的資料,然後找到離4.5秒最近的那次快照資料,也就是第4秒的資料。這兩個資料都有了之後就好辦了,第4秒到第4.5秒之間的資料都能插值得到了。當我回放到第3秒到第4秒之間,同理,找到第3秒的資料,和第4秒的資料進行插值。
代碼如下:
(這部分代碼在RigidbodyTimeline的基類RecorderTimeline裡面。)
//得到插值時間
float t = (laterTime - timeline.time) / (laterTime - earlierTime);
//根據插值得到一個新的快照
interpolatedSnapshot = LerpSnapshots(laterSnapshot, earlierSnapshot, t);
//應用快照
ApplySnapshot(interpolatedSnapshot);
這裡面有幾個核心變量:
- laterTime:在目前時間之後的,離目前時間最近的快照時間。比如目前時間3.5秒,laterTime為4。
- earlierTime:在目前時間之前的,離目前時間最近的快照時間。比如目前時間3.5秒,earlierTime為3。
- laterSnapshot:在目前時間之後的,離目前時間最近的快照。比如目前時間3.5秒,laterSnapshot為第4秒的快照。
- earlierSnapshot:在目前時間之前的,離目前時間最近的快照。比如目前時間3.5秒,earlierSnapshot為第3秒的快照。
- timeline.time:目前時間。不斷累加unscaleDeltaTime * timeScale。注意timeScale可能會是負數或0。
Lerp部分代碼如下:
public static Snapshot Lerp(Snapshot from, Snapshot to, float t)
{
return new Snapshot()
{
position = Vector3.Lerp(from.position, to.position, t),
rotation = Quaternion.Lerp(from.rotation, to.rotation, t),
// scale = Vector3.Lerp(from.scale, to.scale, t),
velocity = Vector3.Lerp(from.velocity, to.velocity, t),
angularVelocity = Vector3.Lerp(from.angularVelocity, to.angularVelocity, t),
lastPositiveTimeScale = Mathf.Lerp(from.lastPositiveTimeScale, to.lastPositiveTimeScale, t),
};
}
rigidbody的這個倒放思路其實就是雙指針的應用。兩個快照資料就是左右兩端指針,目前時間發生變化時,需要插值的兩端資料也會發生變化。
修改NavMeshAgent的運作速度
NavMeshAgentTimeline在寫法上比RigidbodyTimeline簡單了不少。插件這裡沒有使用上一幀的速度 x(目前timeScale / 上一幀的timeScale)得到目前的速度的方法來計算。而是另外儲存了一個速度 _speed ,這個速度表示的1倍速下的速度。在構造函數裡進行記錄。當timeScale發生變化時,navMeshAgent.speed = _speed * timeScale。兩種方法都可以實作當timeScale變化時,元件運作速度可以随時變化。代碼如下:
private float _speed;
public float speed
{
get { return _speed; }
set
{
_speed = value;
AdjustProperties();
}
}
...
public override void CopyProperties(UnityEngine.AI.NavMeshAgent source)
{
_speed = source.speed;
_angularSpeed = source.angularSpeed;
}
public override void AdjustProperties(float timeScale)
{
component.speed = speed * timeScale;
component.angularSpeed = angularSpeed * timeScale;
}
AdjustProperties 和 CopyProperties 是Componentline的虛方法,由Timeline内部統一調用:
當上一幀的timeScale和目前幀的timeScale 不一樣時 AdjustProperties 就會調用。
public abstract class ComponentTimeline<T> : IComponentTimeline<T> where T : Component
{
public ComponentTimeline(Timeline timeline, T component)
{
this.timeline = timeline;
this.component = component;
CopyProperties(component);
}
...
public virtual void CopyProperties(T source) { }
public virtual void AdjustProperties(float timeScale) { }
public void AdjustProperties()
{
AdjustProperties(timeline.timeScale);
}
}
Timeline的部分代碼,其中TimelineEffector是基類
public class Timeline : TimelineEffector
{
internal List<IComponentTimeline> components;
...
protected override void Update()
{
...
if (timeScale != lastTimeScale)
{
for (int i = 0; i < components.Count; i++)
{
components[i].AdjustProperties();
}
}
...
}
}
修改AudioSource的運作速度
注意調整AudioSource的pitch雖然能讓音頻播放速度發生變化,但是聲音會失真。一般來說是不需要修改音頻播放速度的。如果你的項目不需要這個功能,同時又用到了AudioSource,請自行修改AudioSourceTimeline裡面的源碼,或者把這個類從Timeline内部删掉。
public override void AdjustProperties(float timeScale)
{
component.pitch = pitch * timeScale;
}
修改Animator的運作速度
Animator的加速、變慢沒什麼好說的,改變Animator.speed即可。但是它的倒放功能就有的講了。如果想要倒放Animator,直接将speed修改為負數是無效的。在将倒放之前,我們需要深入的了解Animator的功能。
首先Animator裡有個叫AnimatorRecorderMode的枚舉類型表示Animator目前的模式:
- Offline離線模式可以了解為預設模式
- Playback倒放模式
- Record記錄模式
要想進入倒放模式首先需要進入記錄模式,調用Animator.StartRecording(int frameCount) 進入記錄模式。參數是需要記錄多少幀,最大不能超過10000,這是unity本身的限制。設定這個參數後,Animator内部會配置設定一個大小為frameCount的緩沖區用于記錄動畫資訊。調用Animator.StopRecording( ) 退出記錄模式。
除了兩個函數外,還有兩個變量我們會用到:recorderStartTime和recorderStopTime。
- recorderStartTime:緩沖區開始記錄動畫的時間。如果沒有調用StartRecording,這個值為-1。調用之後在目前幀也為-1。之後一直為0,當緩沖區記錄不下目前已經播放的動畫資訊時,這個值會逐漸增大。
- recorderEndTime:調用StopRecording和調用StartRecording之間的間隔時間。
我畫了兩張圖說明這兩個值的機制。注意:記錄了多少幀和記錄了多少秒是不一樣的。有的機器600幀跑了10秒,有的機器600幀5秒就跑完了。
第二張圖的情況是調用StopRecording和StartRecording之間間隔了8秒,但是600幀隻夠記錄5秒。這時緩沖區就會放不下從0到8的動畫資料,此時存放的是第3秒和第8秒的資料。
進入記錄模式并且退出記錄模式後,才能使用StartPlayback進入倒放模式。在此模式下Animator不會更新動畫,需要給Animator.playbackTime指派來告訴Animator播放動畫的位置。比如上圖的情況,第8秒 進入了倒放模式,那麼應該在代碼裡的update裡不停地調用Animator.playbackTime = recordEndTime - deltaTime才能實作動畫倒放的功能。當然playbackTime不能小于緩沖區的起始時間即 recordStartTime。
OK,上面弄懂之後再來看插件裡的代碼就一目了然,相關的類是AnimatorTimeline。
首先,在Start時就進入記錄模式:
public override void Start()
{
component.StartRecording(recordedFrames);
}
- 退出記錄模式并進入倒放模式的時機:上一幀timeScale大于0目前幀小于0
- 退出倒放模式并進入記錄模式的時機:上一幀timeScale小于0目前幀大于0
為何這裡是playbackTime + deltaTime?
因為這裡的deltaTime = Time.unscaledDeltaTime * timeScale。而倒放模式下說明此時timeScale必定為負數,是以使用加号。
public override void Update()
{
float timeScale = timeline.timeScale;
float lastTimeScale = timeline.lastTimeScale;
if (lastTimeScale >= 0 && timeScale < 0) // Started rewind
{
component.StopRecording();
if (component.recorderStartTime >= 0)
{
component.StartPlayback();
component.playbackTime = component.recorderStopTime;
}
}
else if (lastTimeScale <= 0 && timeScale > 0) // Stopped pause or rewind
{
component.StopPlayback();
component.StartRecording(recordedFrames);
}
if (timeScale < 0 && component.recorderMode == AnimatorRecorderMode.Playback)
{
float playbackTime = Mathf.Max(component.recorderStartTime, component.playbackTime + timeline.deltaTime);
component.playbackTime = playbackTime;
}
}
修改Animation的運作速度
修改Animation的運作速度非常簡單,對Animation周遊,修改AnimationState的speed即可。
注意 Chronos這個插件并不支援多個AnimationState的原始速度不同的情況。是以我加入了一個Dictionary來存儲多個AnimationState的原始速度。注釋掉的都是原始代碼。
public class AnimationTimeline : ComponentTimeline<Animation>
{
public AnimationTimeline(Timeline timeline) : base(timeline) { }
private Dictionary<string, float> speedDict = new Dictionary<string, float>();
private float _speed;
public float speed
{
get { return _speed; }
set
{
_speed = value;
AdjustProperties();
}
}
public override void CopyProperties(Animation source)
{
//float firstAnimationStateSpeed = 1;
//bool found = false;
//foreach (AnimationState animationState in source)
//{
// if (found && firstAnimationStateSpeed != animationState.speed)
// {
// Debug.LogWarning("Different animation speeds per state are not supported.");
// }
// firstAnimationStateSpeed = animationState.speed;
// found = true;
//}
//speed = firstAnimationStateSpeed;
foreach(AnimationState animationState in source)
{
speedDict.Add(animationState.name, animationState.speed);
}
}
public override void AdjustProperties(float timeScale)
{
foreach (AnimationState state in component)
{
//state.speed = speed * timeScale;
state.speed = speedDict.TryGet(state.name) * timeScale;
}
}
}
修改ParticleSystem的運作速度
在這個插件裡粒子系統相關的有兩個類。一個是NonRewindableParticleSystemTimeline,另一個是RewindableParticleSystemTimeline。前者是不考慮回放的情況,部分代碼如下:
public class NonRewindableParticleSystemTimeline : ComponentTimeline<ParticleSystem>, IParticleSystemTimeline
{
public NonRewindableParticleSystemTimeline(Timeline timeline, ParticleSystem component) : base(timeline, component) { }
private float _playbackSpeed;
public float playbackSpeed
{
get { return _playbackSpeed; }
set
{
_playbackSpeed = value;
AdjustProperties();
}
}
...
public override void CopyProperties(ParticleSystem source)
{
_playbackSpeed = source.playbackSpeed;
}
public override void AdjustProperties(float timeScale)
{
//component.playbackSpeed = playbackSpeed * timeScale;
var main = component.main;
main.simulationSpeed = playbackSpeed * timeScale;
}
}
隻需要修改playbackSpeed即可。注意插件裡用的playbackSpeed會提示已經過時,建議使用main.simulationSpeed來修改粒子系統的播放速度。particleSystem.main是一個結構體get屬性,是以需要先拿出來,再修改,修改之後并不需要set也能生效,這點和我們平常使用的結構體屬性不太一樣。檢視unity源碼,可以看到内部并沒有緩存一個struct,而是每次調用get時都會new一個結構體。而每次修改main.simulationSpeed時會調用MainModule的get、set函數。
main.simulationSpeed内部代碼
關于粒子系統的回放,這個插件裡的功能有異常,粒子系統的播放速度會極其鬼畜,是以請不要使用RewindableParticleSystemTimeline這個類。
粒子系統如何做回放,在unity官方發的一篇文章有提到過,這裡我就不多講了,【Unity:如何倒放粒子系統】。
核心思想就是調用ParticleSystem.Stop() 把粒子系統停下來,并調用ParticleSystem.Simulate( float t, bool withChildren, bool restart ) 來控制粒子系統播放動畫的位置。
以上就是關于遊戲中的時間系統如何去實作的内容了,以我的經驗來說,在運作時需要倒放的需求比較少見。如果真的需要倒放,可能整個項目架構都要重新設計,不是單靠一個插件能解決的。在編輯器裡倒放動畫倒是很常見的,因為編輯器下經常需要讓策劃或者美術能夠預覽動畫,是以本次内容關于動畫的部分也講得比較細。
既然都看到這裡了,不如關注一下吧
關于作者:
- 水曜日雞,簡稱水雞,ACG宅。曾參與索尼中國之星項目研發,具有2D聯網多人動作遊戲開發經驗。
CSDN部落格:https://blog.csdn.net/j756915370
知乎專欄:https://zhuanlan.zhihu.com/c_1241442143220363264
Q群:891809847