UE5

[week6] Light

검정색필통 2025. 4. 12. 00:44

Texutre Normal Map

 

기존의 normal mapping과정은 vertex의 normal값을 가져와서 pixel shader로 넘길 때,

래스터라이제이션 과정에서 정점마다 넘긴 값을 기준으로 삼각형 내부의 픽셀마다 선형 보간(interpolation)을 자동으로 해줌

 

이 보간은 삼각형 단위로 이루어지기 때문에, 삼각형이 화면에 차지하는 비율이 늘어날 수록 보간이 거칠어져서 빛 표현이 부정확해진다. -> Per-Pixel Lighting이 필요한 이유!

 

저번 주차에서 lighting을 구현할 때, Deferred-shading + Per-pixel Lighting으로 조명을 구현하긴 했다.

그런데 normal은 vertex에만 존재하고, 이를 pixel 각각에 적용하기 위해서 GPU에서 자동으로 픽셀에 보간해준 값을 사용했다.

따라서 정밀도가 떨어져 flat한 표면 느낌만 날 수 있었다.

 

Texture Normal Map을 사용하면 정점 갯수와 무관하게 텍스쳐 해상도만의 법선 정보로 픽셀마다 독립적인 normal을 매핑할 수 있어 픽셀 단위의 정밀한 조명이 표현 가능하다.

 

이 Normal map은 Tangent Space를 기준으로 만든다.

Tangent Space란 표면의 상대좌표계로, 모든 표면의 한 점을 기준으로 거기가 (0,0,0)이라고 생각하고 그 위의 normal 방향을 (0,0,1)로 가정하는 로컬 공간이다.

 

Tangent평면의 TBN을 구하기 위해 다음과 같은 과정을 거친다.

 

그리고 TBN을 tangentNormal과 곱해주면 worldNormal을 구해줄 수 있다.

// 이미 보간된 정점 속성으로부터 T, B, N (모두 정규화된 상태)
float3 T = normalize(input.Tangent);
float3 N = normalize(input.Normal);
// Bitangent는 보통 cross(N, T) * handedness 값으로 결정됨
float3 B = normalize(cross(N, T) * input.Tangent.w);

// TBN 행렬 (열벡터로 구성)
float3x3 TBN = float3x3(T, B, N);

// Normal Map에서 샘플링한 tangent space의 normal (범위 [0,1]을 [-1,1]로 변환)
float3 tangentNormal = NormalMap.Sample(SamplerState, input.UV).rgb * 2.0 - 1.0;

// 월드 공간 노멀로 변환
float3 worldNormal = normalize(mul(TBN, tangentNormal));

 

 

Lighting Model

 

1. Gouraud Shading

vertex에서 조명을 계산하고, 그 결과 색을 삼각형 내부 픽셀로 보간(interpolate)해서 부드러운 조명 효과를 만든다.

struct VS_INPUT
{
    float3 Position : POSITION;
    float3 Normal : NORMAL;
};

struct VS_OUTPUT
{
    float4 Pos : SV_POSITION;
    float4 Color : COLOR0; // 정점 색
};

VS_OUTPUT mainVS(VS_INPUT input)
{
    VS_OUTPUT output;

    // 정점 위치 처리
    float4 worldPos = mul(float4(input.Position, 1.0f), WorldMatrix);
    float3 normal = mul(input.Normal, (float3x3)WorldMatrix);
    output.Pos = mul(worldPos, ViewProjectionMatrix);

    // 조명 계산 (예: 램버트)
    float3 lightDir = normalize(LightPos - worldPos.xyz);
    float diff = max(dot(normalize(normal), lightDir), 0.0f);
    float3 diffuseColor = diff * LightColor.rgb;

    output.Color = float4(diffuseColor, 1.0f); // 정점 색상

    return output;
}
float4 mainPS(VS_OUTPUT input) : SV_TARGET
{
    return input.Color;
}

 

모든 라이팅 계산은 VertexShader에서 일어나고 PixelShader에서는 보간된 값을 찍어주기만 한다.

-> PixelShader가 없던 시절 사용하던 방법!

 

 

