FPS を作り始めたとき、つい「とりあえず Update() に全部書いちゃえ!」となりがちですよね。
マウス入力、カメラ回転、移動、アニメーション、武器切り替え……全部ひとつのスクリプトに押し込むと、最初は動いていても、あとから仕様変更するたびに地獄になります。

特に「武器揺れ(Weapon Sway)」のような見た目の演出は、プレイヤー移動ロジックと混ざりがちです。
・マウス感度を変えたら揺れもおかしくなった
・武器を持ち替えたら位置がズレた
・調整のたびに 1,000 行クラスをスクロール…
といったことになりやすいですね。

そこでこの記事では、「武器揺れ」だけを担当する小さなコンポーネント WeaponSway を作って、
・プレイヤー操作のスクリプト
・カメラ制御のスクリプト
・武器揺れのスクリプト
をキレイに分離していきます。

【Unity】マウスに追従する武器揺れ!「WeaponSway」コンポーネント

ここでは、マウスの移動量に応じて、FPS 視点の武器モデルを「少し遅れて追従」させるコンポーネントを実装します。

  • マウスを右に振ると、武器が少し遅れて右に傾く
  • 止めると、ゆっくり元の位置・回転に戻る
  • スクリプトは武器オブジェクト単体にアタッチするだけ

という構成にしておくと、プレイヤーの入力処理とは完全に独立して調整できます。

フルコード(WeaponSway.cs)


using UnityEngine;
using UnityEngine.InputSystem; // 新Input Systemを使う場合に備えて

/// <summary>
/// マウス移動に応じてFPS武器モデルを「少し遅れて追従」させるコンポーネント。
/// ・位置の揺れ(オフセット)
/// ・回転の揺れ(傾き)
/// をそれぞれ調整可能にしています。
/// 
/// プレイヤーの移動・カメラ回転ロジックとは分離して、
/// 武器モデルの見た目だけを担当させるのが狙いです。
/// </summary>
public class WeaponSway : MonoBehaviour
{
    // ====== 基本設定 ======

    [Header("参照設定")]
    [Tooltip("武器の基準位置・回転。未指定なら、このオブジェクト自身が基準になります。")]
    [SerializeField] private Transform baseTransform;

    [Header("位置の揺れ設定")]
    [Tooltip("マウス移動を位置揺れに変換するスケール。値を大きくするとよく揺れます。")]
    [SerializeField] private Vector2 positionSwayAmount = new Vector2(0.02f, 0.02f);

    [Tooltip("位置揺れの最大量(クランプ)。")]
    [SerializeField] private Vector2 maxPositionOffset = new Vector2(0.15f, 0.15f);

    [Tooltip("位置が元の場所へ戻るスピード。")]
    [SerializeField] private float positionReturnSpeed = 10f;

    [Tooltip("位置揺れの追従スピード(補間のなめらかさ)。")]
    [SerializeField] private float positionSmoothing = 10f;

    [Header("回転の揺れ設定")]
    [Tooltip("マウス移動を回転揺れに変換するスケール。X=左右、Y=上下。")]
    [SerializeField] private Vector2 rotationSwayAmount = new Vector2(2f, 2f);

    [Tooltip("回転揺れの最大角度(クランプ)。")]
    [SerializeField] private Vector2 maxRotationAngle = new Vector2(10f, 10f);

    [Tooltip("回転が元の角度へ戻るスピード。")]
    [SerializeField] private float rotationReturnSpeed = 10f;

    [Tooltip("回転揺れの追従スピード(補間のなめらかさ)。")]
    [SerializeField] private float rotationSmoothing = 10f;

    [Header("入力設定")]
    [Tooltip("新Input SystemのMouse.deltaを使うかどうか。falseなら旧Input(GetAxis)を使用。")]
    [SerializeField] private bool useNewInputSystem = true;

    [Tooltip("マウス感度。実際のマウス移動量に掛ける係数です。")]
    [SerializeField] private float mouseSensitivity = 1.0f;

    [Header("その他")]
    [Tooltip("Time.timeScaleが0(ポーズ中など)のときは揺れを止めるかどうか。")]
    [SerializeField] private bool stopWhenTimeScaleZero = true;

