【ゲーム制作】カメラシェイクの実装|Offsetを減衰させて「効いてる感」を作る

camera shake thumbnail ゲーム制作

「爆発が起きたときにカメラがガクガク揺れる演出、あれどうやって作るの?」「揺れの強さって時間とともにどう減らすの?」「カメラを追従させる処理と、揺らす処理って一緒に書いていいの?」

こんな疑問はありませんか?

今回は、爆発やダメージ時の「効いてる感」を作るもう一つの定番テクニック、カメラシェイクについて解説していきます。実際にコンパイル・実行した結果を交えながら、仕組みと、実装するときに本当につまずいたポイントまで紹介します。

カメラシェイクとは?

カメラシェイクとは、

・爆発やダメージなどのタイミングで、カメラの位置(と回転)にランダムなオフセットを一時的に加え、時間とともに減衰させることで「衝撃を受けた」感覚を演出するテクニック

です。ヒットストップが「時間の流れ方」を操作する演出だったのに対し、カメラシェイクは「カメラそのものの位置」を操作する演出になります(ヒットストップの記事もあわせてどうぞ)。

実装してみる

最小構成のCameraShakeクラスを実際に書いてコンパイル・実行してみます。ポイントは、残り時間の割合を2乗して減衰カーブにするところです。

class CameraShake {
public:
    // intensity: 揺れ幅(0.1〜0.5あたりが目安)
    // duration : 持続時間(秒)
    static void Trigger(float intensity, float duration) {
        s_Intensity = intensity;
        s_Duration = duration;
        s_Timer = duration;
    }

    static void Update(float realDeltaTime) {
        if (s_Timer <= 0.0f) {
            s_PositionOffset = { 0.0f, 0.0f, 0.0f };
            return;
        }
        s_Timer -= realDeltaTime;

        // 残り時間の割合で減衰させる(0.0〜1.0)
        float t = s_Timer / s_Duration;
        if (t < 0.0f) t = 0.0f;
        float falloff = t * t; // 二乗にして終盤の収まりを早くする

        s_PositionOffset.x = RandRange() * s_Intensity * falloff;
        s_PositionOffset.y = RandRange() * s_Intensity * falloff;
    }

    static Vector3 GetPositionOffset() { return s_PositionOffset; }
    static bool IsActive() { return s_Timer > 0.0f; }

private:
    static float   s_Timer;
    static float   s_Duration;
    static float   s_Intensity;
    static Vector3 s_PositionOffset;
};

強め(0.4)・0.2秒のシェイクを発動させ、固定60FPSで実行した結果です。

--- CameraShake::Trigger(0.4f, 0.2f) ---
frame  0: IsActive=1  offset=(-0.181, +0.057) rotZ=+0.579  |offset|=0.189
frame  1: IsActive=1  offset=(+0.097, -0.179) rotZ=+0.357  |offset|=0.204
frame  2: IsActive=1  offset=(+0.152, -0.151) rotZ=+0.523  |offset|=0.214
frame  3: IsActive=1  offset=(+0.076, -0.015) rotZ=-0.052  |offset|=0.078
frame  4: IsActive=1  offset=(+0.034, +0.044) rotZ=-0.171  |offset|=0.056
frame  5: IsActive=1  offset=(-0.014, +0.034) rotZ=-0.113  |offset|=0.037
frame  6: IsActive=1  offset=(+0.041, -0.053) rotZ=-0.078  |offset|=0.067
frame  7: IsActive=1  offset=(-0.022, -0.014) rotZ=-0.009  |offset|=0.026
frame  8: IsActive=1  offset=(-0.021, -0.009) rotZ=+0.023  |offset|=0.023
frame  9: IsActive=1  offset=(-0.009, -0.010) rotZ=+0.023  |offset|=0.013
frame 10: IsActive=1  offset=(+0.001, -0.002) rotZ=-0.002  |offset|=0.003
frame 11: IsActive=1  offset=(+0.000, -0.000) rotZ=+0.000  |offset|=0.000
frame 12: IsActive=0  offset=(-0.000, +0.000) rotZ=-0.000  |offset|=0.000

最初(frame 0〜2)は|offset|(揺れの大きさ)が0.19〜0.21あたりでランダムに暴れていますが、frame 6以降は急激に0へ収束していくのが分かります。falloff = t * tと2乗にしているおかげで、前半は大きく揺れて、後半は急に収まるという、いかにも「衝撃が抜けていく」ような感触になっています。ここを2乗せずtのまま線形に減衰させると、後半までダラダラと揺れが残ってしまい、あまり気持ちよくありません。

