-
[week6] LightUE5 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)을 적용해야 할 수 있습니다.
- 등방성(diffuse reflection):
// 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 - Lambert의 코사인 법칙