1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「QualiArts様の描画最適化術」を試してみる Part.2

Last updated at Posted at 2025-12-15

上記サイト様を参考に自身の勉強のために実装してみるPart2。


前回は、パラメータ定義した大量のWorld座標を作成し

  1. JobSystemでNDC座標に変換し、画面内の判定
    • カメラに映らないなら画面外とする
  2. DrawProceduralを利用してメッシュ描画を高速化

を行いました

実行結果は以下の画像のように、カメラの描画外はメッシュがグレーになっていることが確認できます

image.png

※ 中央座標に対してカリングを行っているため、右下にあるようなグレーの三角形がチラッと見えることがあります


今回は 「ComputeShaderで深度値を利用しての遮蔽判定」 を行います

記事元には

ComputeShaderは数値計算に特化していますが、GPU処理なのでTextureの読み込みも可能です。これにより遮蔽判定をDepthTextureで行えるため、Raycast負荷を0にすることが出来ました。

と記載がある通り、ComputeBufferにDepthTextureを渡し、それを利用して高速に遮蔽判定を行うことでCPU負荷を最大限に減らすことを目標に実装してみます。

【結果画像】
左が遮蔽判定により色が変わる

image.png

細かい実装は下記プロジェクトを参照してください

1. 深度値を保持する

深度判定するために、メッシュごとに深度情報をパラメータとして保持しておく必要があります。

前回作成した、
「WorldPosからScreen座標を計算してカリング」する TransformAndCullJob ジョブを利用する中でView空間のZ値をパラメータとして持っておくことにします。

オブジェクト一つ表すデータ構造にfloatのDepth値を追加

    /// <summary>
    /// オブジェクト一つのデータ構造
    /// </summary>
    [StructLayout(LayoutKind.Sequential)]
    public struct ProceduralParam
    {
        public float3 WorldPosition;    // 表示座標
        public float2 ScreenPosition;   // スクリーン上の座標
        public float4 Color;            // 色
        public float Depth;             // 奥行きの値 <---- 追加
        public int IsVisible;           // 可視状態
        public int IsInScreen;          // スクリーン内の判定
    };

深度値として利用するのは、カメラが持つViewMatrixを乗算したviewPosがカメラ空間の座標Z値です

NDC空間のZを利用すると、プラットフォーム依存によりZの値が変わってしまうためView空間のZ値(プラットフォーム依存しない)を利用することにします。

(以下のサイト様がわかりやすいです)
https://ny-program.hatenablog.com/entry/2021/05/17/222815
https://ny-program.hatenablog.com/entry/2021/10/20/202020
https://light11.hatenadiary.com/entry/2018/06/10/233954


計算処理
var worldPos = WorldPositions[index];
var viewPos = math.mul(ViewMatrix, new float4(worldPos, 1.0f));

viewPos.Zをそのまま利用するのではなく、カメラ空間でのZ値はカメラ位置が0で前方に行くほどマイナスになるため、距離として利用するためにはマイナスを掛けて利用します

Job全文

