本記事は 面白法人グループ Advent Calendar 2025 の8日目の記事です。
1. 概要
こんにちは!カヤック技術部の高石です。 本記事では、Weighted Order Independent Transparency(以下 Weighted OIT)というレンダリング技術をUnity上で実装し、多数の透明オブジェクトをより正確に描画する方法を紹介します。以下に、最終的なWeighted OITと、シンプルな AlphaBlend の描画結果を示します。見た目は地味に見えるかもしれませんが、実装すると微妙な色のフェード感が正しい前後関係の表現に寄与していることが分かります。


Weighted OITは、完全に透明な物体を理想的に合成できるわけではありませんが、通常のAlphaBlendingよりも、透明オブジェクト同士の重なりをより美しく表現できます。また、本記事は単に Weighted OITを紹介するだけでなく、URPにおけるカスタムレンダーパスの拡張、CommandBufferを用いた GPU Instancing、MRT(Multiple Render Target)の活用 など、レンダリング周りの技術を体系的に学ぶきっかけにもなる内容になっているかと思います。興味のある方は、ぜひ読み進めてみてください。 プロジェクトはGitHubにアップされています。
※使用環境 Unity:2022.3 / URP:14.0.11
2. Weighted Order Independent Transparency
Weighted OITはMcGuireらの論文で提案された手法です。この手法を用いることで、透明オブジェクトの描画順に依存せず、適切に合成する高速なレンダリングが可能になります。
アルファブレンディングは描画順に依存するため、通常、透明オブジェクトは「手前から奥」の順に描画しないと正しく見えません。Weighted OITは、この問題を解決するために提案された順序依存しないレンダリング手法です。
具体的なレンダリングの流れは図のような形になります。コンセプトとしては、透明カラー × 透明度 × 重み を累積し、最後に累積された透明度で平均を取る、という処理です。
また、RevealageBuffer は、重なった透明オブジェクトの影響を考慮した背景の透過度を保存するバッファです(RevealageBufferは論文内で使われている造語です)。