    // ====== 内部状態 ======

    private Vector3 _initialLocalPosition;   // 武器の初期ローカル位置
    private Quaternion _initialLocalRotation; // 武器の初期ローカル回転

    private Vector3 _currentPositionOffset;  // 現在の位置オフセット
    private Vector3 _targetPositionOffset;   // 目標の位置オフセット

    private Quaternion _currentRotationOffset = Quaternion.identity; // 現在の回転オフセット
    private Quaternion _targetRotationOffset = Quaternion.identity;  // 目標の回転オフセット

    // 新Input System用のマウス参照(存在しない場合もあるのでnullチェック必須)
    private Mouse _mouse;

    private void Awake()
    {
        // 基準Transformが未設定なら自分自身を基準にする
        if (baseTransform == null)
        {
            baseTransform = transform;
        }

        // 初期ローカル位置・回転を記録
        _initialLocalPosition = baseTransform.localPosition;
        _initialLocalRotation = baseTransform.localRotation;

        // 新Input Systemを使う場合、Mouseを取得しておく
        if (useNewInputSystem)
        {
            _mouse = Mouse.current;
        }
    }

    private void Update()
    {
        // ポーズ中に揺れを止めたい場合
        if (stopWhenTimeScaleZero && Mathf.Approximately(Time.timeScale, 0f))
        {
            // ゆっくり初期状態に戻すだけ
            ReturnToInitialState();
            ApplyOffsets();
            return;
        }

        // 1. マウス入力を取得
        Vector2 mouseDelta = ReadMouseDelta();

        // 2. マウス入力から目標オフセットを計算
        UpdateTargetOffsets(mouseDelta);

        // 3. 現在のオフセットをなめらかに目標へ近づける
        SmoothOffsets();

        // 4. 実際にTransformへ適用
        ApplyOffsets();
    }

    /// <summary>
    /// マウスの移動量を読み取る。
    /// 新Input System or 旧Input Manager のどちらかを使用。
    /// </summary>
    private Vector2 ReadMouseDelta()
    {
        Vector2 delta = Vector2.zero;

        if (useNewInputSystem)
        {
            if (_mouse != null)
            {
                // 新Input SystemのMouse.deltaは「フレームごとの移動量」
                delta = _mouse.delta.ReadValue();
            }
        }
        else
        {
            // 旧Input ManagerのGetAxisを使用
            // Unityのデフォルト設定では "Mouse X", "Mouse Y" が用意されています
            float dx = Input.GetAxis("Mouse X");
            float dy = Input.GetAxis("Mouse Y");
            delta = new Vector2(dx, dy);
        }

        // マウス感度を適用
        delta *= mouseSensitivity;

        return delta;
    }

    /// <summary>
    /// マウス入力から「目標となる位置・回転オフセット」を計算します。
    /// </summary>
    private void UpdateTargetOffsets(Vector2 mouseDelta)
    {
        // --- 位置オフセット ---
        // マウスを右に動かしたら、武器は少し左に残るようなイメージで符号を反転しています。
        float offsetX = -mouseDelta.x * positionSwayAmount.x;
        float offsetY = -mouseDelta.y * positionSwayAmount.y;

        // クランプして暴れすぎを防止
        offsetX = Mathf.Clamp(offsetX, -maxPositionOffset.x, maxPositionOffset.x);
        offsetY = Mathf.Clamp(offsetY, -maxPositionOffset.y, maxPositionOffset.y);

        _targetPositionOffset = new Vector3(offsetX, offsetY, 0f);

        // --- 回転オフセット ---
        // マウスを右に動かしたら、武器を少し右に傾けたいので、こちらは符号をそのままにしています。
        float rotY = mouseDelta.x * rotationSwayAmount.x;   // 左右に振るとヨー回転
        float rotX = -mouseDelta.y * rotationSwayAmount.y;  // 上下に振るとピッチ回転(上下反転)

        rotY = Mathf.Clamp(rotY, -maxRotationAngle.x, maxRotationAngle.x);
        rotX = Mathf.Clamp(rotX, -maxRotationAngle.y, maxRotationAngle.y);

        // Z軸回転(ロール)は今回は使わず0固定
        Quaternion targetRot = Quaternion.Euler(rotX, rotY, 0f);
        _targetRotationOffset = targetRot;
    }

