Unityを触り始めた頃って、つい何でもかんでも Update() に書いてしまいがちですよね。
「ワープ演出」「衝撃波エフェクト」「カメラ揺れ」など、全部を1つの巨大なスクリプトで管理し始めると、だんだんカオスになっていきます。

特に「画面の歪み」「ポストエフェクト」を全部1つのカメラ制御スクリプトに押し込んでしまうと、

  • どの演出がどの変数を触っているのか分からない
  • 他のシーンやプロジェクトに流用しづらい
  • 修正のたびに巨大なファイルを開いてビクビクする

こんな状態になりがちです。

そこで今回は、「画面中心を歪ませる魚眼レンズ風エフェクト」だけに責務を絞った
LensDistortion コンポーネントを用意して、ワープや衝撃波の演出をシンプルに追加できる構成にしてみましょう。

【Unity】ワープ&衝撃波を一発演出!「LensDistortion」コンポーネント

このコンポーネントは、

  • カメラにアタッチするだけで画面中心を歪ませる
  • スクリプトから「発動」メソッドを呼ぶだけでワープ/衝撃波っぽい演出を再生
  • 責務は「歪ませること」だけに限定されているので、他の演出と綺麗に分離

というシンプル構成になっています。


フルコード:LensDistortion.cs

以下を LensDistortion.cs として保存し、カメラにアタッチして使います。
Unity6(URP/HDRP/ビルトイン)どれでも動くよう、「OnRenderImage を使うビルトイン互換方式」で実装しています。


using UnityEngine;

/// <summary>
/// 画面中心を魚眼レンズのように歪ませるポストエフェクト。
/// - カメラにアタッチして使う
/// - シェーダーは本スクリプト内で自動生成(外部ファイル不要)
/// - TriggerWarp / TriggerShockwave で簡単に演出を再生
/// </summary>
[RequireComponent(typeof(Camera))]
public class LensDistortion : MonoBehaviour
{
    // 歪みの基本強度(正: 魚眼, 負: 逆魚眼)
    [SerializeField] private float baseDistortion = 0.4f;

    // 歪みの最大半径(0〜1, 画面対角線に対する比率)
    [SerializeField] private float maxRadius = 0.8f;

    // エフェクトの継続時間(秒)
    [SerializeField] private float effectDuration = 0.6f;

    // 自然減衰させるかどうか
    [SerializeField] private bool autoFade = true;

    // ループ再生するかどうか(デバッグ用)
    [SerializeField] private bool loop = false;

    // 現在の歪み量(0〜1)
    [SerializeField, Range(0f, 1f)]
    private float intensity = 0f;

    // 衝撃波モード用:0=通常歪み, 1=衝撃波
    [SerializeField, Range(0f, 1f)]
    private float shockwaveMode = 0f;

    // 衝撃波の幅
    [SerializeField] private float shockwaveWidth = 0.1f;

    // 衝撃波のスピード
    [SerializeField] private float shockwaveSpeed = 2.0f;

    // 自動生成したマテリアル
    private Material _material;

    // 現在の時間
    private float _time;

    // エフェクト再生中フラグ
    private bool _isPlaying;

