Game Dev/Unity Shader

Volumetric Fog Shader 리뷰하기 - Unity Shader

Septentrions 2026. 1. 11. 03:45

 

이번 쉐이더는 Post-Processing 에서 동작하는 Volumetric Fog 이다.

 

 

Shader 코드 전문

더보기
Shader "Lucide-Boundary/VolumetricFog"
{
    Properties
    {
        _Color("Color", Color) = (1, 1, 1, 1)
        _MaxDistance("Max distance", float) = 100
        _StepSize("Step size", Range(0.1, 20)) = 1
        _DensityMultiplier("Density multiplier", Range(0, 10)) = 1
        _NoiseOffset("Noise offset", float) = 0
        
        _FogNoise("Fog noise", 3D) = "white" {}
        _NoiseTiling("Noise tiling", float) = 1
        _DensityThreshold("Density threshold", Range(0, 1)) = 0.1
        
        [HDR]_LightContribution("Light contribution", Color) = (1, 1, 1, 1)
        _LightScattering("Light scattering", Range(0, 1)) = 0.2
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }

        Pass
        {
            HLSLPROGRAM
            #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/DeclareDepthTexture.hlsl"

            float4 _Color;
            float _MaxDistance;
            float _DensityMultiplier;
            float _StepSize;
            float _NoiseOffset;
            TEXTURE3D(_FogNoise);
            float _DensityThreshold;
            float _NoiseTiling;
            float4 _LightContribution;
            float _LightScattering;

            float henyey_greenstein(float angle, float scattering)
            {
                return (1.0 - angle * angle) / (4.0 * PI * pow(1.0 + scattering * scattering - (2.0 * scattering) * angle, 1.5f));
            }
            
            float get_density(float3 worldPos)
            {
                float4 noise = _FogNoise.SampleLevel(sampler_TrilinearRepeat, worldPos * 0.01 * _NoiseTiling, 0);
                float density = dot(noise, noise);
                density = saturate(density - _DensityThreshold) * _DensityMultiplier;
                return density;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                float4 col = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, IN.texcoord);
                float depth = SampleSceneDepth(IN.texcoord);
                float3 worldPos = ComputeWorldSpacePosition(IN.texcoord, depth, UNITY_MATRIX_I_VP);

                float3 entryPoint = _WorldSpaceCameraPos;
                float3 viewDir = worldPos - _WorldSpaceCameraPos;
                float viewLength = length(viewDir);
                float3 rayDir = normalize(viewDir);

                float2 pixelCoords = IN.texcoord * _BlitTexture_TexelSize.zw;
                float distLimit = min(viewLength, _MaxDistance);
                float distTravelled = InterleavedGradientNoise(pixelCoords, (int)(_Time.y / max(HALF_EPS, unity_DeltaTime.x))) * _NoiseOffset;
                float transmittance = 1;
                float4 fogCol = _Color;

                while(distTravelled < distLimit)
                {
                    float3 rayPos = entryPoint + rayDir * distTravelled;
                    float density = get_density(rayPos);
                    if (density > 0)
                    {
                        Light mainLight = GetMainLight(TransformWorldToShadowCoord(rayPos));
                        fogCol.rgb += mainLight.color.rgb * _LightContribution.rgb * henyey_greenstein(dot(rayDir, mainLight.direction), _LightScattering) * density * mainLight.shadowAttenuation * _StepSize;
                        transmittance *= exp(-density * _StepSize);
                    }
                    distTravelled += _StepSize;
                }
                
                return lerp(col, fogCol, 1.0 - saturate(transmittance));
            }
            ENDHLSL
        }
    }
}

 

-Shader 구조

Shader 구조는 심플한 원 패스 형식이다.

Properties
SubShader
{
	Pass
    {
    	Tags
        HLSLPROGRAM
        ...
        ENDHLSL
    }
}

 

-Properties

Properties
{
    _Color("Color", Color) = (1, 1, 1, 1)
    _MaxDistance("Max distance", float) = 100
    _StepSize("Step size", Range(0.1, 20)) = 1
    _DensityMultiplier("Density multiplier", Range(0, 10)) = 1
    _NoiseOffset("Noise offset", float) = 0

    _FogNoise("Fog noise", 3D) = "white" {}
    _NoiseTiling("Noise tiling", float) = 1
    _DensityThreshold("Density threshold", Range(0, 1)) = 0.1

    [HDR]_LightContribution("Light contribution", Color) = (1, 1, 1, 1)
    _LightScattering("Light scattering", Range(0, 1)) = 0.2
}

 

전역으로 Fog를 깔아줄 것은 아니니 MaxDistance를 설정해줬다.

Fog Noise가 3D로 되어있는건 특징적인 부분이다.

Fog의 밀도를 적용할 DensityMultiplier, DensityThreshold 외에는 별다를 건 없어보인다.

