[자체 엔진] Exponent Height Fog

Exponent Height Fog

https://youtu.be/S5XvH08OHyo

1. Exponent Height Fog란?

안개 효과는 다들 익숙할 것이다.
아마 어색한 것은 Exponent Height일텐데, 현실 안개와 유사하게 높이에 따라 지수적으로 밀도가 변경되는 안개이다.

Params

Exponent Height Fog에는 2가지 주요 Parameters가 있다.

Density: 밀도... 안개의 밀도다.. 깊이에 따른 밀도를 조절해준다.
FallOff: 우리의 안개는 height fog이다. 높이에 따른 밀도를 조절해준다. (값이 크면 높이에 따른 밀도가 급격히 감소한다.)

2. 구현

2-1. Full Screen Quad (Vertex Shader)

안개는 화면 전체에 적용되는 Post Processing 효과 중 하나이다. 때문에 Full Screen Quad를 사용해서 구현하였다.

 

구현방법은 다음과 같다. 

Vertex가 3개가 들어오면, vertexID는 0, 1, 2일것이다.
이 ID에 <<1( * 2) 와 &2 (2번째 비트만 남기기) 를 해주면 0, 2, 0이 될 것이다. 이를 X좌표에 사용한다.

그리고 << 1를 하지않고 &2를 한다면, 0, 0, 2가 될 것이고, 이를 Y좌표에 사용한것이다.

 

최종적으로 (0,0), (2,0), (0,2)인 Full Screen Quad를 얻을 수 있다.
이때 좌표계의 범위가 [0,2]가 되는데,
이를 Pixel Shader에게 보내야하기 때문에 NDC좌표로 변화해준다. (depth == near plane)

삼각형 2개를 사용하지 않고, 손을 타는 Full Screen Quad를 사용하는 이유는 다음과 같다

  1. 삼각형 2개를 사용할 경우, 삼각형 격계가 부각될 수 있음
  2. 삼각형 2개를 사용할 경우, pixel shader 중복 실행
  3. cache 효율 감소
/**
 * Vertex Shader: Fullscreen Triangle 생성
 * Draw(3, 0) 호출 시 화면을 덮는 큰 삼각형 생성
 */

PS_INPUT mainVS(uint vertexID : SV_VertexID)
    PS_INPUT output;
{

    // 비트 연산으로 UV 좌표 생성: (0,0), (2,0), (0,2)
    output.texCoord = float2((vertexID << 1) & 2, vertexID & 2);

    // UV [0,2]를 NDC [-1,1]로 변환하고 Y축 플립
    output.position = float4(
        output.texCoord.x * 2.0f - 1.0f, // X: [0,2] -> [-1,3]
        -output.texCoord.y * 2.0f + 1.0f, // Y: [0,2] -> [1,-3]
        0.0f, // Near plane
        1.0f // W = 1
    );

    return output;
} 

2-2. Pixel Shader

흐름을 보면 다음과 같다.

  1. 화면 크기 변경 대비
  2. Scene Color, Depth Sampling
  3. World Position 복원
  4. Depth를 활용해서 Density 조절

3,4번은 자세히 볼 필요가 있다.

/**
 * Pixel Shader: Exponential Height Fog 적용
 */
float4 mainPS(PS_INPUT input) : SV_TARGET
{
    // ============================================================
    // 1. Viewport 내 상대 UV를 전체 화면 UV로 변환 (viweport 크기변경 대비)
    // ============================================================
    float2 viewportStartNormalized = g_ViewportPos / g_ScreenSize;
    float2 viewportSizeNormalized = g_ViewportSize / g_ScreenSize;
    float2 screenUV = viewportStartNormalized + input.texCoord * viewportSizeNormalized;

    // ============================================================
    // 2. 씬 텍스처와 Depth 샘플링
    // ============================================================
    float4 sceneColor = g_SceneTexture.Sample(g_Sampler, screenUV);
    float depthNDC = g_DepthTexture.Sample(g_Sampler, screenUV).r;

    // ============================================================
    // 3. World Position 복원
    // ============================================================
    float3 worldPos = ReconstructWorldPosition(screenUV, depthNDC);

    // 카메라 위치 (View 역행렬의 마지막 행 = Translation)
    float3 cameraPos = float3(g_InvViewMatrix[3][0], g_InvViewMatrix[3][1], g_InvViewMatrix[3][2]);

    // 카메라에서 픽셀까지의 거리
    float distance = length(worldPos - cameraPos);

    // ============================================================
    // 4. 안개 적용량 계산
    // ============================================================
    float fogAmount = CalculateFogAmount(cameraPos, worldPos, distance);

    // ============================================================
    // 5. 최종 색상: 씬 색상과 안개 색상 블렌딩
    // ============================================================
    // finalColor = lerp(sceneColor, fogColor, fogAmount)
    float3 finalColor = lerp(sceneColor.rgb, g_FogInscatteringColor.rgb, fogAmount);

    return float4(finalColor, sceneColor.a);
}