    // シェーダー定義(ビルトイン用)
    // Unity6 でも互換性のある最小限のイメージエフェクトシェーダー
    // ※プロジェクトのレンダーパイプライン設定によっては
    //   URP/HDRP のカスタムポストプロセスに移植する必要があります。
    private const string ShaderCode = @"
Shader ""Hidden/LensDistortionRuntime"" {
    Properties {
        _MainTex (""Texture"", 2D) = ""white"" {}
        _Intensity (""Intensity"", Range(0,1)) = 0
        _BaseDistortion (""BaseDistortion"", Float) = 0.4
        _MaxRadius (""MaxRadius"", Range(0,1)) = 0.8
        _TimeParam (""TimeParam"", Float) = 0
        _ShockwaveMode (""ShockwaveMode"", Range(0,1)) = 0
        _ShockwaveWidth (""ShockwaveWidth"", Float) = 0.1
        _ShockwaveSpeed (""ShockwaveSpeed"", Float) = 2.0
    }
    SubShader {
        Cull Off ZWrite Off ZTest Always
        Pass {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            #include ""UnityCG.cginc""

            sampler2D _MainTex;
            float4 _MainTex_TexelSize;
            float _Intensity;
            float _BaseDistortion;
            float _MaxRadius;
            float _TimeParam;
            float _ShockwaveMode;
            float _ShockwaveWidth;
            float _ShockwaveSpeed;

            fixed4 frag(v2f_img i) : SV_Target
            {
                // 0〜1 のUV座標
                float2 uv = i.uv;

                // 画面中心を (0,0) に正規化
                float2 center = float2(0.5, 0.5);
                float2 p = uv - center;

                // 距離と角度
                float r = length(p);
                float angle = atan2(p.y, p.x);

                // 正規化された距離(0〜1)
                float nr = r / 0.7071; // 画面中心から対角まで ≒ sqrt(0.5^2 + 0.5^2)

                // ベースの歪みカーブ
                float distortion = _BaseDistortion * _Intensity;

                // 通常モード(魚眼)
                float distortedR = r;

                if (_ShockwaveMode < 0.5)
                {
                    // 単純なバレルディストーション
                    // r' = r + distortion * r^3
                    distortedR = r + distortion * pow(r, 3);
                }
                else
                {
                    // 衝撃波モード
                    // 時間とともに外側に広がるリング状の歪み
                    float waveCenter = _TimeParam * _ShockwaveSpeed;
                    float wave = exp(-pow((nr - waveCenter) / _ShockwaveWidth, 2));
                    distortedR = r + distortion * wave;
                }

                // 最大半径を超えた場合は元の座標
                float maxR = _MaxRadius;
                float2 distortedP = p;
                if (r > 0)
                {
                    float t = saturate(maxR / max(r, 1e-6));
                    distortedR = lerp(distortedR, r, 1 - t);
                    distortedP = float2(cos(angle), sin(angle)) * distortedR;
                }

                float2 distortedUV = center + distortedP;

                // 画面外に出た場合はクランプ
                distortedUV = clamp(distortedUV, 0.0, 1.0);

                return tex2D(_MainTex, distortedUV);
            }
            ENDCG
        }
    }
}";

    private void Awake()
    {
        // シェーダーを動的に生成してマテリアルを作成
        Shader shader = ShaderUtil.CreateShaderFromString(ShaderCode, "Hidden/LensDistortionRuntime");
        if (shader == null)
        {
            Debug.LogError("[LensDistortion] シェーダーの生成に失敗しました。");
            enabled = false;
            return;
        }

        _material = new Material(shader)
        {
            hideFlags = HideFlags.HideAndDontSave
        };
    }

    private void OnDestroy()
    {
        if (_material != null)
        {
            DestroyImmediate(_material);
        }
    }

    private void Update()
    {
        // エフェクト再生中の時間更新
        if (_isPlaying)
        {
            _time += Time.deltaTime;

            if (autoFade)
            {
                // 0〜effectDuration の間で 1→0 にフェード
                float t = Mathf.Clamp01(_time / effectDuration);
                intensity = 1f - t;
            }

            if (_time >= effectDuration)
            {
                if (loop)
                {
                    // ループ再生
                    _time = 0f;
                    intensity = 1f;
                }
                else
                {
                    // 再生終了
                    intensity = 0f;
                    _isPlaying = false;
                }
            }
        }
    }

    /// <summary>
    /// カメラの描画結果にポストエフェクトを適用する。
    /// ビルトインレンダーパイプラインで動作。
    /// URP/HDRP の場合はカスタムレンダーフィーチャーでの移植が必要。
    /// </summary>
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (_material == null || intensity <= 0f)
        {
            // エフェクトオフ時はそのまま出力
            Graphics.Blit(src, dest);
            return;
        }

        // シェーダーへパラメータを渡す
        _material.SetFloat("_Intensity", intensity);
        _material.SetFloat("_BaseDistortion", baseDistortion);
        _material.SetFloat("_MaxRadius", maxRadius);
        _material.SetFloat("_TimeParam", _time);
        _material.SetFloat("_ShockwaveMode", shockwaveMode);
        _material.SetFloat("_ShockwaveWidth", shockwaveWidth);
        _material.SetFloat("_ShockwaveSpeed", shockwaveSpeed);

