이전 글까지는 방향광 하나로만 물체를 비췄습니다. 그래서 금속은 하이라이트 주변 말고는 어둡고, 빛을 등진 면은 임시 ambient 상수로만 밝혔습니다. 이번에는 주변 환경 전체를 광원으로 쓰는 IBL을 넣어서 PBR 머티리얼이 원래 의도대로 보이게 됩니다.
| 단계 | 내용 | 결과 |
| 1 | HDR 환경맵 로딩, 스카이 박스 | 배경에 하늘 |
| 2 | 컴퓨트 셰이더로 큐브맵 변환, Irradiance | 그림자 쪽이 환경색으로 은은하게 밝아짐(난반사 IBL) |
| 3 | Prefiltered맵, BRDF LUT | 금속과 유리에 환경이 비침 (정반사 IBL) |
| 파일 | 역할 |
| Renderer/SkyPass.h/.cpp, Shaders/Sky.hlsl | 스카이박스 |
| Renderer/IBL.h/.cpp | IBL 리소스 생성과 사전 계산 |
| Shaders/IBLCommon.hlsli | 큐브 면 → 방향 변환, 공용 cbuffer |
| Shaders/IBLSampling.hlsli | Hammersley 수열, GGX 중요도 샘플링 |
| Shaders/EquirectToCube.hlsl | 파노라마 → 큐브맵 |
| Shaders/Irradiance.hlsl | 난반사용 Irradiance 맵 |
| Shaders/Prefilter.hlsl | 정반사용 Prefiltered 맵 |
| Shaders/BrdfLut.hlsl | BRDF LUT |
1. HDR 환경맵과 스카이 박스
HDR 이미지 로딩
.hdr 파일은 0~1로 제한되지 않은 선형 float값을 담고 있습니다. 태양 부분은 수십에서 수천까지 올라갑니다.
stb_image의 stbi_loadf로 읽어서 R32G32B32A32_FLOAT 텍스처로 올립니다.
이를 위해 기존 텍스처 업로드 코드를 픽셀 포맷과 무관한 공통 함수로 분리했습니다.
// RGBA8, RGBA32F 등 포맷과 픽셀당 바이트 수만 받아서 업로드
void CreateInternal(Renderer& renderer, DXGI_FORMAT format, UINT bytesPerPixel,
const std::vector<MipData>& mips);
void Texture::LoadHDR(Renderer& renderer, const std::string& path)
{
int w = 0, h = 0, comp = 0;
float* pixels = stbi_loadf(path.c_str(), &w, &h, &comp, 4); // 선형 float RGBA
if (!pixels)
throw std::runtime_error("HDR load failed: " + path);
CreateInternal(renderer, DXGI_FORMAT_R32G32B32A32_FLOAT, 16,
{ { (UINT)w, (UINT)h, reinterpret_cast<const uint8_t*>(pixels) } });
stbi_image_free(pixels);
}
스카이박스 그리기
하늘은 무한히 먼 곳에 있으므로 카메라가 이동해도 따라오지 않고, 회전만 반영해야 합니다.
XMMATRIX view = camera.View();
view.r[3] = XMVectorSet(0, 0, 0, 1); // 이동 성분 제거
const XMMATRIX invVP = XMMatrixInverse(nullptr, view * camera.Proj());
// 풀스크린 삼각형을 깊이 1.0(가장 먼 곳)에 배치
VSOut VSMain(uint id : SV_VertexID)
{
float2 uv = float2((id << 1) & 2, id & 2);
VSOut o;
o.ndc = uv * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
o.pos = float4(o.ndc, 1.0f, 1.0f);
return o;
}
float4 PSMain(VSOut i) : SV_Target
{
// 화면 픽셀 -> 그 픽셀이 바라보는 월드 방향
float4 world = mul(float4(i.ndc, 1.0f, 1.0f), gInvViewProj);
float3 dir = normalize(world.xyz / world.w);
return float4(gEnvMap.SampleLevel(gSampler, dir, 0).rgb * gSkyIntensity, 1.0f);
}
PSO의 깊이 설정이 핵심입니다.
pso.DepthStencilState.DepthWriteMask = D3D12_DEPTH_WRITE_MASK_ZERO; // 깊이는 쓰지 않음
pso.DepthStencilState.DepthFunc = D3D12_COMPARISON_FUNC_LESS_EQUAL; // 1.0 == 1.0 통과
씬을 먼저 그리고 하늘을 나중에 그립니다. 물체가 그려진 픽셀은 깊이가 1.0보다 작아서 하늘이 탈락하고, 빈 픽셀(깊이1.0)에만 하늘이 칠해집니다.
방향 -> 파노라마UV
float2 DirToEquirect(float3 d)
{
float u = 0.5f - atan2(d.z, d.x) / (2.0f * PI); // 수평 각도
float v = acos(clamp(d.y, -1.0f, 1.0f)) / PI; // 수직 각도 (0 = 위, 1 = 아래)
return float2(u, v);
}
1과 2의 과정이 다름.
2. 컴퓨트 셰이더로 큐브맵 만들기
왜 큐브맵인가
- 파노라마는 극점(위아래)에서 픽셀이 한 점으로 모여 해상도가 낭비되고, 좌우 끝에 이음매가 생깁니다.
- 큐브맵은 방향 벡터 하나로 바로 샘플링할 수 있고, 하드웨어가 면 사이 보간을 처리합니다.
- 이후 IBL 계산이 전부 방향->값 형태라서 큐브맵이 자연스럽다.
하나의 리소스, 두가지 뷰
컴퓨트 셰이더는 텍스처에 쓰고, 렌더링 셰이더는 읽습니다.
같은 리소스에 뷰를 두개 만듭니다.
// 6장짜리 텍스처 배열 = 큐브맵. 컴퓨트에서 쓸 수 있게 UAV 허용
auto desc = CD3DX12_RESOURCE_DESC::Tex2D(format, size, size, 6, (UINT16)mips, 1, 0,
D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS);
// 읽기: 큐브맵 (방향 벡터로 샘플링)
srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURECUBE;
srv.TextureCube.MipLevels = mips;
// 쓰기: 2D 배열 (면 번호 = 배열 인덱스). 밉마다 하나씩
uav.ViewDimension = D3D12_UAV_DIMENSION_TEXTURE2DARRAY;
uav.Texture2DArray.MipSlice = m;
uav.Texture2DArray.ArraySize = 6;
큐브 면의 텍셀 -> 방향
컴퓨트 스레드 하나가 텍셀 하나를 맡습니다.
Dispatch(x, y, 6)의 z가 면 번호입니다. D3D 큐브맵 규약(+X, -X, +Y,-Y, +Z, -Z)대로 방향을 만들어야 나중에 하드웨어 샘플링과 일치합니다.
float3 CubeDir(uint3 id)
{
float2 uv = ((float2(id.xy) + 0.5f) / gSize) * 2.0f - 1.0f; // -1..1, v는 아래로 증가
float3 d;
switch (id.z)
{
case 0: d = float3( 1.0f, -uv.y, -uv.x); break; // +X
case 1: d = float3(-1.0f, -uv.y, uv.x); break; // -X
case 2: d = float3( uv.x, 1.0f, uv.y); break; // +Y
case 3: d = float3( uv.x, -1.0f, -uv.y); break; // -Y
case 4: d = float3( uv.x, -uv.y, 1.0f); break; // +Z
default: d = float3(-uv.x, -uv.y, -1.0f); break; // -Z
}
return normalize(d);
}
// EquirectToCube.hlsl
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
if (id.x >= gSize || id.y >= gSize) return;
float3 dir = CubeDir(id);
gOut[id] = float4(gEquirect.SampleLevel(gSampler, DirToEquirect(dir), 0).rgb, 1.0f);
}
컴퓨트 파이프라인
// 루트 시그니처 (모든 IBL 패스 공용): b0 = 파라미터, t0 = 입력, u0 = 출력
params[0].InitAsConstants(4, 0);
params[1].InitAsDescriptorTable(1, &srvRange);
params[2].InitAsDescriptorTable(1, &uavRange);
// PSO
D3D12_COMPUTE_PIPELINE_STATE_DESC desc{};
desc.pRootSignature = rs;
desc.CS = CD3DX12_SHADER_BYTECODE(cs.Get());
device->CreateComputePipelineState(&desc, IID_PPV_ARGS(&pso));
그래픽스와 달리 SetComputeRootSignature, SetComputeRoot32BitConstants, SetComputeRootDescriptorTable, Dispatch 를 씁니다.
리소스 상태
| 리소스 | 상태 변화 |
| 큐브맵들 | UNORDERED_ACCESS로 생성 -> 계산 후 PIXEL |
| 일반 텍스처 | 컴퓨트에서도 읽을 수 있게 최종 상태를 PIXEL |
읽기 전용 상태끼리는 OR로 합칠 수 있습니다. 픽셀 셰이더 전용 상태인 리소스를 컴퓨트 셰이더에서 읽으면 디버그 레이어 에러가 납니다.
4. 정반사 IBL: Split-sum 근사
정반사는 거칠기와 시야 방향에 따라 적분 범위가 달라서, 매 픽셀 계산하기엔 너무 무겁습니다. UE4가 도입한 Split-sum 근사로 두 덩어리로 나눠 미리 계산합니다.
정반사 ≈ [Prefiltered 환경(반사 방향, 거칠기)] × [F0 × LUT.x + LUT.y]
↑ 환경에만 의존 ↑ 재질에만 의존
중요도 샘플링
반구를 균일하게 훑는 대신, GGX분포가 강한 방향(반사 방향 근처)에 샘플을 몰아줍니다.
// 저불일치 수열: 샘플이 고르게 퍼짐
float2 Hammersley(uint i, uint n)
{
return float2(float(i) / float(n), float(reversebits(i)) * 2.3283064365386963e-10f);
}
// GGX 분포에 따라 하프 벡터 H를 선택
float3 ImportanceSampleGGX(float2 xi, float3 N, float roughness)
{
float a = roughness * roughness;
float phi = 2.0f * PI * xi.x;
float cosTheta = sqrt((1.0f - xi.y) / (1.0f + (a * a - 1.0f) * xi.y));
float sinTheta = sqrt(1.0f - cosTheta * cosTheta);
float3 H = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta);
// 접선 공간 -> 월드 (생략)
}
Prefiltered 환경맵
거칠수록 반사가 흐려지고, 흐린 이미지는 해상도가 낮아도 됩니다. 그래서 밉 레벨 = 거칠기로 저장합니다(128x128, 밉 단계)
for (UINT m = 0; m < mips; ++m)
{
const UINT size = std::max(1u, baseSize >> m);
const float roughness = (float)m / (float)(mips - 1); // 0, 0.25, 0.5, 0.75, 1
run(mPrefilterPso.Get(), mEnv.srv.gpu, mPrefiltered.uavs[m].gpu, size, roughness, 6);
}
float3 V = N; // 근사: 시야 방향 = 반사 방향 = N
for (uint i = 0; i < SAMPLES; ++i)
{
float3 H = ImportanceSampleGGX(Hammersley(i, SAMPLES), N, gRoughness);
float3 L = normalize(2.0f * dot(V, H) * H - V);
float NdotL = dot(N, L);
if (NdotL > 0.0f)
{
sum += gEnvCube.SampleLevel(gSampler, L, 0).rgb * NdotL;
weight += NdotL;
}
}
gOut[id] = float4(sum / weight, 1.0f);
BRDF LUT
시야 각도(NdotV)와 거칠기를 축으로, 프레넬의 스케일(A)과 바이어스(B)를 2D 텍스처(256x256, R16G16_FLOAT)에 저장합니다.
float G_Vis = (G * VdotH) / (NdotH * NdotV);
float Fc = pow(1.0f - VdotH, 5.0f);
A += (1.0f - Fc) * G_Vis; // F0에 곱해지는 부분
B += Fc * G_Vis; // 더해지는 부분
이 LUT는 환경과 무관해서, 환경맵을 바꿔도 다시 계산할 필요가 없습니다.
계산 순서 (시작 시 한번)
파노라마 ──EquirectToCube──▶ 환경 큐브맵 (512)
├──Irradiance──▶ Irradiance 큐브맵 (32)
└──Prefilter───▶ Prefiltered 큐브맵 (128, 밉 5)
BrdfLut ──────────────────────▶ BRDF LUT (256×256)
5. 셰이더 연결
씬 루트 시그니처에 t4~t6 슬롯과 LUT용 clamp샘플러(s1)를 추가했습니다. 기존 샘플러는 WRAP이라서, LUT 가장자리에서 거칠기 0과 1의 값이 섞이는 문제가 생기기 때문입니다.
// 프레넬: 거친 표면은 비스듬한 각도에서도 반사가 덜 강해짐
float3 F_SchlickRoughness(float NdotV, float3 F0, float roughness)
{
return F0 + (max((1.0f - roughness).xxx, F0) - F0) * pow(saturate(1.0f - NdotV), 5.0f);
}
float3 kS_amb = F_SchlickRoughness(NdotV, F0, roughness);
float3 kD_amb = (1.0f - kS_amb) * (1.0f - metallic);
// 난반사: 노멀 방향의 irradiance
float3 irradiance = gIrradianceMap.SampleLevel(gSampler, N, 0).rgb;
float3 diffuseIBL = kD_amb * irradiance * albedo;
// 정반사: 반사 방향의 prefiltered 환경 × LUT
float3 R = reflect(-V, N);
float3 prefiltered = gPrefilterMap.SampleLevel(gSampler, R, roughness * kPrefilterMaxLod).rgb;
float2 brdf = gBrdfLut.SampleLevel(gClampSampler, float2(NdotV, roughness), 0).rg;
float3 specularIBL = prefiltered * (kS_amb * brdf.x + brdf.y);
float3 ambient = (diffuseIBL + specularIBL) * gIblIntensity;
roughness x 4 를 LOD로 넣으면 밉 사이를 보간해서 중간 거칠기도 표현됩니다.
결과



'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글
| 렌더러 인프라 정리 (0) | 2026.10.15 |
|---|---|
| Shadow Map에서 Cascaded Shadow Maps (0) | 2026.10.13 |
| glTF 모델 로딩 (0) | 2026.10.09 |
| cgltf API 정리 (사용한것 기준) (0) | 2026.10.09 |
| 라이팅에서 PBR 머티리얼까지 (0) | 2026.10.06 |
