ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [week6] Light
    UE5 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를 해준다.

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

     

     

    'UE5' 카테고리의 다른 글

    [week8] Delegate  (2) 2025.04.25
    [week5] PixelShader - Fog  (1) 2025.04.04
    [week2] Texture Rendering  (1) 2025.03.20
    [UE5] Development Editor에서 효과적으로 디버깅하는 방법  (1) 2025.01.17
    [CG] 레스터라이저 상태(Rasterizer State)  (1) 2025.01.15
Designed by Tistory.