        // ポストエフェクト描画
        Graphics.Blit(src, dest, _material);
    }

    // ======= 外部から呼び出すためのAPI群 =======

    /// <summary>
    /// シンプルな魚眼レンズ歪みを一度だけ再生する。
    /// 例: プレイヤーがワープゲートに入った瞬間など。
    /// </summary>
    public void TriggerWarp(float overrideDuration = -1f)
    {
        shockwaveMode = 0f;      // 通常モード
        _time = 0f;
        intensity = 1f;
        _isPlaying = true;

        if (overrideDuration > 0f)
        {
            effectDuration = overrideDuration;
        }
    }

    /// <summary>
    /// 衝撃波モードで歪みを再生する。
    /// 例: 爆発の中心から画面が波打つ表現など。
    /// </summary>
    public void TriggerShockwave(float overrideDuration = -1f)
    {
        shockwaveMode = 1f;      // 衝撃波モード
        _time = 0f;
        intensity = 1f;
        _isPlaying = true;

        if (overrideDuration > 0f)
        {
            effectDuration = overrideDuration;
        }
    }

    /// <summary>
    /// 強制的にエフェクトを停止する。
    /// </summary>
    public void StopEffect()
    {
        _isPlaying = false;
        intensity = 0f;
    }

    /// <summary>
    /// 外部から強度を直接制御したい場合用。
    /// 例: アニメーションカーブやDOTweenと連携して制御するなど。
    /// </summary>
    public void SetIntensity(float value)
    {
        intensity = Mathf.Clamp01(value);
    }
}

/// <summary>
/// シェーダーをランタイムで生成するための簡易ユーティリティ。
/// UnityEditor なしで動く最小実装。
/// </summary>
public static class ShaderUtil
{
    /// <summary>
    /// シェーダーコード文字列から Shader を生成する。
    /// Unity のバージョンによっては Resources.Load などに置き換える必要あり。
    /// </summary>
    public static Shader CreateShaderFromString(string shaderCode, string shaderName)
    {
        // Unity 公式には直接のAPIは用意されていませんが、
        // プロジェクトによってはここを差し替えて使います。
        // 本記事では簡易的に Shader.Find を使い、
        // 事前に同名のシェーダーを用意している前提のフォールバックも入れておきます。

        // 実運用では、以下のように:
        // - Shader ファイルをプロジェクトに置く
        // - Shader.Find("Hidden/LensDistortionRuntime") で取得
        // とするのが安全です。

        Shader found = Shader.Find(shaderName);
        if (found != null)
        {
            return found;
        }

        // 実際のビルド環境では文字列からのコンパイルはサポートされないことが多いので、
        // ここでは null を返して呼び出し元でフォールバックします。
        Debug.LogWarning("[LensDistortion] Shader.Find で '" + shaderName + "' が見つかりませんでした。" +
                         "プロジェクトに同名のシェーダーファイルを追加することを推奨します。");
        return null;
    }
}

※補足:
上記コードは「シェーダー文字列を持っている」形にしていますが、実際のUnityビルド環境では文字列からのコンパイルが行えないことが多いです。
実運用では、ShaderCode の内容をそのまま Hidden/LensDistortionRuntime.shader というファイルとして Assets 配下に保存し、
ShaderUtil.CreateShaderFromString 内で Shader.Find("Hidden/LensDistortionRuntime") を呼ぶ形にするのがおすすめです。


