C#
Unity3D
Unity
ゲーム制作
カメラワーク

ゲームの質を劇的に上げるカメラワークの3つの手法解説【減衰・FoV・手ブレ】

はじめに

こんにちは、個人ゲーム開発がすきな@yoship1639です。
普段は自作エンジンですがUnityにも手を出しています。

皆様、自分のゲームのカメラワークが安っぽくて絶望したことはありませんでしょうか。
絶望はしなくても、もっと良いカメラワークにしたいと思う人も多いのではないかと思います。

しかし、調べても中々出てこないし大体Cinemachineの記事が引っかかるのではないかと思います。
※ちなみに筆者はCinemachine使ったことありません

そこで、比較的簡単にカメラワークの質を劇的に向上する3つの手法を技術解説し、その素晴らしさに気が付いてもらえたらと思います。

どの程度見た目が違うのか

とりあえず、見ていただければどの程度違うのか分かります。

カメラワークの処理が異なるだけでこれだけ見た目に差が出てきます。カメラワークを工夫するだけで安っぽさが無くなるので、ぜひ試してみてください。

それでは、実際にどの様な手法をとっているのか見てみましょう。

質を上げる3つの手法

カメラワークの質を比較的簡単に上げる3つの手法は「Lerp減衰・FoV変動・PerlinNoise手ブレ」です。

Lerp減衰

Lerp減衰は、線形補間とフレーム間の差分時間を使った減衰手法で、カメラの移動や方向転換がとても滑らかになる手法です。普通のカメラワークはプレイヤ位置にぴったり張り付いてしまうので移動している感が感じにくいのですが、この手法を使うと移動している感が簡単に得られるようになります。そして、この手法はカメラワーク以外にも様々な場面で利用できるので覚えていて絶対損は無いと断言致します。

FoV変動

FoV変動とは、何のこともない、FoV(Field of View)の値を変動させているだけです。例えば、レースゲームで加速する時にいかにも加速している感を演出するためにFoV値を上げたりします。逆に、隠密系のゲームでは隠密時にFoVを下げて視界を狭くしていかにもこそこそ動いている感を出します。この様に、プレイヤの状況に合わせてFoV値を変動させるだけでもカメラワークの質は向上します。

PerlinNoise手ブレ

最後に、PerlinNoise手ブレです。これは、人間がいかにも手でカメラを持って撮影しているかの様な演出を醸し出すシネマチックなカメラワークを作るのに必須の手法です。PerlinNoiseはMinecraftの地形生成などにも使われたりする優れもののノイズです。実はこの手ブレ、Unityだととても簡単に作れてしまいます。なぜなら、すでにPerlinNoise関数がMathfに標準実装されているからです。ご存知でしたか?

これらの3つの手法をカメラワークに組み込むだけで、とても簡単にカメラワークの質を向上させる事が可能です。

実際に実装してみる

それでは、実際にこれらの手法を実装してみたいと思います。
今回は分かりやすくするために、キャラクタの移動をX軸固定にしたキャラクタコントローラとカメラワークを作ります。

キャラクタコントローラ

PlayerController.cs
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public float Speed = 3.0f;
    public float JumpPower = 6.0f;

    Rigidbody rb;

    void Start ()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update ()
    {
        var x = Input.GetAxis("Horizontal");
        transform.position += new Vector3(x * Time.deltaTime * Speed, 0.0f, 0.0f);

        if (Input.GetButtonDown("Jump")) rb.AddForce(0.0f, JumpPower, 0.0f, ForceMode.Impulse);
    }
}

このコードについては特に説明は必要ないと思います。
ただ左右に移動出来てジャンプできるだけです。

カメラコントローラ(Lerp減衰)

まず最初にLerp減衰を実装します。これを実現するには、本来到達すべき値実際の現在の値の二つを用意します。
カメラワークで言うと、本来到達しているべきカメラ位置と現在のカメラ位置です。

