はじめに
シャドウマップをUnityで自作したいなと記事を探していたところ、SRPでの実装方法が見つからず実装に苦戦しました。
そこで、備忘録も兼ねてSRPでシャドウマップを作るための記事を書こうと思い立ちました。
この記事はシリーズ形式で段階を重ねて実装を紹介していく予定です。
まずは準備編ということで、SRPとは?SRPで描画するには?というところをやっていきます。
対象読者
- SRPとか触ったことないけどシェーダーの仕組みを多少知っている
- シャドウマップの仕組みを知りたい
開発環境
- Windows 10 Home 64bt
- Unity 2020.3.12f1
リポジトリ
コードだけ知りたい人はこちらからどうぞ。
ScriptableRenderPipeline(SRP)とは
Scriptable RenderPipeline(SRP)は、C#で描画フローごとカスタマイズ可能になったレンダーパイプラインです。
Unityの今までのレンダーパイプライン(Built-in RenderPipeline)は、描画フローの間にカスタマイズした処理を挟むことしかできませんでした。
しかし、SRPでは柔軟にカスタマイズできる一方で、フルスクラッチで実装していくのは大変です。
そこで、Unityは2つのSRPから派生したレンダーパイプラインを提供しています。
それがUniversalRP(URP)とHighDefinitionRP(HDRP)です。
プロジェクトの準備
プロジェクトが用意できている人は飛ばしてください。
まずは、プロジェクトを準備します。
Unity 2020.3.12f1を使用します。他のバージョンでも良いですが、説明する内容と異なる実装になっている可能性があります。
レンダーパイプラインの準備
SRPを使用してレンダーパイプラインを実装する時に必要な2つのクラスがあります。
RenderPipelineとRenderPipelineAssetです。
RenderPipeline
RenderPipelineクラスは、その名の通り、レンダーパイプラインの実装本体となるクラスです。
RenderPipelineクラスを継承し、Render
メソッドを実装する必要があります。
using UnityEngine;
using UnityEngine.Rendering;
namespace Gamu2059.render_pipeline.Shadowing {
/// <summary>
/// レンダーパイプライン
/// </summary>
public class RenderPipeline : UnityEngine.Rendering.RenderPipeline {
/// <summary>
/// このレンダーパイプラインを使って描画する
/// </summary>
protected override void Render(ScriptableRenderContext context, Camera[] cameras) {
}
}
}
何も描画しない最小のレンダーパイプラインができました。
RenderPipelineAsset
RenderPipelineAssetクラスは、2つの機能を持ったクラスです。
- レンダーパイプラインを生成するファクトリとしての機能
- レンダーパイプラインにパラメータを渡すScriptableObjectとしての機能
RenderPipelineAssetクラスを継承し、CreatePipeline
を実装する必要があります。
using UnityEngine;
namespace Gamu2059.render_pipeline.Shadowing {
/// <summary>
/// レンダーパイプラインアセット
/// </summary>
[ExecuteInEditMode]
[CreateAssetMenu(menuName = "Gamu2059/Shadowing/RenderPipelineAsset", fileName = "render_pipeline_asset.asset")]
public class RenderPipelineAsset : UnityEngine.Rendering.RenderPipelineAsset {
/// <summary>
/// レンダーパイプラインを作る
/// </summary>
protected override UnityEngine.Rendering.RenderPipeline CreatePipeline() {
return new RenderPipeline();
}
}
}
パラメータを渡すことがない、レンダーパイプラインを生成するだけのクラスができました。
自作したレンダーパイプラインを適用する
クラスを作っただけではレンダーパイプラインが適用されません。
適用するために2つの手順が必要になります。
- レンダーパイプラインアセットを作成する
- レンダーパイプラインアセットをプロジェクトにセットする
レンダーパイプラインアセットを作成する
プロジェクトタブからCreate>RenderPipelineAsset
を選択し、レンダーパイプラインアセットを作成します。
適当で問題ありません。
レンダーパイプラインアセットをプロジェクトにセットする
UnityのメニューからEdit/Project Settings…
を選択します。
まずはGraphicsタブを開き、Scriptable Render Pipeline Settings
の項目に先ほど作成したレンダーパイプラインアセットをセットします。
次にQualityタブを開き、Add Quality Level
を押し、Rendering
の項目に先ほど作成したレンダーパイプラインアセットをセットします。
ここまで完了すると黒一色のシーンやゲームビューになるはずです。
レンダーパイプラインの実装
今回は不透明物体とSkyboxを描画するレンダーパイプラインを実装します。
まずは全文を乗せておきます。
RenderPipeline.cs全文
using UnityEngine;
using UnityEngine.Rendering;
namespace Gamu2059.render_pipeline.Shadowing {
/// <summary>
/// レンダーパイプライン
/// </summary>
public class RenderPipeline : UnityEngine.Rendering.RenderPipeline {
/// <summary>
/// パイプライン名
/// </summary>
private const string PipelineName = "RenderPipeline";
/// <summary>
/// 描画用レンダーテクスチャのハッシュ値
/// </summary>
private readonly int RenderTarget;
/// <summary>
/// 描画用レンダーテクスチャのID
/// </summary>
private readonly RenderTargetIdentifier RenderTargetId;
/// <summary>
/// カメラのレンダーターゲットのID
/// </summary>
private readonly RenderTargetIdentifier CameraTargetId;
/// <summary>
/// 描画に使うパスのID
/// </summary>
private readonly ShaderTagId RenderTagId;
/// <summary>
/// コンストラクタ
/// </summary>
public RenderPipeline() {
RenderTarget = Shader.PropertyToID("_RenderTarget");
RenderTargetId = new RenderTargetIdentifier(RenderTarget);
CameraTargetId = new RenderTargetIdentifier(BuiltinRenderTextureType.CameraTarget);
RenderTagId = new ShaderTagId("Forward");
}
/// <summary>
/// このレンダーパイプラインを使って描画する
/// </summary>
protected override void Render(ScriptableRenderContext context, Camera[] cameras) {
foreach (var camera in cameras) {
// コマンドバッファの取得
var cmd = CommandBufferPool.Get(PipelineName);
// カメラプロパティの設定(View行列、Projection行列の設定など)
context.SetupCameraProperties(camera);
// カリングパラメータの取得
if (!camera.TryGetCullingParameters(false, out var cullingParameters)) {
continue;
}
// カメラのカリング
var cullingResults = context.Cull(ref cullingParameters);
// レンダーテクスチャの取得リクエスト
cmd.GetTemporaryRT(RenderTarget, Screen.width, Screen.height, 32);
// 描画ライブラリで操作するレンダーテクスチャの切り替えリクエスト
cmd.SetRenderTarget(RenderTarget);
// レンダーテクスチャの色と深度のクリアリクエスト
cmd.ClearRenderTarget(true, false, camera.backgroundColor, 1);
// レンダーテクスチャの取得とクリアの実行
context.ExecuteCommandBuffer(cmd);
// 不透明描画のソートの仕方の指定
var opaqueSortingSettings = new SortingSettings(camera) {criteria = SortingCriteria.CommonOpaque};
// 不透明描画の描画対象のパスとソートの仕方の指定
var opaqueDrawSettings = new DrawingSettings(RenderTagId, opaqueSortingSettings);
// 不透明描画の描画するRenderQueueの範囲の指定
var opaqueRenderQueueRange = new RenderQueueRange(0, (int) RenderQueue.GeometryLast);
// 不透明描画の描画するRenderQueueの範囲と描画対象のレイヤーの指定
var opaqueFilterSettings = new FilteringSettings(opaqueRenderQueueRange, camera.cullingMask);
// 不透明描画の実行
context.DrawRenderers(cullingResults, ref opaqueDrawSettings, ref opaqueFilterSettings);
// Skyboxの描画
context.DrawSkybox(camera);
// 以前のリクエストのクリア
cmd.Clear();
// レンダーテクスチャからカメラのフレームバッファへのコピーリクエスト
cmd.Blit(RenderTargetId, CameraTargetId);
// レンダーテクスチャの解放リクエスト
cmd.ReleaseTemporaryRT(RenderTarget);
// レンダーテクスチャのコピーと解放の実行
context.ExecuteCommandBuffer(cmd);
// コマンドバッファの解放
CommandBufferPool.Release(cmd);
}
// 今までの全ての処理のリクエストを実行
context.Submit();
}
}
}
このレンダーパイプラインでは、カメラごとに以下の図のような工程で処理していきます。
カメラプロパティのセット
// カメラプロパティの設定(View行列、Projection行列の設定など)
context.SetupCameraProperties(camera);
カメラの向きや透視投影か平行投影かといった情報をグローバルな変数としてシェーダー側にセットします。
とりあえず最初に実行しておけば良いです。
カリング
// カリングパラメータの取得
if (!camera.TryGetCullingParameters(false, out var cullingParameters)) {
continue;
}
// カメラのカリング
var cullingResults = context.Cull(ref cullingParameters);
カリングとは、映らないオブジェクトを描画対象から除外するための処理です。
映らないオブジェクトまで全て描画してしまうと描画コストが高すぎるために行います。
このカリングは、カメラの視界外のオブジェクトを除外するもので、フラスタムカリングと呼ばれます。
カメラを使ってカリングのパラメータを取得した後に、カリングを実行しています。
レンダーテクスチャの初期化
// レンダーテクスチャの取得リクエスト
cmd.GetTemporaryRT(RenderTarget, Screen.width, Screen.height, 32);
// 描画ライブラリで操作するレンダーテクスチャの切り替えリクエスト
cmd.SetRenderTarget(RenderTarget);
// レンダーテクスチャの色と深度のクリアリクエスト
cmd.ClearRenderTarget(true, false, camera.backgroundColor, 1);
// レンダーテクスチャの取得とクリアの実行
context.ExecuteCommandBuffer(cmd);
描画に使うレンダーテクスチャの初期化処理です。
CommandBufferは主にレンダーテクスチャやレンダーターゲットの操作に使用するものです。
レンダーテクスチャは描画結果を実際に保存しておくもので、カラーバッファやデプスバッファと呼んだりします。
レンダーターゲットは描画ライブラリが操作や描画を行うレンダーテクスチャです。
色々なレンダーテクスチャに対して操作や描画を行う時は、毎回レンダーターゲットを切り替えてやる必要があります。
GetTemporaryRT
でレンダーテクスチャを取得します。
今回はスクリーンの解像度で取得していますが、スマホ向けの開発ではスクリーンよりも低解像度で取得することがあります。
SetRenderTarget
でレンダーターゲットを切り替えます。
次のClearRenderTargetで操作するために、先ほど取得したレンダーテクスチャを指定します。
ClearRenderTarget
でレンダーターゲットの色や深度を初期化します。
今回はSkyboxで画面全体を描画するので色の初期化は行わず、深度値を1で初期化します。
深度値の1はカメラの奥を指し、0は手前を指します。
つまり、1での初期化は何でも描画できる状態にしておくということです。
最後にExecuteCommandBuffer
で今までのCommandBufferの操作を実行します。
厳密にはこのタイミングで実行する訳ではないですが、実行するものと考えて問題ないです。
不透明描画
// 不透明描画のソートの仕方の指定
var opaqueSortingSettings = new SortingSettings(camera) {criteria = SortingCriteria.CommonOpaque};
// 不透明描画の描画対象のパスとソートの仕方の指定
var opaqueDrawSettings = new DrawingSettings(RenderTagId, opaqueSortingSettings);
// 不透明描画の描画するRenderQueueの範囲の指定
var opaqueRenderQueueRange = new RenderQueueRange(0, (int) RenderQueue.GeometryLast);
// 不透明描画の描画するRenderQueueの範囲と描画対象のレイヤーの指定
var opaqueFilterSettings = new FilteringSettings(opaqueRenderQueueRange, camera.cullingMask);
// 不透明描画の実行
context.DrawRenderers(cullingResults, ref opaqueDrawSettings, ref opaqueFilterSettings);
不透明描画の処理です。
3D空間の物体を描画するには、何をどういう順番で描画するのかが重要です。
SortingSettings
は、描画順序を指定するデータです。
今回は不透明描画なのでCommonOpaqueを指定します。
描画順の種類は他にもいくつかあるようです。
DrawingSettings
は、描画順序と描画に使うシェーダのパスを指定するデータです。
描画順序はSortingSettingsを指定します。
描画に使うシェーダのパスはRenderTagIdというフィールドで指定しています。
RenderTagIdはコンストラクタで以下のように定義しています。
RenderTagId = new ShaderTagId("Forward");
つまり、描画に使うシェーダのパスはForwardという名前のパスだけに限定することを意味しています。
このForwardという名前はシェーダを作成する時にも登場するので覚えておいてください。
RenderQueueRange
は、描画対象とするレンダーキューの範囲を指定するデータです。
Unityでは一般的に、不透明描画のレンダーキューの範囲は0~2999まで、半透明描画のレンダーキューの範囲は3000~5000のようです。
今回は不透明描画なので、0~GeometryLast(2999)までの範囲を指定します。
FilteringSettings
は、描画対象のレイヤーを指定するデータです。
今回はカメラに設定されているレイヤーをそのまま指定しています。
最後に、DrawRenderers
で今まで指定してきた描画順序と描画対象のデータを使って描画を実行します。
Skybox描画
// Skyboxの描画
context.DrawSkybox(camera);
Skybox描画の処理です。
ここは非常にシンプルで、DrawSkybox
を実行するだけでレンダーターゲットに対してSkyboxを描画できます。
フレームバッファに描画結果をコピー
// 以前のリクエストのクリア
cmd.Clear();
// レンダーテクスチャからカメラのフレームバッファへのコピーリクエスト
cmd.Blit(RenderTargetId, CameraTargetId);
フレームバッファに今までの描画結果をコピーする処理です。
Clear
は、コピーに必要な処理というよりは、以前のリクエストを消すために実行しています。
Blit
は、第1引数のレンダーターゲットを第2引数のレンダーターゲットにコピーしています。
RenderTargetIdは、今まで描画してきたレンダーテクスチャを指します。
CameraTargetIdは、カメラが描画するのに使うフレームバッファと呼ばれる特別な格納場所を指します。
フレームバッファに描画結果を反映することで、画面に描画結果が表示されます。
レンダーテクスチャの解放
// レンダーテクスチャの解放リクエスト
cmd.ReleaseTemporaryRT(RenderTarget);
ReleaseTemporaryRT
でレンダーテクスチャを解放します。
一時的に取得したレンダーテクスチャは不要になったタイミングで適宜解放していくと良いです。
シェーダーの実装
レンダーパイプラインを実装しましたが、この状態だと以下の画像のような色がついていないオブジェクトしかないはずです。
なぜなら、オブジェクトを描画するシェーダーが、自作したレンダーパイプラインに対応していないからです。
つまり、自作したレンダーパイプラインに対応するシェーダーも実装する必要があります。
今回はライトを一切考慮しないUnlitと呼ばれるシェーダーを実装します。まずは全文を載せておきます。
Forward-Unlit.shader全文
Shader "Gamu2059/Shadowing/Forward-Unlit"
{
// Unityのマテリアルのインスペクターで指定するパラメータの定義場所
Properties
{
// テクスチャを指定できるようにする
_MainTex("Diffuse", 2D) = "white" {}
// 色を指定できるようにする
_Color("Color", Color) = (1,1,1,1)
}
SubShader
{
Tags
{
// デフォルトのレンダーキューはGeometry(2000)
"Queue" = "Geometry"
// レンダータイプはOpaque(不透明)
"RenderType" = "Opaque"
}
Pass
{
Tags
{
// 【重要】シェーダーパスのフィルタ名はForward
"LightMode" = "Forward"
}
// ここからHLSL
HLSLPROGRAM
// Unityのシェーダーライブラリを使う(C#のusingみたいなもの)
#include "UnityCG.cginc"
// 頂点シェーダーにvertという関数を指定
#pragma vertex vert
// ピクセルシェーダーfragという関数を指定
#pragma fragment frag
// Unityから頂点シェーダーに渡すデータ
struct Attributes
{
// 頂点の座標(オブジェクト空間)
float3 positionOS : POSITION;
// 頂点のUV座標
float2 uv : TEXCOORD0;
};
// 頂点シェーダーからピクセルシェーダーに渡すデータ
struct Varyings
{
// クリップ空間の座標
float4 positionCS : SV_POSITION;
// UV座標
float2 uv : TEXCOORD0;
};
// プロパティに定義してあるテクスチャを使う
sampler2D _MainTex;
// プロパティに定義してあるテクスチャのスケールとオフセットを定義したデータ
float4 _MainTex_ST;
// プロパティに定義してある色を使う
half4 _Color;
// 頂点シェーダー
Varyings vert(Attributes i)
{
Varyings o;
// 頂点座標をオブジェクト空間からクリップ空間に変換
o.positionCS = UnityObjectToClipPos(i.positionOS);
// スケールとオフセットを考慮してUV座標を再計算
o.uv = TRANSFORM_TEX(i.uv, _MainTex);
return o;
}
// ピクセルシェーダー
half4 frag(Varyings i) : SV_Target
{
// UV座標を使ってテクスチャの色を取得
half4 color = tex2D(_MainTex, i.uv);
// テクスチャの色に指定した色を乗算
color.xyz *= _Color.xyz;
return half4(color.xyz, 1);
}
// ここまでHLSL
ENDHLSL
}
}
}
シェーダーに関しては、お約束が多すぎるので解説が大変なのと入門サイトが沢山あるので解説は割愛します。
このシェーダーで重要な部分は一か所だけです。
Tags
{
// 【重要】シェーダーパスのフィルタ名はForward
"LightMode" = "Forward"
}
今回実装したレンダーパイプラインは不透明描画の時にForwardという名前のシェーダーパスだけを描画するように設定されていました。
LightMode
タグでForwardを指定することで、シェーダーパスの名前をForwardにすることができます。
さいごに
本記事はシャドウマップを自作するための連載記事の第1弾ということでSRPの準備編となりました。
次回はシンプルなディレクショナルライトのシャドウマップを作成する記事にしたいと思います。
ちなみに、SRPでシャドウマップを作るための情報が本当に無かったかというと、そんなことはありませんでした。
このサイトは、この記事を執筆中に偶然見つけたのですが、段階ごとに実装されているので分かりやすいです。
そして、もっと早く見つけていたかった…
まあ、勉強したことをアウトプットするという目的で執筆していくことにします。