はじめに
ブーリアン演算とは様々な形状を集合演算(足し合わせる(和)、一方から他方を引く(差)、一致する部分を抜き出す(積)など)によって作成する手法です。
いくつかの実装方法があると思いますが、スクリーンスペースで G-Buffer を加工することでこれを実現する Unity での実装を id:i-saint さんが作られており、実装も公開されていました。
G-Buffer 加工による利点は 3D ソフトなどで見られるメッシュ自体の変形に比べ、多数のオブジェクトを組合せ爆発しない形で扱うことができ、且つ動的な演算を複雑なメッシュに対しても比較的高速に行うことが可能です。本エントリでは、この i-saint さんの実装を参考にしつつ、差演算のみですが勉強のためになるべくシンプルなコードになるよう実装してみたので、その内容を共有します。
デモ
ソースコード
環境
公式サンプルおよびその問題点
公式の Stencil のマニュアルにも似たような Forward レンダリングによる例が載っています。
ここでは Stencil を使った手法で 3 Pass を使ってデカールのような表現をしています。具体的には 1 Pass 目、2 Pass 目で交差面以外の場所をマーキングしておき、3 Pass 目でサーフェスシェーダで交差面(マーキングした以外の場所)に描画を行います。マーキングは Cull Back
した面は通常とは逆に ZTest Greater
、反対に Cull Front
した面は ZTest Less
する面を ColorMask 0
及び ZWrite Off
して Stencil の情報のみ描きこんでいます。文字で書いても分かりづらいのでキャプチャを見てみましょう(わかりやすいようにステンシルがセットされた領域を赤色にしています)。
交差する部分を残して塗りつぶされているのがわかると思います。そして 3 Pass 目で Cull Front
して面を裏返したものをこの領域以外(Comp NotEqual
)の全て(ZTest Always
)に描画すれば、表面に貼り付けられた様な画が出ます。重要な箇所だけ抜粋すると以下の様なコードになります。
マーキング用のシェーダ
Shader "HolePrepare" { SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry+1"} Stencil { Ref 1 Comp Always Pass Replace } ColorMask 0 ZWrite Off Pass { Cull Front ZTest Less ... } Pass { Cull Back ZTest Greater ... } } }
塗りつぶし用のシェーダ
Shader "Hole" { ... SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry+2"} Stencil { Ref 1 Comp Equal } ColorMask RGB Cull Front ZTest Always ...(Surface Shader Here)... } }
しかしながらこの手法ではジオメトリを変形させるものではないので、貫通表現ができなかったり、視点を変えると破綻したり、複数個組み合わせるとうまく描画されなかったりといった問題が生じます。以下の図は横から見た図です。ジオメトリが変形されていない様子が見て取れると思います。
この後紹介するスクリーンスペースのブーリアン演算では、直接ジオメトリを変形することで、こういった問題の幾つかが解決されるものになります。
スクリーンスペースブーリアン概要
Deferred レンダリングでは、G-Buffer を生成した後にシェーディングが行われます。そこで G-Buffer 生成前に、ブーリアン演算の対象のオブジェクトだけ抜き出して別途これらのオブジェクトのデプスを描いたバッファを作成し、それをデプスバッファへと書き込んだ後、通常の G-Buffer 生成のタイミングではこれらのオブジェクトのデプスは書き込まずに、シェーディングに必要な情報を対象のデプスの領域に書き込むような処理を行えば、ジオメトリを変形することが可能です。
具体的な手法としては CommandBuffer
を利用して G-Buffer の前に処理を行います。CommandBuffer
については以下の記事をご参照ください。
差演算を行うために、差し引かれる立体(Subtractee)と差し引く物体(Subtractor)の 2 つのグループを作成します(簡単のために 1 つずつにして見ていきます)。
言葉で書いても良くわからないと思うので、図にしてみました。一連の流れは以下のようになります。
わかりやすいように色をつけていますが、実際にはこの段階ではデプスの更新しか行いません。
- まず、通常通り Subtractee のデプスを書き込みます。
- 次に Subtractor で削られ得る領域をステンシルでマークしておきます(半透明 緑色の領域)。
- そしてこのマークした領域に対して裏側(
Cull Front
)を通常とは逆のZTest Greater
でえぐるように描画します。ステンシルを施していないと球の真ん中も削れてしまうので 2. が必要というわけです。 - しかしながらえぐっただけでは Subtractor の裏側のデプスが書き込まれるだけで貫通はしないので、ここで事前に用意しておいた Subtractee の裏側のデプスと比較し、奥側のデプスの領域は貫通するようにデプスを最大値(1.0)にしておきます。
- 最後にマーク用に使用したステンシルを削除します。
そしてこれをデプスバッファを書き込んだあとは、他のオブジェクトも含め通常の G-Buffer に書き込む流れとなります。ただし、Subtractee と Subtractor は Tags
で "Queue"="Geometry-100"
等として最初にレンダリングされるようにしておき、既に前のプロセスで生成されているためデプスは書かず(ZWrite Off
)、先に計算したデプスと同じになる(ZTest Equal
)ピクセルへと各情報(拡散色やスペキュラなど)を書き込みます。
スクリプト
では、具体的にコードを見ていきます。まずはスクリプトからです。
Command Buffer のコード
まずは CommandBuffer
を使って描画するコードを見てみます。本質でないところは省いています。毎フレーム CommandBuffer
をクリアし、アクティブな Subtractee
インスタンスと Subtractor
インスタンスをかき集めて先述のように描画をするよう予約しておきます。
[ExecuteInEditMode] public class SubtractionRenderer : MonoBehaviour { ... void OnWillRenderObject() { AddCommandBuffer(); UpdateCommandBuffer(); } void AddCommandBuffer() { ... var camera = Camera.current; ... var cb = new CommandBuffer(); var pass = CameraEvent.BeforeGBuffer; cb.name = "ScreenSpaceBooleanRenderer"; camera.AddCommandBuffer(pass, cb); ... } void UpdateCommandBuffer() { ... var cb = cameras_[Camera.current].commandBuffer; cb.Clear(); IssueDrawBackDepth(cb); IssueComposite(cb); } // Subtractee の裏面を保存したバッファを作成 void IssueDrawBackDepth(CommandBuffer cb) { var id = Shader.PropertyToID("SubtracteeBackDepth"); // Depth を書き込む RenderTexture を用意して描画対象とする // 描画しない領域は一番手前(Near Clip)になるように Depth を 0f にする cb.GetTemporaryRT(id, -1, -1, 24, FilterMode.Point, RenderTextureFormat.Depth); cb.SetRenderTarget(id); cb.ClearRenderTarget(true, true, Color.black, 0f); // 全ての Subtractee の裏面を描画 foreach (var subtractee in Subtractee.GetAll()) { subtractee.IssueDrawBack(cb); } // 次の段で使えるように名前をつけて保存 cb.SetGlobalTexture("_SubtracteeBackDepth", id); } // 前述のように Subtractee を Subtractor で削る処理をする void IssueComposite(CommandBuffer cb) { var id = Shader.PropertyToID("SubtractionDepth"); // 描画領域を作成 // 今度は通常通り描画しない領域は 1f で一番奥(Far Clip)にする cb.GetTemporaryRT(id, -1, -1, 24, FilterMode.Point, RenderTextureFormat.Depth); cb.SetRenderTarget(id); cb.ClearRenderTarget(true, true, Color.black, 1f); // まずは通常通り Subtractee を描画 foreach (var subtractee in Subtractee.GetAll()) { subtractee.IssueDrawFront(cb); } // 次にステンシルを使いながら Subtractor で Subtractee を削る foreach (var subtractor in Subtractor.GetAll()) { subtractor.IssueDrawMask(cb); } // 描画した RenderTexture を保存 cb.SetGlobalTexture("_SubtractionDepth", id); // 通常のカメラのデプスバッファに反映 cb.SetRenderTarget(BuiltinRenderTextureType.CameraTarget); cb.DrawMesh(quad, Matrix4x4.identity, compositeMaterial); } }
先ほど説明した流れは、まずはメインのカメラのデプスバッファではなく CommandBuffer.GetTemporaryRT()
を利用して用意した RenderTexture
をレンダーターゲットとしてこちらに書き込んでいます。ドキュメントにも書いてあるとおり、Deferred レンダリングではライティングまではステンシルは制限されるためです。そこで i-saint さんの記事で紹介されていたように、別のバッファを用意して、そちらでステンシルを使用してレンダリングを行い、その画をメインのカメラのレンダーターゲットへコピーしている、という形をとっています。
(が、レンダーターゲットを BuiltinRenderTextureType.CameraTarget
にして上記処理をしてみたところ、Game ビューではうまくレンダリングされており、シーンビューではスカイボックスが Mask の 3 Pass 目の色になってしまうことを除いてうまくレンダリングされていました...、なぜでしょう)。
Subtractee
インスタンスをかき集めるのと、指定された流れでドローコールを発行するように関数を出しておきます。マテリアルには後述のシェーダを適用したものをインスペクタからセットしておきます。
using UnityEngine; using UnityEngine.Rendering; using System.Collections.Generic; namespace ScreenSpaceBoolean { [ExecuteInEditMode] public class Subtractee : MonoBehaviour { [SerializeField] Material depthMaterial; [SerializeField] int frontDepthPass = 0; [SerializeField] int backDepthPass = 1; static public HashSet<Subtractee> instances = new HashSet<Subtractee>(); void OnEnable() { instances.Add(this); } void OnDisable() { instances.Remove(this); } static public HashSet<Subtractee> GetAll() { return instances; } public void IssueDrawFront(CommandBuffer cb) { if (depthMaterial) { cb.DrawRenderer(GetComponent<Renderer>(), depthMaterial, 0, frontDepthPass); } } public void IssueDrawBack(CommandBuffer cb) { if (depthMaterial) { cb.DrawRenderer(GetComponent<Renderer>(), depthMaterial, 0, backDepthPass); } } } }
Subtractor
大体 Subtractee
と同じなので一部省略しています。
... [ExecuteInEditMode] [RequireComponent(typeof(Renderer))] public class Subtractor : MonoBehaviour { [SerializeField] Material maskMaterial; [SerializeField] int stencilMaskPass = 0; [SerializeField] int depthWithMaskPass = 1; [SerializeField] int clearDepthPass = 2; [SerializeField] int clearStencilPass = 3; ... public void IssueDrawMask(CommandBuffer cb) { if (maskMaterial) { var renderer = GetComponent<Renderer>(); cb.DrawRenderer(renderer, maskMaterial, 0, stencilMaskPass); cb.DrawRenderer(renderer, maskMaterial, 0, depthWithMaskPass); cb.DrawRenderer(renderer, maskMaterial, 0, clearDepthPass); cb.DrawRenderer(renderer, maskMaterial, 0, clearStencilPass); } } } }
デプス生成用シェーダ
次に CommandBuffer
の処理で使用しているシェーダを見ていきましょう。
Subtractee 用の前面および背面描画用シェーダ
次のように Cull Back
および Cull Front
する 2 つのパスを持つシェーダを用意しています。やっていることはシンプルで、デプスを書き出しているだけです。
Shader "ScreenSpaceBoolean/FrontBack" { SubShader { CGINCLUDE #include "UnityCG.cginc" float ComputeDepth(float4 spos) { #if defined(UNITY_UV_STARTS_AT_TOP) return (spos.z / spos.w); #else return (spos.z / spos.w) * 0.5 + 0.5; #endif } struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; float4 spos : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.vertex = mul(UNITY_MATRIX_MVP, v.vertex); o.spos = ComputeScreenPos(o.vertex); return o; } float4 frag(v2f i) : SV_Target { return ComputeDepth(i.spos); } ENDCG Pass { Cull Back ZTest Less ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG } Pass { Cull Front ZTest Greater ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG } } }
Subtractor 用のシェーダ
こちらは前述のように、前面の領域のステンシルのセット、背面でえぐるためのデプスの上書き、貫通判定、ステンシル削除の 4 パスのシェーダになります。
Shader "ScreenSpaceBoolean/Mask" { SubShader { CGINCLUDE #include "UnityCG.cginc" float ComputeDepth(float4 spos) { #if defined(UNITY_UV_STARTS_AT_TOP) return (spos.z / spos.w); #else return (spos.z / spos.w) * 0.5 + 0.5; #endif } struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; float4 spos : TEXCOORD0; }; struct gbuffer_out { half4 color : SV_Target; float depth : SV_Depth; }; sampler2D _SubtracteeBackDepth; v2f vert(appdata v) { v2f o; o.vertex = mul(UNITY_MATRIX_MVP, v.vertex); o.spos = ComputeScreenPos(o.vertex); return o; } float4 frag(v2f i) : SV_Target { return ComputeDepth(i.spos); } gbuffer_out frag_depth(v2f i) { float2 uv = i.spos.xy / i.spos.w; float subtracteeBackDepth = tex2D(_SubtracteeBackDepth, uv); float subtractorBackDepth = ComputeDepth(i.spos); if (subtractorBackDepth <= subtracteeBackDepth) discard; gbuffer_out o; o.color = o.depth = 1.0; return o; } ENDCG Pass { Stencil { Ref 1 Comp Always Pass Replace } Cull Back ZTest Less ZWrite Off ColorMask 0 CGPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag ENDCG } Pass { Stencil { Ref 1 Comp Equal } Cull Front ZTest Greater ZWrite On CGPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag ENDCG } Pass { Stencil { Ref 1 Comp Equal } Cull Front ZTest Greater ZWrite On CGPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag_depth ENDCG } Pass { Stencil { Ref 0 Comp Always Pass Replace } Cull Back ZTest Always ZWrite Off ColorMask 0 CGPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag ENDCG } } }
i-saint さんのブログで解説されていましたが、コードを見ると 2 パス目のえぐる処理と 3 パス目の貫通判定が冗長なように思えます。次のようにして 1 つのパスにまとめてしまった方が効率的なように思えます。
gbuffer_out frag_depth(v2f i) { float2 uv = i.spos.xy / i.spos.w; float subtracteeBackDepth = tex2D(_SubtracteeBackDepth, uv); float subtractorBackDepth = ComputeDepth(i.spos); float depth; if (subtractorBackDepth <= subtracteeBackDepth) { depth = subtractorBackDepth; } else { depth = 1.0; } gbuffer_out o; o.color = o.depth = depth; return o; }
しかしながらこの処理を経由すると SV_Depth
を経由してデプスが出力されます。この処理を経由しない場合は frag()
で SV_Target
を経由してデプスが出力され、flag_depth()
で discard
されてそのままになります。このように SV_Depth
を経由してしまうと、後述の G-Buffer に情報をのせるシェーダで ZDepth Equal
するときに一致しないパターンが増えてしまい、以下のように 1 pixel の穴がポチポチ空いてしまうようです(Game ビューでは少し見づらいですが...、Scene ビューでは顕著です)。
そのため、2 つのパスを使って処理しているようです。
コンポジット
デプスバッファに書き出す用のシェーダです。画面を覆うメッシュを作成(SubtractionRenderer
内で生成)して、そのメッシュに上記で書きだしたデプスの RenderTexture
をカメラのデプスバッファにコピーします。
Shader "ScreenSpaceBoolean/CompositeSubtraction" { SubShader { CGINCLUDE sampler2D _SubtractionDepth; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; float4 spos : TEXCOORD0; }; struct gbuffer_out { half4 color : SV_Target; float depth : SV_Depth; }; v2f vert(appdata v) { v2f o; o.vertex = o.spos = v.vertex; o.spos.y *= _ProjectionParams.x; return o; } gbuffer_out frag(v2f i) { float2 uv = i.spos.xy * 0.5 + 0.5; gbuffer_out o; o.color = o.depth = tex2D(_SubtractionDepth, uv).x; if (o.depth == 1.0) discard; return o; } ENDCG Pass { Cull Off ZTest LEqual ZWrite On ColorMask 0 CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG } } }
描画用シェーダ
描画用のシェーダは ZWrite Off
でデプスを書き込まず、ZTest Equal
で先ほど CommandBuffer
の処理内で生成したデプスと同じになる場所にシェーディングに必要な情報を書き出すものになります。標準の Surface シェーダ(Project > Create > Shader > Standard Surface Shader)を少しだけいじった形になり、他のオブジェクトに先駆けて描画されるよう Tags
の Queue
を小さい値にしておきます。まずは Subtractee
用のシェーダを見てみます。
Shader "Custom/Subtractee(Standard)" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry-100" "DisableBatching"="True" } Cull Back ZWrite Off ZTest Equal CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 color = tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo = color.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = color.a; } ENDCG } FallBack "Diffuse" }
i-saint さんのコードのように、Standard シェーダを改造しても良いと思います。
次に Subtractor
側のシェーダを見てみます。基本的には同じなのですが、Cull Front
して裏面を描画するため、法線が逆になってしまうので反転させる処理が含まれています。
Shader "Custom/Subtractor(Standard)" { ... SubShader { ... Cull Front ZWrite Off ZTest Equal CGPROGRAM #pragma surface surf Standard fullforwardshadows vertex:vert ... void vert (inout appdata_full v) { v.normal *= -1; } ... } ENDCG } ... }
描画の流れの確認
Frame Debugger をキャプチャしたものが以下になります。これまでの説明の流れが見て取れると思います。
おさらいすると、
- Near Clip で全面をクリアした
RenderTexture
を用意 - Subtractee の背面を描画
- Far Clip で全面をクリアした
RenderTexture
を用意 - Subtractor の前面をステンシルでマーク
- 同背面を
ZTest Greater
でえぐるように描画 - 2 と比較して貫通処理
- ステンシルをクリア
- デプスバッファへコピー
- Subtractor を
ZTest Equal
になる領域に描画 - Subtractee を
ZTest Equal
になる領域に描画 - 以下通常のレンダリング
少し手順も多く、ドローコールも通常の描画より増えてしまいますが見た目はとてもおもしろいです。
問題点
しかしながらこの手法も万能ではなく問題点があります。
立体交差
i-saint さんの別のエントリでも言及されていましたが、立体交差がある場合は破綻します。
これは Subtractee の前面のデプスを描いた時点で、後ろにある Subtractee の前面のデプスの情報が失われるからです。
これを改善するには更に計算量を増やさないとなりません。しかしながら Subtractee 以外のオブジェクトの影響は受けないので、割り切りでも良いシーンの方が多いと考えられます。
複数の Subtractee の干渉
複数の Subtractor で交差させる場合、描画順によって削れたり削れなかったりします。
これはマスク処理をするときの 1 Pass 目のステンシルの領域が削られた後と前で異なるからです。
先に削られてしまうとこのように内部の差演算が行われないまま処理が終了してしまいます。最も簡単な解決方法としては IssueDrawMask()
している foreach
文を 2 回以上呼べば解消されます(公開しているサンプルではこの手法を使っています)が、かなり無駄な処理が増えるのでユースケースによっては割り切りで良いと思われます。
影が元の形状のまま
i-saint さんの記事でも言及されていましたが、シャドウマップには影響を与えないので、影はそのままになってしまいます。シャドウマップ用のパスは 1 つしかないので対策は思いつきません。。
おわりに
ジオメトリを直接変形させるデカールなど他にも色々と使えそうです。ステンシルと G-Buffer の改変の良い勉強になりました。