最初に
本記事の内容は、TimelineのMarkerとCinemachineを組み合わせて動的にカメラ制御を行うシステムの入門を紹介しております。
カットシーンやイベント演出は、ゲームの世界観やストーリーをプレイヤーに伝える重要な要素であることは皆さんご存じかと思います。Unityでは、Timeline と Cinemachine を活用することで、複雑なカメラ演出を簡単に実現できます。その一方、キャラクターを入れ替えられるシステムが存在する場合に、カメラ演出をどう工夫するべきか悩む開発者の方もいると思います(私がそうです)。
そういった方向けに、スクリプトベースでカスタマイズできるMarkerを使いつつ、Cinemachineを制御する方法を本記事内で見ていこうと思います。
本記事の対象読者は以下の通りです。
- Unityを使ったゲーム開発経験があり、TimelineやCinemachineを使った演出に挑戦したい方
- 動的にカメラのターゲットや挙動を変更するシステムを作りたい方
- カットシーンのクオリティを向上させたい方
環境
本記事の環境は以下の通りとなります。
- Unity:6000.0.31f1
- OS:Windows
- Timeline:1.8.7
- Cinemachine:3.1.2
今回のゴールについて
本記事で実装したサンプルプロジェクトは以下のリポジトリに用意しておきました。
予め、TimelineとCinemachineの設定を済ませている状態にしています。Assetsフォルダ下のScenesフォルダにSampleSceneがありますので、そちらを開いてご確認ください。
本記事での実装を行う前は、以下の動画のような見え方になるタイムラインとなっています。いわゆる、Beforeです。
実装後(After)は以下の動画のように、MarkerのあるタイミングでLookAtが動的に設定されて発火する状態となります。
用途
では、今回の記事で実装する内容の用途はなんでしょうか?
本記事で紹介するTimelineのMarkerとCinemachineを組み合わせた動的カメラ制御システムは、主に以下のようなゲームプロジェクトでの使用を想定しています。
キャラクター演出のカットシーン制作
- 用途例:RPGやアクションゲームでの必殺技シーンや勝利演出など
- キャラクターの身長や体型に合わせてカメラの位置やアングルを動的に調整できるため、キャラクターごとの個性を活かした演出が可能です。
- また、Timelineアセットを使いまわすことができるようになるため、アセット数および工数の削減が可能となります。
リアルタイムイベントのカメラ演出
- 用途例:オープンワールドゲームのイベントシーンや探索ゲームの発見演出など
- スクリプトベースでターゲットや視線を切り替えられるため、応用すれば動的なシーン遷移にも対応できます。
デバッグ用の視点切り替えシステム
- 用途例:キャラクターやオブジェクトの位置関係を確認するためのツールなど
- Markerを利用してデバッグポイントを作成し、動的に視点を切り替えることで開発効率を向上させます。
どうでしょう?夢が広がりませんか?
ところで、TimelineにはSignalという、Markerに似た機能もあります。SignalとMarkerの違いにも注目していきましょう。
MarkerとSignalの違い
UnityのTimelineには、特定のタイミングでイベントを発生させるためのMarkerとSignalという2つの機能があります。これらは似た目的を持っていますが、用途や特性が異なります。以下でそれぞれの特徴とメリット・デメリットを詳しく見ていきます。
Marker
Timeline上の任意のフレームに配置でき、再生時にMarkerポイントのタイミングで、何かしらのパラメータと一緒に通知ができる機能です。スクリプトによるカスタマイズがメインで、複雑なデータを渡したり、状態の管理が可能です。 またSignalのようにアセットが増えることもないので、管理も楽です。
メリット
-
柔軟性:
スクリプトベースでカスタマイズ可能なため、複雑な処理やデータの受け渡しに適しています。 -
データ保持:
パラメータや状態情報を保持できます。 -
拡張性:
独自のロジックや条件分岐を実装しやすく、高度な演出に対応可能です。
デメリット
-
実装難易度:
スクリプトの記述が必要なため、プログラミングの知識が求められます。 -
設定の手間:
一つのカスタムMarkerにデータを持たせすぎると設定の手間が生じます。
Signal
Timelineからイベントを発火し、シーン上のオブジェクトに通知を送るための仕組みです。「Signal Emitter」と「Signal Receiver」を組み合わせて使用します。主にGUIベースで設定を行います。
メリット
-
簡便性:
GUIベースで設定できるため、非エンジニアでも扱いやすいです。 -
即時性:
特定のタイミングで即座にイベントを発火させるのに適しています。
デメリット
-
データ保持不可:
Signal自体はデータを保持できないため、複雑な情報の受け渡しには向いていません。 -
柔軟性の制限:
単純なイベント発火には適していますが、複雑なロジックをSignal側で用意するのは不向きです。
比較
機能 | Marker | Signal |
---|---|---|
柔軟性 | スクリプトベースでカスタマイズ可能。複雑な処理やデータ管理に適する。 | GUIベースで簡単に設定可能。単純なイベント発火に適する。 |
データ保持 | パラメータなどの情報を設定・保持できる。 | データの保持は不可。 |
通知機能 | INotificationを実装することで通知が可能。 | Signal Receiverを通じて通知を行う。 |
拡張性 | 独自のロジックや条件分岐の実装が容易。 | 複雑なロジックや状態管理には不向き。 |
実装難易度 | スクリプトの記述が必要で、プログラミング知識が求められる。 | GUIベースで設定可能なため、初心者にも扱いやすい。 |
今回はイベント発火者自体に情報を保持させ、それを元にCinemachine側の設定変更を行うため、Markerの方が適しています。
それでは実際に、スクリプトを用意していきましょう。
マーカーの作成
まずは、マーカー自体を実装するクラスとして、CameraControlMarker.cs
を用意します。
using System;
using System.ComponentModel;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
[Serializable, DisplayName("カメラ制御マーカーを追加する")]
// MarkerとINotificationを継承する
public class CameraControlMarker : Marker, INotification
{
// INotificationインターフェースのidプロパティを実装
// このidは、通知システムでイベントを一意に識別するために使用される
// Timelineの通知はINotificationReceiverを実装したクラスで受け取る設計になっているため、
// このidが通知の発信元と受信先を結びつける役割を果たす
public PropertyName id => new();
[Header("LookAt を有効にするかどうか")]
public bool isEnable = true;
[Header("カメラの向きを戻すかどうか")]
public bool isReturnRotation = true;
[Header("カメラの向きを戻す時間")]
public float returnDuration = 1.0f;
}
こちらのスクリプトを追加することで、タイムライン上にMarkerが設定できるようになるはずです。
受信側の作成
続いてMarker受信側のクラスとなるCameraControlMarkerReceiver.cs
を用意します。このスクリプトはキャラクターのRoot Prefabなどにアタッチすることを想定して用意しています。
また、今回の通知はAnimatorトラックに関連付けされたGameObjectのCameraControlMarkerReceiver
に対して行われるため、RequireComponent
でAnimator
指定をしています。
using Unity.Cinemachine;
using UnityEngine;
using UnityEngine.Playables;
[RequireComponent(typeof(Animator))]
// Markerからの通知を受け取るためにINotificationReceiverを実装
public sealed class CameraControlMarkerReceiver : MonoBehaviour, INotificationReceiver
{
// カメラのLookAtターゲットとして設定するTransformオブジェクト
// 今回はキャラクターの頭に注視したいため、頭用のTransformを設定できるように
[SerializeField]
private Transform headTarget;
// メインカメラのCinemachineBrainコンポーネントへの参照
private CinemachineBrain _cinemachineBrain;
// カメラの回転補間を開始する前の状態を保持する変数
private Quaternion _prevRotation;
// 回転補間処理中であることを示すフラグ
private bool _isReturningRotation;
// 回転補間の進行状況を保持するタイマー
private float _rotationLerpTime;
// 現在処理中のCameraControlMarkerの参照
private CameraControlMarker _cameraControlMarker;
private void Awake()
{
if (Camera.main != null)
{
// 初期化時にCinemachineBrainを取得
// メインカメラにアタッチされたCinemachineBrainを取得
_cinemachineBrain = Camera.main.GetComponent<CinemachineBrain>();
}
}
// Timeline上の通知を受け取る処理
// INotificationReceiverインターフェースの実装メソッド
public void OnNotify(Playable _origin, INotification _notification, object _context)
{
// 通知がCameraControlMarker型であるかをチェックし、キャスト
_cameraControlMarker = _notification as CameraControlMarker;
if (_cameraControlMarker == null)
{
return;
}
// Markerの設定に基づいてLookAt開始または停止を切り替える
if (_cameraControlMarker.isEnable)
{
StartLookAt();
}
else
{
StopLookAt();
}
}
private void StartLookAt()
{
// 現在アクティブなCinemachineカメラを取得
var activeCamera = _cinemachineBrain.ActiveVirtualCamera as CinemachineCamera;
if (activeCamera == null)
{
return;
}
// 現在のカメラの回転を記録し、LookAtターゲットを設定
_prevRotation = activeCamera.transform.rotation;
activeCamera.LookAt = headTarget;
_isReturningRotation = false;
}
private void StopLookAt()
{
// 現在アクティブなCinemachineカメラを取得
var activeCamera = _cinemachineBrain.ActiveVirtualCamera as CinemachineCamera;
if (activeCamera == null || _cameraControlMarker.isReturnRotation == false)
{
return;
}
// LookAtを解除してカメラの注視を停止
activeCamera.LookAt = null;
// 補間用のフラグとタイマーをリセット
// 回転補間処理を開始
_isReturningRotation = true;
_rotationLerpTime = 0f;
}
// フレームごとに回転補間処理を実行する
private void Update()
{
// 回転補間が有効でなければ処理をスキップ
if (!_isReturningRotation)
{
return;
}
// 現在アクティブなCinemachineカメラを取得
var activeCamera = _cinemachineBrain.ActiveVirtualCamera as CinemachineCamera;
if (activeCamera == null)
{
return;
}
// 時間に基づく回転補間を計算(returnDurationに基づく補間速度)
_rotationLerpTime += Time.deltaTime / _cameraControlMarker.returnDuration;
// Quaternion.Slerpを使用して滑らかに元の回転に戻す
activeCamera.transform.rotation = Quaternion.Slerp(activeCamera.transform.rotation, _prevRotation, _rotationLerpTime);
// 補間が完了したら終了
if (_rotationLerpTime >= 1.0f)
{
_isReturningRotation = false;
}
}
}
LookAt終了後は元の回転値に滑らかに戻すために、Updateメソッドを用いて回転補間処理を行なっています。もしもUniTask、R3を使用したプロジェクトであれば、より良い実装方法があると思います。
Timelineエディタ上のMarkersエリアは何用?
Timelineエディタを見ると、隠されたMarkersエリアに気付くかもしれません。
このMarkersエリアに設置されたMarkerは、シーン内のPlayableDirectorコンポーネントがアタッチされたゲームオブジェクトに対して通知を行います(もちろんINotificationReceiver
を実装したクラスも一緒にアタッチされている必要があります)。
このMarkersエリアは以下の用途で活用できると思います。
- シーン全体に影響を与えるイベント(例: 環境の変化、音楽の切り替え、全体的なエフェクトの発生など)を特定のタイミングで実行する
- シーンの遷移や特定のシーン内イベントの開始・終了を制御する
- 特定のタイミングでログを出力したり、デバッグ用の処理を挿入する
プロジェクト専用のMarker用トラックを用意したい場合
UnityのTimelineでは、標準のトラックを使用するだけでなく、カスタムトラックを定義してプロジェクト固有の処理をまとめることができます。例えば以下のような、ProjectMarkerTrack.cs
を用意してみます。
using Unity.Cinemachine;
using UnityEngine.Timeline;
// タイムラインエディタ上でのトラックカラーを指定
[TrackColor(0.8f, 0.2f, 0.2f)]
// CustomClip.cs自体は自身で用意する想定
// またはCinemachineShotでもいいかも
[TrackClipType(typeof(CustomClip))]
// トラックにバインドしたGameObjectに対して通知を出すため
[TrackBindingType(typeof(GameObject))]
public class ProjectMarkerTrack : MarkerTrack
{
}
改善案
ざっとスクリプトを用意してみましたが、実際にプロジェクトで運用していくのであれば以下のような改善を盛り込んでみると良いかもしれません。
- Marker側で、どのパーツにフォーカスするのかを決定するEnumパラメータを持たせる
- → 受信側で対応パーツをLookAtやFollowなどに設定
- Marker側にカメラのオフセットパラメータやキャラクタータイプなどを追加する
- → 見栄えが改善され、かなり小さいキャラ・かなり大きいキャラでも対応が可能になる
- エディタ拡張として、isEnable が false になっている時のみ、必要となるパラメータを表示させる
- isEnable が true の Marker が入ったタイミングから任意の秒数後自動でオフになる処理を入れる
- Rotationだけではなく、NoiseやFollowも入れてみる
まとめ
本記事では、TimelineのMarkerとCinemachineを組み合わせた動的カメラ制御システムの実装について見ていきました。
- 動的カメラ制御システムの実装により、汎用的で再利用可能なカットシーン制作を実現できるようになります。
- MarkerとSignalの違いを理解し、用途に応じて最適な選択をしていきましょう。
- カスタムトラックや通知システムを用いることで、シーンごとの柔軟なカメラ制御を可能にしました。
- 今後の拡張や改善のアイデアも出し、実際のプロジェクトに応じたカスタマイズをしていきましょう。
MarkerとCinemachineを組み合わせると、演出の幅が広がると思います。是非、実際のプロジェクトでも試してみてください。