処理内容を具体的に示すと、以下のようになります。
// Weighted OITの簡易処理例 for each pixel of transparent object: float alpha = material.alpha // オブジェクトの透過度 float weight = alpha // 重みの計算(単純化) accumBuffer.rgb += material.color.rgb * alpha * weight // 色を累積 accumBuffer.a += alpha * weight // 透明度を累積 revealageBuffer *= (1 - alpha) // 背景の透過度を更新 for each pixel: finalColor.rgb = accumBuffer.rgb / max(accumBuffer.a, 0.001) // 平均化 finalColor.a = 1 - revealageBuffer // 最終アルファ計算
3. Unityへ導入するモチベーション
Particle Systemはカメラからの距離で自動的にソートされるため、透明オブジェクトでも比較的綺麗に描画されます。 一方、Unity で GPU Instancingを使用して描画される大量のオブジェクトは、エンジン側で自動的にソートされません。そのため、透明オブジェクトを自前で Instancingした場合は、透明オブジェクト同士の描画順序を自分で管理する必要があります。この問題を解決する手段の一つとしてWeighted OIT を導入することで、描画順序に依存せずに透明オブジェクトを適切に合成できるようになります。
Multi Render Targets
上の図でレンダリングの流れを確認したように、Weighted OIT を実現するには、通常のレンダリングのようにカメラのカラーバッファに結果を書き込むだけでは不十分です。色や透明度の累積値を保持する Accumulation Buffer、背景が最終的にどれくらい影響するかを示す Revealage Buffer に対してデータを書き込む必要があります。
この時点で気づくかもしれませんが、通常のレンダリングでは Color Buffer は 1 つだけですが、Weighted OIT では Accumulation Buffer と Revealage Buffer の2つが存在します。このように、複数のバッファに同時に書き込む仕組みをMulti Render Targets(MRT)と呼びます。
少し脱線しますが、MRT はさまざまなレンダリング処理に応用されています。代表的な例として Deferred Rendering があります。通常の Forward Rendering では、ジオメトリごとにライトやライティング計算を行いますが、Deferred Rendering では G-Bufferと呼ばれるRenderTarget群にシーン全体の座標、法線、アルベド、深度などの情報を書き出し、その後のライトパスでスクリーンスペース上でライティング計算を行います。そのため、ライトの数が増えても処理負荷が大きく上がらず、PostProcessing 系のシェーダでの効果処理とも相性が良いのが特徴です。
4. カスタムレンダーパスの追加方法
UnityのUniversal Render Pipeline(URP)では、レンダリングの流れを柔軟に拡張することができます。Weighted OIT のような特殊な描画を行う場合、カスタムレンダーパス を追加するのが一般的です。ここでは、簡単にRenderFeature と RenderPass の関係を整理しつつ、実際に追加する手順をわかりやすく解説します。
4.1. URPの基本構造
Renderer Asset: カメラや描画対象ごとの設定を持つAssetファイル
Renderer Feature: ScriptableRendererFeature を継承した拡張モジュール。複数の RenderPass をまとめて Renderer に追加可能
Render Pass: ScriptableRenderPass を継承した描画単位。実際に GPU に対して描画命令を出す
つまり、Renderer Asset がパイプラインの“本体”、RenderFeature が“拡張モジュール”、RenderPass が“描画タスク” というイメージです。
4.2. RenderFeatureとRenderPassの関係
RenderPass
1つのレンダリング処理単位で、GPU への描画命令を実行します。例えば Weighted OIT なら、Accumulation PassとResolve Passの2つに分かれます。
RenderFeature
複数のRenderPassをまとめて管理するモジュールで、Renderer Assetに追加するだけで、自動的にパイプラインに組み込まれます。RenderPass の生成や登録処理は Feature 内で行います。
5. 実装
5.1. Weighted OIT Accumulation Pass
public class WeightedOITAccumulationPass : ScriptableRenderPass { private string profilerTag = "WeightedOITRenderPass"; private Material material; private Mesh srcMesh; private Matrix4x4[] matrices; private int instanceCount; private GraphicsBuffer paramsBuffer; public WeightedOITAccumulationPass(Material mat, Mesh mesh, Matrix4x4[] matrices, GraphicsBuffer paramsBuffer) { this.material = mat; this.srcMesh = mesh; this.matrices = matrices; this.instanceCount = matrices.Length; this.paramsBuffer = paramsBuffer; renderPassEvent = RenderPassEvent.BeforeRenderingTransparents; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (srcMesh == null) { Debug.LogWarning("Source Mesh is null"); return; } CommandBuffer cmd = CommandBufferPool.Get(profilerTag); cmd.SetRenderTarget(RenderTargetBuffer.AcumulationRT, RenderTargetBuffer.DepthAttachment); cmd.ClearRenderTarget(true, true, Color.clear); cmd.SetRenderTarget(RenderTargetBuffer.RevealageRT, RenderTargetBuffer.DepthAttachment); cmd.ClearRenderTarget(false, true, Color.white); RenderTargetIdentifier[] mrt = new RenderTargetIdentifier[2]; mrt[0] = RenderTargetBuffer.AcumulationRT; mrt[1] = RenderTargetBuffer.RevealageRT; cmd.SetRenderTarget(mrt, RenderTargetBuffer.DepthAttachment); if (paramsBuffer != null) { cmd.SetGlobalBuffer("paramsBuffer", paramsBuffer); } cmd.DrawMeshInstanced(srcMesh, 0, material, 0, matrices, instanceCount); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }
RenderTarget のセット
RenderPassでは、どのタイミングで、何に対して、何を行うか を記述します。基本的な処理はCommandBufferを通して実行されます。 通常、RenderBufferのセットやクリアはRenderFeature内やOnCameraSetup関数内で行うのが一般的です。しかし、今回は 各バッファを異なる初期値でクリアしてセットする必要 があったため、この処理は Execute 関数内で行う 必要がありました。 厳密には、複数の RenderTargetの登録自体はOnCameraSetup内でも可能ですが、異なる初期値でのクリアはExecute内でしかできない、という制約があります。
GPU Instancing
今回のWeighted OIT実装のコアの一部である GPU Instancing は、cmd.DrawMeshInstanced を使用して実現しています。各インスタンスの座標は 行列(Matrix) で渡します。さらに発展的な拡張を考慮し、透明度や UV 情報は GraphicsBuffer(ComputeBuffer と互換性あり) を使用して、各インスタンスごとに個別に渡すようにしています。この構成により、Instancing前にCompute Shaderを介してバッファの値を更新し、その後 Instancing に渡すことも可能です。例えば、透明度を動的に変化させたり、UV を加工したりといった柔軟な処理が行えます。
5.2 Shader
Shader に関しては非常に長いため、ピンポイントに重要な部分だけ抜粋 します。
RenderTarget ごとのブレンド方法
RenderTarget ごとにブレンド方法を変えることができます。
Blend 0 One One // Accum: 加算 Blend 1 Zero OneMinusSrcColor // Reveal: 乗算
MRT 出力の設定
複数のRenderTarget(MRT)にカラーを出力する場合は、以下のように構造体を定義します。
struct FragOutput { float4 accum : SV_Target0; float4 reveal : SV_Target1; };
Accumulationの計算
Weighted OITの肝となる部分です。ここでは 重みの値にクリップ空間の深度を使用しています。つまり、色の濃さは透明度と距離に関連している、ということです。この深度ベースの重み付けは、Weighted OITの論文でも紹介されています。
さらに発展的な方法として、Compute Shader を用いてバッファを加工する場合、深度計算をより正確に行うことも可能です。例えば、カメラからシーン中で最も遠い物体までの距離を求め、それを基準に各オブジェクトの距離を正規化して深度として利用する方法です。こうすることで、より滑らかで正確な透過表現が得られる可能性があります。
float z = input.depth; float weight = alpha * clamp(10.0 / (1e-5 + abs(z)/5.0 + pow(abs(z), 2.0)/200.0), 1e-2, 3e3); output.accum = float4(finalColor * alpha, alpha) * weight; output.reveal = float4(alpha, alpha, alpha, alpha);
5.3 Weighted OIT Resolve Pass
前工程でレンダリングした結果を合成するパスになっています。 ここではシンプルにRenderTextureとしてRenderTargetをセットします。
public class WeightedOITResolvePass : ScriptableRenderPass { private Material material; private Shader shader; public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (material == null) return; CommandBuffer cmd = CommandBufferPool.Get("WeightedOITResolvePass"); cmd.SetGlobalTexture("_AccumTexture", RenderTargetBuffer.AcumulationRT); cmd.SetGlobalTexture("_RevealTexture", RenderTargetBuffer.RevealageRT); //default Color Render Targetに合成結果を出力 RTHandle cameraTargetHandle = renderingData.cameraData.renderer.cameraColorTargetHandle; Blitter.BlitCameraTexture(cmd, RenderTargetBuffer.AcumulationRT, cameraTargetHandle, material, 0); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }
5.4 Shader
以下合成用のShaderです。累積されたバッファの平均をとり、背景の見える具合をrevealageで決定するといった感じです。
half4 frag (Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float2 uv = input.texcoord.xy;
// Accumulation textureから色とアルファを取得
half4 accum = SAMPLE_TEXTURE2D(_AccumTexture, sampler_AccumTexture, uv);
// Revealage textureから透過率の積を取得
float revealage = SAMPLE_TEXTURE2D(_RevealTexture, sampler_RevealTexture, uv).r;
// ========================================
// 最終的な合成
// ========================================
if (accum.a < 1e-6)
{
discard;
}
half3 averageColor = accum.rgb / max(accum.a, 1e-5);
half alpha = 1.0 - revealage;
return half4(averageColor, alpha);
}
6. カスタムレンダーパスの拡張初学者に向けて
Rendering Pipelineを拡張していると、どこまでうまくいっているのかデバッグが大変になってくるとことがあります。そんな問題に対して、UnityのFrameDebuggerはわりと優秀です。MRTをしていてもそれぞれのRenderTargetに書き込まれている結果を確認出来たり、いまのレンダリングでShaderがどんなTextureをうけとっているかなど色々な情報の確認ができます。

7. まとめ
少し長めの記事になりつつも、細かいところを端折っています。手元で動作を確認しながら記事を読んでいただけるとより理解が深まるかと思います。ありがとうございました!