ShaderGraph 走査線(スキャンライン)動的生成【URP】

unity技術

ShaderGraphを使用して「走査線」を生成する方法です。
今回のはGemini式になります。

従来(開発中ゲーム)に使用していた「低解像度テクスチャの引き伸ばし表示」を廃止し、
GPUの数式計算のみで無限解像度の走査線を常時描画する手法に切り替えるためのものです。
テクスチャフェッチ(VRAMアクセス)がゼロになるため、ロースペックPCでメモリ帯域逼迫の解消に大きく貢献します。

簡易版

  • FullscreenShaderGraphを作成
  • ShaderGraph(ノード)を作成
  • Materialを作成
  • URP Renderer に Full Screen Pass Renderer Feature を追加
  • Pass MaterialにMaterialを指定
  • Materialインスペクターで走査線の設定

全体構造(アーキテクチャ)

数式で生成した「0(黒)〜1(白)」のシマシマ模様を、
ゲーム画面(URP Sample Buffer)と掛け合わせることで乗算化(黒い線だけを残す処理)し、
最終的に Lerp ノードを使って元の画面とブレンド(透明度調整)しています。

ShaderGraphの構築手順

FullscreenShaderGraph作成

任意の場所に、新規ファイルである「FullscreenShaderGraph」を作成します。
「Create」>「ShaderGraph」>「URP」>「FullscreenShaderGraph

※ここでのFullscreenShaderGraphファイル名は「ScanlineShader」としています。

ShaderGraphウィンドウ

作成した「FullscreenShaderGraph(例:ScanlineShader)」ファイルをダブルクリックすると
ShaderGraphウィンドウ」が開きます。
この中で作業をしていきます。

プロパティ(Blackboard)の設定

左側にある「Blackboard」と呼ばれる「+」が表示されてるボードに「Float」を追加していきます。
+」をクリックし「Float」を選択します。

3つの「Float」を作成し、それぞれ名前を「ScanlineCount」「Thickness」「Opacity」とします。
そして各「NodeSettings」のデフォルト設定を以下にしておきます。

  • ScanlineCount (Float / Mode:Default) : 走査線の本数(初期値: 500)
  • Thickness (Float / Mode:Slider) : 線の太さ(Min: 0, Max: 1, 初期値: 0.5)
  • Opacity (Float / Mode:Slider) : 走査線の透明度(Min: 0, Max: 1, 初期値: 0.3)

ノードの作成

何もないところを右クリックしメニューを出します。
CreateNode」を選択すると「検索欄」が出現するので、作成したいノードの名前で検索し、選択します。

上記例では「ScreenPosition」ノードを作るために検索・選択しています。

必要なノードを追加作成していき、それぞれを繋いでいくのが「ShaderGraph」となります。

ノードの接続マトリクス

ノードを作成して、ノードとノードを繋いでいく工程です。

走査線の生成:

  1. Screen Position」「Split」「Multiply」の3ノードを作成
  2. Screen Position」Out(4) → 「Split」In(4)
  3. SplitG(1) → 「MultiplyA(1)

密度の決定:

  1. 左側にある「Blackboard」と呼ばれる「+」が表示されてるボードから「ScanlineCount」をドラッグ
  2. Sine」ノードを追加作成
  3. ScanlineCount」 → 「MultiplyB(1)
  4. Multiply」Out(1) → 「Sine」In(1)

エラー回避とクランプ:

  1. Fraction」ノードを追加作成
  2. Sine」Out(1) → 「Fraction」In(1)
    Sineは -1〜1 を出力するため、そのままPowerに渡すと負の数により計算エラー(プレビューがピンク色になる現象)が発生します。Fraction を挟むことで確実に 0〜1 にクランプします。

太さの調整:

  1. Power」ノードを追加作成
  2. 左側にある「Blackboard」と呼ばれる「+」が表示されてるボードから「Thickness」をドラッグ
  3. Fraction」Out(1) → 「PowerA(1)
  4. Thickness」 → 「PowerB(1)

元の画面の取得:

  1. URP Sample Buffer」ノードを追加作成、設定を BlitSource に変更

乗算(黒ラインの合成):

  1. Multiply」ノードを追加作成
  2. URP Sample Buffer」Out(4) → 「MultiplyA(4)
  3. Power」Out(1) → 「MultiplyB(4)

