- 노멀과 방향광 (Blinn-Phong)
- HDR 렌더 타깃, 톤매핑, 감마 보정
- PBR (Cook-Torrance)
- 텍스처 로딩과 밉맵
- 머티리얼, 탄젠트, 노멀 맵
1. 노멀과 방향광
정점과 상수 버퍼
조명을 계산하려면 표면이 어느 쪽을 향하는지 알아야 합니다. 정점에 노멀을 추가했고, 큐브는 면마다 노멀이 달라야 해서 정점이 8개에서 24개로 늘었습니다.
보내 데이터가 늘면서 루트 상수 16개로는 부족해졌습니다. 상수 버퍼를 두 개로 나눴습니다.
| 버퍼 | 갱신 주기 | 내용 |
| PerFrame(b0) | 프레임당 1회 | ViewProj, 카메라 위치, 라이트 방향/색, amblient |
| PerObject(b1) | 드로우당 1회 | World, WorldInvTranspose, 베이스 컬러, 머티리얼 값 |
UploadRing: 프레임별 상수 버퍼 할당기
CPU가 다음 프레임의 상수를 쓰는 동안 GPU는 아직 이전 프레임을 그리고 있을 수 있습니다. 같은 버퍼를 쓰면 GPU가 읽는 도중에 값이 바뀝니다. 그래서 프레임 인덱스별로 업로드 버퍼를 따로 두고, 256바이트 단위로 잘라 순서대로 나눠 씁니다. 자기 차례가 돌아오면(GPU가 이미 다 쓴 뒤) 처음부터 다시 씁니다.
WorldInvTranspose
노멀을 월드 행렬로 그대로 변환하면 비균등 스케일에서 방향이 틀어집니다. 노멀에는 월드 행렬의 역전치 행렬을 써야 표면에 수직인 상태가 유지됩니다.
2. HDR과 톤매핑
문제
조명 계산 결과는 1.0을 쉽게 넘습니다. 8비트 렌더 타켓에 바로 그리면 쓰는 순간 1.0에서 잘려서, 밝은 면이 색 구분 없이 하얗게 뭉개집니다.
구조
씬 렌더링 → [sceneRT: R16G16B16A16_FLOAT] → 톤매핑 패스 → [viewportRT: R8G8B8A8_UNORM] → ImGui
| sceneRT | viewportRT | |
| 담는 값 | 실제 빛의 양 (1.0초과 가능) | 모니터로 내보낼 최종 색 |
| 깊이 버퍼 | 있음 | 없음 |
같은 텍스처를 읽으면서 동시에 쓸 수느 없어서 입력과 출력이 분리돼야 합니다.
블룸이나 안티앨리어싱 같은 포스트프로세스도 이 사이에 단계가 하나씩 끼어드는 구조로 늘어납니다.
톤 매핑 패스
- 풀 스크린 삼각형: 버텍스 버퍼 없이 SV_VertexID(0,1,2)만으로 화면보다 큰 삼각형 하나를 만들어 덮습니다.
- ACES: 밝은 값을 잘라 내지 않고 곡선으로 눌러서 계조를 남깁니다.
- 감마 보정: 모니터는 입력을 약2.2제곱해서 표시합니다. 계산은 선형 공간에서 하고, 마지막에 1/2.2제곱을 해서 내보냅니다. 반대로 색상 피커에서 고른 색은 셰이더에서 선형으로 바꾼 뒤 계산합니다.
3. PBR (Cook-Torrance)
표면을 아주 작은 거울 조각(미세면)이 모인 것으로 보는 모델입니다.
| 항 | 묻는것 | 화면에서 보이는 효과 |
| D (GGX) | 미세면 중 몇 개가 반사 방향을 향하는가 | 거칠기가 낮으면 작고 강한 하이라이트, 높으면 넓고 흐린 하이라이트 |
| G (Smith) | 미세면끼리 서로 가리는 정도 | 비스듬한 각도에서 과하게 밝아지는 걸 억제 |
| F (Schlick) | 보는 각도에 따른 반사율 변화 | 가장자리로 갈수록 반사가 강해짐 |
float3 F0 = lerp(0.04, albedo, metallic); // 비금속 4%, 금속은 자기 색
float3 specular = (D * G * F) / max(4 * NdotV * NdotL, 1e-4);
float3 diffuse = (1 - F) * (1 - metallic) * albedo / PI; // 금속은 난반사 없음
- 금속성은 두 가지를 바꿉니다. 금속은 난반사가 없고, 반사광에 자기 색이 묻어납니다.
- 난반사는 π로 나누는 것은 에너지 보존을 위한 것입니다. Blinn-Phong 때보다 약 3배 어두워져서 기본 라이트 세기를 3.0으로 올렸습니다.
- 현재 한계: 금속은 주변 환경을 반사해서 보이는 재질인데, 지금은 방향과 하나뿐이라 하이라이트 주변 말고는 어둡습니다. 환경 조명(IBL)이 들어가야 제대로 보입니다.
4. 텍스처와 밉맵
업로드 경로
CPU는 UPLOAD힙에만 쓸 수 있고, GPU는 DEFAULT힙에서 읽는게 빠릅니다. 그래서 두 단계를 거칩니다.
픽셀 데이터 → 업로드 버퍼(UPLOAD 힙) → CopyTextureRegion → 텍스처(DEFAULT 힙) → SRV
- Renderer::Immediate: 초기화 시점에는 열린 커맨드 리스트가 없어서, 람다를 받아 즉시 실행하고 완료까지 기다리는 함수를 만들었습니다.
- RowPitch: GPU가 요구하는 행 간격이 width x 4 보다 클 수 있습니다.
- sRGB포맷: 색상 텍스처는 _SRGB포맷으로 만들면 샘플링할 때 GPU가 선형으로 바꿔 줍니다. 노멀이나 러프니스 같은 데이터 텍스처는 일반 포맷을 씁니다.
- 기본 텍스처: 텍스처가 없는 슬롯에는 1x1흰색을 바인딩해서 셰이더를 한가지 경로로 유지합니다.
이미지 포맷
| 일반 이미지(PNG, JPG, TGA) | GPU전용(DDS, KTX2) | |
| 로딩 | CPU에서 압축을 풀어 RGBA로 변환 | 거의 그대로 업로드 |
| VRAM | 큼 | BC압축으로 4분의 1에서 8분의 1 |
| 밉맵 | 없음, 직접 생성 | 파일에 포함 |
| 용도 | 제작과 교환 | 런타임 |
지금은 stb_image로 일반 이미지를 읽습니다. 나중에 에셋 파이프라인에서 BC압축 DDS로 변환해 캐시하는 구조로 갈 계획입니다.
밉맵
멀리 있는 표면은 화면의 한 픽셀이 텍스처의 여러 칸을 덮습니다. 그중 한 점만 읽으면 자글자글하게 깨지고, 움직일 때 깜빡입니다. 절반씩 줄인 사본을 미리 만들어 두고 거리에 맞는 사본을 읽게 합니다.
- DX12에는 밉맵 자동 생성 함수가 없어서 CPU에서 2x2평균으로 만들었습니다.
- 밉 레벨 하나하나가 서브리소스이고, 복사 명령은 서브리소스 단위로 내립니다.
- sRGB는 선형으로 바꾼 뒤 평균해야 합니다. 검정과 흰색을 바이트 그대로 평균하면 128이 되는데, 실제 밝기로는 약 22%입니다. 올바른 값은 약 188입니다.
- 메모리는 원본의 약 1.33배가 됩니다.
5. 머티리얼과 노멀 맵
Material 구조
엔티티가 낱개로 들고 있던 값을 묶었습니다. Scene이 머티리얼을 소유하고, 엔티티는 포인터로 참조합니다.
여러 엔티티가 같은 머티리얼을 공유할 수 있습니다.
| 슬롯 | 내용 | 기본값 |
| t0 Albedo | 색 (sRGB) | 흰색 |
| t1 Normal | 표면 요철 방향(선형) | (128,128,255) |
| t2 Metallic-Roughness | G = 거칠기, B = 금속성 (glTF 규약) | 흰색 |
텍스처마다 SRV가 힙의 다른 위치에 있어서, 디스크립터 테이블을 슬롯별로 하나씩 따로 뒀습니다.
탄젠트 공간
노멀 맵의 RGB는 방향 벡터인데, 월드 기준이 아니라 표면에 붙은 좌표계 기준입니다.
- T: 텍스처의 U가 증가하는 방향(정점 속성으로 저장)
- B: V가 증가하는 방향. cross(N, T) x w로 계산
- N: 표면 노멀
float3 nTS = gNormalMap.Sample(gSampler, uv).xyz * 2 - 1;
N = normalize(nTS.x * T + nTS.y * B + nTS.z * N);
이 좌표계를 쓰기 때문에 같은 노멀 맵을 큐브의 어느 면에든, 구에든 그대로 쓸 수 있습니다.
실제 지오메트리는 바뀌지 않고 조명 계산에 쓰는 노멀만 바뀌므로, 실루엣은 여전히 매끈합니다.
결과

'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글
| 그리드, Scene/Entity, Hierarchy·Inspector, 기즈모 (0) | 2026.10.05 |
|---|---|
| 서브모듈 정리, 카메라 (0) | 2026.10.03 |
| Viewport 만들기 (0) | 2026.10.02 |
| 엔진 구상 (0) | 2026.10.01 |