/// <summary>
    /// 一つのオブジェクトのパラメータが画面内に収まっているかを並列で処理する
    /// </summary>
    [BurstCompile]
    public struct TransformAndCullJob : IJobParallelFor
    {
        // ReadOnlyにすることで安全性が高いことを伝える
        [ReadOnly] public NativeArray<float3> WorldPositions;   // オブジェクトの座標配列
        [ReadOnly] public float4x4 ViewMatrix;
        [ReadOnly] public float4x4 ProjectionMatrix;
        [ReadOnly] public float2 ScreenSize;
        [ReadOnly] public float NearClip;
        [ReadOnly] public float FarClip;

        public NativeArray<ProceduralParam> Params;
  
        public void Execute(int index) // Indexが配列の各要素のIndex
        {
            // オブジェクト一つが画面内に収まっているか確認。もし収まっていなければ描画しなくて良いフラグを立てる
            var worldPos = WorldPositions[index];
            var viewPos = math.mul(ViewMatrix, new float4(worldPos, 1.0f)); // Z座標は前方 -Z に変換される
            var clipPos = math.mul(ProjectionMatrix, viewPos);
            
            // 画面内判定
            var isInScreen = 1;
            // 正規化デバイス空間に収まっているか(XYは従来どおりチェック)
            if (clipPos.w <= 0.001f) // そもそもカメラの後方にいるなら
            {
                isInScreen = 0;
            }
            else
            {
                var ndc = clipPos.xyz / clipPos.w;
                if (math.abs(ndc.x) > 1.0f || math.abs(ndc.y) > 1.0f)
                {
                    isInScreen = 0;
                }
            }

            // Zだけはビュー空間のnear/farで判定する(近すぎ/遠すぎを拾う)
            // viewPos.z は前方がマイナスなので、-NearClip <= viewPos.z <= -FarClip の範囲にあるかを見る
            var inZRange = viewPos.z <= -NearClip && viewPos.z >= -FarClip;
            if (!inZRange)
            {
                isInScreen = 0;
            }

            var ndcPos = clipPos.w > 0 ? clipPos.xyz / clipPos.w : float3.zero;
            
            // 画面内での座標を計算
            var screenPos = new float2(
                (ndcPos.x * 0.5f + 0.5f) * ScreenSize.x, // X: [-1,+1] -> [0,width]
                (1.0f - (ndcPos.y * 0.5f + 0.5f)) * ScreenSize.y // Y: [-1,+1] -> [0,height] (Y軸反転)
            );
            
            Params[index] = new ProceduralParam
            {
                WorldPosition = worldPos,
                ScreenPosition = screenPos,
                Color = new float4(1.0f, 1.0f, 1.0f, 1.0f), // 色は適当
                Depth = -viewPos.z,  // カメラ空間で深度比較する。前方がマイナスなので *-1 をしてプラスにする
                IsVisible = isInScreen,  // 今回は画面に収まってないに対して、非表示フラグも立てる
                IsInScreen = isInScreen,
            };
        }
    }

2. ComputeShaderを利用する

今回の肝であるComputeShaderを利用して高速に深度テストを行い
「手前にオブジェクトが描画済みであれば色を変更する(本来は描画スキップ)」 ようにします

ComputeShaderは描画用シェーダーと異なるシェーダーを用意し、GPU上で実行することによりGPUの並列計算能力を利用し高速な計算処理を行うことができます。

(参考サイト様)
https://light11.hatenadiary.com/entry/2019/11/21/003137


ComputeShader側でZ判定するメリットとしては

  1. 深度テクスチャがGPUそのまま読み込めるため、CPU側にテクスチャを持ってくる必要がない(GPUとCPUの転送)
  2. 並列処理において、CPUはコア依存になりGPUのほうが多くの処理を行える
  3. 結果をそのまま描画パスに任せられるため、判定から描画までをGPUで完結できる

が挙げられます

まず、遮蔽判定を行うために
「オブジェクト一つのパラメータ(ProceduralParamのDepth)」「深度テクスチャ」 をComputeShaderに流し込みます

ComputeShaderを作成後、ComputeShader上部でパラメータを定義。
必要なものは、オブジェクトのパラメータ型、オブジェクトのパラメータバッファ、深度テクスチャの宣言

ComputeShader.hlsl
// C#側の構造体とサイズを合わせる
struct ProceduralParam
{
    float3 WorldPosition;    // 表示座標
    float2 ScreenPosition;   // スクリーン上の座標
    float4 Color;            // 色
    float Depth;             // 奥行きの値
    int IsVisible;           // 可視状態
    int IsInScreen;          // スクリーン内の判定
};

// SetComputeIntParamで渡されている
int _ObjectCount;

// パラメータの入出力バッファ
RWStructuredBuffer<ProceduralParam> _ObjectParamBuffer;

// DepthTexture
Texture2D<float> _DepthTexture;
SamplerState sampler_DepthTexture;

そして実際に処理を行うカーネル(メソッド)を定義します。
長くないため、全文を載せます

重要なのは深度テクスチャを LinearEyeDepth でカメラ空間に正規化する点です

float sceneEye = LinearEyeDepth(sceneDepth, _ZBufferParams); // カメラ空間座標に正規化

前述で紹介したサイト様

を参考していただけると分かりますが、Z値は非線形で情報が格納されています

image.png
その70 完全ホワイトボックスなパースペクティブ射影変換行列 引用