public class CameraController : MonoBehaviour
{
    public Transform Target; // 注目する対象(プレイヤー)

    public float Distance = 5.0f; // 距離
    public float Height = 2.0f;  // 高さ

    public float AttenRate = 3.0f; // 減衰比率

    void Update ()
    {
        var pos = Target.position + new Vector3(0.0f, Height, -Distance); // 本来到達しているべきカメラ位置
        transform.position = Vector3.Lerp(transform.position, pos, Time.deltaTime * AttenRate); // Lerp減衰
    }
}

注目すべき処理はここです

transform.position = Vector3.Lerp(transform.position, pos, Time.deltaTime * AttenRate);

transform.positionは現在のカメラ位置を表しています。
posは本来到達しているべきカメラ位置です。
Vector3.Lerpの第3引数を0に近づけるとtransform.positionに、1に近づけるとposになります。線形補間のVector3バージョンです。

Lerp減衰は、第3引数にフレーム間の差分時間であるTime.deltaTimeを使います。
Time.deltaTimeはたかだか1/60.0f程度の値しかないので、この線形補間の結果は限りなく元の値のtransform.positionになります。しかし、Updateは1秒間に何度も呼ばれます。そのため、ほんの少しづつだけど本来到達しているべきカメラ位置posに滑らかに近づいていくのです。これがLerp減衰です。

AttenRateの値を変えると滑らかさがゆっくりになったり早くなったりします。

今回はカメラの移動のみにLerp減衰を適用していますが、プレイヤーの移動やカメラ向きなどにも応用できるため、あらゆる場面で活躍します。是非試してみてください。

カメラコントローラ(FoV変動)

次に、FoV変動を実装してみます。これはとても簡単で、Camera.fieldOfViewの値をキャラクタの移動に合わせてLerp減衰で変えるだけです。コードは下記のようになります。

public class CameraController : MonoBehaviour
{
    public Transform Target; // 注目する対象(プレイヤー)
    public Camera Camera; // カメラ

    public float FoVAttenRate = 3.0f; // FoVの減衰比率
    public float MovedFoV = 65.0f; // プレイヤーが移動している時のFoV
    public float FoV = 50.0f; // プレイヤーが立ち止まっている時のFoV

    private Vector3 prevTargetPos; // 前フレームのターゲットの位置

    void Start()
    {
        prevTargetPos = Target.position;
    }

    void Update ()
    {
        var moved = Target.position != prevTargetPos;
        prevTargetPos = Target.position;

        var fov = moved ? MovedFoV : FoV;
        Camera.fieldOfView = Mathf.Lerp(Camera.fieldOfView, fov, Time.deltaTime * FoVAttenRate);
    }
}

これも先ほどのLerp減衰をfieldOfViewに適用させただけのコードです。プレイヤが移動している時にFoV値を上げ、立ち止まっている時にFoV値を下げています。これだけ短くても侮ることなかれ、カメラワークの質はしっかり向上します。

カメラコントローラ(PerlinNoise手ブレ)

最後に、手ブレを実現する手法であるパーリンノイズ(PerlinNoise)をカメラに適用させます。このパーリンノイズの詳細は
パーリンノイズを理解する:https://postd.cc/understanding-perlin-noise
スクリプトリファレンス:https://docs.unity3d.com/ja/2017.4/ScriptReference/Mathf.PerlinNoise.html
などをご覧になって下さい。

public class CameraController : MonoBehaviour
{
    public Transform Target; // 注目する対象(プレイヤー)
    public Camera Camera; // カメラ

    public float AngleX = 15.0f; // カメラX軸角度
    public float AngleAttenRate = 5.0f; // カメラ向きの減衰比率
    public float NoiseSpeed = 0.5f;  // プレイヤが止まっている時のノイズ速度
    public float MoveNoiseSpeed = 1.5f;  // プレイヤが動いてる時のノイズ速度
    public float NoiseCoeff = 1.3f;  // プレイヤが止まっている時のノイズ係数
    public float MoveNoiseCoeff = 2.2f;  // プレイヤが動いてる時のノイズ係数

