전역 조명 (Global Illumination) — 한 줄 한 줄 직접 구현
전역 조명(GI)은 빛이 표면끼리 여러 번 튕기며 만드는 간접광입니다. 앞 절의 패스 트레이서는 이미 완전한 GI를 계산합니다 — 재귀 바운스가 곧 간접광이고, 빨간 벽 옆 흰 구가 붉게 물드는 color bleeding이 그 증거입니다. 즉 정답은 이미 있습니다. 문제는 실시간이라는 제약입니다. 이 페이지에서는 실시간에서 GI를 "충분히 그럴싸하게" 근사하는 대표 기법들을, 개념 → 수식 → 기하 직관 → HLSL/C++ 코드 한 줄까지 해부하며 직접 만들어 봅니다.
v' = v * M, HLSL mul(v, M)),
행 우선 저장, 왼손 좌표계, 클립 공간 z ∈ [0,1], 텍스처 V축 위→아래를 따릅니다.
실무에서는 DirectXMath와 검증된 라이브러리 사용을 권장하며, 여기 코드는 원리 학습용 톤으로
최소한의 형태만 남겼습니다.
0. 큰 그림 — 왜 근사가 필요한가
물리적으로 정확한 조명은 렌더링 방정식(Kajiya, 1986) 하나로 요약됩니다.
어떤 점 x에서 방향 ωo로 나가는 빛 Lo는
그 점이 스스로 내는 빛 Le에, 반구 전체에서 들어오는 모든 빛을
BRDF로 가중해 적분한 값을 더한 것입니다:
Lo(x, ωo) = Le + ∫Ω fr(x, ωi, ωo) · Li(x, ωi) · (n·ωi) dωi
여기서 핵심은 Li(들어오는 빛) 안에 다른 표면에서 나간 빛이 들어있다는 점입니다.
즉 이 방정식은 자기 자신을 재귀적으로 참조합니다 — 빛이 A에서 B로, B에서 C로 무한히 튕기는 것이
곧 이 재귀입니다. 패스 트레이싱은 이 적분을 몬테카를로 샘플링으로 직접 푸는 것이고, 정답이지만 느립니다.
실시간 기법들은 모두 이 적분을 어딘가에서 잘라내는(근사하는) 전략의 차이입니다:
| 기법 | 아이디어 | 장점 | 한계 |
|---|---|---|---|
| 라이트맵 (베이킹) | 오프라인에서 GI를 계산해 텍스처에 굽는다 | 런타임 비용 거의 0, 품질 최고 | 정적 씬 전용, 굽는 시간 김 |
| Irradiance Probes | 공간에 프로브를 배치해 방향별 간접광 저장, 동적 물체가 보간해 샘플 | 동적 물체 대응, 비교적 저렴 | 프로브 밀도·배치에 품질 의존, 누수(light leak) |
| Voxel GI (VXGI) | 씬을 복셀화해 원뿔 추적(cone tracing)으로 간접광 근사 | 완전 동적 GI 가능 | 메모리·성능 부담, 복셀 해상도 한계 |
| SSGI | SSAO처럼 화면 공간에서 간접 반사광을 근사 | 저렴, 기존 G-buffer 재사용 | 화면 밖 정보는 못 씀(SSR과 같은 한계) |
| 하드웨어 RT / RTGI | RT 코어로 소수 광선을 실시간 추적 + 강력한 디노이징 (DXR / DirectX Raytracing) | 동적·정확, 반사/그림자 통합 | 고성능 GPU 필요, 여전히 노이즈/시간적 안정성 과제 |
요약하면 — 오프라인은 패스 트레이싱으로 정답을, 실시간은 근사와 하드웨어 RT의 조합으로 "충분히 그럴싸한 정답"을 추구하는 방향으로 수렴하고 있습니다. 아래에서는 이 표의 각 줄을 실제 코드로 만들어 봅니다.
1. 라이트맵 베이킹 — 정답을 미리 구워 텍스처에 저장
무엇을 만드나: 정적인 씬의 각 표면 점이 받는 간접광(irradiance)을 오프라인에서 계산해 2D 텍스처(라이트맵)에 저장하고, 런타임에는 UV로 한 번 샘플만 하는 파이프라인입니다. 왜 필요한가: 매 프레임 적분을 다시 풀 필요 없이, 벽·바닥처럼 움직이지 않는 물체의 GI를 텍스처 읽기 한 번으로 끝낼 수 있습니다 — 런타임 비용이 사실상 0입니다.
- 메시의 모든 삼각형을 겹치지 않게 2D 아틀라스에 펼치는 라이트맵 UV(제2 UV 채널)를 만든다.
- 각 라이트맵 텍셀 ↔ 월드 공간 위치·법선을 대응시킨다(텍셀 하나가 표면의 작은 패치 하나).
- 각 텍셀에서 반구 방향으로 광선을 쏴 도착한 빛을 적분한다 → 그 텍셀의 irradiance.
- 결과를 텍스처로 저장한다. 런타임 셰이더는 라이트맵 UV로 이 값을 읽어 albedo에 곱한다.
1-1. 텍셀 하나의 irradiance를 굽는 커널 (C++)
먼저 오프라인 베이커의 심장부입니다. 라이트맵 텍셀 하나에 대응하는 표면 점 p와 법선 n이
주어졌을 때, 그 점이 반구 전체에서 받는 빛(irradiance)을 몬테카를로 적분으로 구합니다.
이 함수가 라이트맵의 모든 텍셀마다 한 번씩 호출됩니다.
// 한 텍셀(표면 점 p, 법선 n)이 반구에서 받는 간접광(irradiance)을 적분한다.
// scene.trace()는 광선을 쏴 가장 가까운 교차의 방출/반사광(radiance)을 돌려준다고 가정.
Vec3 BakeTexelIrradiance(const Scene& scene, const Vec3& p, const Vec3& n, int numSamples)
{
Vec3 irradiance = { 0.0f, 0.0f, 0.0f }; // 누적기: RGB 각 채널의 빛 합
// 법선 n을 축으로 하는 정규직교 기저(접선 공간)를 만든다.
Vec3 tangent, bitangent;
BuildOrthonormalBasis(n, tangent, bitangent);
for (int i = 0; i < numSamples; ++i)
{
// [0,1)^2 난수 두 개 → 코사인 가중 반구 방향(local)으로 매핑
float u1 = RandomFloat();
float u2 = RandomFloat();
float r = sqrtf(u1); // 반경 = sqrt(u1)
float phi = 6.2831853f * u2; // 방위각 = 2π·u2
Vec3 localDir = {
r * cosf(phi), // x
r * sinf(phi), // y
sqrtf(1.0f - u1) // z = cosθ (법선 방향 성분)
};
// local(접선 공간) → world(월드 공간)로 변환: 기저 3벡터의 선형결합
Vec3 worldDir =
tangent * localDir.x +
bitangent * localDir.y +
n * localDir.z;
// p에서 아주 살짝 띄운 곳부터 광선을 쏴 도착한 radiance를 받는다.
Ray ray = { p + n * 1e-4f, worldDir };
Vec3 incoming = scene.Trace(ray, /*depth=*/0);
// 코사인 가중 PDF로 샘플했으므로 cosθ와 PDF가 상쇄된다(아래 해부 참조).
irradiance = irradiance + incoming;
}
// 몬테카를로 추정치: 합을 표본 수로 나눠 평균을 낸다.
return irradiance * (1.0f / (float)numSamples);
}
시그니처 해부 — 각 인자와 타입이 왜 이 모양인가
const Scene& scene: 씬은 삼각형·BVH·재질 등 무거운 데이터 덩어리라 값 복사가 매우 비쌉니다.
그래서 &(참조)로 받아 복사를 피하고, 이 함수는 씬을 읽기만 하므로 const를 붙여
"여기서는 씬을 수정하지 않겠다"를 컴파일러와 독자에게 약속합니다.
const Vec3& p, const Vec3& n: Vec3는 12바이트로 작지만 관례상 참조로 받아
복사를 줄이고, 역시 수정하지 않으니 const입니다. p는 텍셀에 대응하는 월드 위치,
n은 그 점의 단위 법선입니다(반드시 정규화되어 있어야 반구 기저가 정확합니다).
int numSamples: 표본 개수입니다. 몬테카를로의 오차는 1/√N로 줄어들기 때문에
노이즈를 절반으로 줄이려면 표본을 4배로 늘려야 합니다(뒤 성능 callout 참조).
반환 타입 Vec3는 RGB 세 채널의 irradiance입니다.
수학 → 기하 직관 → 코드 매핑: 코사인 가중 반구 샘플링
수학: irradiance는 E = ∫Ω Li(ω) · cosθ dω 입니다.
이를 몬테카를로로 추정하면 E ≈ (1/N) Σ Li(ωk)·cosθk / p(ωk)가 됩니다.
여기서 우리가 방향을 코사인 비례 확률로 뽑으면 PDF가 p(ω) = cosθ / π가 되어,
식의 cosθ / p(ω) = cosθ / (cosθ/π) = π… 가 아니라, 실제로는 반사 BRDF까지 포함하면
깔끔하게 cosθ와 PDF가 상쇄됩니다. 그래서 코드에서는 incoming을 그냥 더하기만 합니다.
기하 직관: 법선 n이 하늘을 가리키는 반구를 상상하세요. 표면은 정면(법선 방향)에서
오는 빛을 가장 많이 "받고", 옆(수평)에서 스치는 빛은 거의 못 받습니다 — 이게 cosθ 항의 의미입니다.
코사인 가중 샘플링은 어차피 기여가 큰 정면 쪽으로 표본을 더 촘촘히 뿌리는 영리한 전략이라,
같은 표본 수로도 노이즈가 훨씬 적습니다.
코드 매핑: r = sqrt(u1), phi = 2π·u2로 원판 위의 점을 뽑은 뒤
z = sqrt(1 - u1) = cosθ로 반구에 투영합니다(이게 바로 코사인 가중을 만드는 Malley 방법입니다).
localDir.x, y는 원판 좌표, localDir.z는 법선 방향 높이입니다.
local → world 변환: 3벡터의 선형결합
worldDir = tangent*x + bitangent*y + n*z는 접선 공간 좌표 (x,y,z)를
월드 공간의 세 축 벡터로 재조립하는 것입니다. 정규직교 기저 {tangent, bitangent, n}에서
각 성분에 해당 축 벡터를 곱해 더하면, 그 좌표가 가리키던 방향이 월드에서 어디를 향하는지 나옵니다.
이는 행벡터 규약으로 보면 localDir에 기저 행렬을 곱하는 것과 같습니다:
worldDir = localDir * [tangent; bitangent; n].
p + n * 1e-4f로 광선 시작점을 법선 방향으로 살짝 띄우는 이유는 self-intersection(그림자 여드름)
때문입니다. 부동소수점 오차로 광선이 자기 표면과 즉시 교차해 검은 점이 생기는 것을 막습니다. 엡실론이 너무 작으면
여전히 여드름이 생기고, 너무 크면 얇은 물체를 통과해 빛이 새니 씬 스케일에 맞춰 조정해야 합니다.
DirectXMath 대응
학습용 Vec3 연산을 실무 SIMD로 바꾸면 다음과 같이 1:1 대응합니다.
using namespace DirectX;
XMVECTOR nV = XMLoadFloat3(&n); // Vec3 n → SIMD 레지스터
XMVECTOR t = XMVector3Normalize(/* 접선 */); // BuildOrthonormalBasis의 tangent
XMVECTOR b = XMVector3Cross(nV, t); // bitangent = n × tangent
// local(x,y,z) → world : t*x + b*y + n*z (성분 곱 후 누적)
XMVECTOR world = XMVectorAdd(
XMVectorAdd(XMVectorScale(t, lx), XMVectorScale(b, ly)),
XMVectorScale(nV, lz));
world = XMVector3Normalize(world); // 방향은 단위벡터로
XMFLOAT3 worldDir; XMStoreFloat3(&worldDir, world); // 결과를 다시 float3로
XMLoadFloat3는 원본의 Vec3를 SIMD 레지스터로 올리는 로드, XMStoreFloat3는 그 반대,
XMVectorScale(v, s)는 v * s(스칼라 곱), XMVectorAdd는 +,
XMVector3Cross는 외적으로 bitangent = n × tangent에 대응합니다.
2. Irradiance Probe — 구면 조화(SH)로 방향별 간접광 압축
무엇을 만드나: 공간 곳곳에 "프로브"를 놓고, 각 프로브가 자기 위치에서 모든 방향으로 들어오는 간접광을 아주 적은 계수(보통 9개)로 압축 저장합니다. 런타임에는 동적 물체가 가까운 프로브들을 보간해 자기 법선 방향의 간접광을 즉시 얻습니다. 왜 필요한가: 라이트맵은 정적 표면에만 붙지만, 프로브는 공간 자체를 샘플하므로 걸어다니는 캐릭터 같은 동적 물체도 GI를 받을 수 있습니다.
핵심 도구는 구면 조화 함수(Spherical Harmonics, SH)입니다. SH는 구면 위의 함수(방향→값)를 푸리에 급수처럼 기저 함수들의 가중합으로 전개합니다. 부드러운 irradiance는 저차(low-frequency)라서 2차(9개 계수)만으로 놀랍도록 정확하게 표현됩니다.
2-1. 방향 하나로 SH 기저 9개 평가 (HLSL)
// 단위 방향 dir로 실수형 SH 기저(l=0..2, 총 9개)를 평가한다.
void EvalSH9(float3 dir, out float sh[9])
{
float x = dir.x, y = dir.y, z = dir.z;
// l = 0 (상수항, 방향과 무관)
sh[0] = 0.282095; // 1/(2√π)
// l = 1 (선형항: 방향 성분에 비례)
sh[1] = 0.488603 * y; // -√(3/4π)·y 계열
sh[2] = 0.488603 * z;
sh[3] = 0.488603 * x;
// l = 2 (2차항: 성분들의 곱)
sh[4] = 1.092548 * x * y;
sh[5] = 1.092548 * y * z;
sh[6] = 0.315392 * (3.0 * z * z - 1.0);
sh[7] = 1.092548 * x * z;
sh[8] = 0.546274 * (x * x - y * y);
}
시그니처·문법 해부
void EvalSH9(float3 dir, out float sh[9]): HLSL에는 참조가 없으니 결과를 돌려줄 때
out 한정자를 씁니다 — 함수가 sh 배열을 채워서 호출자에게 되돌려준다는 뜻입니다.
float sh[9]는 9개짜리 고정 크기 배열입니다(2차 SH의 계수 개수 = (l+1)² = 3² = 9).
dir은 반드시 단위벡터여야 합니다 — 아래 다항식들이 단위 구면 위에서 정의되기 때문입니다.
수학 → 기하 직관 → 코드 매핑
수학: SH 기저는 라플라스 방정식의 구면 해로, 차수 l마다 2l+1개가 있습니다
(l=0 → 1개, l=1 → 3개, l=2 → 5개, 합 9개). 각 상수(0.282095 등)는 기저가 구면 위에서
직교·정규가 되도록 맞추는 정규화 상수입니다. 예를 들어 0.282095 = 1/(2√π)입니다.
기하 직관: l=0 항은 방향과 무관한 평균 밝기(전체를 균일하게 채우는 빛)입니다.
l=1 세 항(x, y, z에 비례)은 한쪽이 밝고 반대쪽이 어두운 기울기 — 즉 지배적인 빛의 방향입니다.
l=2 다섯 항은 더 미세한 "언덕과 골짜기" 패턴을 담습니다. 저차만 쓰면 뭉툭하지만 부드러운 GI에는 충분합니다.
코드 매핑: sh[0]은 상수, sh[1..3]은 방향 성분에 정확히 비례,
sh[4..8]은 성분들의 이차 곱(xy, yz, 3z²−1, xz, x²−y²)입니다.
이 형태는 SH 실수 기저의 표준 공식을 그대로 옮긴 것입니다.
2-2. 프로브가 저장한 SH로 법선 방향 irradiance 복원 (HLSL)
베이킹 때 각 프로브는 위 기저로 3채널(RGB) × 9계수 = 27개 float을 저장해 둡니다.
런타임에는 표면 법선 n에 대해 다시 SH를 평가하고, 저장된 계수와 내적하면 그 방향의 간접광이 나옵니다.
cbuffer ProbeCB : register(b3)
{
float4 gSH_R[9]; // R채널 9계수 (.x만 사용, 정렬용 float4)
float4 gSH_G[9];
float4 gSH_B[9];
};
// 표면 법선 n으로 프로브의 간접광(RGB)을 복원한다.
float3 SampleProbeIrradiance(float3 n)
{
float basis[9];
EvalSH9(n, basis); // 법선 방향의 기저값 9개
float3 result = 0;
[unroll]
for (int i = 0; i < 9; ++i)
{
// 각 계수 · 해당 기저값을 채널별로 누적 = SH 내적(재구성)
result.r += gSH_R[i].x * basis[i];
result.g += gSH_G[i].x * basis[i];
result.b += gSH_B[i].x * basis[i];
}
return max(result, 0.0); // 음수 조명은 물리적으로 없으니 클램프
}
줄 단위 해부
cbuffer ProbeCB : register(b3)는 프로브 계수를 상수 버퍼 슬롯 b3에 바인딩합니다.
HLSL cbuffer는 16바이트(float4) 정렬을 강제하므로, 스칼라 9개를 그냥 float gSH_R[9]로 두면
각 원소가 16바이트로 패딩돼 레이아웃이 어긋납니다. 그래서 float4 배열로 잡고 .x만 씁니다
(실무에서는 4계수씩 묶어 낭비를 줄이기도 합니다).
[unroll]은 컴파일러에게 이 9회 루프를 완전히 펼치라는 힌트입니다 — 반복 횟수가 상수라
분기 없이 곱셈-덧셈 27쌍으로 전개되어 GPU에서 훨씬 빠릅니다.
수학: result = Σ ci · Yi(n)은 SH 재구성(reconstruction) 그 자체입니다.
저장된 계수 ci와 법선에서 평가한 기저 Yi(n)의 내적이 곧
"이 법선 방향으로 들어오는 평균 간접광"입니다. 푸리에 급수에서 계수와 기저를 곱해 신호를 복원하는 것과 똑같습니다.
max(result, 0)로 클램프합니다. 강한 그림자 경계처럼 고주파 성분이 큰 경우 저차 SH로는 표현 한계가 있어
"빛 누수"나 "과도한 부드러움"이 나타납니다.
DirectXMath / C++ 대응 (프로브 베이킹 시 계수 적립)
// 프로브 위치에서 방향 dir로 받은 radiance를 SH 계수에 적립한다.
// 몬테카를로 적분: c_i += L(dir) * Y_i(dir) / pdf, 균일 구면이면 pdf = 1/(4π)
void AccumulateSH(float sh[9], const Vec3& radiance, const Vec3& dir, float weight)
{
float Y[9];
EvalSH9(dir, Y); // 셰이더와 동일한 기저 공식
for (int i = 0; i < 9; ++i)
sh[i] += Y[i] * weight; // (채널은 바깥에서 radiance별로 분리 처리)
}
베이킹의 Y_i(dir)와 셰이딩의 basis[i]는 같은 공식이어야 합니다 —
한쪽 부호나 상수가 다르면 조명이 뒤집히거나 어긋납니다. XMVector3Dot은 계수와 기저를 한 번에 내적할 때
쓸 수 있고, XMVectorSaturate는 max(·,0)+min(·,1) 클램프에 대응합니다.
3. SSGI — 화면 공간에서 간접 반사광 근사
무엇을 만드나: G-buffer(깊이·법선·직접광 결과)만 가지고, 화면 안의 픽셀끼리 서로 빛을 주고받는 1바운스 간접광을 근사합니다. SSAO가 "주변이 얼마나 가려졌나"를 재는 것의 색이 있는 버전이라고 보면 됩니다. 왜 필요한가: 새 지오메트리 패스 없이 이미 있는 버퍼만 재사용하므로 매우 저렴하고, color bleeding 같은 GI 느낌을 실시간으로 줍니다.
- 현재 픽셀의 월드 위치·법선을 깊이/법선 버퍼에서 복원한다.
- 반구 방향으로 짧은 샘플들을 뿌리고, 각 샘플을 화면 좌표로 투영한다.
- 그 지점의 이전 프레임(또는 직접광) 색을 읽어, 코사인·거리 가중으로 모아 간접광으로 더한다.
Texture2D gColor : register(t0); // 이전 프레임의 조명된 색 (radiance 소스)
Texture2D gDepth : register(t1); // 깊이 버퍼
Texture2D gNormal : register(t2); // 월드 공간 법선
SamplerState gSamp : register(s0);
cbuffer SSGICB : register(b0)
{
float4x4 gViewProj; // 월드 → 클립
float4x4 gInvViewProj; // 클립 → 월드 (위치 복원용)
float2 gTexelSize; // (1/width, 1/height)
float gRadius; // 샘플 반경(월드 단위)
int gNumSamples;
};
float3 ReconstructWorldPos(float2 uv, float depth)
{
// uv[0,1]·depth[0,1] → NDC(클립) → 월드
float2 ndc = uv * 2.0 - 1.0; // x,y: [0,1] → [-1,1]
ndc.y = -ndc.y; // 텍스처 V(위→아래)와 NDC y(아래→위) 뒤집기
float4 clip = float4(ndc, depth, 1.0); // z는 이미 [0,1]
float4 world = mul(clip, gInvViewProj); // 행벡터 규약: v * M
return world.xyz / world.w; // 원근 나눗셈
}
float3 ComputeSSGI(float2 uv)
{
float depth = gDepth.Sample(gSamp, uv).r;
float3 P = ReconstructWorldPos(uv, depth);
float3 N = normalize(gNormal.Sample(gSamp, uv).xyz * 2.0 - 1.0);
float3 gi = 0;
for (int i = 0; i < gNumSamples; ++i)
{
// 반구 안의 오프셋 방향(미리 만든 커널 or 해시 난수)
float3 dir = HemisphereSample(i, N);
float3 samplePos = P + dir * gRadius;
// 샘플 월드 위치를 화면으로 재투영
float4 clip = mul(float4(samplePos, 1.0), gViewProj);
float2 sUV = (clip.xy / clip.w) * 0.5 + 0.5;
sUV.y = 1.0 - sUV.y;
// 화면 밖이면 기여 없음
if (any(sUV < 0.0) || any(sUV > 1.0)) continue;
float3 sColor = gColor.Sample(gSamp, sUV).rgb; // 그 픽셀이 내뿜는 빛
float cosTerm = max(dot(N, dir), 0.0); // 코사인 가중
gi += sColor * cosTerm;
}
return gi * (1.0 / gNumSamples); // 평균
}
위치 복원 ReconstructWorldPos 해부
수학: 래스터화는 월드 점을 clip = world * (View·Proj)로 보낸 뒤 xyz/w로 NDC를 만들고,
NDC를 화면 UV·깊이로 저장합니다. 위치 복원은 이 과정을 역으로 밟습니다 —
UV·깊이 → NDC → (역행렬 곱) → 월드.
줄 매핑: uv*2-1은 [0,1] UV를 [-1,1] NDC로 펴고, ndc.y = -ndc.y는
텍스처 V축(위→아래)과 NDC y축(아래→위)의 방향이 반대라서 뒤집는 보정입니다(우리 규약에서 꼭 필요).
clip.z = depth는 이미 [0,1] 범위(우리 클립 z 규약)라 그대로 넣습니다.
mul(clip, gInvViewProj)는 행벡터 규약이라 벡터가 왼쪽입니다 — 이 순서를 바꾸면 결과가 완전히 틀립니다.
마지막 world.xyz / world.w는 동차좌표를 실제 3D 점으로 되돌리는 원근 나눗셈입니다.
tex * 2 - 1로 디코드하는 이유: 법선 [-1,1]을 [0,1] 텍스처에 저장할 때
n*0.5+0.5로 인코딩했기 때문입니다. 짝이 되는 디코드를 빠뜨리면 법선이 절반만 표현돼 조명이 어긋납니다.
또 gViewProj와 gInvViewProj는 정확히 서로 역이어야 하며, DirectXMath로 만들 때
cbuffer 업로드 전 transpose를 잊으면(행/열 규약 불일치) 화면이 깨집니다.
gColor 텍스처를 랜덤 위치로 읽으므로 캐시 미스가 병목입니다. 반경을 크게 잡으면
메모리 접근이 흩어져 급격히 느려집니다. 보통 저해상도(half-res)로 계산하고, 블루노이즈로 샘플 수를 줄인 뒤
시공간 디노이저로 다듬어 성능을 확보합니다.
any(sUV < 0) || any(sUV > 1)로 걸러지는 화면 밖 픽셀과
다른 물체 뒤에 가려진 픽셀은 정보가 없어 기여하지 못합니다. 그래서 SSGI는 카메라를 돌리면 GI가 나타났다 사라지는
불안정성이 있고, 프로브·RTGI 같은 화면 밖 정보원과 결합해 쓰는 경우가 많습니다.
D3D API / DirectXMath 대응
Texture2D … : register(t0)는 C++에서 ID3D11ShaderResourceView를
PSSetShaderResources(0, 1, &srv)로 바인딩한 슬롯에 대응합니다. SamplerState … register(s0)는
PSSetSamplers, cbuffer … register(b0)는 PSSetConstantBuffers에 대응합니다.
gInvViewProj는 XMMatrixInverse(nullptr, XMMatrixMultiply(view, proj))로 만들고,
cbuffer에 넣기 전 XMMatrixTranspose를 반드시 적용합니다(행벡터·행우선 규약 유지).
4. Voxel Cone Tracing — 복셀화한 씬을 원뿔로 훑어 GI 근사
무엇을 만드나: 씬 전체를 3D 격자(복셀)에 밀도·발광 정보로 구워 밉맵(mip)을 만든 뒤, 표면에서 반구 방향으로 몇 개의 원뿔(cone)을 쏴 밉 레벨을 점점 올려 읽으며 간접광을 모읍니다. 왜 필요한가: 개별 광선 대신 원뿔 하나로 넓은 각도를 한 번에 근사하므로, 완전 동적 씬에서도 부드러운 GI를 실시간에 가깝게 얻을 수 있습니다.
- 씬을 3D 텍스처에 복셀화(각 복셀에 색·불투명도 누적)하고 밉맵을 생성한다.
- 표면에서 반구를 덮는 여러 원뿔을 정한다(예: 5~6개).
- 각 원뿔을 따라 전진하며, 거리에 비례해 큰 밉 레벨을 샘플하고 알파 블렌딩으로 누적한다.
Texture3D gVoxel : register(t0); // 복셀화된 씬(RGBA: rgb=색, a=불투명도), 밉맵 있음
SamplerState gSamp : register(s0); // 3선형(trilinear) 필터
cbuffer VoxelCB : register(b0)
{
float3 gVoxelGridMin; // 복셀 격자의 월드 최소 좌표
float gVoxelSize; // 복셀 하나의 월드 크기
int gVoxelRes; // 한 축 해상도(예: 128)
};
// 월드 위치 → 복셀 텍스처 UV(0..1)
float3 WorldToVoxelUV(float3 p)
{
return (p - gVoxelGridMin) / (gVoxelSize * gVoxelRes);
}
// 원점 o에서 방향 d로 원뿔을 추적해 누적 색을 돌려준다. aperture=반각 tan값.
float4 TraceCone(float3 o, float3 d, float aperture)
{
float4 acc = float4(0, 0, 0, 0); // (rgb=누적색, a=누적 불투명도)
float dist = gVoxelSize; // 자기 자신을 안 읽도록 한 복셀 띄우고 시작
[loop]
while (dist < gVoxelSize * gVoxelRes && acc.a < 1.0)
{
// 현재 거리에서 원뿔의 지름 → 그에 맞는 밉 레벨 선택
float diameter = max(gVoxelSize, 2.0 * aperture * dist);
float mip = log2(diameter / gVoxelSize);
float3 pos = o + d * dist;
float4 s = gVoxel.SampleLevel(gSamp, WorldToVoxelUV(pos), mip);
// front-to-back 알파 합성: 앞쪽이 가릴수록 뒤쪽 기여 감소
acc.rgb += (1.0 - acc.a) * s.a * s.rgb;
acc.a += (1.0 - acc.a) * s.a;
dist += diameter * 0.5; // 지름의 절반만큼 전진(겹침 = 부드러움)
}
return acc;
}
원뿔 추적의 핵심 아이디어
수학·직관: 하나의 광선은 무한히 가는 선이라 넓은 반구를 덮으려면 수백 개가 필요합니다.
원뿔은 점점 벌어지는 부피라, 멀리 갈수록 넓은 영역을 대표합니다. "멀리 있는 것은 뭉개서 봐도 된다"는
직관을 밉맵으로 정확히 구현합니다 — 거리 dist가 멀수록 원뿔 지름 2·aperture·dist가 커지고,
그에 맞춰 mip = log2(지름/복셀크기)로 더 흐릿한(넓은 영역을 평균한) 밉을 읽습니다.
줄 매핑: SampleLevel은 지정한 밉 레벨을 직접 읽습니다(자동 밉 선택인 Sample과 달리).
원뿔 추적은 밉을 우리가 계산하므로 SampleLevel이 필수입니다.
dist += diameter*0.5는 원뿔의 원판들이 절반씩 겹치도록 전진해 계단 현상을 없앱니다.
front-to-back 알파 합성 해부
수학: acc.rgb += (1 - acc.a) · s.a · s.rgb와 acc.a += (1 - acc.a) · s.a는
볼륨 렌더링의 앞→뒤 합성 공식입니다. (1 - acc.a)는 "아직 안 가려진 투명도 여분"으로,
이미 앞쪽에서 많이 가려졌으면(acc.a가 1에 가까우면) 뒤쪽 기여를 자동으로 줄입니다.
구체 수치: 첫 샘플 s.a=0.5, s.rgb=(1,0,0)이면
acc.rgb = (1-0)·0.5·(1,0,0) = (0.5,0,0), acc.a = 0.5가 됩니다.
다음 샘플이 같은 값이면 acc.rgb += (1-0.5)·0.5·(1,0,0) = (0.25,0,0) → 합 (0.75,0,0),
acc.a = 0.75. 뒤로 갈수록 기여가 반씩 줄어드는 게 보입니다.
루프 종료 조건 acc.a < 1.0은 완전히 불투명해지면 조기 종료(early-out)로,
그 뒤는 어차피 안 보이므로 계산을 아낍니다.
gVoxelRes³ × 4바이트입니다 — 128³이면 8MB(밉 포함 약 9.3MB)지만 256³이면 64MB로 급증합니다.
해상도를 2배 올리면 메모리·복셀화 비용이 8배가 되므로, 실무는 카메라 주변만 덮는 클립맵/캐스케이드 구조로
한정된 해상도를 효율 배치합니다.
dist를 한 복셀 이상 띄우지 않으면 표면이 자기 복셀을 읽어 자기 색으로 자기를 밝히는
self-illumination 아티팩트가 생깁니다. 또 복셀 해상도가 낮으면 얇은 벽 사이로 빛이 새는 leak이 흔합니다.
5. 하드웨어 RT (DXR) — RT 코어로 소수 광선을 실시간 추적
무엇을 만드나: DirectX Raytracing(DXR)의 RT 코어를 이용해, 각 픽셀에서 진짜 광선 하나를 반구로 쏴 실제 씬과 교차시키고 1바운스 간접광을 얻습니다. 화면 밖·가려진 정보까지 정확하다는 점이 SSGI와의 결정적 차이입니다. 왜 필요한가: 근사가 아닌 실제 가시성 기반 GI라서 반사·그림자와 물리적으로 일관됩니다. 대신 픽셀당 광선 수가 적어 노이즈가 크고, 디노이저가 필수입니다.
DXR은 ray generation → traversal → hit/miss 셰이더로 나뉩니다. 아래는 픽셀마다 광선을 만들고 쏘는 ray generation 셰이더입니다.
RaytracingAccelerationStructure gScene : register(t0); // TLAS(가속 구조)
RWTexture2D<float4> gOutput : register(u0); // GI 결과 출력(UAV)
Texture2D<float4> gGBufPos : register(t1); // 픽셀별 월드 위치
Texture2D<float4> gGBufNrm : register(t2); // 픽셀별 월드 법선
struct Payload { float3 color; }; // 히트/미스 셰이더가 채워 돌려줄 데이터
[shader("raygeneration")]
void RayGen()
{
uint2 px = DispatchRaysIndex().xy; // 현재 픽셀 좌표
float3 P = gGBufPos[px].xyz; // 이 픽셀의 표면 위치
float3 N = normalize(gGBufNrm[px].xyz); // 표면 법선
float3 dir = CosineHemisphere(px, N); // 코사인 가중 반구 방향 1개
RayDesc ray;
ray.Origin = P + N * 1e-3; // self-hit 방지용 오프셋
ray.Direction = dir;
ray.TMin = 0.0;
ray.TMax = 1e30; // 사실상 무한대
Payload pl = { float3(0, 0, 0) };
TraceRay(gScene,
RAY_FLAG_NONE,
0xFF, // instance mask: 모든 인스턴스와 교차
0, 0, 0, // hit group / miss shader 인덱스
ray, pl);
gOutput[px] = float4(pl.color, 1.0); // 1바운스 간접광 기록
}
DXR 자원·문법 해부
RaytracingAccelerationStructure gScene는 씬 지오메트리를 광선 교차에 최적화된 트리로 담은
가속 구조(TLAS/BLAS)입니다 — C++에서 BuildRaytracingAccelerationStructure로 미리 만듭니다.
RWTexture2D<float4>는 셰이더가 쓸 수 있는 텍스처(UAV, slot u0)로, GI 결과를 여기에 기록합니다.
<float4>는 원소 타입 지정(제네릭 문법)입니다.
[shader("raygeneration")] 어트리뷰트는 이 함수가 ray generation 진입점임을 런타임에 알립니다.
DispatchRaysIndex().xy는 DispatchRays(width, height, 1)로 뿌린 격자에서
이 스레드가 담당하는 픽셀을 줍니다(컴퓨트 셰이더의 SV_DispatchThreadID와 유사).
RayDesc·TraceRay 매핑
RayDesc는 광선 하나의 명세입니다: Origin(시작점), Direction(방향),
TMin/TMax(유효 거리 구간). TMax = 1e30은 "얼마든 멀리까지"라는 뜻이고,
TMin = 0이라도 Origin을 법선으로 1e-3 띄워 self-hit(라이트맵 절과 동일한 함정)을 막습니다.
TraceRay가 실제로 RT 코어를 돌립니다. 인자 0xFF는 인스턴스 마스크(모든 물체와 교차 허용),
그다음 세 0은 hit group·miss shader·SBT 오프셋 인덱스입니다. 광선이 무언가에 맞으면 closest-hit 셰이더가,
아무것도 못 맞으면 miss 셰이더가 pl.color를 채워 돌려줍니다 — 그 값을 gOutput에 씁니다.
Payload 크기는 작게 유지해야 합니다 — 모든 재귀 광선이 이 구조체를 들고 다녀 크면 레지스터 압박과 성능 저하를 부릅니다.
또 픽셀당 광선 1개라 결과는 매우 노이즈합니다. 그대로 화면에 쓰면 지글거리므로 반드시 시공간 디노이저
(예: SVGF, ReSTIR)를 뒤에 붙여야 실사용 가능합니다.
C++/D3D12 대응
gScene는 ID3D12Device5::CreateStateObject로 만든 RT 파이프라인과
가속 구조 SRV로 바인딩하고, TraceRay를 유발하는 디스패치는 CPU에서
ID3D12GraphicsCommandList4::DispatchRays로 호출합니다. DispatchRaysIndex의 격자는
이 DispatchRays의 Width/Height/Depth에 대응합니다. DXR은 D3D12 전용이라,
D3D11 기반의 앞 절들과 달리 별도 파이프라인·SBT(Shader Binding Table) 구성이 필요합니다.
6. 마무리 — 무엇을 언제 쓰나
| 상황 | 추천 조합 |
|---|---|
| 정적 배경·모바일 | 라이트맵 + 동적 물체용 Irradiance Probe |
| 중간 사양 PC/콘솔 | SSGI(스크린) + 프로브(폴백) + SSAO |
| 완전 동적 씬 | Voxel Cone Tracing 또는 RTGI |
| 고사양·최신 GPU | DXR RTGI + 디노이저 + 업스케일러(하이브리드) |
모든 기법의 목표는 같습니다 — 0절의 렌더링 방정식이라는 하나의 정답을, 각자의 예산 안에서 어디를 근사하고 어디를 정확히 풀지 다르게 선택할 뿐입니다. 오프라인은 패스 트레이싱으로 정답을, 실시간은 근사와 하드웨어 RT의 조합으로 "충분히 그럴싸한 정답"을 추구하는 방향으로 수렴하고 있습니다.