Light 관련 부분은 Fragment Shader에서 자세히 알아보자.

 

-Keyword

#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/DeclareDepthTexture.hlsl"

 

 

#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN

https://docs.unity3d.com/6000.5/Documentation/Manual/urp/use-built-in-shader-methods-shadows.html

 

Custom Shadow를 사용하기 위한 프리 컴파일러이다.

메인 광원으로부터 Shadow Map을 가져올 수 있게 해준다.

 

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

Core / Light는 각각 헬퍼 함수들을 가져오고.

 

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

Blit은 Texture를 복사하는 연산을 도와주는 헬퍼 함수들을 포함하고 있다.

 

// The DeclareDepthTexture.hlsl file contains utilities for sampling the Camera
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"

https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@16.0/manual/writing-shaders-urp-reconstruct-world-position.html

 

주석에 적혀있는 것처럼, 카메라를 샘플링하기 위한 헬퍼 함수들을 포함하고 있다.

Depth Texture가 URP에서 Enable 되어 있는지 체크하자.

 

- Shader 분석

이 쉐이더는 Vert 함수는 구현하고 있지 않다.

Fragment Shader만 보면 되는데, 중간에 함수들이 몇개 포함되어 있다.

Vert  X
henyey_greenstein angle, scattering float 값을 받아서, float 값을 리턴
get_density worldPos float3 값을 받아서, float 값을 리턴
frag Varyings IN 값을 받아서 Fragment Shader 처리

 

(1) frag 

half4 frag(Varyings IN) : SV_Target
{
    float4 col = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, IN.texcoord);
    float depth = SampleSceneDepth(IN.texcoord);
    float3 worldPos = ComputeWorldSpacePosition(IN.texcoord, depth, UNITY_MATRIX_I_VP);

    float3 entryPoint = _WorldSpaceCameraPos;
    float3 viewDir = worldPos - _WorldSpaceCameraPos;
    float viewLength = length(viewDir);
    float3 rayDir = normalize(viewDir);

    float2 pixelCoords = IN.texcoord * _BlitTexture_TexelSize.zw;
    float distLimit = min(viewLength, _MaxDistance);
    float distTravelled = InterleavedGradientNoise(pixelCoords, (int)(_Time.y / max(HALF_EPS, unity_DeltaTime.x))) * _NoiseOffset;
    float transmittance = 1;
    float4 fogCol = _Color;

    while(distTravelled < distLimit)
    {
        float3 rayPos = entryPoint + rayDir * distTravelled;
        float density = get_density(rayPos);
        if (density > 0)
        {
            Light mainLight = GetMainLight(TransformWorldToShadowCoord(rayPos));
            fogCol.rgb += mainLight.color.rgb * _LightContribution.rgb * henyey_greenstein(dot(rayDir, mainLight.direction), _LightScattering) * density * mainLight.shadowAttenuation * _StepSize;
            transmittance *= exp(-density * _StepSize);
        }
        distTravelled += _StepSize;
    }

    return lerp(col, fogCol, 1.0 - saturate(transmittance));
}

 

float4 col  : blit 헬퍼 함수에서 가져온 _BlitTexture를 통해, 화면의 텍스처를 가져와서 샘플링했다.

float depth : SampleSceneDepth는 입력 받은 UV를 통해 depth 값을 구해준다.

float3 worldPos :depth 값은 world position 값을 구하기 위함이다.

 

float3 entryPoint : _WorldSpaceCameraPos는 Shader 내장 변수로 카메라 위치를 가져올 수 있다.

float3 viewDir : 오브젝트의 WorldPosition에 카메라 WorldPosition을 빼줘서 뷰 벡터를 계산한다.

float viewLength : length(viewDir)

float3 rayDir : viewDir의 normalize 벡터

 

float2 pixelCoords :  _BlitTexture_TexelSize는 _BlitTexture의 해상도 관련 값을 가져올 수 있다. zw 차원은 각각 width, height를 의미하므로. 입력 UV * (width, height)를 해주고 있다. 이 의미는 0~1 사이의 UV와 실제 Width, Height를 고려하여

_BlitTexture에 대한 ScreenSpace의 픽셀 위치를 의미한다.

float distLimit : 오브젝트의 카메라 거리와 _MaxDistance (Properties 파라미터) 값을 비교하여 작은 값을 가져온다. 

float distTravelled : 현재 픽셀의 위치와 Time / max(HALF_EPS, unity_DeltaTime.x) 를 이용하여  InterleavedGradientNoise를 만들어준다.

https://blog.demofox.org/2022/01/01/interleaved-gradient-noise-a-different-kind-of-low-discrepancy-sequence/

 

Interleaved Gradient Noise: A Different Kind of Low Discrepancy Sequence

