🦁

UnityでRaymarchingを用いてVolumetricFogを実装する

に公開

VolumetricFog

VolumetricFogとはVolumeRenderingによって作成されるFogのことを指します。
以下のように霧のようなものを作成することが出来ます。

普段からよく見るShaderはSurfaceRenderingと呼ばれメッシュの表面に色を塗ることを指します。
UnityのProjectViewで右クリックをした際にShaderの項目でStandardSurfaceShaderという名前を目にしたことがあると思いますがそれはこのことを指します。

反対にVolumeRenderingは内部まで描画します。
なので内部の詳細を描くことが出来るので雲や煙といった密度がまばらなものを表現するのに適しています。
どのようにして内部をレンダリングするかというと一般的にはRaymarchingを使用します。

Raymarching

RaymarchingとはカメラからRayを一定距離飛ばし、そのRayの座標の状況によってその空間の状況を定義し、レンダリングする手法になります。

例えばオブジェクトをレンダリングしたいとなれば、距離関数という当たり判定のようなものを使いRayがその距離関数を参照してヒット判定を行った個所に色を塗るという作業を行います。

VolumetricFogに関して言えば、Rayを飛ばしその座標における光の散乱などを計測して色を加算していくことによりFogを表すことが出来ます。

実装

PostEffectによってVolumetricFogを作成しようと思います。
PostEffectのRenderPassについてはこの記事と同じものを使用しているので割愛します。

頂点シェーダー

Blitにより描画されているので頂点シェーダーはBlit.hlslにあるVertをそのまま使用します。

#pragma vertex Vert
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

Rayを飛ばす

次にフラグメントシェーダーです。
Rayのベクトルは画面のDepthをもとに取得したWorld座標から算出します。
そうすることによってRayの最大距離も同時に算出することが出来ます。

// スクリーンの深度値から一番手前の座標を取得
float depth = SampleSceneDepth(input.texcoord);
float3 world_position = ComputeWorldSpacePosition(input.texcoord, depth, UNITY_MATRIX_I_VP);

// レイのベクトルと最大の長さを取得
float3 ray_vector = world_position - _WorldSpaceCameraPos;
float distance = length(ray_vector);
float3 ray_direction = normalize(ray_vector);

Fogを出す

まずはFogを画面全体に表示させます。

half4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, input.texcoord);

float current_distance = 0;
[loop]
for (int i = 0; i < _StepNum; i++)
{
    // 最大距離を超えたらループを抜ける
    current_distance += _StepSize;
    if (current_distance > distance)
    {
        break;
    }
    // 密度の分色を加える
    color += _Density;
}
return color;

_Densityをとても小さい値にすると画面が白っぽくなると思います。
これがもっとも単純なVolumetricFogです。

Fogは空気中の微小な水滴に光が散乱することによって見ることが出来ます。
その微小な水滴の密度を表しているのが_Densityというわけです。

距離に応じて光の透過率を減衰させる

Fogは遠ければ遠いほど多く散乱しているので濃くなります。
遠くの山がかすんでたりするのはそのためです。
今回はランベルトベールの法則を用いて光の減衰を表します。

式は以下のようになります。
I = I₀ × e^(-ε × C × L)
・I₀: 入射光の強度
・ε: モル吸光係数
・C: 濃度
・L: 進んだ距離
引用:https://www.optics-words.com/kogaku_kiso/Lambert-Beers-law.html
となります。

今回は物質のパラメーターなどは設定しないので密度と進んだ距離だけ用いて以下のように表します。
float step_transmittance = exp(-_Density * _StepSize);
これは減衰を表すので1ですべて透過し、0ですべて吸収されます。
なのでFogにかけるときは1から引くことになります。

さらに到達した光から減衰を考えるので累計の透過率を乗算します。
float fog_contribution = total_transmittance * (1.0 - step_transmittance);
また十分に減衰したら計算する必要がないので探索を終了させます。

コードは以下のようになります。

float total_transmittance = 1.0;