2-3. Screen Space To World Space

ReconstructWorldPosition 방법에 대해서 설명하면 다음과 같다.
사실 간단하다
World Space -> Camera Space -> Clip -> NDC -> Sreen Space 이 순서를 역행하면 된다.

  1. Pixel Shader는 Screen Space이니, NDC로 변환
  2. Clip Space로 변환을 위한 w복원 (w == z)
  3. View Space로 변환 (Projection Maxtrx 역행렬)
  4. World Space로 변환 (View 역행렬) 
  5. /** * Screen UV와 Depth로부터 World Position 복원 */ float3 ReconstructWorldPosition(float2 screenUV, float depthNDC) { // 1. NDC 좌표 계산 (UV [0,1] -> NDC [-1,1]) float2 ndcXY = screenUV * 2.0f - 1.0f; ndcXY.y = -ndcXY.y; // Y축 플립 (UV와 NDC의 Y축 방향이 반대) // 2. Clip Space 좌표 생성 (w = 1.0 가정) float4 clipPos = float4(ndcXY, depthNDC, 1.0f); // 3. View Space로 변환 (Projection 역행렬) float4 viewPos = mul(clipPos, g_InvProjectionMatrix); viewPos /= viewPos.w; // Perspective divide // 4. World Space로 변환 (View 역행렬) float4 worldPos = mul(viewPos, g_InvViewMatrix); return worldPos.xyz; }

 

 2-4. Exponential Height Fog



그림과 같이 X의 값이 커질 수록 점차 지수적으로 감소하는 형태를 띈다.  



이를 이용해서 높이에 따른 밀도 변화를 나타낸 것이다. 이 때 감소되는 정도를 `FallOff`값으로 조절하도록 하였다. 


 
/**
 * 특정 높이에서의 안개 밀도 계산 (Exponential Height Fog)
 */
float CalculateFogDensityAtHeight(float worldHeight)
{
    // 안개 컴포넌트 높이와의 차이
    float heightDifference = worldHeight - g_FogComponentPosition.z;

    // 지수 함수로 높이에 따른 밀도 감소
    // Density(h) = GlobalDensity * exp(-HeightFalloff * (h - FogHeight))
    float densityAtHeight = g_FogDensity * exp(-g_FogHeightFalloff * heightDifference);

    return max(0.0f, densityAtHeight);
}

이제 안개의 밀도에 대해서 이야기 해보자

카메라에서 픽셀(worldPos)까지의 선분을 따라 밀도를 여러 번 샘플링해서 평균 밀도를 구하고, 그 평균 밀도에 거리(distance)를 곱해 경로 전체의 누적 안개량을 근사한다.

  1. 시작 부분에는 안개 X
  2. 안개의 최대거리 부터는 밀도 최대치
  3. 높이에 따른 밀도 변화가 적용된 수치에서 Sampling을 한 뒤, Fog Rendering
/**
 * 카메라에서 특정 지점까지의 안개 적용량 계산
 */
float CalculateFogAmount(float3 cameraPos, float3 worldPos, float distance)
{
    // 1. StartDistance 이전에는 안개 없음
    if (distance < g_FogStartDistance)
        return 0.0f;

    // 2. CutoffDistance 이후에는 최대 불투명도
    if (g_FogCutoffDistance > 0.0 && distance > g_FogCutoffDistance)
        return g_FogMaxOpacity;

    // 3. 높이에 따른 안개 밀도 적분 (샘플링)
    float heightIntegral = 0.0;
    int numSamples = 8;
    for (int i = 0; i < numSamples; ++i)
    {
        float t = i / (float) numSamples;
        float3 samplePos = lerp(cameraPos, worldPos, t);
        heightIntegral += CalculateFogDensityAtHeight(samplePos.z);
    }
    float fogAmount = heightIntegral / numSamples * distance;

    return saturate(fogAmount);
}

3번 코드에 대해서 조금 더 자세히 설명하면
numSamples는 몇 번 Sampling할 지 정하는 것이다. ( Ray가 앞으로 나아갈지 스텝 수라고 생각하면 된다.)

SamplePos: Sampling할 Posistion은 CamPos, Pixel의 WorldPos, numSamples를 사용해서 얻을 수 있다.

SamplePos를 활용해서 얻은 밀도들의 합을 numSample로 나눠서 평균 밀도를 얻고, 이를 거리에 곱해서 최종 밀도를 얻는다.

3. 최종 정리

Exponential Height Fog를 높이에 따라 지수적으로 밀도가 변하고, 거리에 따라 linear하게 밀도가 변하도록 구현했다.

'ComputerGraphics > 자체엔진' 카테고리의 다른 글

[자체 엔진] Decal  (0) 2026.03.09
[자체 엔진] FXAA  (0) 2026.03.09
[자체 엔진] Batch Line Rendering  (0) 2026.03.09
[자체 엔진] Billboard  (0) 2026.03.09
[자체 엔진] Features  (0) 2026.03.09
myoskin