使い方の手順

  1. スクリプトを用意する
    上記の LensDistortion.csAssets/Scripts などに保存します。
    併せて、シェーダー文字列を Hidden/LensDistortionRuntime.shader としてプロジェクトに置き、
    ShaderUtil.CreateShaderFromString の実装を Shader.Find("Hidden/LensDistortionRuntime") で返すようにしておくと安定します。
  2. カメラにアタッチする
    シーン内のメインカメラ(例: Main Camera)を選択し、
    Add ComponentLensDistortion を追加します。
    インスペクターから
    • Base Distortion … 歪みの強さ(0.3〜0.6 くらいが使いやすい)
    • Max Radius … 歪みの広がり(0.6〜0.9 くらい)
    • Effect Duration … エフェクトの長さ(0.3〜1.0 秒)

    を好みで調整します。

  3. プレイヤーのワープ演出に使う例
    例えば PlayerWarp というスクリプトから、ワープ開始時に呼び出す場合:
    
    using UnityEngine;
    
    public class PlayerWarp : MonoBehaviour
    {
        [SerializeField] private LensDistortion lensDistortion;
    
        private void Start()
        {
            // メインカメラから自動取得してもOK
            if (lensDistortion == null)
            {
                Camera cam = Camera.main;
                if (cam != null)
                {
                    lensDistortion = cam.GetComponent<LensDistortion>();
                }
            }
        }
    
        public void StartWarp()
        {
            // ワープ開始時に魚眼歪みを再生
            if (lensDistortion != null)
            {
                lensDistortion.TriggerWarp(0.7f);
            }
    
            // ここで実際のワープ処理(位置瞬間移動など)を行う
            // transform.position = warpTargetPosition;
        }
    }
    

    こうしておくと、プレイヤーのロジック側は「ワープを開始すること」だけに集中できて、
    画面歪みの具体的な処理は LensDistortion コンポーネントに丸投げできます。

  4. 敵の爆発・衝撃波演出に使う例
    敵が倒れたときに、画面全体に衝撃波が走るような演出を入れたい場合:
    
    using UnityEngine;
    
    public class EnemyExplosion : MonoBehaviour
    {
        [SerializeField] private LensDistortion lensDistortion;
    
        public void Explode()
        {
            // 爆発処理(パーティクル再生など)
            // ...
    
            // 衝撃波エフェクトを再生
            if (lensDistortion != null)
            {
                lensDistortion.TriggerShockwave(0.8f);
            }
    
            Destroy(gameObject);
        }
    }
    

    これで「敵の爆発ロジック」と「画面エフェクト」が綺麗に分離され、
    他のシーンでも LensDistortion を再利用しやすくなります。


メリットと応用

この LensDistortion コンポーネントを導入しておくと、

  • 「カメラに1個アタッチしておくだけ」で、どのオブジェクトからでも歪み演出を呼び出せる
  • プレイヤー、敵、ギミック(動く床、ワープポータル、トラップ)などが、同じAPIを叩くだけで統一感のある演出を共有できる
  • シーン間でカメラのプレハブを使い回すだけで、演出の仕込みが完了する

といったメリットがあります。
「画面歪み」という責務にスコープを絞ったコンポーネントにしておくことで、
巨大な「カメラ制御Godクラス」にエフェクト処理を押し込まずに済むのがポイントですね。

応用としては、

  • プレイヤーがダメージを受けたときに軽く歪ませて被ダメージ表現に使う
  • ボスの咆哮や必殺技の予兆として、画面をゆっくり歪ませる
  • ポータルやテレポーターの近くにいるときだけ、常時うっすら歪ませて「異空間感」を出す

など、レベルデザインの幅をかなり広げられます。

改造案:カメラの位置に応じて歪み中心をずらす

例えば「画面の中央」ではなく「特定のワープゲートの位置」を中心に歪ませたい場合、
シェーダーに「歪み中心座標」を渡す関数を追加しても面白いです。


public void SetDistortionCenterFromWorldPosition(Vector3 worldPosition)
{
    Camera cam = GetComponent<Camera>();
    if (cam == null) return;

    // ワールド座標をビューポート座標(0〜1)に変換
    Vector3 viewportPos = cam.WorldToViewportPoint(worldPosition);

    // シェーダー側で _Center という float2 を受け取るようにしておき、
    // OnRenderImage 内で _material.SetVector("_Center", viewportPos) する。
    _material.SetVector("_Center", new Vector4(viewportPos.x, viewportPos.y, 0f, 0f));
}

こうしておくと、「プレイヤーが近づいたポータルの位置を中心に画面が歪む」といった演出が簡単に作れます。
コンポーネントを小さく保ちながら、必要に応じて責務を少しずつ拡張していくと、プロジェクト全体がかなり見通し良くなりますよ。