    /// <summary>
    /// 現在のオフセットを「目標オフセット」に向かってなめらかに補間します。
    /// </summary>
    private void SmoothOffsets()
    {
        float dt = Time.deltaTime;

        // 位置:目標オフセットに向かってLerp
        _currentPositionOffset = Vector3.Lerp(
            _currentPositionOffset,
            _targetPositionOffset,
            1f - Mathf.Exp(-positionSmoothing * dt) // Expを使ったスムーズ減衰
        );

        // 回転:目標オフセットに向かってSlerp
        _currentRotationOffset = Quaternion.Slerp(
            _currentRotationOffset,
            _targetRotationOffset,
            1f - Mathf.Exp(-rotationSmoothing * dt)
        );

        // 「元に戻る力」を少し追加しておくと、マウスを止めたときに
        // ふわっとセンターに戻る感じになります。
        _targetPositionOffset = Vector3.Lerp(
            _targetPositionOffset,
            Vector3.zero,
            positionReturnSpeed * dt
        );

        _targetRotationOffset = Quaternion.Slerp(
            _targetRotationOffset,
            Quaternion.identity,
            rotationReturnSpeed * dt
        );
    }

    /// <summary>
    /// 初期位置・初期回転へゆっくり戻すだけの処理。
    /// ポーズ中などに使用します。
    /// </summary>
    private void ReturnToInitialState()
    {
        float dt = Time.unscaledDeltaTime;

        _currentPositionOffset = Vector3.Lerp(
            _currentPositionOffset,
            Vector3.zero,
            1f - Mathf.Exp(-positionSmoothing * dt)
        );

        _currentRotationOffset = Quaternion.Slerp(
            _currentRotationOffset,
            Quaternion.identity,
            1f - Mathf.Exp(-rotationSmoothing * dt)
        );

        _targetPositionOffset = Vector3.zero;
        _targetRotationOffset = Quaternion.identity;
    }

    /// <summary>
    /// 計算したオフセットを実際のTransformに適用します。
    /// </summary>
    private void ApplyOffsets()
    {
        // 位置は初期ローカル位置 + オフセット
        baseTransform.localPosition = _initialLocalPosition + _currentPositionOffset;

        // 回転は初期ローカル回転 * オフセット回転
        baseTransform.localRotation = _initialLocalRotation * _currentRotationOffset;
    }

    /// <summary>
    /// 外部から「揺れを一時的にリセットしたい」ときに呼び出す用。
    /// 武器を持ち替えた直後などに使えます。
    /// </summary>
    public void ResetSwayImmediate()
    {
        _currentPositionOffset = Vector3.zero;
        _targetPositionOffset = Vector3.zero;
        _currentRotationOffset = Quaternion.identity;
        _targetRotationOffset = Quaternion.identity;

        ApplyOffsets();
    }
}

使い方の手順