【重要】実際にハマった罠:カメラ追従とシェイクを混ぜて壊した話

これは今回のデモに限らず、以前実際に自分のゲーム制作で踏んだ罠です。カメラには大抵、プレイヤーを追いかける「追従(follow)」処理が別に存在します。この追従処理とシェイクを迂闊に混ぜると、思わぬ壊れ方をします。

「NG」は、シェイクのオフセットをPosition(カメラの本来の位置)に直接足し込んでしまうパターン。「OK」は、Positionはシェイクの影響を受けないきれいな値のまま保ち、実際に描画で使う最終位置を求めるときだけオフセットを重ねるパターンです。

// NG: シェイクのオフセットをPositionに直接加算してしまう
cam.Update(); // 追従計算(毎フレーム必ず呼ばれる)
Vector3 off = CameraShake::GetOffset();
cam.Position.x += off.x;   // ← Position自体を汚してしまう
cam.Position.y += off.y;

// OK: Positionはクリーンに保ち、最後にオフセットを重ねるだけ
cam.Update(); // 追従計算のみ。シェイクは一切混ぜない
Vector3 off = CameraShake::GetOffset();
float renderX = cam.Position.x + off.x; // 描画用の最終値だけに反映
float renderY = cam.Position.y + off.y;

実際に5フレーム分実行した結果です。

=== NG: シェイクのオフセットをPositionに直接加算してしまう ===
frame 0: Position=(3.255, 0.746)
frame 1: Position=(7.007, 1.244)
frame 2: Position=(8.318, -0.146)
frame 3: Position=(9.543, -0.941)
frame 4: Position=(9.155, -0.764)

=== OK: Positionはクリーンに保ち、最後にオフセットを重ねるだけ ===
frame 0: Position=(5.000, 0.000)  RenderPos=(5.827, 1.751)
frame 1: Position=(7.500, 0.000)  RenderPos=(8.833, 1.577)
frame 2: Position=(8.750, 0.000)  RenderPos=(9.235, 1.023)
frame 3: Position=(9.375, 0.000)  RenderPos=(8.883, -0.075)
frame 4: Position=(9.688, 0.000)  RenderPos=(9.943, 0.045)

NG版はPosition自体が毎フレーム揺れの影響を受けて汚染され、frame 4でX座標が9.543→9.155と、目標(10)に近づくどころか後退してしまっています。追従計算は「今のPositionから目標にどれだけ近づくか」を計算しているため、シェイクで揺らされた位置がそのまま次のフレームの追従計算の出発点になってしまい、追従とシェイクがお互いに干渉し合う状態になるからです。

一方OK版は、Positionは5.0→7.5→8.75→9.375→9.688ときれいに目標(10)へ近づき続けています。シェイクの効果はRenderPosという別の値にだけ現れていて、追従計算そのものには一切影響していません。

カメラシェイクのよくある失敗例と対処法

①追従処理の「本来の位置」を揺れで汚してしまう

上で実演した通りです。「本来のカメラ位置」と「揺れを含めた最終描画位置」は必ず別の変数として持つようにしましょう。

②減衰を線形(1乗)のままにしてしまう

fallofftのまま使うと、終盤までダラダラ揺れが残って締まりません。2乗、あるいは3乗にするだけで「バシッと止まる」感触に近づきます。

③回転オフセットの単位を間違える

位置のオフセットと同じ感覚で回転オフセットにも同じ強度をそのまま使うと、度数法(degree)なのかラジアン(radian)なのかで揺れ方が極端に変わります。位置とは別に、回転用の強度パラメータを分けておくのがおすすめです。

注意点

  • 「本来の位置」と「揺れを含む最終位置」は別変数にする(追従処理との干渉を防ぐ)
  • 減衰は2乗以上にすると終盤の収まりが良い
  • 位置と回転で強度パラメータを分ける
  • ヒットストップと同時に使うと、より「効いてる感」が強まる

まとめ

  • カメラシェイクは位置オフセットを一時的に加えて時間で減衰させる演出
  • 減衰カーブは2乗にすると自然な収まり方になる
  • 追従処理の「本来の位置」を揺れで汚してしまうと、追従自体が壊れる(実際にハマった)
  • 対処法は、本来の位置と最終描画位置を分けるだけ

カメラシェイク自体はランダムなオフセットを足すだけのシンプルな処理ですが、「他の処理と混ぜてはいけない」という一点だけは、実際に壊してみて初めて実感が持てました。

ここまで読んでくださりありがとうございました。

関連記事