    private Vector3 prevTargetPos; // 前フレームのターゲットの位置

    void Start()
    {
        prevTargetPos = Target.position;
    }

    void Update ()
    {
        var moved = Target.position != prevTargetPos;
        prevTargetPos = Target.position;

        var ns = (moved ? MoveNoiseSpeed : NoiseSpeed);
        var nc = (moved ? MoveNoiseCoeff : NoiseCoeff);

        var t = Time.time * ns;
        var nx = Mathf.PerlinNoise(t, t + 5.0f) * nc;
        var ny = Mathf.PerlinNoise(t + 10.0f, t + 15.0f) * nc;
        var nz = Mathf.PerlinNoise(t + 25.0f, t + 20.0f) * nc * 0.5f;
        var noise = new Vector3(nx, ny, nz);

        var noiseRot = Quaternion.Euler(AngleX + noise.x, noise.y, noise.z);
        transform.rotation = Quaternion.Slerp(transform.rotation, noiseRot, Time.deltaTime * AngleAttenRate);
    }
}

今回は、プレイヤが移動している時は手ブレが大きく、移動していないときは手ブレが小さくなるようにコーディングしました。Mathf.PerlinNoiseを呼ぶだけで簡単にノイズを手に入れることができるのでとても楽ですね。

PerlinNoiseでそれぞれ、X,Y,Z方向のカメラの向きのずれを生成します。このずれ(ノイズ)が手ブレになります。引数のtは経過時間を渡すだけで大丈夫です。tの倍率を変えるとノイズが早くなったり遅くなったりします。

次に注目するべきは、Quaternion.Slerpです。これは球面線形補間と言って回転の線形補間に使われるLerpです。カメラの手ブレも、SLerp減衰で滑らかにすることで、さらにシネマチックなカメラワークを実現しています。

3手法を合わせたカメラワーク

これですべての手法を紹介し終えました。最後にこれらをすべてまとめ、少し手を加えたコードを載せます。

CameraController.cs
using UnityEngine;

public class CameraController : MonoBehaviour
{
    public GameObject TargetObject;
    public float Height = 0.0f;
    public float Distance = 5.0f;
    public float AngleX = 15.0f;
    public float AngleAttenRate = 5.0f;

    public bool EnableAtten = true;
    public float AttenRate = 3.0f;

    public bool EnableNoise = true;
    public float NoiseSpeed = 0.5f;
    public float MoveNoiseSpeed = 1.5f;
    public float NoiseCoeff = 1.3f;
    public float MoveNoiseCoeff = 2.2f;

    public bool EnableFieldOfViewAtten = true;
    public float FieldOfView = 50.0f;
    public float MoveFieldOfView = 60.0f;

    public float ForwardDistance = 3.0f;
    public float ForwardSpeed = 3.0f;

    private Camera cam;
    private Vector3 deltaTarget;
    private Vector3 nowPos;
    private float nowfov;

    private float addX;

    void Start ()
    {
        cam = GetComponent<Camera>();
        nowfov = FieldOfView;
        nowPos = TargetObject.transform.position;
    }

