凹みTips

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

Unity で Command Buffer を使って Raymarching をしてみた

はじめに

id:i-saint さんのこちらの記事rendering fractals in Unity5 - primitive: blog)に触発されて勉強中です。Raymarching はポリゴンベースでなく、距離関数(distance function)と呼ばれる数式を元にオブジェクトをレンダリングする方法で、工夫次第でスゴイ画が作れるテクニックです。

Unity では先の記事で解説したように Command Buffer を使うことによって Deferred Rendering で使う G-Buffer を直接操作することが可能で、G-Buffer へ raymarching の結果を書き出すことによって前後関係も表現でき、Unity のライティングの機能もそのまま利用することが出来ます。

本エントリでは、その導入として最小限のコードをご紹介したいと思います。Raymaching については wgld.org が大変詳しいので、詳細は割愛します。

下回りを整えれば 5 行くらいで以下のような図形が出ます。

f:id:hecomi:20160316010427g:plain

ダウンロード

サンプルです。

まずは球を出してみる

描いてみる画

まずは以下のように球を描いてみます。

f:id:hecomi:20160317020343p:plain

キューブは普通のポリゴンベースのオブジェクトで、球が Raymarching によって描かれたものです。オブジェクト同士が交差するだけでなく、影も落ちますし、ライティングも行われ、SSAO のような Image Effect も効きます。Deferred Rendering では、G-Buffer(Geometry Buffer)と呼ばれるテクスチャにレンダリングに必要な情報(拡散色、深度、法線など)をオブジェクト全てに対してまとめて書き込んでおき、その情報を使ってまとめてライティングを行うことで、動的なライトを低負荷で大量に配置できるといったメリットがあります。

この G-Buffer に直接シェーダ内で計算した図形の情報を書き込むことで、ポリゴンベースのオブジェクトと同時に描画しても問題のない画が作れる仕組みです。Unity ではデフォルトで Forward Rendering となっているので、Player Settings から Rendering PathForward から Deferred に変更する必要があります。

Command Buffer の作成

Command Buffer の解説は以前の記事をご参照下さい。CameraEvent.BeforeGBuffer タイミングでカメラ全面を覆うような板ポリを描くようにし、この板ポリを深度や法線、拡散色などを直接書き込むシェーダを適用したマテリアルで描画します。

RaymarchingRenderer.cs

using UnityEngine;
using UnityEngine.Rendering;
using System.Collections.Generic;

[ExecuteInEditMode]
public class RaymarchingRenderer : MonoBehaviour
{
    Dictionary<Camera, CommandBuffer> cameras_ = new Dictionary<Camera, CommandBuffer>();
    Mesh quad_;

    [SerializeField] Material material = null;
    [SerializeField] CameraEvent pass = CameraEvent.BeforeGBuffer;

    Mesh GenerateQuad()
    {
        var mesh = new Mesh();
        mesh.vertices = new Vector3[4] {
            new Vector3( 1.0f , 1.0f,  0.0f),
            new Vector3(-1.0f , 1.0f,  0.0f),
            new Vector3(-1.0f ,-1.0f,  0.0f),
            new Vector3( 1.0f ,-1.0f,  0.0f),
        };
        mesh.triangles = new int[6] { 0, 1, 2, 2, 3, 0 };
        return mesh;
    }

    void CleanUp()
    {
        foreach (var pair in cameras_) {
            var camera = pair.Key;
            var buffer = pair.Value;
            if (camera) {
                camera.RemoveCommandBuffer(pass, buffer);
            }
        }
        cameras_.Clear();
    }

    void OnEnable()
    {
        CleanUp();
    }

    void OnDisable()
    {
        CleanUp();
    }

    void OnWillRenderObject()
    {
        UpdateCommandBuffer();
    }

    void UpdateCommandBuffer()
    {
        var act = gameObject.activeInHierarchy && enabled;
        if (!act) {
            OnDisable();
            return;
        }

        var camera = Camera.current;
        if (!camera) return;

        if (cameras_.ContainsKey(camera)) return; 

        if (!quad_) quad_ = GenerateQuad();

        var buffer = new CommandBuffer();
        buffer.name = "Raymarching";
        buffer.DrawMesh(quad_, Matrix4x4.identity, material, 0, 0);
        camera.AddCommandBuffer(pass, buffer);
        cameras_.Add(camera, buffer);
    }
}