[loop]
for (int i = 0; i < _StepNum; i++)
{
    current_distance += _StepSize;
    if (current_distance > distance)
    {
        break;
    }

    // 光の減衰を計算する
    float step_transmittance = exp(-_Density * _StepSize);
    float fog_contribution = total_transmittance * (1.0 - step_transmittance);
    color.rgb += fog_contribution;

    // 現状の光の減衰量を計算する
    total_transmittance *= step_transmittance;

    // 十分に減衰してたら探索を終了させる
    if (total_transmittance < 0.01)
    {
        break;
    }
}

これで遠くなるほどfogを濃くすることが出来ました。

Fogをライティングする

Fogは大気中の水滴に光が散乱することによって見ることが出来ます。
なので太陽や電球のようなものからの光でライティングされます。
この光が当たっているかどうかを判定するためにShadowMapを使用します。

まずは必要な定義を加えます。

// ShadowMapを用いるために必要
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN

Rayの現在の座標を計算しそこからShadowMapを取得してライティングします。

// Rayの座標を計算する
float3 ray_position = _WorldSpaceCameraPos + current_distance * ray_direction;

// 遮蔽されているかどうかを調べる
Light main_light = GetMainLight(TransformWorldToShadowCoord(ray_position));
float fog_contribution = total_transmittance * (1.0 - step_transmittance);

// 光が当たるFogの色を計算する
float3 lit_fog_color = main_light.color.rgb * _LightingFogColor * main_light.shadowAttenuation;

// 光が当たっていない場所のFogの色を計算する
float3 ambient_fog_color = _AmbientFogColor * (1.0 - main_light.shadowAttenuation);

// 色を加える
float3 finalFogColor = lit_fog_color + ambient_fog_color;
color.rgb += finalFogColor * fog_contribution;

これによってfogがライティングされるようになりました。

Noiseを除去する

StepSizeを大きくすればするほど今作成されたFogには円形のNoiseが見えるかと思います。
これはRayが画面のピクセルから同じStepSizeで飛ぶことにより、カメラの中心から等距離の地点でFogの密度が計算されてしまうためです。

これを解消するためにノイズでRayの開始位置にランダムなオフセットをかけます。

// ノイズを使用して開始地点にオフセットをかける
float2 pixel_coords = input.texcoord * _BlitTexture_TexelSize.zw;
float random_offset = frac(sin(dot(pixel_coords, float2(12.9898, 78.233))) * 43758.5453) * _StepSize;
float current_distance = random_offset;

これでかなり円形のNoiseは除去されました。

空気感を出す

これでvolumetricFogとしては最低限の見た目は担保されていますがもう少し工夫します。
実際霧というものは流動性を持つもので微動だにしないということはあまりないと思います。
そこで3DTextureを用いて密度の差分を出すことにより流動性を持たせてみましょう。
_Densityを以下の関数に置き換えます

float Density(float3 world_position)
{
    float3 mask = SAMPLE_TEXTURE3D(_DensityMask, sampler_TrilinearRepeat, frac(world_position * 0.01) * _DensityMaskTiling + _Time.x * _DensityMaskScrollSpeed);
    mask = mask - _DensityMaskThreshold;
    return mask * _Density;
}

このように空気感が出るようになりました。

Henyey-Greenstein位相関数

Henyey-Greenstein位相関数とはVolumeRenderingでよく用いられる散乱位相関数モデルです。
これは入射した光がどの方向にどのように散乱されるかの確立を表すものになります。

式は以下のように表されます。
f(θ) = (1 - g^2) / (4π * (1 + g^2 + 2g cosθ)^(3/2))
・cosθ: 入射光方向と散乱光方向のなす角のCosθ
・g: 散乱の方向性を表すパラメーター(-1 ≤ g ≤ 1)

gが0以上なら光の進行方向に散乱し、0以下なら逆方向に散乱します。
以下のように実装します。

float HenyeyGreenstein(float cos, float g)
{
    float g2 = g * g;
    return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * cos, 1.5));
}

float cos = dot(-ray_direction, main_light.direction);
float phase_function = HenyeyGreenstein(cos, _ScatteringG);
float3 lit_fog_color = main_light.color.rgb * _LightingFogColor.rgb * phase_function * main_light.shadowAttenuation;

これで_ScatteringGの値が0以上なら光の進行方向が明るくなり、0以下なら逆方向が明るくなると思います。

全体の実装

最終的な実装は以下のようになります。