これをそのまま利用できない。
かつ、オブジェクト一つのDepthパラメータはカメラ空間のZ値を持っています。

LinearEyeDepth は、線形に正規化かつカメラ空間の距離に変更してくれるため扱いやすくなります。

後はこの値を利用して判定するだけです

ool isOccluded = param.Depth > (sceneEye + 0.0001); // 少しバイアスをかける

上記に貼った画像の通り、Z値は限りなく小さい値が格納されるため多少バイアスを掛けることが重要です


ComputeShader全文

#pragma kernel DepthTest

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

// C#側の構造体とサイズを合わせる
struct ProceduralParam
{
    float3 WorldPosition;    // 表示座標
    float2 ScreenPosition;   // スクリーン上の座標
    float4 Color;            // 色
    float Depth;             // 奥行きの値
    int IsVisible;           // 可視状態
    int IsInScreen;          // スクリーン内の判定
};

// SetComputeIntParamで渡されている
int _ObjectCount;

// 入出力バッファ
RWStructuredBuffer<ProceduralParam> _ObjectParamBuffer;

// DepthTexture
Texture2D<float> _DepthTexture;
SamplerState sampler_DepthTexture;

// 実行メイン
[numthreads(32, 1, 1)]  // 32スレッドで並列処理
void DepthTest(uint3 id : SV_DispatchThreadID) // id.xがオブジェクトのインデックス
{
    if (id.x >= _ObjectCount) return; // 余分スレッドがバッファ範囲外を書かないように即リターン
    ProceduralParam param = _ObjectParamBuffer[id.x];

    // 画面外のオブジェクトは処理しない
    if (param.IsInScreen == 0)
    {
        param.IsVisible = 0;
        _ObjectParamBuffer[id.x] = param;
        return;
    }

    // デプステクスチャのサイズを取得
    uint2 depthTextureSize;
    _DepthTexture.GetDimensions(depthTextureSize.x, depthTextureSize.y);

    // スクリーン座標をUVに変換
    float2 uv = param.ScreenPosition / float2(depthTextureSize);

    // UV座標が有効範囲内か
    if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1)
    {
        // じゃなかったら描画しない
        param.IsVisible = 0;
        _ObjectParamBuffer[id.x] = param;
        return;
    }

    // 深度テクスチャから深度値をサンプリング
    float sceneDepth = _DepthTexture.SampleLevel(sampler_DepthTexture, uv, 0).r;
    float sceneEye = LinearEyeDepth(sceneDepth, _ZBufferParams); // カメラ空間座標に正規化
    
    // 深度比較
    // param.Depthはカメラ空間のZ(奥がプラス
    bool isOccluded = param.Depth > (sceneEye + 0.0001); // 少しバイアスをかける
    if (isOccluded)
    {
        // 遮蔽されている
        param.Color = float4(1,0,1,1);
    }
    else
    {
        param.Color = float4(1,1,1,1); 
    }

    _ObjectParamBuffer[id.x] = param;
}

3. ComputeShadeの実行と描画

最後に、上記のシェーダーの実行と描画を行います。

ひとまずCPUで計算してオブジェクトパラメータをComputeShaderに渡す必要があります。