2. Lambert Shading

  • Lambert의 코사인 법칙
    • Lambertian 표면에서는 어떤 입사광이 표면에 떨어질 떄, 표면이 그 빛을 모든 방향으로 동일하게 산란한다고 가정
    • 이때 실제 관측되는 밝기는 표면의 노멀과 광원 방향 사이의 각도에 따라 달라짐
  • Lambert Shading의 특징
    • 등방성(diffuse reflection):
      Lambertian 표면은 빛을 모든 방향으로 균일하게 산란시키므로, 관측 방향과 무관하게 같은 밝기가 나타납니다.
      → 결과적으로 시점(viewing angle)의 변화에 크게 민감하지 않습니다
    • 계산 효율:
      단순히 내적(dot product) 연산과 스칼라 곱으로 밝기를 계산하므로, 실시간 렌더링에서 매우 효율적입니다.
    • 현실성:
      완벽한 매트(matte)한 표면이나 벽, 종이, 천과 같은 재질에 적합합니다.
      → 반사광(스페큘러) 효과는 표현하지 않으며, 기초적인 확산 반사를 모델링합니다.
    • 제한:
      실제 재질은 대개 확산 반사와 반사(스페큘러) 반사가 혼합되어 나타나므로, 보다 현실적인 쉐이딩을 위해서는 Blinn-Phong, Cook-Torrance 같은 모델과 함께 사용하거나 추가적인 기법(예: Normal Mapping)을 적용해야 할 수 있습니다.
// vNormal: 표면의 정규화된 노멀 벡터 (float3)
// vLightDir: 광원 방향 (빛이 표면에 도달하는 방향으로 정규화됨)
// k_d: 확산 반사 계수 (색상 또는 스칼라)
// I: 광원의 강도 (색상 또는 스칼라)

float NdotL = saturate(dot(vNormal, vLightDir));  // saturate()는 [0,1] 범위로 클램핑
float3 diffuseColor = k_d * I * NdotL;

 

 

3. Blinn-phong Shading

 

 

Blinn-Phong모델은 Phong 모델을 발전 시킨 형태로,

Phong반사 모델은 View Vector를 Normal 방향으로 기준으로 반사하는 Reflection Vector가 Light Vector의 방향이 가장 일치할 때 밝기가 제일 밝다는 것에서 출발한다.

즉, Reflection Vector와 Light Vector의 내적 dot(R, L)이 기본 공식이다.

그런데 Reflection Vector를 얻기 위해서는 R = 2N*dot(L,N) - L을 구해야하는데, 여기서 또 내적이 등장한다.

여러번 내적 해야해 값이 비싼 Phong 모델 대신에 Blinn-Phong 모델이 등장한다.

 

Blinn-Phong 모델에서는 R과 V가 최대 정렬되는 상황을 근사하기 위해 Halfway Vector를 사용한다.

Halfway Vector는 L과 V의 중간값으로(normalize(L+V)), 이 값이 N과 정렬될수록 시선과 빛이 대칭된다.

이는 내적계산을 하는 Reflection Vector를 구하는 것에 비해 값이 훨씬 싸다!

 

물론 빛의 방향이 대칭되지 않을 수록 Phong모델 값과 오차는 커지지만

우리는 Specular값을 구하는데 이 모델을 사용할 것이기 때문에 빛이 강하게 반사되는 중심이 훨씬 중요하다.

 

result.Ambient = gcGlobalAmbientLight.rgb * Material.AmbientColor;
result.Diffuse = gLights[nIndex].m_cDiffuse * LambertLightingModel(vToLight, vNormal) * safeDiffuse;
result.Specular = gLights[nIndex].m_cSpecular * BlinnPhongLightingModel(vToLight, vPosition, vNormal, Material.SpecularScalar) * Material.SpecularColor;

 

이를 모두 더한 값이 빛의 color가 된다!

 

 

Uber Shader

 

Uber Shader는 하나의 큰 셰이더 파일에 다양한 기능(ex. 여러 라이팅 모델, 그림자 처리 방식, 텍스처 유무 등)을 전처리기 매크로로 조건 분기해서 모두 포함시킨 셰이더. 일종의 셰이더 기능 통합본

#if USE_NORMAL_MAP
    // 노멀 맵 계산
#endif

