Universal Render Pipeline (URP) に搭載されているForward+ レンダリングパスは、従来のフォワードレンダリングの制限であった「ピクセルライト制限(デフォルトで最大8個)」を取り払い、1つのシーン内に数百から数千の光源を配置できる非常に強力なレンダリング手法です。しかし、このForward+環境下で動的なポイントライトやスポットライトを多数配置した際、「カメラを動かすと一部のライトがピチピチと激しく点滅(フリッカー)する」「特定の角度で見えなくなるライトがある」という問題に直面することがあります。本記事では、このForward+特有のライト上限バグが発生する内部的なタイルカリングの仕組みと、実務での根本的な解決手順を解説します。

1. 根本原因:画面分割と「1タイルあたりの最大ライト数」の限界

このフリッカーおよび消灯バグが発生する理由は、Forward+がライトを走査・計算する際の「タイルドカリング(Tiled Culling)」という手法と、URPの内部制限にあります。

Forward+レンダリングでは、画面をピクセル単位のグリッド(Unity URPでは通常16x16ピクセルのタイル)に分割します。そして、カメラから見えているライトが「どのタイルに重なっているか」を事前にGPUで計算(カリング)し、タイルごとに影響するライトのインデックス(一覧リスト)を保持した「ライトインデックスバッファ」を作成します。

しかし、このバッファ構造には以下の固定の上限が設定されています。

  • 1タイルあたりの最大ライト数制限(Max Lights Per Tile): URP 14(Unity 2022.3)およびUnity 6では、1つの16x16ピクセル内に存在できる追加ライト(Additional Lights)の最大数は32個に固定されています。
  • 画面全体の最大表示ライト数(Max Visible Lights): シーン内で動的に計算できる追加ライトの総数は最大256個(プラットフォームや設定により異なる)となっています。

シーン内に多数のライトが狭いエリアに密集している場合や、ライトの範囲(Range)が広すぎて複数のタイルに重なり合っている場合、特定のタイルにおいてこの「32個」の上限を突破してしまいます。上限を超えたライトはカリング処理でリストから切り捨て(ドロップ)されます。

カメラを少し動かすと、スクリーンスペース上でのオブジェクトとライトの相対位置が変化し、タイル境界がズレます。これによって、ある1フレームではリストに載っていたライトが、次のフレームでは切り捨て対象になり、ライトの点滅(フリッカー)や不自然な消灯バグが発生します。

2. 解決アプローチ①:ライト密度と影響範囲の最適化(基本)

最も推奨される根本解決は、1つのタイル(画面上の16x16ピクセル領域)に同時に重なるライトの数を減らすことです。グラフィックスの負荷も劇的に下がるため、まずは以下の最適化を行ってください。

対策アクション 具体的な設定・効果
ライトのRange(半径)を最小にする ライトの影響半径が広いほど、多くのタイルと交差します。必要最低限の半径まで狭め、強さ(Intensity)を上げて調整します。
Culling Mask(レイヤー)で分ける ライトの「Culling Mask」を設定し、不要なオブジェクト(地面や遠景など)への照射をオフにすることで、タイルのライトスロット消費を防ぎます。
不要なライトのShadowを無効にする シャドウマップを描画するライトは計算コストが高く、バッファ制限とは別枠でオーバーヘッドになります。必要なメインライト以外は影をオフにします。
ライトのベイク(静的化) 動かない環境光は、ライトマップまたはライトプローブにベイクします。ベイクされたライトは動的なタイルカリングの制限対象から外れます。

3. 解決アプローチ②:URPアセットの Additional Lights 制限値の引き上げ

シーン構成上、どうしてもライト数を多く維持する必要がある場合は、URP Assetでの追加ライト上限設定を見直します。

  1. プロジェクトウィンドウから現在有効な「Universal Render Pipeline Asset」(例:`URP-HighQuality`など)を選択します。
  2. インスペクターで「Lighting」セクションを開きます。
  3. Additional Lights」の項目を確認します。
  4. Per Object Limit」の値を、動的ライトの合計数に応じて引き上げます(※Forward+使用時でも、一部のプラットフォームフォールバックやシェーダーコンパイル時の互換性パラメータとして使用されます。最大8個から16個、あるいは32個に設定します)。
【重要】WebGLやモバイルでの制限
Forward+はCBuffer(定数バッファ)やStructuredBufferをフル活用してGPU側で高速にカリングします。そのため、古いモバイル端末やWebGL 2.0環境など、一部の環境ではデバイスがサポートする最大バッファサイズに達すると、たとえForward+を設定していても通常のForwardパス(Additional Lights=8個)にフォールバックされます。ターゲットプラットフォームのスペックに合わせた上限設計が必要です。

4. 解決アプローチ③:C#スクリプトによる動的なライトグループ管理(距離カリング)

プレイヤーの周囲にあるライトのみをアクティブにし、遠方にあるライトのコンポーネントを無効化(`enabled = false`)または非表示レイヤーに切り替えることで、同時に計算される動的ライトを常に一定数以下に抑制します。

以下は、プレイヤーからの距離に応じて自動でライトをオン・オフし、Forward+のタイル制限オーバーを防ぐ軽量なライト管理スクリプトです。

using UnityEngine;
using System.Collections.Generic;

public class DynamicLightCuller : MonoBehaviour
{
    public Transform playerTransform;
    public float cullDistance = 25f; // ライトを有効にする距離
    public float updateInterval = 0.5f; // 更新の間隔(秒)

    private List<Light> dynamicLights = new List<Light>();
    private float nextUpdateTime = 0f;

    void Start()
    {
        // シーン内のすべての動的追加ライトをキャッシュ(タグなどでフィルタリング推奨)
        Light[] lights = FindObjectsByType<Light>(FindObjectsSortMode.None);
        foreach (Light l in lights)
        {
            // ディレクショナルライトとベイク済みライトは除外
            if (l.type != LightType.Directional && l.lightmapBakeType == LightmapBakeType.Realtime)
            {
                dynamicLights.Add(l);
            }
        }
    }

    void Update()
    {
        if (playerTransform == null) return;

        // CPU負荷を下げるため、毎フレームではなく一定の間隔で処理
        if (Time.time >= nextUpdateTime)
        {
            nextUpdateTime = Time.time + updateInterval;
            CullLights();
        }
    }

    void CullLights()
    {
        Vector3 playerPos = playerTransform.position;
        float sqrCullDistance = cullDistance * cullDistance;

        for (int i = 0; i < dynamicLights.Count; i++)
        {
            Light light = dynamicLights[i];
            if (light == null) continue;

            // プレイヤーとライトの距離を二乗比較(平方根の計算を避けて高速化)
            float sqrDist = (light.transform.position - playerPos).sqrMagnitude;
            
            // 範囲外のライトを無効化することでForward+のタイル制限(32個)に入らないようにする
            bool shouldBeActive = sqrDist < sqrCullDistance;
            if (light.enabled != shouldBeActive)
            {
                light.enabled = shouldBeActive;
            }
        }
    }
}

このスクリプトを使用すれば、オープンワールドやダンジョンのように数千個のライトが配置されたシーンであっても、現在カメラの描画範囲に入る実質的なアクティブライト数を常に32個未満に維持することができ、一切フリッカーを起こさずに高品質なシーンを維持できます。