凹みTips

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

Unity でカスタムライトを使ったスクリーンスペースシャドウを試してみた

はじめに

前回に引き続き Raymarching 関連記事です。

G-Buffer に Raymarching して描画したオブジェクトではポリゴンのオブジェクトから影を受けることは出来るのですが、Raymarching したオブジェクトから自身とポリゴンのオブジェクトへの影を作ることが出来なかったため、以下の解説にありましたスクリーンスペースシャドウを試してみました。

なんかゴテゴテした画になってしまいましたが...、Raymarching したオブジェクトでもポリゴンベースのオブジェクトでも光が遮られているのがお分かりいただけると思います。

f:id:hecomi:20160325012551p:plain

Raymarching 時に行った場合

コード

Screen Space Shadow に入る前に wgld.org に載っている以下のソフトシャドウを試してみます。

詳しい仕組みは wgld.org を見ていただくとして、ざっくり言うと、Raymarching の終端からライト方向へ再度レイを飛ばし、途中でぶつかったら影とする、というものです。

float GetDirectionalLightShadow(
    float3 pos, 
    float3 lightDir, 
    float minDist, 
    int loop, 
    int hardFactor)
{
    float distance = 0.0;
    float length = 0.0;
    float shadow = 0.5;
    float ratio = 1.0;
    lightDir *= -1;

    for (int i = 0; i < loop; ++i) {
        distance = DistanceFunc(pos);
        if (distance < minDist) {
            return shadow;
        }
        length += distance;
        pos += lightDir * distance;
        ratio = min(ratio, hardFactor * distance / length);
    }

    return 1.0 - shadow + ratio * shadow;
}


GBufferOut frag(VertOutput i)
{
    float3 rayDir = GetRayDirection(i.screenPos);

    float3 camPos = GetCameraPosition();
    int loop = 100;
    float minDist = 0.01;
    float maxDist = GetCameraMaxDistance();
    float distance, length;
    float3 pos = camPos + _ProjectionParams.y * rayDir;

    Raymarching(pos, rayDir, loop, minDist, maxDist, distance, length);
    if (distance > 0.01) discard;

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

    float shadow = GetDirectionalLightShadow(
        pos + normal * minDist * 2, 
        float3(0.5, -1.0, 0.5), // lightdir
        minDist,
        16, 
        10);

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

    GBufferOut o;
    o.diffuse  = float4(1.0, 1.0, 1.0, 1.0) * shadow;
    o.specular = float4(0.5, 0.5, 0.5, 1.0);
    o.emission = tex2D(_MainTex, float2(u, v)) * 3;
    o.depth    = depth;
    o.normal   = float4(normal, 1.0);

    return o;
}

結果

f:id:hecomi:20160323005339p:plain

ただし、これだとポリゴンで描いたオブジェクトにその影が影響しませんし、通常のポイントライトでポリゴンベースのオブジェクトは影が落ちるのに、Raymarching で描いたオブジェクトには影が落ちない、といった形になります。

f:id:hecomi:20160323005602p:plain

Screen Space Shadow

そこで、id:i-saint さんの下記記事で紹介されているものを参考に、影の落ちるカスタムライトを自作します。仕組みとしては、以前解説した公式の Command Buffer のサンプルにあるカスタムライトの光量に、スクリーンスペースで行った遮蔽判定を元に遮蔽度合いを乗算する、というものです。

スクリプト

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

[ExecuteInEditMode]
public class PointLightWithScreenSpaceShadow : MonoBehaviour 
{
    Dictionary<Camera, CommandBuffer> cameras_ = new Dictionary<Camera, CommandBuffer>();
    private const CameraEvent pass = CameraEvent.AfterLighting;

    [SerializeField] Mesh sphere;
    [SerializeField] Color color = Color.white;
    [SerializeField] float intensity = 1.0f;
    [SerializeField] float range = 10.0f;
    [SerializeField] Shader shader = null;

    Material material_ = null;

    void OnEnable()
    {
        CleanUp();
    }

    void OnDisable()
    {
        CleanUp();
    }

    void OnWillRenderObject()
    {
        UpdateCommandBuffer();
    }

    void OnRenderObject()
    {
        CleanUp();
    }

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

    void UpdateCommandBuffer()
    {
        if (!gameObject.activeInHierarchy || !enabled) {
            OnDisable();
            return;
        }

        if (!shader) {
            Debug.LogError("shader must be set.");
            return;
        }

        if (!material_) {
            material_ = new Material(shader);
        }

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

        var buffer = new CommandBuffer();
        buffer.name = "Point Light With Screen Space Shadow";

        var propColor = Shader.PropertyToID("_Color");
        var propRadius = Shader.PropertyToID("_InvSqRadius");
        buffer.SetGlobalVector(propColor, color);
        buffer.SetGlobalFloat(propRadius, 1f / (range * range));

        var trs = Matrix4x4.TRS(transform.position, transform.rotation, new Vector3(range, range, range) * 2);
        buffer.DrawMesh(sphere, trs, material_, 0, 0);

        camera.AddCommandBuffer(pass, buffer);
        cameras_.Add(camera, buffer);
    }