The python code that goes along with this post can be found at In 2014, Jorge Jimenez from Activision presented a type of noise optimized for use with Temporal Anti Aliasing called Interleaved Grad…

blog.demofox.org

InterleavedGradientNoise (IGN) 은 저 불일치 수열을 가진 노이즈인데, 무작위성을 가진 것처럼 보이는 수열이다.

이는 같은 입력에 항상 같은 출력을 보여주면서, 결과는 무작위처럼 보이도록 한다.

 

Time / max(HALF_EPS, unity_DeltaTime.x)은 division zero 문제를 해결하면서, Frame에 상관없이 일정한 값을 가진다.

 

 

float transmittance : transmittance (투과율)은 우선, 100% 값으로 초기화한다.

 

while(distTravelled < distLimit)
{
    float3 rayPos = entryPoint + rayDir * distTravelled;
    float density = get_density(rayPos);
    if (density > 0)
    {
        Light mainLight = GetMainLight(TransformWorldToShadowCoord(rayPos));
        fogCol.rgb += mainLight.color.rgb * _LightContribution.rgb * henyey_greenstein(dot(rayDir, mainLight.direction), _LightScattering) * density * mainLight.shadowAttenuation * _StepSize;
        transmittance *= exp(-density * _StepSize);
    }
    distTravelled += _StepSize;
}

 

while(distTravelled < distLimit) 의 의미는 무엇일까?

일단은 오브젝트의 거리 혹은 최대 거리를 설정한 거리가 IGN으로 계산된 랜덤 distance 값보다 작을떄까지 반복한다는 뜻인데.

초기 distTravelled는 난수면서 매우 작은 값을 가지고 있으며, 이 값을 while을 통해 조금씩 올려가며 색상을 선택하는 용도이다.

드디어 Step 의 용도의 의미가 풀렸다. (Ray Marching)

 

float3 rayPos

카메라 위치 (Entry Point)에서 오브젝트로의 방향 (여기서 오브젝트가 아닌 화면상의 위치라고 할수도 있다. 대상은 BlitTexture니까..) * distTravelled를 해줬는데.

아!! 개발자의 의도가 오브젝트로의 방향으로 Step 만큼 샘플링 해가면서 투과율을 계산하려는 의도를 눈치챌  수 있을 것이다.

 

float density

float get_density(float3 worldPos)
{
    float4 noise = _FogNoise.SampleLevel(sampler_TrilinearRepeat, worldPos * 0.01 * _NoiseTiling, 0);
    float density = dot(noise, noise);
    density = saturate(density - _DensityThreshold) * _DensityMultiplier;
    return density;
}

직관적인 이름의 get_density 함수를 통해 density 값을 구해주고 있다.

rayPos를 입력 값으로 받고 있다.

카메라와 화면의 distTravelled 만큼의 위치에서 Density 값을 계산하는데.. 

https://learn.microsoft.com/ko-kr/windows/win32/direct3dhlsl/dx-graphics-hlsl-to-samplelevel

 

SampleLevel(DirectX HLSL 텍스처 개체) - Win32 apps

mipmap 수준 오프셋을 사용하여 텍스처를 샘플링합니다.

learn.microsoft.com

3D 텍스처의 SampleLevel을 이용하여 3D의 특정 깊이, 특정 오프셋, 특정 픽셀 위치를 고려하여 샘플링된 컬러값을 가져온다는 뜻이다. (이 코드에서는 sampler_TrilinearRepeat 방식으로 샘플링한다.)

샘플링된 컬러값은 결국 노이즈에서 가져온 것이므로, noise 자체가 되겠다.

자기 자신을 내적하여 스칼라화 된 제곱 값을 density로 지정했다.

이 density 값은 Threshhold와 MultiPlier를 통해 필터링 되면서, 강조시킬 값을 곱해준다.

 

Light mainLight : TransformWorldToShadowCoord 은 특정 위치에서 Shadow Space로 변환된 빛의 값을 가져오고 있다.

fogCol.rgb += 

mainLight.color.rgb * 

_LightContribution.rgb * 

henyey_greenstein(dot(rayDir, mainLight.direction), _LightScattering) * 

density * 

mainLight.shadowAttenuation * 

_StepSize;

 

ShadowSpace의 색상, HDR 컬러, density, Shadow 감쇠, 현재 스텝에 henyey_greenstein 처리를 곱해주고 있다.

float henyey_greenstein(float angle, float scattering)
{
    return (1.0 - angle * angle) / (4.0 * PI * pow(1.0 + scattering * scattering - (2.0 * scattering) * angle, 1.5f));
}

 

이 함수는 산란 관련 함수로, Light 값을 산란 각도에 따라 광원 값을 변경해준다.

스텝 순서대로 계속 더해주며, 산란도를 구해주며 최종 산란도와 광원 값을 얻는다.

생각보다 빡세다..