これを OnWillRenderObject() が呼ばれるように、地面のように常に見えるオブジェクトなどに仕掛けておきます。メインのカメラだけでなくすべてのカメラに対して登録することで、Scene 上でも結果を見ることが出来ます。

シェーダ

まずはキューブを出すまでの完成コードを書いてみます。ちょっと長いですが後で要点のみ解説します。

Raymarching.shader

Shader "Raymarching/Test"
{

SubShader
{

Tags { "RenderType" = "Opaque" "DisableBatching" = "True" "Queue" = "Geometry+10" }
Cull Off

CGINCLUDE

#include "UnityCG.cginc"

float sphere(float3 pos, float radius)
{
    return length(pos) - radius;
}

float DistanceFunc(float3 pos)
{
    return sphere(pos, 1.f);
}

float3 GetCameraPosition()    { return _WorldSpaceCameraPos;      }
float3 GetCameraForward()     { return -UNITY_MATRIX_V[2].xyz;    }
float3 GetCameraUp()          { return UNITY_MATRIX_V[1].xyz;     }
float3 GetCameraRight()       { return UNITY_MATRIX_V[0].xyz;     }
float  GetCameraFocalLength() { return abs(UNITY_MATRIX_P[1][1]); }
float  GetCameraMaxDistance() { return _ProjectionParams.z - _ProjectionParams.y; }

float GetDepth(float3 pos)
{
    float4 vpPos = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
#if defined(SHADER_TARGET_GLSL)
    return (vpPos.z / vpPos.w) * 0.5 + 0.5;
#else 
    return vpPos.z / vpPos.w;
#endif 
}

float3 GetNormal(float3 pos)
{
    const float d = 0.001;
    return 0.5 + 0.5 * normalize(float3(
        DistanceFunc(pos + float3(  d, 0.0, 0.0)) - DistanceFunc(pos + float3( -d, 0.0, 0.0)),
        DistanceFunc(pos + float3(0.0,   d, 0.0)) - DistanceFunc(pos + float3(0.0,  -d, 0.0)),
        DistanceFunc(pos + float3(0.0, 0.0,   d)) - DistanceFunc(pos + float3(0.0, 0.0,  -d))));
}

ENDCG

Pass
{
    Tags { "LightMode" = "Deferred" }

    Stencil 
    {
        Comp Always
        Pass Replace
        Ref 128
    }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 3.0
    #pragma multi_compile ___ UNITY_HDR_ON

    #include "UnityCG.cginc"

    struct VertInput
    {
        float4 vertex : POSITION;
    };

    struct VertOutput
    {
        float4 vertex    : SV_POSITION;
        float4 screenPos : TEXCOORD0;
    };

    struct GBufferOut
    {
        half4 diffuse  : SV_Target0; // rgb: diffuse,  a: occlusion
        half4 specular : SV_Target1; // rgb: specular, a: smoothness
        half4 normal   : SV_Target2; // rgb: normal,   a: unused
        half4 emission : SV_Target3; // rgb: emission, a: unused
        float depth    : SV_Depth;
    };

    VertOutput vert(VertInput v)
    {
        VertOutput o;
        o.vertex = v.vertex;
        o.screenPos = o.vertex;
        return o;
    }
    
    GBufferOut frag(VertOutput i)
    {
        float4 screenPos = i.screenPos;
#if UNITY_UV_STARTS_AT_TOP
        screenPos.y *= -1.0;
#endif
        screenPos.x *= _ScreenParams.x / _ScreenParams.y;

        float3 camPos      = GetCameraPosition();
        float3 camDir      = GetCameraForward();
        float3 camUp       = GetCameraUp();
        float3 camSide     = GetCameraRight();
        float  focalLen    = GetCameraFocalLength();
        float  maxDistance = GetCameraMaxDistance();

        float3 rayDir = normalize(
            camSide * screenPos.x + 
            camUp   * screenPos.y + 
            camDir  * focalLen);

        float distance = 0.0;
        float len = 0.0;
        float3 pos = camPos + _ProjectionParams.y * rayDir;
        for (int i = 0; i < 50; ++i) {
            distance = DistanceFunc(pos);
            len += distance;
            pos += rayDir * distance;
            if (distance < 0.001 || len > maxDistance) break;
        }

        if (distance > 0.001) discard;

        float depth = GetDepth(pos);
        float3 normal = GetNormal(pos);

        GBufferOut o;
        o.diffuse  = float4(1.0, 1.0, 1.0, 1.0);
        o.specular = float4(0.5, 0.5, 0.5, 1.0);
        o.emission = float4(0.0, 0.0, 0.0, 0.0);
        o.depth    = depth;
        o.normal   = float4(normal, 1.0);

#ifndef UNITY_HDR_ON
        o.emission = exp2(-o.emission);
#endif

        return o;
    }

    ENDCG
}

}

Fallback Off
}