    Color GetLinearColor()
    {
        return new Color(
            Mathf.GammaToLinearSpace(color.r * intensity),
            Mathf.GammaToLinearSpace(color.g * intensity),
            Mathf.GammaToLinearSpace(color.b * intensity),
            1.0f
        );
    }

    void OnDrawGizmos()
    {
        Gizmos.DrawIcon(transform.position, "PointLight Gizmo", true);
    }

    public void OnDrawGizmosSelected()
    {
        Gizmos.color = color;
        Gizmos.matrix = Matrix4x4.identity;
        Gizmos.DrawWireSphere(transform.position, range);
    }
}

AfterLighting のタイミングでポイントライトの範囲である球のメッシュを描き、このシェーダの中で G-Buffer を参照してライティング処理を行います。ちょっとどうやるのが正しいのかわからなかったのですが、位置を変更したときに追従するよう OnRenderObject() のタイミングで一度 Command Buffer を破棄して再度 OnWillRenderObject() のタイミングで生成しています。

シェーダ

ライティングの処理は公式サンプルと同じものです。コメントがないところは基本的にコピペです。

Shader "Hidden/PointLightWithScreenSpaceShadow"
{
SubShader 
{
Tags { "Queue" = "Transparent-1" }

CGINCLUDE

#include "UnityCG.cginc"
#include "UnityPBSLighting.cginc"
#include "UnityDeferredLibrary.cginc"

half4 _Color;
float _InvSqRadius;

sampler2D _CameraGBufferTexture0;
sampler2D _CameraGBufferTexture1;
sampler2D _CameraGBufferTexture2;

float GetDepth(float2 uv) 
{ 
    return SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); 
}

float ComputeDepth(float4 clippos)
{
#if defined(SHADER_TARGET_GLSL) || defined(SHADER_API_GLES) || defined(SHADER_API_GLES3)
    return (clippos.z / clippos.w) * 0.5 + 0.5;
#else
    return clippos.z / clippos.w;
#endif
}

half3 CalcPointLight(float3 pos, float3 lightPos, float3 eyeVec, half3 normal, float sphereRad)
{
    half3 viewDir = -eyeVec;
    half3 r = reflect(viewDir, normal);

    float3 L = lightPos - pos;
    float3 centerToRay = dot(L, r) * r - L;
    float3 closestPoint = L + centerToRay * saturate(sphereRad / length(centerToRay));
    return normalize(closestPoint);
}

void DeferredCalculateLightParams(
    unity_v2f_deferred i,
    out float3 outWorldPos,
    out float2 outUV,
    out half3 outLightDir,
    out float outAtten,
    out float outFadeDist)
{
    i.ray = i.ray * (_ProjectionParams.z / i.ray.z);
    float2 uv = i.uv.xy / i.uv.w;

    float depth = GetDepth(uv);
    depth = Linear01Depth(depth);
    float4 vpos = float4(i.ray * depth, 1.0);
    float3 wpos = mul(_CameraToWorld, vpos).xyz;

    float3 lightPos = float3(_Object2World[0][3], _Object2World[1][3], _Object2World[2][3]);
    float3 tolight = wpos - lightPos;
    half3 lightDir = -normalize(tolight);

    float att = dot(tolight, tolight) * _InvSqRadius;
    float atten = tex2D(_LightTextureB0, att.rr).UNITY_ATTEN_CHANNEL;

    outWorldPos = wpos;
    outUV = uv;
    outLightDir = lightDir;
    outAtten = atten;
    outFadeDist = 0;
}

unity_v2f_deferred vert(float4 vertex : POSITION)
{
    unity_v2f_deferred o;
    o.pos = mul(UNITY_MATRIX_MVP, vertex);
    o.uv = ComputeScreenPos(o.pos);
    o.ray = mul(UNITY_MATRIX_MV, vertex).xyz * float3(-1,-1,1);
    return o;
}

half4 frag(unity_v2f_deferred i) : SV_Target
{
    // 与えられた構造体からパラメタをアンパック
    float3 wpos;
    float2 uv;
    float atten, fadeDist;
    UnityLight light = (UnityLight)0; // UnityLight 構造体を 0 で初期化
    DeferredCalculateLightParams(i, wpos, uv, light.dir, atten, fadeDist);

    // G-Buffer を取得
    half4 gbuffer0 = tex2D(_CameraGBufferTexture0, uv);
    half4 gbuffer1 = tex2D(_CameraGBufferTexture1, uv);
    half4 gbuffer2 = tex2D(_CameraGBufferTexture2, uv);

    // G-Buffer の情報を展開
    light.color = _Color.rgb * atten;
    half3 baseColor = gbuffer0.rgb;
    half3 specColor = gbuffer1.rgb;
    half3 normalWorld = gbuffer2.rgb * 2 - 1;
    normalWorld = normalize(normalWorld);
    half oneMinusRoughness = gbuffer1.a;
    half oneMinusReflectivity = 1 - SpecularStrength(specColor.rgb);

    // カメラから現在のピクセルへの方向
    float3 eyeVec = normalize(wpos - _WorldSpaceCameraPos);

    // 光源の中心位置
    float3 lightPos = float3(_Object2World[0][3], _Object2World[1][3], _Object2World[2][3]);

    // 法線と高原方向の内積
    light.ndotl = LambertTerm(normalWorld, light.dir);

    // BRDF でカメラ方向に対してどれだけ光が入ってくるかを計算
    UnityIndirect ind;
    UNITY_INITIALIZE_OUTPUT(UnityIndirect, ind);
    ind.diffuse = 0;
    ind.specular = 0;
    half4 res = UNITY_BRDF_PBS(baseColor, specColor, oneMinusReflectivity, oneMinusRoughness, normalWorld, -eyeVec, light, ind);

    // --- ここから公式サンプルへの追加コード ----
    // レイマーチングでオクルージョンを検出する

    // ループ数(小さいと影が荒くなる)
    const int MARCH_LOOP_NUM = 20;
    // 遮蔽度合い
    float occlusion = 0.0;
    // 影の鮮明度(小さいとソフトシャドウ、大きいとハードシャドウ)
    float sharpness = 2.0 / MARCH_LOOP_NUM;
    // ライトから現在の位置(ライトが地面にプロジェクションされている座標)
    float3 diff = wpos - lightPos;
    // マーチングループのステップ
    float3 dPos = diff / MARCH_LOOP_NUM;

    for (int n = 0; n < MARCH_LOOP_NUM; ++n) {
        // 現在のレイの位置
        float3 rayPos = lightPos + (dPos * n);
        // ビュープロジェクション変換後の座標
        float4 vpPos = mul(UNITY_MATRIX_VP, float4(rayPos, 1.0));
        // _ProjectionParams.x は 1.0 か -1.0(ここでは -1.0)
        // プロジェクション行列を掛けた後はこれで正しい向きに修正する
        vpPos.y *= _ProjectionParams.x;
        // 現在のレイの位置のスクリーン UV 座標を求める(0.0 - 1.0)
        float2 uv = (vpPos.xy / vpPos.w * 0.5 + 0.5);
        // 現在のレイの位置の深度を算出
        float rayDepth = ComputeDepth(vpPos);
        // 現在のレイの UV 座標に書き込まれた G-Buffer から深度を算出
        float gbufferDepth = GetDepth(uv);
        // 遮蔽されていた(レイが G-Buffer のデプスより奥側)ら 0、そうでなければ 1
        float occuluded = clamp((rayDepth - gbufferDepth) * 100000, 0.0, 1.0);
        // 遮蔽されていたら遮蔽度を増加させる
        occlusion += sharpness * occuluded;
    }

    // 遮蔽度に応じて光量を減らす
    res *= max(1.0 - occlusion, 0.0);

    // --- ここまで ---

    return res;
}

ENDCG

Pass
{
    Fog { Mode Off }
    ZWrite Off
    ZTest Always
    Blend One One
    Cull Front

    CGPROGRAM
    #pragma target 3.0
    #pragma vertex vert
    #pragma fragment frag
    #pragma exclude_renderers nomrt
    ENDCG
}

}

Fallback Off
}

「ここから公式サンプルへの追加コード」と書かれた範囲内が追加コードで、ここでレイをステップ区切りで撃ちで遮蔽度合いを調べ、それを光量へと反映させています。

結果

f:id:hecomi:20160325005812p:plain

綺麗に光が遮られているのが分かります。sharpness を大きくするとハードシャドウになります。

カメラからのスクリーンスペースで見る関係上、正確な遮蔽関係はわからないため、球で丸影が出来るわけではないです。これはこれで格好いいですが。

f:id:hecomi:20160325011547p:plain

その他の方法

Twitter で以下のように id:i-saint さんに教えていただきました。

オブジェクトスペースの Raymarching であれば通常通り使えるとのことなので次回見てみようと思いましたが、既に id:radwimps-september さんが記事を書かれていました。

おわりに

アルゴリズム自体は簡単にもかかわらず、見た目が大きく変わるのはとても面白いですね。サンシャフトも同じアルゴリズムで書けそうです(というか同じ?)。