コンテンツへスキップ

QualiArtsengineer blog

「IDOLY PRIDE」の着替えのカメラ実装の紹介

「IDOLY PRIDE」の着替えのカメラ実装の紹介

10 min read

はじめに

株式会社QualiArtsでUnityエンジニアをしている片岡です。IDOLY PRIDE(以下、アイプラ)にて主にアウトゲーム開発を行っています。

アイプラには着替えという機能があり、ゲーム内の他機能で着用するキャラクターの衣装を着せ替えられます。 3Dのキャラクターの周囲を回るようにして操作できるカメラによって、アイプラの中で一番キャラクターの見た目を観察できる機能となります。 カメラはUnityのCinemachineを利用して実装しています。 本記事ではそのような着替えのカメラについて、実装方法を交えつつ紹介をします。

本記事の基礎編として、同様の内容をCinemachineの基本設定を交えて簡単に説明した過去の記事 があります。 また、説明のために一部でコアラの3Dモデルを使用しますが、このモデルについても別の過去の記事 で紹介していますので、ぜひそちらも合わせてご覧ください。

アイプラの着替えにおけるカメラ操作について

着替えには「回転」「ズーム」「高さの調整と水平移動」の3種類のカメラ操作があります。

1. 回転

1本指でドラッグすることで、キャラクターの周りをぐるぐる回ることができます。

2. ズーム

ピンチイン・ピンチアウトで操作し、指を動かした分だけカメラをズームさせます。

詳細については後述しますが、ズームする際にカメラの焦点距離を変化させるだけでなく、キャラクターとカメラとの距離も変化させています。

3.高さの調整と水平移動

2本指でドラッグすることで、ドラッグした分だけカメラを移動させます。

着替えカメラについて

着替えカメラの主な特徴として、キャラクターの周りをぐるぐる回ることができることが挙げられます。 我々はそのような特徴を持つカメラを、Orbital Cameraと呼んでいます。 Orbitalとあるように、画像のように軌道に沿って動かすカメラになります。

orbital
カメラの軌道のイメージ

アイプラでは、キャラクターの周りをぐるぐる回るだけでなく、ズームしたり、カメラの高さを調整したり水平移動ができたりします。 カメラの実装にはUnityのCinemachineを利用しており、Virtual Cameraには備え付けのOrbitalTransposer がありますが、 柔軟な操作を実現するためにCinemachineExtension を利用した独自実装をしています。 CinemachineExtensionを継承した独自クラスを用意し、PostPipelineStageCallback()を実装しています。

回転について

回転はOrbital Cameraの基本的な操作で、アイプラのように3Dのキャラクターを観察するためには欠かせません。 カメラはキャラクターに向けた状態で、キャラクターの周りを囲むような軌道に沿って動くようにします。

回転を実装してみる

回転の処理は以下のように記述できます。

using Cinemachine;
using UnityEngine;

public class CostumeCameraTest : CinemachineExtension
{
    private const float RotateSpeed = 2f;

    private Vector2 _currentRotate;
    private Transform _lookAt;

    private CinemachineVirtualCamera _virtualCamera;

    private void Update()
    {
        // 簡易的に操作を受け付けてみる
        if (Input.GetKey(KeyCode.LeftArrow)) Rotation(new Vector2(1f, 0f));

        if (Input.GetKey(KeyCode.RightArrow)) Rotation(new Vector2(-1f, 0f));

        if (Input.GetKey(KeyCode.UpArrow)) Rotation(new Vector2(0f, 1f));

        if (Input.GetKey(KeyCode.DownArrow)) Rotation(new Vector2(0f, -1f));
    }
    
    /// <summary>
    /// 回転
    /// </summary>
    private void Rotation(Vector2 diffDelta)
    {
        diffDelta *= RotateSpeed * Time.deltaTime * 30;
        _currentRotate.x += diffDelta.y;
        _currentRotate.y += diffDelta.x;
    }

    protected override void ConnectToVcam(bool connect)
    {
        base.ConnectToVcam(connect);
        if (connect == false) return;

        // 初期化処理
        // VirtualCameraのLookAt対象を生成しておく。わざわざ生成しなくても直接対象物を設定も可。
        _lookAt = GameObject.Find("LookAt")?.transform;
        if (_lookAt == null)
        {
            _lookAt = new GameObject("LookAt").transform;
            _lookAt.position = new Vector3(0f, 0.5f, 0f);
        }

        _virtualCamera = VirtualCamera as CinemachineVirtualCamera;
        _virtualCamera.LookAt = _lookAt;
    }