    void Update ()
    {
        var delta = TargetObject.transform.position - deltaTarget;
        deltaTarget = TargetObject.transform.position;

        // 減衰
        if (EnableAtten)
        {
            if (delta.x > 0.004f) addX += Time.deltaTime * ForwardSpeed;
            else if (delta.x < -0.004f) addX -= Time.deltaTime * ForwardSpeed;
            else addX = Mathf.Lerp(addX, 0.0f, Time.deltaTime * ForwardSpeed);
            addX = Mathf.Clamp(addX, -ForwardDistance, ForwardDistance);

            var add = new Vector3(addX, delta.y * 20.0f, 0.0f);
            nowPos = Vector3.Lerp(nowPos, TargetObject.transform.position + add, Mathf.Clamp(Time.deltaTime * AttenRate, 0.0f, 1.0f));
        }
        else nowPos = TargetObject.transform.position;

        // 手ブレ
        bool move = Mathf.Abs(delta.x) > 0.0f;
        var noise = new Vector3();
        if (EnableNoise)
        {
            var ns = (move ? MoveNoiseSpeed : NoiseSpeed);
            var nc = (move ? MoveNoiseCoeff : NoiseCoeff);

            var t = Time.time * ns;

            var n1 = Mathf.PerlinNoise(t, t + 5.0f) * nc;
            var n2 = Mathf.PerlinNoise(t + 10.0f, t + 15.0f) * nc;
            var n3 = Mathf.PerlinNoise(t + 25.0f, t + 20.0f) * nc;
            noise = new Vector3(n1, n2, n3 * 0.5f);
        }

        // FoV
        if (EnableFieldOfViewAtten) nowfov = Mathf.Lerp(nowfov, move ? MoveFieldOfView : FieldOfView, Time.deltaTime);
        else nowfov = FieldOfView;
        cam.fieldOfView = nowfov;

        transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.Euler(AngleX + noise.x, noise.y, noise.z), Time.deltaTime * AngleAttenRate);
        transform.position = nowPos + new Vector3(0.0f, Height, -Distance);
    }
}

Enable系でLerp減衰、FoV変動、PerlinNoise手ブレをON,OFF可能です。先ほどのプレイヤコントローラと合わせて確認してみてください。きっと質の高いカメラワークが実現できているはずです。

追記:3D移動に対応させたバージョン

多大な反響ありがとうございます。嬉しい限りです。
お礼を込めて、改良して3D移動に対応させたバージョンも作りました。
ただ、ノイズ部分にバグがあるのかパラメータ設定によってはカクツキが出てしまうようです。。。パラメータ設定にはお気を付けください

キャラクタコントローラ3D対応ver

PlayerController.cs
public class PlayerController : MonoBehaviour
{
    public float Speed = 3.0f;
    public float JumpPower = 6.0f;

    Rigidbody rb;
    public Camera Camera;

    void Start ()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update ()
    {
        var x = Input.GetAxis("Horizontal");
        var z = Input.GetAxis("Vertical");
        Vector3 camForward = Vector3.Scale(Camera.transform.forward, new Vector3(1, 0, 1)).normalized;
        Vector3 moveForward = camForward * z + Camera.transform.right * x;
        transform.position += moveForward * Speed * Time.deltaTime;

        if (moveForward.sqrMagnitude > 0.0f) transform.rotation = Quaternion.LookRotation(moveForward);

        if (Input.GetButtonDown("Jump")) rb.AddForce(0.0f, JumpPower, 0.0f, ForceMode.Impulse);
    }
}

カメラコントローラ3D対応ver

public class CameraController3D : MonoBehaviour
{
    public GameObject TargetObject;
    public float Height = 1.5f;
    public float Distance = 5.0f;
    public float HeightAngle = 10.0f;
    public float RotAngle = 0.0f;
    public float RotAngleAttenRate = 5.0f;
    public float AngleAttenRate = 40.0f;

    public bool EnableAtten = true;
    public float AttenRate = 3.0f;

    public bool EnableNoise = true;
    public float NoiseSpeed = 0.5f;
    public float MoveNoiseSpeed = 1.5f;
    public float NoiseCoeff = 1.3f;
    public float MoveNoiseCoeff = 2.5f;

    public bool EnableFieldOfViewAtten = true;
    public float FieldOfView = 50.0f;
    public float MoveFieldOfView = 60.0f;

    public float ForwardDistance = 2.0f;

    private Camera cam;
    private Vector3 addForward;
    private Vector3 deltaTarget;
    private Vector3 nowPos;
    private float nowfov;

    private float nowRotAngle;
    private float nowHeightAngle;