Shader "Custom/VolumetricFogPostEffect"
{
    Properties
    {
        _Density ("Density", Range(0, 1)) = 0.1
        _StepNum ("StepNum", Range(10, 500)) = 200
        _StepSize ("StepSize", Range(0.01, 1)) = 0.01
        _LightingFogColor ("LightingFogColor", Color) = (1,1,1,1)
        _AmbientFogColor ("AmbientFogColor", Color) = (0,0,0,1)
        _DensityMask("DensityMask", 3D) = "white" {}
        _DensityMaskScrollSpeed ("DensityMaskScrollSpeed", Vector) = (1, 1, 1, 0)
        _DensityMaskTiling ("DensityMaskTiling", Vector) = (1, 1, 0, 0)
        _DensityMaskThreshold ("DensityMaskThreshold", Range(0, 1)) = 0.1
        _ScatteringG ("Scattering", Range(-0.99, 0.99)) = 0.0
    }

    SubShader
    {
        Tags 
        { 
            "RenderType" = "Opaque" 
            "RenderPipeline" = "UniversalPipeline"
        }
        
        LOD 100
        Cull Off 
        ZWrite Off 
        ZTest Always

        Pass
        {
            Name "PostEffect"
            
            HLSLPROGRAM
            #pragma target 3.5
            #pragma vertex Vert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
            #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
            
            float _Density;
            int _StepNum;
            float _StepSize;
            float4 _LightingFogColor;
            float4 _AmbientFogColor;
            TEXTURE3D(_DensityMask);
            float3 _DensityMaskScrollSpeed;
            float3 _DensityMaskTiling;
            float _DensityMaskThreshold;
            float _ScatteringG;


            float HenyeyGreenstein(float cos, float g)
            {
                float g2 = g * g;
                return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * cos, 1.5));
            }
                        
            float Density(float3 world_position)
            {
                float3 mask = SAMPLE_TEXTURE3D(_DensityMask, sampler_TrilinearRepeat, frac(world_position * 0.01) * _DensityMaskTiling + _Time.x * _DensityMaskScrollSpeed);
                mask = mask - _DensityMaskThreshold;
                return mask * _Density;
            }
            
            half4 frag(Varyings input) : SV_Target
            {
                float depth = SampleSceneDepth(input.texcoord);
                float3 world_position = ComputeWorldSpacePosition(input.texcoord, depth, UNITY_MATRIX_I_VP);
                
                float3 ray_vector = world_position - _WorldSpaceCameraPos;
                float distance = length(ray_vector);
                float3 ray_direction = normalize(ray_vector);

                half4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, input.texcoord);

                float2 pixel_coords = input.texcoord * _BlitTexture_TexelSize.zw;
                float random_offset = frac(sin(dot(pixel_coords, float2(12.9898, 78.233))) * 43758.5453) * _StepSize;
                float current_distance = random_offset;
                float total_transmittance = 1.0;
                [loop]
                for (int i = 0; i < _StepNum; i++)
                {
                    current_distance += _StepSize;
                    if (current_distance > distance)
                    {
                        break;
                    }
                    
                    float3 ray_position = _WorldSpaceCameraPos + current_distance * ray_direction;
                    
                    float step_transmittance = exp(-Density(ray_position) * _StepSize);
                    float fog_contribution = total_transmittance * (1.0 - step_transmittance);

                    Light main_light = GetMainLight(TransformWorldToShadowCoord(ray_position));
                    
                    float cos = dot(-ray_direction, main_light.direction);
                    float phase_function = HenyeyGreenstein(cos, _ScatteringG);
                    
                    float3 lit_fog_color = main_light.color.rgb * _LightingFogColor.rgb * phase_function * main_light.shadowAttenuation;
                    float3 ambient_fog_color = _AmbientFogColor.rgb * (1.0 - main_light.shadowAttenuation);
                    float3 finalFogColor = lit_fog_color + ambient_fog_color;
                    color.rgb += finalFogColor * fog_contribution;
                    
                    total_transmittance *= step_transmittance;
                    if (total_transmittance < 0.01)
                    {
                        break;
                    }
                }
                
                return color;
            }
            ENDHLSL
        }
    }
}

最後に

スポットライトなどもDirectionalLightと同じように実装すればこのように同じようなことが出来ます。

以上VolumetricFogの実装でした。

Discussion