    protected override void PostPipelineStageCallback(CinemachineVirtualCameraBase vcam,
        CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
    {
        var (pos, rotate) = GetTransform(_virtualCamera.LookAt.localToWorldMatrix);
        state.RawPosition = pos;
        state.RawOrientation = rotate;
    }

    /// <summary>
    /// カメラのTransformを取得
    /// </summary>
    private (Vector3 pos, Quaternion rotate) GetTransform(Matrix4x4 baseCenterMatrix)
    {
        // 中心点を回転させる
        var centerMatrix = baseCenterMatrix * Matrix4x4.Rotate(Quaternion.AngleAxis(_currentRotate.y, Vector3.up) *
                                                               Quaternion.AngleAxis(_currentRotate.x, Vector3.right));

        // 回転に基づいたカメラの位置を取得
        var distanceVector = new Vector3(0f, 0f, -3f);
        var pos = centerMatrix.MultiplyPoint3x4(distanceVector);

        return (pos, centerMatrix.rotation);
    }
}

CinemachineExtensionを継承したCostumeCameraTestクラスを作成して実装します。 PostPipelineStageCallback()内で、state.RawPositionとstate.RawOrientationに代入してカメラの位置や回転を変更する処理となります。 Rotation()を呼び出すことで、カメラの位置や回転を変更できます。

ズームについて

一般的にはカメラの焦点距離を変更するだけで実現できますが、アイプラでは近くに寄れている感覚を大事にし、カメラを物理的に動かすようにしています。 また、カメラの焦点距離も変更することで、どんなに近くに寄っても可愛く見えるようにしています。

焦点距離の特性を生かす

焦点距離とは、カメラのレンズの中心からセンサー(レンズを通して映る景色をカメラに取り込むもの)までの距離のことです。 「どんなに近くによっても可愛く見える」については、この焦点距離の特性を利用して実現しています。 焦点距離の理屈については本記事では説明しませんが、以下の表を見てイメージを掴んでいただければ幸いです。

焦点距離が短いほど、広く映すことができますが、近くのものと遠くのものの映り方の差が顕著に現れ、歪んだ映りになりやすいという特性があります。 もし焦点距離が短い状態でキャラクターに近づいていくと、体や顔のパーツ同士でも映り方に差が出て、可愛さも台無しになってしまいます。 一方で、焦点距離が長いほど、映る範囲は狭くなりますが、近くのものと遠くのものの映り方の差は現れにくく、歪みを抑えやすくなるという特性があります。 焦点距離が長い状態でキャラに近づいていくことで、歪みを抑え、可愛さを保つことができます。

焦点距離が短い焦点距離が長い
同じカメラ位置で映る範囲
16 wide
70 narrow
被写体を一定の大きさで
カメラに収めたときの違い
16 near
70 far

カメラがキャラに近づくほど焦点距離を長くすることで、近くに寄れている感覚を生み出しつつ、歪みを抑えることができます。 カメラがキャラから遠ざかるほど焦点距離を短くすることで、キャラの歪みを抑えつつ、キャラと背景をしっかり映すことができます。

カメラが遠いときカメラが近いとき
焦点距離の変化短くなる長くなる
着替えの画面 far near

ズームを実装してみる

CostumeCameraTestクラスに以下のように記述します。

    private const float ZoomSpeed = 1f;
    private const float MaxDistance = 3f;
    private const float MaxFocalLength = 100f;
    private const float MinFocalLength = 20f;
    private const float MiddleFocalLength = 50f;
    
    private float _currentDistance = 3f;
    
    private float _focalLength = 50f;
    
    private void Start()
    {
        // 焦点距離を使いたいので物理カメラに設定
        _camera.orthographic = false;
        _camera.usePhysicalProperties = true;
    }
    
    private void Update()
    {
        // 簡易的に操作を受け付けてみる
        if (Input.GetKey(KeyCode.W)) Scale(-0.1f);

        if (Input.GetKey(KeyCode.S)) Scale(0.1f);
    }
    
    /// <summary>
    /// ズーム
    /// </summary>
    private void Scale(float delta)
    {
        delta *= ZoomSpeed * Time.deltaTime;
        // ズーム具合
        var ratio = (_focalLength - MinFocalLength) / (MaxFocalLength - MinFocalLength);
        // 引数も合わせて0~1の範囲に変更
        ratio = Mathf.Clamp01(ratio + delta);
        // 変更後の焦点距離を求める
        _focalLength = (MaxFocalLength - MinFocalLength) * ratio + MinFocalLength;
        // virtualCameraには直接焦点距離をセットできないので、焦点距離をFieldOfViewに変換して代入
        _virtualCamera.m_Lens.FieldOfView = GetVerticalFOVFromFocalLength(_focalLength);
        // 焦点距離から現在距離を求める
        _currentDistance = GetCurrentDistance();

        // 焦点距離から現在距離を求める
        float GetCurrentDistance()
        {
            if (_focalLength <= MiddleFocalLength)
            {
                return MaxDistance;
            }

            return Mathf.Lerp(MaxDistance, 0f,
                (_focalLength - MiddleFocalLength) / (MaxFocalLength - MiddleFocalLength));
        }

        // 焦点距離からfovを求める
        float GetVerticalFOVFromFocalLength(float focalLength)
        {
            if (focalLength < UnityVectorExtensions.Epsilon)
                return 180f;
            var sensorSizeY = _virtualCamera.m_Lens.SensorSize.y;
            return Mathf.Rad2Deg * 2.0f * Mathf.Atan(sensorSizeY * 0.5f / focalLength);
        }
    }
    
