アニメーション
概要
アニメーションは、プレイヤーの入力による移動やアクションなど、ゲームプレイに非常に重要なフィードバックを与えます。
ここでは、Fusion製のマルチプレイヤーゲームで、適切なアニメーションを選択する方法について取り上げます。キャラクターのアニメーションに焦点を当てた例になっていますが、ネットワーク上でアニメーションを同期するためのコンセプトは、他のアニメーションするオブジェクトにも適用可能です。
実践的なサンプルについては、Technical SamplesのAnimationsをご覧ください。
アニメーションの精度
Fusionでアニメーションの実装を始める前に、アニメーションシステムに要求する基準の精度を定めることが重要です。
一般的に言えば、アニメーションに対するアプローチは、その精度によって2つに分かれます。
- 描画精度アニメーション(Render Accurate Animations)
- ティック精度アニメーション(Tick Accurate Animations)
描画精度アニメーション
描画精度アニメーションは、シミュレーション外(Render
/MonoBehaviour.Update
)、またはFixedUpdateNetwork
のForward
ステージ(Runner.IsForward == true
)でのみ実行されます。アニメーションの開始は、大まかにだけ同期されるのが普通で、キャラクターのポーズが再シミュレーション中に巻き戻ることはありません。アニメーションの完全な状態(再生中のアニメーション・再生時間・アニメーションのウェイトなど)が、ネットワーク上のデータに含まれないため、特定の時間にすべてのクライアント上で、キャラクターが全く同じポーズになる保証はありません。
描画精度アニメーションを使用すると、手足の位置計算が大きくずれる可能性があります。どの程度ずれるかは、アニメーションの種類に大きく依存します。「アイドル中」「エイム中」などの静的なアニメーションなら十分正確かもしれませんが、「走行」「素早いアクション」などでは非常に悪い結果になるかもしれません。シミュレーションの計算(例:手のひらから発射するレーザー光線の計算)には、アニメーション階層内のtransform
を使用しないことを推奨します。このような使用方法が必要になった場合は、まずティック精度アニメーションは必要ですか?をご覧ください。
✅ 長所:
- 一般的なアニメーションシステム(AnimatorやAnimancer)が使用できます
- 開発チームのアニメーション制作パイプラインを変更する必要がありません
- サードーパーティ製のアニメーションアセットがそのまま使用できます
❌ 短所:
- アニメーション階層内の
Transform
を、シミュレーションの正確な計算に使用することはできません - アニメーションに依存するヒットボックスの位置が不正確になります(これを軽減する方法については、アニメーションとラグ補償をご覧ください)
ティック精度アニメーション
ティック精度アニメーションは、アニメーションの状態が、任意のティックにおいて、すべてのクライアントとサーバー上で全く同じになります。言い換えれば、アニメーションの完全な状態がネットワーク上のデータに含まれ、再シミュレーション中にアニメーションが巻き戻って(逐次的に)再生されます。キャラクターの手足に付いているラグ補償コライダーにとって、すべてのクライアント上でキャラクターが全く同じポーズをとることは非常に重要です。例えば、クライアント上で走行アニメーション中の他プレイヤーの脚に攻撃を当てると、サーバー上でも全く同じ当たり判定が発生します。
✅ 長所:
- すべてのクライアントとサーバー上で、キャラクターは常に同じポーズをとっているため、すべての当たり判定が正確に行われます
- 手足の正確な位置関係に基づく計算が可能になります(例:手のひらから発射されるレーザー光線・近接攻撃の当たり判定)
❌ 短所:
- 独自のアニメーションシステムが必要になるため、実装が困難です(UnityのAnimatorは、ティック精度アニメーションには対応していません)
- サードパーティー製のアニメーションアセットは動作しない可能性があります(Animatorに依存している・再シミュレーションを処理できないなどの理由によります)
- アニメーションシステムはコード中心になりがちで、Animatorなどと比較して、アニメーションの設定に手間がかかる可能性があります
クライアントは、最新のティックのデータをサーバーから受信すると、(入力権限を持つ)ローカルプレイヤーのアニメーションについて、受信したデータのティックからローカルで予測していたティックまでの再シミュレーションを行います。プロキシはリモートの(サーバーから受信したデータに基づいた)時間でアニメーションを再生しているため、再シミュレーション中に特別な処理は不要で、表示が変わることもありません。
プロキシで正確なラグ補償を行うためには、
HitboxManager
がヒットボックスの位置を保存する前に、FixedUpdateNetwork
のForward
ステージ(Runner.IsForward == true
)で一度だけアニメーションを計算すれば十分です。再シミュレーション中、プロキシのキャラクターのポーズは変更されないため、既に保存されているヒットボックスの位置を使用して、正確なラグ補償が行われます。
ティック精度アニメーションは必要ですか?
ティック精度アニメーションシステムの実装は、複雑で時間がかかるため、追加工数をかけてでもプロジェクトに絶対必要なものなのかをよく検討してください。ほとんどのケースで、ティック精度アニメーションは不要です。描画精度アニメーションのプロジェクトでも、プレイヤーに違和感を感じさせることなく、うまく動作させることは可能です。どちらのアプローチを選択するかは、ゲームの仕様に大きく依存します。
一般的に言えば、ティック精度アニメーションは、以下のようなケースがうまくいかないプロジェクトで必要になります。
- キャラクターのアニメーションするパーツに配置されたヒットボックスに対して、100%正確な当たり判定が必要になる
- アニメーションに大きく影響を受け、手動の計算には簡単に置き換えられない、
transform
の位置を使用する計算がある(例:近接攻撃中の拳に設定された当たり判定)
キャラクターの位置や方向に基づく正確な計算は、描画精度アニメーションでも実現可能です。アニメーションするtransform
の階層(ボーン)に影響を受けるtransform
に基づく計算はできないというだけのことです。以下の例で説明されるように、ゲームロジックで計算される位置とビジュアル表現は異なることがあります。
例1: FPSゲームでは、アニメーションの影響を受ける実際の銃身からではなく、カメラから射撃の計算を行うのが一般的な方法です。実際の弾道がどのように補間されるかは、Fusion Projectilesをご覧ください。もし、プレイヤーのアニメーションに影響を受ける実際の銃身の位置・回転から計算をしなければならないのなら、ティック精度アニメーションが必要です。
例2: 近接攻撃を行う際、当たり判定はスクリプト側で定義された経路に従います。再シミュレーション中、その経路はキャラクターのアニメーションとは独立して実行されます。もし、アニメーション通りの複雑な経路に従ってキャラクターの拳に当たり判定を付けたいなら、ティック精度アニメーションが必要です。
描画精度アニメーションシステム
推奨される描画精度アニメーションシステムを、いくつか紹介します。
備考: 「Unity Mecanim Animator」は単に「Animator」と表記します。
Animator/Animancer + 既存のネットワーク上の状態
描画精度アプローチ
通常、既存のネットワーク上の状態を使用して、アニメーションを制御できます。アニメーションは、ネットワーク上のデータに基づいて、Render
メソッド内で設定します。この方法は、実装が容易で、ネットワークのリソースを無駄にせず、必要に応じて精度を高めることもできるため、とても推奨できます。
C#
public override void Render()
{
_animator.SetFloat("Speed", _kcc.Data.RealSpeed);
}
必要なら、標準的なネットワークプロパティを使用して、アニメーション用の追加データを簡単に同期できます。
C#
[Networked]
private NetworkButtons _lastButtonsInput { get; set; }
[Networked]
private int _jumpCount { get; set; }
private int _lastVisibleJump;
public override void Spawned()
{
_lastVisibleJump = _jumpCount;
}
public override void FixedUpdateNetwork()
{
var input = GetInput<PlayerInput>();
if (input.HasValue == false)
return;
if (input.Value.Buttons.WasPressed(_lastButtonsInput, EInputButtons.Jump) == true)
{
DoJump();
_jumpCount++;
}
_lastButtonsInput = input.Value.Buttons;
}
public override void Render()
{
if (_jumpCount > _lastVisibleJump)
{
_animator.SetTrigger("Jump");
// Play jump sound/particle effect
}
_lastVisibleJump = _jumpCount;
}
ChangeDetector
をかわりに使用しても、同様の機能を実装できます。
C#
[Networked]
private NetworkButtons _lastButtonsInput { get; set; }
[Networked]
private int _jumpCount { get; set; }
private ChangeDetector _changes;
public override void Spawned()
{
_changes = GetChangeDetector(ChangeDetector.Source.SnapshotFrom);
}
public override void FixedUpdateNetwork()
{
var input = GetInput<PlayerInput>();
if (input.HasValue == false)
return;
if (input.Value.Buttons.WasPressed(_lastButtonsInput, EInputButtons.Jump) == true)
{
//DoJump();
_jumpCount++;
}
_lastButtonsInput = input.Value.Buttons;
}
public override void Render()
{
foreach (string propertyName in _changes.DetectChanges(this, out var previousBuffer, out var currentBuffer))
{
switch (propertyName)
{
case nameof(_jumpCount):
var reader = GetPropertyReader<int>(nameof(_jumpCount));
var values = reader.Read(previousBuffer, currentBuffer);
if (values.Item2 > values.Item1)
{
_animator.SetTrigger("Jump");
// Play jump sound/particle effect
}
break;
}
}
}
以下のように、DetectChanges
のブロックを簡略化することも可能です。しかし、この方法は若干エラーが起こりやすく、サーバー上でジャンプできなかった(例:ジャンプする前に敵に妨害された)場合、ローカルプレイヤーの"Jump"
トリガーが2回(ローカルの予測でジャンプした時と、サーバーからデータを受信してロールバックされた時)起動することになります。
C#
foreach (string propertyName in _changes.DetectChanges(this, out var previousBuffer, out var currentBuffer))
{
switch (propertyName)
{
case nameof(_jumpCount):
_animator.SetTrigger("Jump");
}
}
実践的な例については、Technical SamplesのAnimations(Example 1-3)をご覧ください。
Animator + NetworkMecanimAnimator
描画精度アプローチ
Animatorのプロパティを同期するには、FusionのNetworkMecanimAnimator
コンポーネントを使用します。NetworkMecanimAnimator
にAnimatorを渡すと、Animatorのプロパティの設定が、すべてのクライアントに自動的に同期されるようになります。ただし、トリガーは例外で、NetworkMecanimAnimator
コンポーネントから設定する必要があります。(詳細はNetwork Mecanim Animatorをご覧ください)
プロキシオブジェクトが突然出現した時(例:途中参加時や、プロキシオブジェクトが関心領域に入った時)のアニメーションの挙動を改善するには、StateRoot
(AnimatorController
の最初のレイヤー)とStateLayers
(その他すべてのレイヤー)の同期を有効にしてください。これによって、正しい状態が正しい時間で再生されることが保証されるようになります。
備考: このオプションを有効にすると、データ通信量が大幅に増加します。
実践的な例については、Technical SamplesのAnimations(Example 4)をご覧ください。
Animator/Animancer + FSMによる同期
描画精度アプローチ
FSM(有限ステートマシン)の状態(例:ジャンプ状態・攻撃状態・ロコモーション状態)から、アニメーションを再生します。現在の状態はネットワークを通して同期し、アニメーションに必要な追加のデータはネットワークプロパティに格納します。
C#
public class JumpState : PlayerState
{
protected override void OnEnterState()
{
DoJump();
}
protected override void OnEnterStateRender()
{
Animator.SetTrigger("Jump");
}
}
アニメーションの制御を状態ごとに分割することで、複雑なアニメーションの管理や、アニメーションに合わせて発生するビジュアルエフェクト(例:ジャンプ時のエフェクトとジャンプ音の再生)の制御が容易になります。FSMが再シミュレーションに対応して適切に同期されているなら、状態(実際にはAIやプレイヤーの挙動など)を他のシミュレーションロジックに使用することもできます。
実践的な例については、Technical SamplesのAnimations(Example 5)をご覧ください。
ティック精度アニメーションシステム
Unity標準のAnimatorは、アニメーションの状態を制御するオプションが限られているため、ティック精度アニメーションに使用することはできません。Unityの低レベルなPlayables APIには十分な機能が備わっているため、ティック精度アニメーションを採用するプロジェクトでは、Playablesに基づいた実装が必要になる可能性が高いでしょう。
Animancer + AnimancerStateのティック精度のラッパー
ティック精度アプローチ
AnimancerはPlayablesに基づいているため、AnimancerState
のプロパティを同期して、AnimancerをFixedUpdateNetwork
から手動でステップ実行することで、ティック精度アニメーションを実現できます。
このアプローチは、「十分に正確なティック精度」が許容されるプロジェクトでのみ推奨します。Animancerは内部で特殊な処理(特定のフェード中にウェイト無しの状態を作成するなど)を行うため、100%正確なティック精度を実現するには手間がかかります。長期的なプロジェクトでは、Playablesに基づいた独自システムを実装する方が好ましいかもしれません。
1. すべての
AnimancerState
のプロパティを、個別に同期する2. 状態の配列(
AnimancerLayer.States
)全体をそのまま同期するプロジェクトで要求されるアニメーションシステムの複雑さによりますが、2の方が簡単で安全かもしれません。
Playables APIを使用した独自システム
ティック精度アプローチ
Playables APIは、本マニュアルの範囲外です。基本的には、アニメーションの状態を表す独自のデータ構造を使用して、必要なデータをネットワークを通して同期し、PlayableGraph
を構築します。そして、プロジェクトのニーズに合わせて、Fusionの標準的なメソッドでPlayableGraph
を評価します。
コードドリブンなシステムのサンプルはFusion BRを、より洗練・簡略化したバージョンはTechnical SamplesのAnimations(Example 6)をご覧ください。
UnityのレガシーなAnimation + AnimationStatesのティック精度のラッパー
ティック精度アプローチ
独自のデータ構造を使用して、AnimationStates
関連のデータを同期します。ティックごとに正しいキャラクターのポーズを再現するには、Animation.Sample
を使用してください。
プロジェクトのアニメーション制作パイプラインが、既にレガシーなアニメーションシステムを使用しているのでもない限りは、この方法は推奨されません。
アニメーションとラグ補償
ラグ補償は、あるクライアントで発生した当たり判定が、サーバー上でも正しく認識されることを保証します。
ヒットボックスがアニメーションの影響を受ける場合、ヒットボックスの状態がキャプチャされる前に、キャラクターに正しいポーズをとらせることが非常に重要です。ヒットボックスのデータは、FixedUpdateNetwork
のForward
ステージで、HitboxManager
によって自動的にキャプチャされることに注意してください。再シミュレーション中は、レイキャストの正確な計算に、既に保存されたヒットボックスの位置が使用されるため、プロキシのキャラクターのアニメーションをロールバックする必要はありません。これがまさに、正しく実行さえすれば、描画精度アプローチでもラグ補償の当たり判定が十分に正確になる理由です。
サーバーは、クライアントがプロキシのキャラクターをリモートのタイムフレームで描画することを想定しているため、サーバー上のラグ補償レイキャストにはリモートのタイムフレームが使用されます。したがって、クライアント上のプロキシのアニメーションも、リモートのタイムフレームに従う必要があります。言い換えると、ラグ補償を正確に行うためには、クライアント上のプロキシは、補間されたデータに基づいてアニメーションを再生する必要があります。
正確なティック精度アニメーションを実現するには、アニメーションのタイミングを正しく合わせます。プロキシのキャラクターのポーズを再現するために、任意のティックのFixedUpdateNetwork
のForward
ステージ、または任意の時間のRender
で、補間されたアニメーションの状態のデータを使用してください。プロキシのキャラクターは、補間されたサーバーのデータを表示しているだけなので、本当にアニメーションを「再生」するわけではありません。
一方、描画精度アニメーションでは、プロキシのキャラクターのアニメーションを完全に「再生」します。そのため、Animatorのパラメーターがいつ設定されたか、アニメーションクリップがいつ開始されたかが重要になります。単純なアプローチは、全体的な精度を無視して、最新のネットワーク上のデータに基づいて動作(以下の例を参照)させることです。アニメーションをより正確にするためには、アニメーションのパラメーターの設定やアニメーションクリップの開始を、補間されたデータに基づいて動作させることが必要です。
アニメーション(アニメーションの状態と再生時間)は、定期的または特別な状況(キャラクターがクライアントの関心領域に入るなど)でも、同期する必要があることが多いです。詳細は描画精度で状態を同期するためのヒントをご覧ください。
以下は、最新のネットワーク上のデータ(_speed
・_jumpCount
)に基づいた描画精度アニメーションのサンプルです。
C#
[Networked]
private float _speed { get; set; }
[Networked]
private int _jumpCount { get; set; }
private int _lastVisibleJump;
public override void Spawned()
{
_lastVisibleJump = _jumpCount;
}
public override void FixedUpdateNetwork()
{
// _speed and _jumpCount is changing here
}
public override void Render()
{
_animator.SetFloat("Speed", _speed);
if (_lastVisibleJump < _jumpCount)
{
_animator.SetTrigger("Jump");
}
_lastVisibleJump = _jumpCount;
}
以下は、補間されたデータに基づいた描画精度アニメーションのサンプルです。アニメーションで補間を使用すると、少し複雑にはなりますが、精度が向上します。
C#
[Networked]
private float _speed { get; set; }
[Networked]
private int _jumpCount { get; set; }
public override void FixedUpdateNetwork()
{
// _speed and _jumpCount is changing here
}
public override void Render()
{
var interpolator = new NetworkBehaviourBufferInterpolator(this);
_animator.SetFloat("Speed", interpolator.Float(nameof(_speed)));
int interpolatedJumpCount = interpolator.Int(nameof(_jumpCount));
if (_lastVisibleJump < interpolatedJumpCount)
{
_animator.SetTrigger("Jump");
}
_lastVisibleJump = interpolatedJumpCount;
}
ヒント
描画精度で状態を同期するためのヒント
描画精度アプローチは、アニメーションの状態の同期ではなく、正しいタイミングでアニメーションの変更を同期・適用することに重点を置きます。しかし、このアプローチの精度を向上させるなら、何らかの状態同期を実装した方が良いでしょう。これは、再生中のキャラクターアニメーションの状態と再生時間を、定期的または特定のイベントで同期させることを意味します。
例えば、プロキシのキャラクターが走っていて、その速度が分かっているとします。キャラクターが既に20秒走り続けていたとしても、Animatorに初めて値が設定される(ゲームに途中参加した後や、プロキシのキャラクターが関心領域に入った時など)なら、ロコモーションは最初から再生されます。これは、クライアント間でアニメーションの再生タイミングが異なる原因となり、アニメーションの同期が不正確になります。この問題は通常、キャラクターが他のアクション(ジャンプなど)を行った時に自動的に直りますが、それまではアニメーションのタイミングが大きくずれることになります。
UnityのAnimatorではAnimator.GetCurrentAnimatorStateInfo()
を使用して、現在の状態の完全なパスのハッシュと正規化された時間を取得できます。これらをネットワークプロパティで同期し、Animator.Play(int stateNameHash, int layer, float normalizedTime)
からプロキシのキャラクターに適用させましょう。Animatorの遷移が外れた時のみ、これを同期することで、不要なビジュアル上の問題を防ぐことができます。
NetworkMecanimAnimator
でStateRoot
の同期を有効にすると、まさにこの処理が行われます。最初のレイヤーで再生中のアニメーションの状態と再生時間が、継続的に(Animatorが状態間を遷移している時以外)同期されます。ただし、正規化された時間のような、常に変化する値を継続的に同期することは、通信量が増加するため理想的ではありません。
ティック精度で状態同期するためのヒント
Playables
・AnimancerStates
・UnityのAnimationStates
など、どのシステムに基づいたアニメーションの状態でも、正確な再計算に必須となるデータのみを同期し、時間経過で変化する値は同期しないことが理想です。例えば、毎ティック「時間」と「ウェイト」を送信するのではなく、各クライアントが現在の「時間」と「ウェイト」の値を計算で求められるように、状態の「開始ティック」「フェード速度」「目標ウェイト」を同期することが非常に重要になります。