アクションゲームでモンスターが咆哮した際や大爆発が起きた際、「ボスの口元を中心に空間が集中線状に歪む演出」を入れると画面の迫力が一気に跳ね上がります。
しかし、いざシェーダー(HLSL)で中心からの角度(atan2)を使って放射状のノイズを作ろうとすると、以下の3つの罠にハマりがちです。
- 画面比率(16:9など)の影響で、正円ではなく横長の楕円に歪んでしまう
atan2の戻り値($-\pi \sim \pi$)の境界である「左水平方向(180度)」にパキッとしたノイズの切れ目(シーム)が出る- 3D空間のボスの位置と、シェーダー内の2DスクリーンUV座標が一致しない
本記事では、これら3つの課題を数学的に解決し、破綻のない「集中線ストリーク歪み」を実装するコアテクニックを解説します。
全体の処理フロー
C#側から渡された3Dターゲット座標をスクリーンUVに変換し、フラグメントシェーダー内で極座標ノイズを計算するまでの流れは以下の通りです。
1. アスペクト比補正と「中心クリアマスク」の計算
フルスクリーンシェーダーのUV座標は画面サイズに関わらず 0.0 〜 1.0 に正規化されています。そのまま距離(length)を計算すると、16:9の画面では横方向に引き伸ばされた楕円になってしまいます。
_ScreenParams.x / _ScreenParams.y をX成分に掛けて補正し、さらに「ボスの顔(中心)はクッキリ見せ、外周だけを歪ませる」ためのドーナツ状マスクを作ります。
// 発生中心(_RoarCenter.xy)から現在のピクセル(uv)へのベクトル
float2 dir = uv - _RoarCenter.xy;
// アスペクト比補正(16:9等の画面でも正円の距離場を作る)
float aspect = _ScreenParams.x / max(_ScreenParams.y, 1.0);
float2 dirAspect = float2(dir.x * aspect, dir.y);
float dist = length(dirAspect);
// 中心(_CenterClearRadius)は0、外周に向かって1になる滑らかなマスク
float radialMask = smoothstep(_CenterClearRadius, _CenterClearRadius + _MaskSmoothness, dist);
float activeEffect = _Intensity * radialMask;
2. atan2 の境界シーム(左端の切れ目)を完全に消す数式
放射状の集中線を作るには atan2(y, x) で角度を求め、それを1次元ノイズ関数(Noise1D)に入力します。
しかし、atan2 の値域は $-\pi \sim \pi$ です。そのまま Noise1D(angle * _StreakFrequency) のように書くと、左端($-\pi$ と $\pi$ が接する場所)で位相が不連続になり、くっきりと境界線が見えてしまいます。
これを防ぐには、①角度を 0.0 〜 1.0 に正規化した上で、②掛ける周波数(Frequency)を floor で整数に丸めるのが鉄則です。
// 1次元バリューノイズ関数
float Hash(float co)
{
float3 p3 = frac(float3(co, co, co) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return frac((p3.x + p3.y) * p3.z);
}
float Noise1D(float x)
{
float i = floor(x);
float f = frac(x);
f = f * f * (3.0 - 2.0 * f); // エルミート補間(Smoothstep)
return lerp(Hash(i), Hash(i + 1.0), f);
}
// --- フラグメントシェーダー内 ---
// 1. atan2(-PI〜PI) を 0.0〜1.0 の正規化角度に変換
float normalizedAngle = (atan2(dirAspect.y, dirAspect.x) + PI) / TWO_PI;
// 2. 周波数を整数に丸めることで、0.0と1.0の境界での位相ズレを抑える
float freq = floor(_StreakFrequency);
float t = _Time.y * _StreakSpeed;
// 異なる周期・逆方向の2つのノイズを合成して単調さを消す
float streakNoise = Noise1D(normalizedAngle * freq + t) * 2.0 - 1.0;
float streakNoise2 = Noise1D(normalizedAngle * (freq * 0.5) - t * 1.3) * 2.0 - 1.0;
float combinedStreak = (streakNoise * 0.6 + streakNoise2 * 0.4);
さらに、求めたノイズ値を使ってUVをズラす際、中心からの「法線方向(外側へ向かうベクトル)」だけでなく、直交する「接線方向(回転方向のベクトル)」を 0.5 ほど混ぜることで、空気の渦がうねるようなリアルな歪みになります。
// ゼロ除算防止の微小値(1e-5)を足して正規化
float2 normDir = normalize(dir + 1e-5);
// 法線に直交する接線ベクトル (-y, x)
float2 tangentDir = float2(-normDir.y, normDir.x);
// 放射方向+回転方向にUVオフセットを合成
float2 streakOffset = (normDir + tangentDir * 0.5) * (combinedStreak * _StreakStrength * activeEffect);
3. C#側での3Dワールド座標 → スクリーンUV自動変換
ボスの口元(Transform)を中心にエフェクトを発生させるには、Camera.WorldToViewportPoint を使います。Viewport座標は左下 (0, 0) 〜 右上 (1, 1) なので、そのままシェーダーのUV座標として渡せます。
ただし、ターゲットがカメラの背後(z < 0)に回り込んだ際に座標が反転する問題があるため、必ず viewportPos.z の符号チェックを入れます。
private static readonly int PropRoarCenter = Shader.PropertyToID("_RoarCenter");
private void UpdateRoarCenter(Material mat, Camera cam, Transform target)
{
Vector3 viewportPos = cam.WorldToViewportPoint(target.position);
// カメラの背後にいる場合は画面中央(0.5, 0.5)へフォールバック
Vector2 centerUV = (viewportPos.z < 0f)
? new Vector2(0.5f, 0.5f)
: new Vector2(viewportPos.x, viewportPos.x); // ※yはviewportPos.y
centerUV.y = (viewportPos.z < 0f) ? 0.5f : viewportPos.y;
mat.SetVector(PropRoarCenter, new Vector4(centerUV.x, centerUV.y, 0f, 0f));
}
さらに実践的な「咆哮・衝撃波システム」に仕上げるには?
本記事で紹介した「集中線ストリーク歪み」に、以下の要素を組み合わせると商用アクションゲームと同等の画面演出になります。
- パルス振動付きラジアルブラー(放射状ブラー)との統合
- 外周ほど強くRGBが分離するマルチサンプル色収差(Chromatic Aberration)
- モバイルやSwitchでも負荷を落とさない
#pragma multi_compile_localと[unroll]による静的ループ展開 - GC Allocゼロで
PlayRoar()1行で減衰再生できるC#コントローラー&ワンクリックプリセット付きCustomEditor
📦 すぐ使える完成品パッケージ(BOOTH)
「自分のゲームに今すぐ30秒で導入したい!」という方向けに、シェーダー・3D座標追従スクリプト・専用インスペクター拡張(3種のプリセット&テスト再生ボタン付き)をまとめた完成品パッケージをBOOTHで公開しています。
👉 【URP】RoarFX - 咆哮・衝撃波ポストプロセスシェーダー(BOOTHリンク)
📘 全コードと最適化手法の完全解説本(Zenn Book)
また、ラジアルブラー・色収差の統合アルゴリズムから、ShaderGUI / CustomEditor の実装コード全文までをステップバイステップで解説した技術書もZennで公開中です。仕組みから深く学びたい方はぜひご覧ください!
👉 Unity URP 実践ポストプロセス開発:咆哮・衝撃波シェーダーとエディタ拡張の作り方(Zennリンク)