ちょっとコードが長いので整理します。まずはレイの定義を見てみます。カメラに関する情報は id:i-saint さんのブログで解説されているものを利用しています。

float4 screenPos = i.screenPos;
#if UNITY_UV_STARTS_AT_TOP
screenPos.y *= -1.0;
#endif
screenPos.x *= _ScreenParams.x / _ScreenParams.y;

float3 camPos      = GetCameraPosition();
float3 camDir      = GetCameraForward();
float3 camUp       = GetCameraUp();
float3 camSide     = GetCameraRight();
float  focalLen    = GetCameraFocalLength();
float  maxDistance = GetCameraMaxDistance();

float3 rayDir = normalize(
    camSide * screenPos.x + 
    camUp   * screenPos.y + 
    camDir  * focalLen);

カメラの方向に対してレイを伸ばしています。Y 方向を 1 とした時、X 方向は _ScreenParams を使うことでアスペクト比が分かるため求まり、Z 方向は Focal Length を求めることでわかります。

このレイを利用して Raymarching のループを回します。

float distance = 0.0;
float len = 0.0;
float3 pos = camPos + _ProjectionParams.y * rayDir;
for (int i = 0; i < 50; ++i) {
    distance = DistanceFunc(pos);
    len += distance;
    pos += rayDir * distance;
    if (distance < 0.001 || len > maxDistance) break;
}

if (distance > 0.001) discard;

最初は Near Clip(_ProjectionParams.y)からスタートし、スフィアトレーシングで徐々に近づけていく形です。距離関数として与えているのは以下のように球を描くものです。

float sphere(float3 pos, float radius)
{
    return length(pos) - radius;
}

float DistanceFunc(float3 pos)
{
    return sphere(pos, 1.f);
}

こうして得られた distance を使って GBufferOut へ格納します。

struct GBufferOut
{
    half4 diffuse  : SV_Target0; // rgb: diffuse,  a: occlusion
    half4 specular : SV_Target1; // rgb: specular, a: smoothness
    half4 normal   : SV_Target2; // rgb: normal,   a: unused
    half4 emission : SV_Target3; // rgb: emission, a: unused
    float depth    : SV_Depth;
};

...

float depth = GetDepth(pos);
float3 normal = GetNormal(pos);

GBufferOut o;
o.diffuse  = float4(1.0, 1.0, 1.0, 1.0);
o.specular = float4(0.5, 0.5, 0.5, 1.0);
o.emission = float4(0.0, 0.0, 0.0, 0.0);
o.depth    = depth;
o.normal   = float4(normal, 1.0);

GetDepth() は Raymarching の結果得られたワールド空間での位置に View-Projection 行列を掛け、カメラから見た座標へと変換します。GetNormal() はシンプルな偏微分を行っています。

float GetDepth(float3 pos)
{
    float4 vpPos = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
#if defined(SHADER_TARGET_GLSL)
    return (vpPos.z / vpPos.w) * 0.5 + 0.5;
#else 
    return vpPos.z / vpPos.w;
#endif 
}

float3 GetNormal(float3 pos)
{
    const float d = 0.001;
    return 0.5 + 0.5 * normalize(float3(
        DistanceFunc(pos + float3(  d, 0.0, 0.0)) - DistanceFunc(pos + float3( -d, 0.0, 0.0)),
        DistanceFunc(pos + float3(0.0,   d, 0.0)) - DistanceFunc(pos + float3(0.0,  -d, 0.0)),
        DistanceFunc(pos + float3(0.0, 0.0,   d)) - DistanceFunc(pos + float3(0.0, 0.0,  -d))));
}

ワールド座標をプロジェクション座標へ変換してデプスを 0 ~ 1 の値へと変換します。w の詳細は以下の解説が大変詳しいです。