    /// <summary>
    /// カメラのTransformを取得
    /// </summary>
    private (Vector3 pos, Quaternion rotate) GetTransform(Matrix4x4 baseCenterMatrix)
    {
        // 中心点を回転させる
        var centerMatrix = baseCenterMatrix * Matrix4x4.Rotate(Quaternion.AngleAxis(_currentRotate.y, Vector3.up) *
                                                               Quaternion.AngleAxis(_currentRotate.x, Vector3.right));

        // 回転に基づいたカメラの位置を取得
        _distanceVector.z = -_currentDistance; // -にしておく(カメラの角度と初期位置的による)
        var pos = centerMatrix.MultiplyPoint3x4(_distanceVector);

        return (pos, centerMatrix.rotation);
    }

GetTransform内で_distanceVector.zに代入するためのフィールド変数_currentDistanceを用意しておきます。 _currentDistanceは今回用意したScale()内のGetCurrentDistance()で計算しています。 焦点距離を変更しつつ、カメラの位置も変更しています。 焦点距離で扱っているので、FieldOfViewを焦点距離に変換するメソッドGetVerticalFOVFromFocalLength()も用意しています。 以上の処理で、カメラを動かしつつ、焦点距離を変えるズームを実現できます。

カメラの高さの調整と水平移動について

キャラクターの周りを回ってズームできるようになりましたが、この場合、よく見ることができるのはキャラクターの中心部分だけになります。 身体の中心から離れた手や頭など、キャラクターの中心以外の特定の箇所をもっとよく見てみたい場合、カメラが映す場所を変える必要があります。 そこでアイプラではカメラの高さの調整と水平移動ができるようにしています。

カメラの高さの調整と水平移動を実装してみる

CostumeCameraTestクラスに以下のように記述します。

    private void Update()
    {
        // 簡易的に操作を受け付けてみる
        if (Input.GetKey(KeyCode.LeftShift)) MoveOffset(new Vector2(0f, 1f));

        if (Input.GetKey(KeyCode.LeftControl)) MoveOffset(new Vector2(0f, -1f));

        if (Input.GetKey(KeyCode.D)) MoveOffset(new Vector2(1f, 0f));

        if (Input.GetKey(KeyCode.A)) MoveOffset(new Vector2(-1f, 0f));
    }
    
    /// <summary>
    /// 移動
    /// </summary>
    private void MoveOffset(Vector2 diffDelta)
    {
        diffDelta *= MoveOffsetSpeed * Time.deltaTime;
        _offset.x += diffDelta.x;
        _offset.y += diffDelta.y;
    }
    
    /// <summary>
    /// カメラのTransformを取得
    /// </summary>
    private (Vector3 pos, Quaternion rotate) GetTransform(Matrix4x4 baseCenterMatrix)
    {
        // 中心点を回転させる
        var centerMatrix = baseCenterMatrix * Matrix4x4.Rotate(Quaternion.AngleAxis(_currentRotate.y, Vector3.up) *
                                                               Quaternion.AngleAxis(_currentRotate.x, Vector3.right));

        // 回転に基づいたカメラの位置を取得
        _distanceVector.z = -_currentDistance; // -にしておく(カメラの角度と初期位置的による)
        var pos = centerMatrix.MultiplyPoint3x4(_distanceVector)
                  + GetAddOffset(centerMatrix.rotation);

        return (pos, centerMatrix.rotation);
    }

    /// <summary>
    /// 回転を考慮したx座標のoffsetを取得
    /// </summary>
    private Vector3 GetAddOffset(Quaternion rawOrientation)
    {
        var offset = rawOrientation * new Vector3(_offset.x, 0f, 0f);
        offset.y = _offset.y;
        return offset;
    }

GetAddOffset()で高さと水平移動の量の取得を行い、GetTransform()のカメラ位置に加算して処理します。 高さの調整は単純にカメラのY軸を変更するだけで可能ですが、水平移動については少し工夫が必要です。 カメラはキャラクターの方を向いたままなので、方向によってx軸以外にz軸など、水平移動になるベクトルを渡してあげる必要があります。 しかし、いちいちそれを考慮してベクトルを渡すのは面倒なので、Quaternionを使って回転を考慮したベクトルを取得するようにしています。 そうすることで渡すベクトルがx軸のみでよくなり、簡単に水平移動を実現できます。 Quaternionの詳細については本記事では省略します。

おわりに

本記事では、アイプラの着替えにおけるカメラについて紹介しました。 何気なく触れられる機能ですが、実装にはいくつかの工夫が込められています。 実装方法も記載しましたので、ぜひ参考にしていただければ幸いです。

2020年にサイバーエージェントに新卒入社。その後、QualiArtsにてUnityエンジニアとして開発に携わる