凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity でスクリーンスペースのブーリアン演算をやってみた

はじめに

ブーリアン演算とは様々な形状を集合演算(足し合わせる(和)、一方から他方を引く(差)、一致する部分を抜き出す(積)など)によって作成する手法です。

いくつかの実装方法があると思いますが、スクリーンスペースで G-Buffer を加工することでこれを実現する Unity での実装を id:i-saint さんが作られており、実装も公開されていました。

G-Buffer 加工による利点は 3D ソフトなどで見られるメッシュ自体の変形に比べ、多数のオブジェクトを組合せ爆発しない形で扱うことができ、且つ動的な演算を複雑なメッシュに対しても比較的高速に行うことが可能です。本エントリでは、この i-saint さんの実装を参考にしつつ、差演算のみですが勉強のためになるべくシンプルなコードになるよう実装してみたので、その内容を共有します。

デモ

f:id:hecomi:20160910022341g:plain

f:id:hecomi:20160910022352g:plain

ソースコード

環境

公式サンプルおよびその問題点

公式の Stencil のマニュアルにも似たような Forward レンダリングによる例が載っています。

f:id:hecomi:20160905003420p:plain

ここでは Stencil を使った手法で 3 Pass を使ってデカールのような表現をしています。具体的には 1 Pass 目、2 Pass 目で交差面以外の場所をマーキングしておき、3 Pass 目でサーフェスシェーダで交差面(マーキングした以外の場所)に描画を行います。マーキングは Cull Back した面は通常とは逆に ZTest Greater、反対に Cull Front した面は ZTest Less する面を ColorMask 0 及び ZWrite Off して Stencil の情報のみ描きこんでいます。文字で書いても分かりづらいのでキャプチャを見てみましょう(わかりやすいようにステンシルがセットされた領域を赤色にしています)。

f:id:hecomi:20160905204250p:plain

交差する部分を残して塗りつぶされているのがわかると思います。そして 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)...

} 
}

しかしながらこの手法ではジオメトリを変形させるものではないので、貫通表現ができなかったり、視点を変えると破綻したり、複数個組み合わせるとうまく描画されなかったりといった問題が生じます。以下の図は横から見た図です。ジオメトリが変形されていない様子が見て取れると思います。

f:id:hecomi:20160905210319p:plain

この後紹介するスクリーンスペースのブーリアン演算では、直接ジオメトリを変形することで、こういった問題の幾つかが解決されるものになります。

スクリーンスペースブーリアン概要

Deferred レンダリングでは、G-Buffer を生成した後にシェーディングが行われます。そこで G-Buffer 生成前に、ブーリアン演算の対象のオブジェクトだけ抜き出して別途これらのオブジェクトのデプスを描いたバッファを作成し、それをデプスバッファへと書き込んだ後、通常の G-Buffer 生成のタイミングではこれらのオブジェクトのデプスは書き込まずに、シェーディングに必要な情報を対象のデプスの領域に書き込むような処理を行えば、ジオメトリを変形することが可能です。

具体的な手法としては CommandBuffer を利用して G-Buffer の前に処理を行います。CommandBuffer については以下の記事をご参照ください。

差演算を行うために、差し引かれる立体(Subtractee)と差し引く物体(Subtractor)の 2 つのグループを作成します(簡単のために 1 つずつにして見ていきます)。

f:id:hecomi:20160906020621p:plain

言葉で書いても良くわからないと思うので、図にしてみました。一連の流れは以下のようになります。

f:id:hecomi:20160908004423p:plain

わかりやすいように色をつけていますが、実際にはこの段階ではデプスの更新しか行いません。

  1. まず、通常通り Subtractee のデプスを書き込みます。
  2. 次に Subtractor で削られ得る領域をステンシルでマークしておきます(半透明 緑色の領域)。
  3. そしてこのマークした領域に対して裏側(Cull Front)を通常とは逆の ZTest Greater でえぐるように描画します。ステンシルを施していないと球の真ん中も削れてしまうので 2. が必要というわけです。
  4. しかしながらえぐっただけでは Subtractor の裏側のデプスが書き込まれるだけで貫通はしないので、ここで事前に用意しておいた Subtractee の裏側のデプスと比較し、奥側のデプスの領域は貫通するようにデプスを最大値(1.0)にしておきます。
  5. 最後にマーク用に使用したステンシルを削除します。

そしてこれをデプスバッファを書き込んだあとは、他のオブジェクトも含め通常の G-Buffer に書き込む流れとなります。ただし、SubtracteeSubtractorTags"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 ビューでは顕著です)。

f:id:hecomi:20160910162318p:plain

そのため、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)を少しだけいじった形になり、他のオブジェクトに先駆けて描画されるよう TagsQueue を小さい値にしておきます。まずは 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 をキャプチャしたものが以下になります。これまでの説明の流れが見て取れると思います。

f:id:hecomi:20160909021422g:plain

おさらいすると、

  1. Near Clip で全面をクリアした RenderTexture を用意
  2. Subtractee の背面を描画
  3. Far Clip で全面をクリアした RenderTexture を用意
  4. Subtractor の前面をステンシルでマーク
  5. 同背面を ZTest Greater でえぐるように描画
  6. 2 と比較して貫通処理
  7. ステンシルをクリア
  8. デプスバッファへコピー
  9. SubtractorZTest Equal になる領域に描画
  10. SubtracteeZTest Equal になる領域に描画
  11. 以下通常のレンダリング

少し手順も多く、ドローコールも通常の描画より増えてしまいますが見た目はとてもおもしろいです。

問題点

しかしながらこの手法も万能ではなく問題点があります。

立体交差

i-saint さんの別のエントリでも言及されていましたが、立体交差がある場合は破綻します。

f:id:hecomi:20160910005947p:plain

これは Subtractee の前面のデプスを描いた時点で、後ろにある Subtractee の前面のデプスの情報が失われるからです。

f:id:hecomi:20160910010152g:plain

これを改善するには更に計算量を増やさないとなりません。しかしながら Subtractee 以外のオブジェクトの影響は受けないので、割り切りでも良いシーンの方が多いと考えられます。

f:id:hecomi:20160910010552p:plain

複数の Subtractee の干渉

複数の Subtractor で交差させる場合、描画順によって削れたり削れなかったりします。

f:id:hecomi:20160910011004g:plain

これはマスク処理をするときの 1 Pass 目のステンシルの領域が削られた後と前で異なるからです。

f:id:hecomi:20160910011304g:plain

先に削られてしまうとこのように内部の差演算が行われないまま処理が終了してしまいます。最も簡単な解決方法としては IssueDrawMask() している foreach 文を 2 回以上呼べば解消されます(公開しているサンプルではこの手法を使っています)が、かなり無駄な処理が増えるのでユースケースによっては割り切りで良いと思われます。

影が元の形状のまま

i-saint さんの記事でも言及されていましたが、シャドウマップには影響を与えないので、影はそのままになってしまいます。シャドウマップ用のパスは 1 つしかないので対策は思いつきません。。

おわりに

ジオメトリを直接変形させるデカールなど他にも色々と使えそうです。ステンシルと G-Buffer の改変の良い勉強になりました。