id:i-saint さんの記事で解説されていたように、OpenGL 系と DirectX 系でデプスの値に差があるため、SHADER_TARGET_GLSL が定義済みかどうかで処理を分けます(手元の Mac で検証したところ、この #if 文の中に入ってくれず...、どなたか原因がおわかりであればご教授下さい)。

Deferred Rendering のベースとライティングのパスでは Stencil バッファはライティング用途に使われます。コード中にあるように 8 bit 目を 1 で立てて置かないとライティングが行われませんので注意が必要です。

Stencil 
{
    Comp Always
    Pass Replace
    Ref 128
}

これでスクショのような画が出力されます。

f:id:hecomi:20160315011642g:plain

色々試してみる

wgld.org距離関数の一覧を見ながら色々試してみます。

繰り返し

float3 mod(float3 a, float3 b)
{
    return frac(abs(a / b)) * abs(b);
}

float3 repeat(float3 pos, float3 span)
{
    return mod(pos, span) - span * 0.5;
}

float DistanceFunc(float3 pos)
{
    return roundBox(repeat(pos, 2.f), 1.f, 0.2f);
}

f:id:hecomi:20160315020849p:plain

合成

float torus(float3 pos, float2 radius)
{
    float2 r = float2(length(pos.xy) - radius.x, pos.z);
    return length(r) - radius.y;
}

float floor(float3 pos)
{
    return dot(pos, float3(0.0, 1.0, 0.0)) + 1.0;
}

float smoothMin(float d1, float d2, float k)
{
    float h = exp(-k * d1) + exp(-k * d2);
    return -log(h) / k;
}

float DistanceFunc(float3 pos)
{
    return smoothMin(
        floor(pos), 
        torus(
            (pos - float3(0, 1, 0)), 
            float2(0.75, 0.25)), 
            1.0);
}

f:id:hecomi:20160315233642p:plain

ひねり

float torus(float3 pos, float2 radius)
{
    float2 r = float2(length(pos.xy) - radius.x, pos.z);
    return length(r) - radius.y;
}

float3 twistY(float3 p, float power)
{
    float s = sin(power * p.y);
    float c = cos(power * p.y);
    float3x3 m = float3x3(
          c, 0.0,  -s,
        0.0, 1.0, 0.0,
          s, 0.0,   c
    );
    return mul(m, p);
}

float DistanceFunc(float3 pos)
{
    return torus(twistY(pos, 2.0), float2(2.0, 0.6));
}

f:id:hecomi:20160316003451p:plain

テクスチャ投影

float u = (1.0 - floor(fmod(pos.x, 2.0))) * 10;
float v = (1.0 - floor(fmod(pos.z, 2.0))) * 10;

GBufferOut o;
...
o.emission = tex2D(_MainTex, float2(u, v)) * 3;
...

f:id:hecomi:20160316003246p:plain

アニメーション

float DistanceFunc(float3 pos)
{
    float r = abs(sin(2 * PI * _Time.y / 2.0));
    float d1 = roundBox(repeat(pos, float3(6, 6, 6)), 1, r);
    float d2 = sphere(pos, 3.0);
    float d3 = floor(pos - float3(0, -3, 0));
    return smoothMin(smoothMin(d1, d2, 1.0), d3, 1.0);
}

f:id:hecomi:20160316010427g:plain

フラクタル

ちょっと重いですが。

float RecursiveTetrahedron(float3 p)
{
    p = repeat(p / 2, 3.0);

    const float3 a1 = float3( 1.0,  1.0,  1.0);
    const float3 a2 = float3(-1.0, -1.0,  1.0);
    const float3 a3 = float3( 1.0, -1.0, -1.0);
    const float3 a4 = float3(-1.0,  1.0, -1.0);

    const float scale = 2.0;
    float d;
    for (int n = 0; n < 20; ++n) {
        float3 c = a1; 
        float minDist = length(p - a1);
        d = length(p - a2); if (d < minDist) { c = a2; minDist = d; }
        d = length(p - a3); if (d < minDist) { c = a3; minDist = d; }
        d = length(p - a4); if (d < minDist) { c = a4; minDist = d; }
        p = scale * p - c * (scale - 1.0);
    }
 
    return length(p) * pow(scale, float(-n));
}

f:id:hecomi:20160317014806p:plain

おわりに

ループを増やすと結構な負荷になってしまいますが、気をつけていればかなり凝った表現でも 60 fps キープできそうな感じがします。ようやく基礎のところが出来たので、ここから色々と追加していくエフェクトの勉強をしたり、当たり判定を入れたりと試していきたいです。