透明度のブレンド:

  1. Lerp」ノードを追加作成
  2. 左側にある「Blackboard」と呼ばれる「+」が表示されてるボードから「Opacity」をドラッグ
  3. URP Sample Buffer」Out(4) → 「LerpA(4) :(透明時 / 走査線なし)
  4. Multiply」Out(4) → 「LerpB(4) :(不透明時 / 走査線100%)
  5. Opacity」 → 「LerpT(4) :(ブレンド率)

出力:

  1. Lerp」Out(4) → 「Fragment」マスタースタック Base Color(3)

保存:

  1. グラフが完成したら、左上の 「Save Asset」(フロッピーアイコン) を押して保存します。

プロジェクトへの適用手順(URP設定)

シェーダーを割り当てたマテリアルを作成

新規Material(例:M_Scanline)を作成し、
ShaderGraph」に、先に作成したFullscreenShaderGraph(例:ScanlineShader)を指定します。

使用している URP Renderer に RendererFeature を追加

インスペクター最下部「Add Renderer Feature」にて「Full Screen Pass Renderer Feature」を追加します。

Pass Material:新規Material(例:M_Scanline)を指定
Injection Point:After Rendering Post Processing(ポストプロセス適用後の最終画面に重ねるため)

使用している URP Renderer は Project Settings > Graphics で確認できます。

走査線の設定

新規Material(例:M_Scanline)のインスペクター内で設定が可能です。

ScanlineCount:走査線の本数(初期値: 500)
Thickness:線の太さ(Min: 0, Max: 1, 初期値: 0.5)
Opacity:走査線の透明度(Min: 0, Max: 1, 初期値: 0.3)

解像度に依存しない走査線シェーダーへの修正

画面解像度への依存度(解像度バグの可能性)

上記シェーダーは Screen Position(画面のピクセル位置)をベースにシマシマを計算しています。
そのため、プレイヤーの環境が「フルHD(1920×1080)」の時と「4K(3840×2160)」の時で、走査線の密度が物理的に2倍変わってしまいます。
4K画面では走査線が細かすぎて、ただ画面が暗くなったように見えてしまう恐れがあります。

現在のグラフの Screen Position から始まる最初の部分を少し書き換えるだけで、
「ゲーム画面の解像度が 720p だろうが 4K だろうが、常に画面内に指定した本数(例:500本)の走査線をきれいに等間隔で並べる」 という 解像度不変(解像度独立)のロジック にアップグレードできます。

現在の接続を一部解除

  1. Split ノードの G 成分から、Multiply ノードの A に繋がっている線を右クリックして Delete(削除)します。

解像度を固定するためのノードを追加

  1. 新規に「UV」ノードを作成
  2. 新規に「Screen」ノードを作成
    ※この Screen ノードは、現在プレイヤーが遊んでいる画面の「実際の横ピクセル数・縦ピクセル数」をリアルタイムに取得してくれる賢いノードです。

ノードの繋ぎ直し

  1. UV」Out(4) → 「Split」In(4)
    ※元々 Screen Position が繋がっていた場所に、代わりに UV を繋ぐ形になります。
    これによって、画面サイズがどれだけ大きくなろうが、画面の縦方向を「0〜1」という比率として扱うようになります。
  2. Multiply」ノードを追加作成
  3. SplitG(1) → 「MultiplyA(1)
  4. Screen」Height(1) → 「MultiplyB(1)
  5. Multiply」Out(1) → 「MultiplyA(1)

なぜ直ったのか?

修正前の Screen Position は、「4K画面なら縦に2160ピクセルある」という生のピクセル位置を返していました。
そのため、4Kでは線の密度が跳ね上がっていました。
修正後は、UV(0〜1の比率)に対して一度 Screen (Height)(縦の解像度、例えば1080など)を掛け算しています。
これにより、内部の計算が「画面の物理的なドット(ピクセル)と1対1で完全に同期する」ようになります。

ScanlineCount の「意味」

この修正を入れたことで、Blackboardにある ScanlineCount の数値の意味が以下のように変化します。
修正前:「画面全体に合計何本出すか」という大雑把な数値(500 など)
修正後:何ピクセルごとに1本の走査線を入れるか」というピクセル単位の周期(波の細かさ)

そのため、セーブした直後は画面のシマシマがもの凄く細かくなっているか、逆に消えたように見えるかもしれません。
インスペクター(またはBlackboardの初期値)で、ScanlineCount の数値を 1 や 2 、あるいは 0.1 などの小さな値に調整してみてください。