#if USE_PARALLAX
    // 패럴럭스 맵 계산
#endif

 

이러한 매크로는 모두 셰이더 컴파일 타임에 결정됨

 

- 전처리기 매크로를 사용하는 것이 ConstantBuffer를 이용해 flag로 인자를 넘기는 것보다 효과적인 이유?

  • if문 사용 시 (동적 분기):
    모든 분기 코드는 조건에 따라 "마스킹"되어 실행되는데,
    — 즉, 워프 내에서 조건이 true인 픽셀들만 결과를 반영하지만,
    실제로는 두 경로 모두 실행되므로, 마스킹 처리를 위한 추가 비용이 발생함
  • if문 없이 전처리기를 사용하면:
    분기가 아예 존재하지 않으므로,
    모든 픽셀이 같은 명령어 집합을 실행하고,
    추가 마스킹 오버헤드가 발생하지 않아 더 빠름

따라서, 분기 조건으로 인해 워프 내에 여러 실행 경로가 생기면 마스킹 및 divergence 오버헤드가 발생하므로,
컴파일 타임 분기로 분기를 제거해 한 경로로 통합하면 성능면에서 이점을 얻을 수 있다!

 

 

Shader Hot Reload

 

Shader가 변경되었을 때, 런타임에서 Shader 변경을 감지하고, Release->Create하는 방식으로 실시간으로 적용할 수 있다.

구현하는 방식으로

1. EngineLoop의 Tick마다 파일의 변경을 감지하는 방법

2. Thread를 이용해 파일의 변경을 감지하고 MainLoop로 이 사실을 던지는 방법

 

이중에 나는 2번을 이용했다.

 

void FEngineLoop::HotReloadShader(const std::wstring& dir)
{
    // 감시할 디렉토리의 변경 이벤트 핸들 얻기
    HANDLE hChange = FindFirstChangeNotificationW(
        dir.c_str(),
        TRUE,   // 하위 디렉토리 포함
        FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_LAST_WRITE
    );

    if (hChange == INVALID_HANDLE_VALUE)
    {
        std::wcerr << L"FindFirstChangeNotification failed: " << GetLastError() << std::endl;
        return;
    }

    std::wcout << L"Monitoring directory with FindFirstChangeNotification: " << dir << std::endl;

    while (!g_bShaderThreadExit.load())
    {
        // 변경 이벤트가 발생할 때까지 대기
        DWORD waitStatus = WaitForSingleObject(hChange, 1000);
        if (waitStatus == WAIT_OBJECT_0)
        {
            std::wcout << L"Directory change detected!" << std::endl;

            // 쉐이더 리로드
            bShaderChanged = true;

            // 변경 이벤트 핸들을 재설정
            if (!FindNextChangeNotification(hChange))
            {
                std::wcerr << L"FindNextChangeNotification failed: " << GetLastError() << std::endl;
                break;
            }
        }
        else if (waitStatus == WAIT_TIMEOUT)
        {
            continue;
        }
        else
        {
            std::wcerr << L"WaitForSingleObject error: " << GetLastError() << std::endl;
            break;
        }
    }

    FindCloseChangeNotification(hChange);
}

 

쉐이더의 변경이 감지되면 bShaderChanged bool변수를 true로 바꿔준다.

 

    // 쉐이더 파일 변경 감지, 핫 리로드
    if (bShaderChanged)
    {
        Renderer.ShaderManager->ReleaseAllShader();
        Renderer.StaticMeshRenderPass->CreateShader();
        Renderer.BillboardRenderPass->CreateShader();
        Renderer.GizmoRenderPass->CreateShader();
        Renderer.DepthBufferDebugPass->CreateShader();
        Renderer.FogRenderPass->CreateShader();
        Renderer.LineRenderPass->CreateShader();
        bShaderChanged = false;
    }

 

그리고 MainLoop에서 GraphicDevice에서 SwapBuffer가 끝나는 시점 다음에 ShaderRelease -> ShaderCreate를 해준다.

현재는 폴더 내의 파일 하나라도 변경이 감지되면 모든 쉐이더가 재성성되는 방식인데, 추후 개별적으로 동작할 수 있도록 리팩토링 예정이다.