CPUデータをCopmuteShaderに渡す為に ComputeBuffer を利用します。
バッファにパラメータを詰めて、ComputeShaderに渡す。という流れです

    public class ComputeShaderDepthTest : MonoBehaviour
    {
        // Bufferの宣言
        private ComputeBuffer _paramBuffer; // Shader内部で利用するのでComputeBuffer

TransformAndCullJob で計算した結果をバッファにセット

        private void Update()
        {
            // Jobを実行する
            var job = new TransformAndCullJob() 
            {
                ...
                Params = _params,   // これに書き込んでもらう
            }
            
            // 書き込んだデータをComputeShaderに渡すために、ComputeBufferにつめる
            _paramBuffer.SetData(_params);
        }

後はComputeBufferにこのComputeBufferを渡すだけでComputeBuffer側で利用可能になります

今回は

  • ComputeShader を利用した遮蔽判定
  • DrawProcedural を利用した描画処理

を同時に行いたいため、RenderGraph を利用して判定と描画パスをスクリプトで作成して実行します。

RenderGraphについては下記サイト様がわかりやすくおすすめです

まず、RenderGraphの細かい部分はカットして、コードの核となる部分を記載します

流れは

  1. ScriptableRendererFeature でパスの設計図を作成。必要なパラメータの定義
  2. ScriptableRenderPass でパスの実行の中身を作る
  3. RecordRenderGraph このメソッドで必要になるリソース(今回は深度テクスチャ)の定義、そして実行する中身を書く
/// <summary>
    /// DepthTextureを利用して遮蔽判定を行うComputeShaderを実行する
    ///
    /// パスの組み立て役
    /// </summary>
    public class DepthTestFeature : ScriptableRendererFeature
    {
        /// <summary>
        /// 値をまとめたstruct:Passにセットする為のもの
        /// </summary>
        public class Param
        {
            public int ObjectCount;
            public ComputeBuffer ParamBuffer; 
            public ComputeShader DepthTestShader;
            public Material DrawMaterial;
        }
        
        [SerializeField] private ComputeShader _computeShader;  // 遮蔽判定用ComputeShader
...

        /// <summary>
        /// フレーム単位で描画キューを組み立てる(カメラ毎)
        /// </summary>
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
...
            // 処理前に実行に必要なパラメータを渡す
            _param.DepthTestShader = _computeShader;
            _renderPass.Param = _param;
...
        }
...
    }

    /// <summary>
    /// 実際のレンダリングパス
    /// </summary>
    public class DepthTestRenderPass : ScriptableRenderPass
    {
...
        // RenderGraph用のパスデータ
        private class DepthTestPassData
        {
            public TextureHandle DepthTexture;
            public DepthTestFeature.Param Param;
        }

        // 描画用のパスデータ
        private class DrawPassData
        {
            public DepthTestFeature.Param Param;
        }

...
        /// <summary>
        /// RenderGraphを組み立てる際に呼び出される。描画に関する設定から実行までを行う
        /// リソース利用宣言や依存関係などが解決される
        /// </summary>
        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            // URPのリソース情報とカメラ情報の取得
            var resourceData = frameData.Get<UniversalResourceData>();
            
            // pass 1: 遮蔽判定
            {
                // どういうリソースを利用するか、実行者の登録。など
                using var builder = renderGraph.AddComputePass<DepthTestPassData>("Depth Test", out var passData);
                builder.AllowPassCulling(false); // 書き込みがないパスはカリング処理されてスキップされるので、これで副作用あり扱いにしてRenderGraphのカリングを止める
                
                // カメラDepthは通常「不透明のみ」(cameraDepthTexture)
                // 今回は”テスト用”で、透明ZWriteも拾ってほしいため実際の深度ターゲットを読む(activeDepthTexture)
//                passData.DepthTexture = resourceData.cameraDepthTexture;
                passData.DepthTexture = resourceData.activeDepthTexture;
                passData.Param = Param;

                // 深度テスト用のテクスチャを読み込み専用で利用することを宣言
                builder.UseTexture(passData.DepthTexture, AccessFlags.Read);    

                builder.SetRenderFunc(static (DepthTestPassData data, ComputeGraphContext context) =>
                {
                    var param = data.Param;
                    var cmd = context.cmd;
                    
                    // ComputeBufferを実行
                    var kernel = param.DepthTestShader.FindKernel("DepthTest");
                    cmd.SetComputeTextureParam(param.DepthTestShader, kernel, "_DepthTexture", data.DepthTexture);  // 深度テクスチャの設定
                    cmd.SetComputeBufferParam(param.DepthTestShader, kernel, "_ObjectParamBuffer", param.ParamBuffer);  // オブジェクトのパラメータデータを設定
                    cmd.SetComputeIntParam(param.DepthTestShader, "_ObjectCount", param.ObjectCount);   // オブジェクト数の設定

                    // ComputeShaderの実行処理
                    var threadGroups = Mathf.CeilToInt(param.ObjectCount / 32.0f);
                    cmd.DispatchCompute(param.DepthTestShader, kernel, threadGroups, 1, 1);
                });
            }

            // pass 2 : 通常の描画(ラスタライズ)
            {
                using var drawPassBuilder = renderGraph.AddRasterRenderPass<DrawPassData>("Draw Procedural Objects", out var passData);

                passData.Param = Param;
                
                // 描画結果先のテクスチャ設定 : どのテクスチャのどのIndexに書き込むか
                drawPassBuilder.SetRenderAttachment(resourceData.activeColorTexture, 0); // カメラのカラーテクスチャ(バッファ)に描画
                drawPassBuilder.SetRenderAttachmentDepth(resourceData.activeDepthTexture); // 深度テクスチャ

                drawPassBuilder.SetRenderFunc(static (DrawPassData data, RasterGraphContext context) =>
                {
                    var param = data.Param;

                    // ComputeBuffer 描画シェーダーにパラメータを送る
                    param.DrawMaterial.SetBuffer("_ParamBuffer", param.ParamBuffer); 
            
                    // DrawProceduralを利用して三角形を描画。頂点は3つなのでx3のデータを渡す。
                    // URPのコマンドバッファに統合するためにcontext.cmdで描画
                    context.cmd.DrawProcedural(
                        Matrix4x4.identity,
                        param.DrawMaterial,
                        0,
                        MeshTopology.Triangles,
                        3,
                        param.ObjectCount);
                });
            }
        }
    }