例えば、ScanlineCount を調整して「2ピクセルに1本の割合で綺麗に黒ラインが入る」ように設定すれば、
プレイヤーが1280×720で遊ぼうが、4Kモニターで遊ぼうが、
常に液晶のドットピクセルに完璧に噛み合った、一切モアレ(モヤモヤした模様)の出ない極上の走査線が完成します。

数式シェーダーが画像より圧倒的に軽い「3つの理由」

「ShaderGraphのノードの群れで、たくさん処理が連なっているなら、ちっぽけな1枚の画像をペタッと貼るだけのほうが直感的に軽そう」に見えるが、今回のケースでは画像を使うよりシェーダー(数式)のほうが圧倒的に軽くなります。

現代のGPUは「計算」が超得意で、「メモリ読み込み」が超苦手

ロースペックPCが画面を描画するときに最も悲鳴を上げるのは、
実は「計算」ではなく「VRAM(ビデオメモリ)とのデータのやり取り(メモリ帯域の逼迫)」です。

画像の場合:
画面のピクセルを処理するたびに「VRAMにあるテクスチャの、この位置の色を取ってきて!」という通信(テクスチャフェッチ)が毎フレーム数百万回発生します。低解像度で粗い画像であっても、引き伸ばすための「補間計算」も加わるため、メモリの通り道(帯域)をかなり圧迫します。

数式(今回)の場合:
VRAMへのアクセスはゲーム画面を取得する1回(URP Sample Buffer)だけで、あとはGPUの内部だけで「掛け算、引き算、Sine」をパパッと計算して終わります。現代のGPUは、この程度の算数なら何万個連なっていても一瞬で処理できるほど計算能力が過剰に高いため、ノードの数(10個程度)は負荷にすらなりません。

「ファイルサイズ」と「VRAM容量」が完全にゼロ

画像を使う場合、
どんなに小さな画像(数キロバイト)であっても、ゲーム起動時にメモリに展開され、常にVRAMの容量を食い続けます。
今回のシェーダーは
数行のプログラムコード(テキストデータ)に変換されてビルドされるため、容量は実質ゼロ(数バイト)です。

「数式の方が絶対軽い」というのは今回の走査線のような単純な算数だから言えることです。
以下のケースでは逆にシェーダーが重くなる(あるいは画像の方が軽くなる)ことがあるので注意してください。

画像の方が良い場合

ノベルのスキップ時のビデオエフェクト(白っぽい砂嵐)
横方向の太いブレ(ノイズ)細かな砂嵐が混ざり合っています。

画像のままの方が良い理由(シェーダー化の弱点):
これを数式シェーダーだけで完全再現しようとすると、計算負荷が跳ね上がります。
GPUでこのようなザラザラした「ランダムなノイズ」を生成するには、一般的に複雑なハッシュ関数(数学的な乱数計算)をピクセルごとに何重も走査させる必要があります。

ロースペックPCにおいては、「4枚の小さな画像をループ再生する負荷」よりも「毎フレーム画面全体で複雑な乱数計算を行う負荷」の方が重くなる可能性が非常に高いです。

背景のエフェクト(黒ベースにカラーノイズ)
暗い画面の中にRGBの細かなドットノイズが散りばめられ、時折横線が入るような精密なエフェクトです。

シェーダー化を検討しても良いが、条件がある
画面全体を覆う場合:1枚目と同様、ピクセルごとのカラーノイズ計算はロースペックPCには酷です。
小さな枠内だけの場合:処理するピクセル数が少ないため、シェーダー化しても重くなりません。

最適な意思決定の基準

エフェクト推奨アプローチ理由・要点
白砂嵐低解像度画像(4枚)のまま数式での砂嵐生成は計算負荷が高すぎるため。
画像を小さくして引き伸ばすのが最軽量。
ダイアログ背景画像の低解像度化、または
1枚のノイズ画像をシェーダーで動かす
範囲が狭ければどちらでもOK。
C#のスクリプト管理を減らして綺麗に見せたいなら、
シェーダーでのUVずらしがスマートです。

前回の走査線は「規則正しい直線」だったからこそ数式が圧倒的に有利でしたが、
今回の「砂嵐(ランダム)」は画像が有利な領域です。

特定のSceneだけ非表示にする方法