    private Vector3 prevTargetPos;

    // Use this for initialization
    void Start ()
    {
        cam = GetComponent<Camera>();
        nowfov = FieldOfView;
        nowPos = TargetObject.transform.position;
    }

    // Update is called once per frame
    void Update ()
    {
        RotAngle -= Input.GetAxis("Mouse X") * 5.0f;
        HeightAngle += Input.GetAxis("Mouse Y") * 5.0f;
        HeightAngle = Mathf.Clamp(HeightAngle, 0.0f, 80.0f);

        var delta = TargetObject.transform.position - deltaTarget;
        deltaTarget = TargetObject.transform.position;

        // 減衰
        if (EnableAtten)
        {
            var deltaPos = TargetObject.transform.position - prevTargetPos;
            prevTargetPos = TargetObject.transform.position;
            deltaPos *= ForwardDistance;

            addForward += deltaPos * Time.deltaTime * 20.0f;
            addForward = Vector3.Lerp(addForward, Vector3.zero, Time.deltaTime * AttenRate);

            nowPos = Vector3.Lerp(nowPos, TargetObject.transform.position + Vector3.up * Height + addForward, Mathf.Clamp(Time.deltaTime * AttenRate, 0.0f, 1.0f));
        }
        else nowPos = TargetObject.transform.position + Vector3.up * Height;

        // 手ブレ
        bool move = Mathf.Abs(delta.x) > 0.0f;
        var noise = new Vector3();
        if (EnableNoise)
        {
            var ns = (move ? MoveNoiseSpeed : NoiseSpeed);
            var nc = (move ? MoveNoiseCoeff : NoiseCoeff);

            var t = Time.time * ns;

            var nx = Mathf.PerlinNoise(t, t) * nc;
            var ny = Mathf.PerlinNoise(t + 10.0f, t + 10.0f) * nc;
            var nz = Mathf.PerlinNoise(t + 20.0f, t + 20.0f) * nc * 0.5f;
            noise = new Vector3(nx, ny, nz);
        }

        // FoV
        if (EnableFieldOfViewAtten) nowfov = Mathf.Lerp(nowfov, move ? MoveFieldOfView : FieldOfView, Time.deltaTime);
        else nowfov = FieldOfView;
        cam.fieldOfView = nowfov;

        // カメラ位置
        if (EnableAtten) nowRotAngle = Mathf.Lerp(nowRotAngle, RotAngle, Time.deltaTime * RotAngleAttenRate);
        else nowRotAngle = RotAngle;

        if (EnableAtten) nowHeightAngle = Mathf.Lerp(nowHeightAngle, HeightAngle, Time.deltaTime * RotAngleAttenRate);
        else nowHeightAngle = HeightAngle;

        var deg = Mathf.PI / 180.0f;
        var cx = Mathf.Sin(nowRotAngle * deg) * Mathf.Cos(nowHeightAngle * deg) * Distance;
        var cz = -Mathf.Cos(nowRotAngle * deg) * Mathf.Cos(nowHeightAngle * deg) * Distance;
        var cy = Mathf.Sin(nowHeightAngle * deg) * Distance;
        transform.position = nowPos + new Vector3(cx, cy, cz);

        // カメラ向き
        var rot = Quaternion.LookRotation((nowPos - transform.position).normalized) * Quaternion.Euler(noise);
        if (EnableAtten) transform.rotation = Quaternion.Slerp(transform.rotation, rot, Time.deltaTime * AngleAttenRate);
        else transform.rotation = rot;
    }
}

ぜひご利用ください!

まとめ

ゲームの質を上げるカメラワークの3手法として、Lerp減衰、FoV変動、PerlinNoise手ブレを紹介・解説・実装しました。これにより、比較的簡単に追従型のカメラワークの質を劇的に向上させることが出来る様になりました。

この手の手法を紹介している記事がなかなか見つからなかったため記事にした次第です。

皆様のゲーム制作の役に立つことを期待しています!