ここでは「FPS プレイヤーの右手に持った武器モデル」に揺れを付ける例で説明します。

  1. 武器モデル用の子オブジェクトを用意する
    プレイヤーカメラ(例: PlayerCamera)の子として、武器を表示するオブジェクトを作ります。
    例:
    Player
     └─ PlayerCamera (Camera)
         └─ WeaponRoot   <-- ここに武器モデルをぶら下げる
             └─ RifleModel
    
    • WeaponRoot の位置・回転を「画面右下にちょうどよく見える位置」に調整しておきます。
    • 武器を持ち替えるときは、RifleModel を差し替える運用にすると楽です。
  2. WeaponSway コンポーネントをアタッチする
    • WeaponRoot(または武器モデルの親にしたいオブジェクト)を選択
    • Add Component から WeaponSway を追加
    • Base Transform が空なら、自動的にそのオブジェクト自身が基準になります
  3. パラメータを調整する
    ゲームを再生しながら、以下の値をいじって好みの揺れにしましょう。
    • Position Sway Amount:0.01~0.05 くらいから試す
    • Max Position Offset:0.1~0.2 程度
    • Rotation Sway Amount:1~4 くらい
    • Max Rotation Angle:5~15 度程度
    • Position/Rotation Smoothing:大きいほど「ぬるっ」と動く
    • Mouse Sensitivity:自分のゲーム全体の感度に合わせて

    「マウスを素早く振ると、武器がちょっと遅れてついてくる」感覚が出ればOKです。

  4. 具体的な使用例
    • プレイヤーのメイン武器
      典型的な FPS プレイヤーの銃モデルにこのコンポーネントを付けるだけで、「生身の手で持っている感」が出ます。
      さらに、走っているときは別の「Bob(上下揺れ)」コンポーネントと組み合わせると、よりリッチな見た目になります。
    • 敵 AI が持っている武器
      敵キャラクターの肩や手のボーンの子に、見た目用のカメラを付けて「一人称視点のリプレイ」を作るときにも使えます。
      敵の視点で見ると、同じ WeaponSway がかかった武器が揺れていて、プレイヤーと同じ感覚を演出できます。
    • 動く監視カメラの前にぶら下がった装飾
      FPS 武器に限らず、「視点の動きに遅れてついてくるオブジェクト」全般に使えます。
      監視カメラの前にぶら下がっているプレートや、ロボットの頭部カメラに付いているアンテナなどに適用しても面白いです。

メリットと応用

WeaponSway を「武器の見た目だけを担当するコンポーネント」として切り出しておくと、いろいろとメリットがあります。

  • 巨大なプレイヤースクリプトからロジックを分離できる
    プレイヤーの移動・カメラ回転・入力処理とは完全に独立しているため、
    「武器揺れの調整」をしているときに、誤って移動ロジックを壊すことがありません。
  • プレハブ単位で見た目を完結できる
    武器のプレハブに WeaponSway を含めておけば、「この武器はこの揺れ設定」として完結します。
    新しい武器を追加するときも、既存プレハブを複製してパラメータをちょっと変えるだけで済みます。
  • レベルデザイン時に「画面の気持ちよさ」を局所的に調整できる
    ステージによっては「ゆっくり探索するホラー寄りのシーン」や「高速で駆け抜けるアクションシーン」がありますよね。
    そうしたシーンごとに、WeaponSway のパラメータを ScriptableObject やプレハブバリアントで変えることで、
    ゲーム全体のテンポを視覚的にコントロールしやすくなります。
  • 他の演出コンポーネントと組み合わせやすい
    WeaponSway は「マウス入力に応じた揺れ」だけを担当しているので、
    ・歩行時の上下揺れ(Head Bob)
    ・発砲時のリコイル(反動)
    ・被ダメージ時のカメラシェイク
    などと足し合わせても管理しやすい構造になります。

さらに、WeaponSway 自体も小さなコンポーネントなので、必要に応じて簡単に改造できます。

例えば「ダッシュ中は揺れを少し強くする」ような改造は、外部からフラグを渡すだけで実現できます。


    /// <summary>
    /// ダッシュ中かどうかを外部から設定し、
    /// ダッシュ中は揺れを少し強くする簡単な改造例。
    /// PlayerMovement などから呼び出して使います。
    /// </summary>
    public void SetRunning(bool isRunning)
    {
        if (isRunning)
        {
            // ダッシュ中は揺れを強める
            positionSwayAmount *= 1.5f;
            rotationSwayAmount *= 1.5f;
        }
        else
        {
            // 通常に戻す(ここでは単純に元に近い値に戻す例)
            positionSwayAmount *= 2f / 3f;
            rotationSwayAmount *= 2f / 3f;
        }
    }

実際に使うときは、「元の値を保持しておいて、そこから倍率を掛ける」ようにするとより安全ですが、
コンセプトとしては「外部から状態を渡して、WeaponSway はそれを見てパラメータを変えるだけ」という形を保つのがおすすめです。

このように、小さなコンポーネントを組み合わせて FPS の見た目を作っていくと、
あとからの調整や差し替えがとても楽になります。
ぜひ、自分のプロジェクト用にパラメータや挙動をカスタマイズしてみてください。