提案1:C#スクリプトでRenderer FeatureのON/OFFを切り替える

これが最もパフォーマンスが良く、管理もしやすい王道の方法です。
Renderer Feature自体をC#から直接アクセスして、有効(True)/ 無効(False)を切り替えます。

手順A:Renderer Featureに名前(タグ)をつける

  1. URP Rendererデータを開き、追加した Full Screen Pass Renderer Feature の設定画面を見ます。
  2. 一番上の名前がデフォルトのままであれば、分かりやすく ScanlineFeature などに変更しておきます。

手順B:Sceneマネージャーと連動するスクリプトを作る

空のGameObject(例えば SceneEffectController など、常駐するもの)に以下のスクリプトをアタッチします。

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class ScanlineController : MonoBehaviour
{
    [SerializeField] private UniversalRenderPipelineAsset urpAsset;
    [SerializeField] private string featureName = "ScanlineFeature";
    
    // 走査線を「非表示」にしたいSceneの名前リスト
    [SerializeField] private string[] disabledScenes = { "TitleScene", "EndingScene" };

    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        if (urpAsset == null) return;

        // 現在のRendererデータを取得
        var rendererData = urpAsset.scriptableRendererDataList[0]; 
        
        // 指定した名前のRenderer Featureを探す
        foreach (var feature in rendererData.rendererFeatures)
        {
            if (feature.name == featureName)
            {
                // 現在のScene名がリストに含まれているかチェック
                bool shouldDisable = System.Array.Exists(disabledScenes, name => name == scene.name);
                
                // 含まれていれば無効(false)、なければ有効(true)にする
                feature.setActive(!shouldDisable);
                break;
            }
        }
    }
}

弱点:URP Rendererデータは「全Sceneで共有の資産」である

スクリプトで feature.setActive(false) を実行すると、ゲーム中だけでなくUnityエディタの設定自体が書き換わります。
そのため、ゲームを再生停止したあと、エディタ上で別のSceneを作っているときにも走査線が消えたままになってしまい、「あれ?シェーダーが壊れた?」と勘違いを誘発しやすいです(ゲームを再度起動すればスクリプトがまた判定してくれますが、開発中に少し不便です)。

■ 代替案A:マテリアルの Opacity(透明度)を 0 にする

非表示にしたいSceneにだけ、以下のような超簡単なC#スクリプトを置きます。

using UnityEngine;

public class DisableScanlineInThisScene : MonoBehaviour
{
    [SerializeField] private Material scanlineMaterial;

    void Start()
    {
        if (scanlineMaterial != null)
        {
            // 透明度を0にして見えなくする
            scanlineMaterial.SetFloat("_Opacity", 0f); 
        }
    }
}
  • ※元のSceneに戻るときに 0.3f などに戻す処理が必要になりますが、マテリアルの数値をいじるだけなので安全です。
  • ロースペックPC視点での弱点: 画面上は見えなくなりますが、GPUは裏で「透明度0の走査線ノード」を健気に計算し続けてしまうため、ごく僅かですが無駄な処理が発生します。

■ 代替案B:URPの「Universal Additional Camera Data」を使う(最もクリーン)

  1. 走査線を表示したくないSceneの Main Camera を選択します。
  2. インスペクターの最下部にある Universal Additional Camera Data(URPのカメラ拡張コンポーネント)を見ます。
  3. そこにある Renderer の項目を、デフォルト(走査線が入っているRenderer)から、
    「走査線Renderer Featureを追加していない、真っ新な別のURP Rendererデータ」 に切り替えます。

※Projectウィンドウで右クリック ➔ Create ➔ Rendering ➔ URP Forward Renderer で、エフェクト無しの予備のRendererデータを1つ作っておく必要があります。

これの何が良いか?
スクリプトを1行も書く必要がありません。「このSceneのカメラはエフェクト無しのRendererを使う」と指定するだけなので、エディタの設定が壊れることもなく、描画負荷も完全にゼロになります。

  • Scene数が多く、一括で「このSceneとこのSceneは除外」と管理したい場合:
    提案1(C#でFeatureをON/OFF) が最も楽です。
  • 非表示にしたいSceneが1〜2個しかなく、スクリプト管理を極力減らしたい場合:
    代替案B(カメラのRendererデータを切り替える) が、Unity 6の標準的な機能だけで美しく完結するため非常におすすめです。

コメント