見て分かる通り最重要な箇所は RecordRenderGraph メソッドです
ここでは

  • Pass1: 遮蔽判定
  • Pass2: 描画処理

を行います

Pass1のComputeShaderの実行箇所を切り抜いた部分が以下

// ComputeBufferを実行
var kernel = param.DepthTestShader.FindKernel("DepthTest");
cmd.SetComputeTextureParam(param.DepthTestShader, kernel, "_DepthTexture", data.DepthTexture);  // 深度テクスチャの設定
cmd.SetComputeBufferParam(param.DepthTestShader, kernel, "_ObjectParamBuffer", param.ParamBuffer);  // オブジェクトのパラメータデータを設定
cmd.SetComputeIntParam(param.DepthTestShader, "_ObjectCount", param.ObjectCount);   // オブジェクト数の設定

// ComputeShaderの実行処理
var threadGroups = Mathf.CeilToInt(param.ObjectCount / 32.0f);
cmd.DispatchCompute(param.DepthTestShader, kernel, threadGroups, 1, 1);

上から
FindKernel で実行するメソッドを受け取り、必要なパラメータをセットして、DispatchCompute で実行。

これでComputeShaderが動きます。
うまく行けばComputeShaderで書いている下記が働き、遮蔽されていれば色が紫に、遮蔽されてなければ色が白になるはずです

CompueteShader.hlsl
    // 深度比較
    // param.Depthはカメラ空間のZ(奥がプラス
    bool isOccluded = param.Depth > (sceneEye + 0.0001); // 少しバイアスをかける
    if (isOccluded)
    {
        // 遮蔽されている
        param.Color = float4(1,0,1,1);
    }
    else
    {
        param.Color = float4(1,1,1,1); 
    }

上記実行後、Pass2が動きます
Pass2では以前利用済みの DrawProcedural で描画処理を走らせますがその前にComputeBufferをシェーダーにセットします。
これをすることにより ComputeShaderで実行した結果をそのまま描画シェーダーで利用 することができます

// ComputeBuffer 描画シェーダーにパラメータを送る
param.DrawMaterial.SetBuffer("_ParamBuffer", param.ParamBuffer); 
            
// DrawProceduralを利用して三角形を描画。頂点は3つなのでx3のデータを渡す。
// URPのコマンドバッファに統合するためにcontext.cmdで描画
context.cmd.DrawProcedural(
     Matrix4x4.identity,
     param.DrawMaterial,
     0,
     MeshTopology.Triangles,
     3,
     param.ObjectCount);

結果

今回は、遮蔽処理がわかりやすくなるように左半分を半透明なPlaneで覆ってみます

image.png

Mainには実行するスクリプト

image.png

実行した結果は以下のようになりました

image.png

半透明なPlaneに覆われているMeshのみ色が紫色、右半分は白色になっているため判定が成功しています

今回は以上になります。

勉強に利用したサンプルプロジェクトはシーンを分けていますので目的にあったシーンを実行していただけると読み取りやすくなるかなと思います

ありがとうございました

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?