← 개념 · 데모 가이드 | 구현 허브 | 7장. 실전 렌더링 기법 | 앰비언트 오클루전 (Ambient Occlusion)
IMPLEMENTATION · CHAPTER 7

앰비언트 오클루전 (Ambient Occlusion) — 한 줄 한 줄 직접 구현

실내 사진을 보면 벽과 바닥이 만나는 구석, 책상 위에 놓인 물건의 접촉면, 옷의 주름 골이 살짝 어둡습니다. 이것은 그 좁은 틈으로는 사방에서 오는 간접광(하늘빛·벽에 반사된 빛)이 잘 들어오지 못하기 때문입니다. 앰비언트 오클루전(AO)은 바로 이 "주변에 얼마나 가려져 있는가"를 계산해서 틈새를 어둡게 만드는 기법입니다. AO가 없으면 물체가 바닥에서 붕 떠 보이고 형태가 밋밋해 보이는데, AO 한 방울이면 갑자기 접지감과 입체감이 살아납니다. 이 페이지에서는 실시간 렌더링의 표준인 SSAO(Screen-Space Ambient Occlusion)를 반구 커널 생성(C++)부터 픽셀 셰이더의 회전·투영·깊이 비교, 블러, 최종 합성까지 한 줄도 건너뛰지 않고 해부합니다.

규약 이 페이지는 Direct3D 11 규약으로 통일합니다: 왼손 좌표계, 클립 공간 z는 [0,1], 텍스처 V축은 위→아래. 행렬은 행벡터 규약(v' = v * M, HLSL에서 mul(v, M))에 행 우선 저장이며, DirectXMath 행렬을 cbuffer로 올릴 때는 transpose가 필요합니다(뒤에서 설명). 직접 구현은 원리를 이해하기 위한 학습용이고, 실무에서는 DirectXMath와 검증된 파이프라인을 권장합니다.

0. AO가 무엇을 계산하는가 — 30초 직관

빛에는 두 종류가 있습니다. 광원(태양·전등)에서 직접 도달하는 직접광과, 그 빛이 벽·바닥·하늘에 부딪혀 사방으로 튕겨 온 간접광(앰비언트)입니다. 실시간 렌더링에서는 간접광을 일일이 추적하기가 비싸서, 흔히 "사방에서 일정하게 은은한 빛이 온다"고 근사합니다(상수 앰비언트). 그런데 이렇게 하면 구석이든 뻥 뚫린 벽면이든 똑같이 밝아 버립니다 — 이게 밋밋함의 정체입니다.

AO는 각 지점에서 위쪽 반구를 올려다봤을 때 하늘(간접광)이 얼마나 보이는가를 0~1로 잰 값입니다. 뻥 뚫린 평지는 반구 전체가 열려 있어 AO≈1(가림 없음), 좁은 구석은 주변 벽이 반구를 절반쯤 막아 AO≈0.5, 아주 좁은 틈은 AO≈0(거의 완전히 가림)입니다. 이 값을 앰비언트 항에만 곱하면 구석이 자연스럽게 어두워집니다.

함정 AO는 직접광에는 곱하지 않습니다. 태양이 직접 비추는 밝은 구석은 실제로 밝기 때문입니다. AO를 최종 색 전체에 곱해 버리면 그림자가 두 번 들어간 것처럼 부자연스럽게 탁해집니다. 반드시 앰비언트(간접광) 성분에만 적용하세요. (자세한 합성은 4절.)

1. SSAO의 큰 그림 — 화면 공간에서 반구를 훑는다

정확한 AO는 각 점에서 반구 전체로 광선을 쏘아 무엇에 가로막히는지 봐야 하지만(레이 트레이싱), 실시간에는 너무 비쌉니다. SSAO는 영리한 근사입니다: 이미 그려 둔 화면 정보(깊이법선)만으로 "이웃 픽셀들이 나보다 카메라에 더 가까이 튀어나와 있으면 나를 가리는 것"이라고 간주합니다. 반구 안에 샘플 점 수십 개를 뿌리고, 각 샘플 위치를 화면에 되투영해 그 자리의 실제 저장된 깊이와 비교하는 식입니다.

  1. 준비(C++): 법선 방향 반구 안에 커널 샘플 64개를 만들고, 밴딩을 없앨 4×4 랜덤 회전 노이즈 텍스처를 만든다.
  2. 입력: G-버퍼에서 각 픽셀의 뷰 공간 위치·법선을 읽는다.
  3. 회전 기저: 노이즈로 픽셀마다 살짝 다른 TBN(접선·종법선·법선) 축을 세워 커널을 돌린다.
  4. 가림 누적: 각 샘플을 뷰 공간에 놓고 → 화면으로 투영 → 저장 깊이와 비교 → 더 앞이면 "가려짐" 카운트.
  5. 블러: 노이즈 회전으로 생긴 지글거림을 4×4 박스 블러로 문지른다.
  6. 합성: AO 값을 라이팅 패스의 앰비언트 항에만 곱한다.
주의 "뷰 공간(view space)"은 카메라를 원점에 놓고 본 좌표계입니다. SSAO의 모든 위치·법선·깊이 비교는 이 뷰 공간에서 이뤄집니다. 왼손 좌표계에서 카메라는 +Z 방향을 바라보므로, 카메라에서 멀어질수록 z 값이 커집니다. "더 가까이 있다 = z가 더 작다"라는 이 부호 감각을 계속 기억하세요 — 셰이더의 깊이 비교 부등호가 여기서 나옵니다.

2. 준비 단계 — 반구 커널과 노이즈 텍스처 (C++)

무엇을 만드나: 셰이더에 넘겨줄 두 가지 데이터입니다. (1) 법선 반구 안에 흩뿌린 샘플 방향 64개 = 커널, (2) 픽셀마다 커널을 무작위로 살짝 돌려 줄 노이즈 텍스처. 왜 필요한가: 커널은 "이 점 주변 어디어디를 찔러 볼까"의 목록이고, 노이즈는 적은 샘플 수로도 계단 무늬(밴딩)가 안 생기게 하는 트릭입니다.

ssao_setup.cppcpp
#include <random>
#include <vector>
#include <DirectXMath.h>
using namespace DirectX;
using Microsoft::WRL::ComPtr;

std::uniform_real_distribution<float> rnd(0.0f, 1.0f);
std::default_random_engine gen;
auto lerpf = [](float a, float b, float t){ return a + t * (b - a); };

// 1) 반구 커널: 법선(+z) 반구 안의 샘플 64개. cbuffer 정렬을 위해 float4로
std::vector<XMFLOAT4> kernel;
for (unsigned i = 0; i < 64; ++i) {
    XMVECTOR s = XMVectorSet(rnd(gen) * 2.0f - 1.0f,   // x: -1..1
                            rnd(gen) * 2.0f - 1.0f,    // y: -1..1
                            rnd(gen),                  // z:  0..1 → 반구
                            0.0f);
    s = XMVector3Normalize(s) * rnd(gen);
    // 중심에 샘플을 더 몰아준다 (가까운 가림이 중요)
    float scale = (float)i / 64.0f;
    s = s * lerpf(0.1f, 1.0f, scale * scale);
    XMFLOAT4 v; XMStoreFloat4(&v, s);
    kernel.push_back(v);
}

// 2) 4x4 랜덤 회전 노이즈 (z=0인 접평면 벡터)
std::vector<XMFLOAT4> noise;
for (unsigned i = 0; i < 16; ++i)
    noise.push_back({ rnd(gen) * 2.0f - 1.0f, rnd(gen) * 2.0f - 1.0f, 0.0f, 0.0f });

// 노이즈 텍스처: 4x4, REPEAT 샘플러로 화면에 타일링
ComPtr<ID3D11Texture2D>           noiseTex;
ComPtr<ID3D11ShaderResourceView>  noiseSRV;
D3D11_TEXTURE2D_DESC td = {};
td.Width = 4; td.Height = 4; td.MipLevels = 1; td.ArraySize = 1;
td.Format = DXGI_FORMAT_R32G32B32A32_FLOAT;
td.SampleDesc.Count = 1; td.Usage = D3D11_USAGE_IMMUTABLE;
td.BindFlags = D3D11_BIND_SHADER_RESOURCE;
D3D11_SUBRESOURCE_DATA init = { noise.data(), 4 * sizeof(XMFLOAT4), 0 };
device->CreateTexture2D(&td, &init, &noiseTex);
device->CreateShaderResourceView(noiseTex.Get(), nullptr, &noiseSRV);

// 커널은 상수 버퍼(BIND_CONSTANT_BUFFER)로 셰이더에 업로드
// 노이즈 샘플러: POINT + WRAP (타일링)

2-1. 난수 준비 — rnd, gen, lerpf

std::uniform_real_distribution<float> rnd(0.0f, 1.0f);0 이상 1 미만의 실수를 고르게(uniform) 뽑는 난수 분포입니다. rnd(gen)처럼 엔진과 함께 호출하면 매번 다른 [0,1) 값을 하나 돌려줍니다. std::default_random_engine gen; 이 실제 난수를 만드는 엔진(비트 생성기)이고, 분포 객체는 그 비트를 "0~1 실수"로 변환하는 필터라고 보면 됩니다.

auto lerpf = [](float a, float b, float t){ return a + t * (b - a); };선형 보간 (linear interpolation) 람다입니다. t=0이면 a, t=1이면 b, 그 사이면 비율대로 섞습니다. 수식으로는 lerp(a,b,t) = a + t(b−a) = (1−t)a + t·b 입니다. []는 캡처 목록(바깥 변수를 안 가져옴), (float a, float b, float t)는 인자, { ... }는 본문인 람다 표현식이라 이름 붙은 작은 함수처럼 씁니다. 나중에 커널을 중심으로 몰 때 이 lerpf를 쓸 겁니다.

2-2. 반구 커널 한 샘플 만들기 — 성분 하나씩

수학적 의미: 우리는 법선을 +Z 축이라고 가정한 "표준 반구" 안에 점들을 뿌립니다. (실제 법선 방향으로 돌리는 일은 셰이더에서 TBN으로 합니다 — 3절.) 반구는 z가 위(양수)인 쪽 절반의 공이라 x·y는 −1~1, z는 0~1로 잡아야 합니다.

XMVECTOR s = XMVectorSet(rnd(gen) * 2.0f - 1.0f,   // x: -1..1
                        rnd(gen) * 2.0f - 1.0f,    // y: -1..1
                        rnd(gen),                  // z:  0..1 → 반구
                        0.0f);

XMVectorSet(x, y, z, w)는 네 개의 float을 SIMD 레지스터 벡터 XMVECTOR 하나로 묶습니다. 성분별로 보면:

기하 직관 지금 만든 점들은 한 변이 2인 정육면체의 위쪽 절반 상자(x,y ∈ [−1,1], z ∈ [0,1]) 안에 고르게 흩뿌려진 상태입니다. 아직 "반구(둥근 돔)"가 아니라 "네모난 반쪽 상자"라는 점에 주의하세요 — 둥글게 만드는 건 다음 줄의 정규화입니다.

2-3. 정규화 + 무작위 길이 — 반구 부피를 채우기

s = XMVector3Normalize(s) * rnd(gen);

두 단계가 한 줄에 있습니다. XMVector3Normalize(s) — 벡터를 길이 1로 만듭니다. 수식으로 ŝ = s / ‖s‖ (각 성분을 벡터 길이로 나눔). 이렇게 하면 아까 네모 상자에 있던 점들이 모두 반지름 1인 반구 껍질(표면)로 옮겨집니다 — 방향은 유지하되 길이만 1로 통일하기 때문입니다.

* rnd(gen) — 여기에 다시 [0,1) 난수를 곱해 길이를 무작위로 줄입니다. 껍질(길이 1)에만 있으면 표면만 훑게 되니, 길이를 0~1로 흩어 반구 안쪽 부피 전체에 샘플이 퍼지게 만드는 것입니다. XMVECTOR * float는 DirectXMath의 연산자 오버로딩으로, 벡터의 네 성분에 똑같이 스칼라를 곱합니다.

2-4. 중심에 몰아주기 — 가까운 가림을 더 중요하게

float scale = (float)i / 64.0f;
s = s * lerpf(0.1f, 1.0f, scale * scale);

왜 이렇게 하나: AO에서 실제로 중요한 것은 바로 근처의 가림입니다(구석의 좁은 틈). 멀리 있는 샘플은 가림 판정에 기여가 적으니, 샘플들을 표면 가까이로 당겨 두는 편이 품질이 좋습니다. 그래서 인덱스 i가 작을수록(=먼저 생성된 샘플일수록) 길이를 짧게, 뒤로 갈수록 길게 배분합니다.

scale = i / 640 → 1로 커지는 진행도이고, lerpf(0.1, 1.0, scale·scale)는 그 진행도를 제곱해서 넣습니다. 제곱을 쓰면 그래프가 완만하게 시작해 뒤에서 급히 오르는 2차 곡선이 되어, 초반(작은 i)에 0.1에 오래 머물다 뒤에서 1.0에 도달합니다. 결과적으로 샘플의 상당수가 길이 0.1 근처, 즉 표면 코앞에 몰립니다.

iscale = i/64scale²lerpf(0.1,1.0,scale²) = 배율
00.000.000.10 (표면 코앞)
160.250.06250.156
320.500.250.325
480.750.56250.606
630.9840.9690.972 (거의 반지름 끝)

표에서 보듯 앞쪽 절반(i<32)이 배율 0.1~0.325에 몰려 있습니다. lerpf(0.1, 1.0, t)를 손으로 풀어 보면 0.1 + t·(1.0 − 0.1) = 0.1 + 0.9t 이고, t=0.250.1 + 0.9×0.25 = 0.325로 표와 일치합니다.

2-5. cbuffer 정렬 — 왜 float3가 아니라 float4인가

XMFLOAT4 v; XMStoreFloat4(&v, s);
kernel.push_back(v);

XMVECTOR는 SIMD 계산용 타입이라 CPU 메모리에 그대로 저장할 수 없습니다. XMStoreFloat4(&v, s)가 SIMD 벡터 s의 네 성분을 일반 구조체 XMFLOAT4 v로 풀어 담습니다(반대 방향은 XMLoadFloat4). 방향 벡터라 xyz만 쓰지만 일부러 4성분으로 저장합니다.

함정 HLSL의 상수 버퍼는 16바이트(float4) 경계로 정렬됩니다. 커널을 float3 samples[64]로 선언하면 배열 원소마다 패딩 규칙이 꼬여 CPU 쪽 XMFLOAT3 배열과 바이트 배치가 어긋나고, 셰이더가 엉뚱한 값을 읽습니다. float4 samples[64]로 맞춰 두면 원소당 정확히 16바이트라 CPU/GPU 배치가 일치합니다. 이래서 방향인데도 XMFLOAT4로 저장하는 것입니다.

2-6. 노이즈 텍스처 — 4×4 회전 벡터 16개

std::vector<XMFLOAT4> noise;
for (unsigned i = 0; i < 16; ++i)
    noise.push_back({ rnd(gen) * 2.0f - 1.0f, rnd(gen) * 2.0f - 1.0f, 0.0f, 0.0f });

무엇을 만드나: 픽셀마다 커널을 무작위 각도로 돌리기 위한 접평면 위의 랜덤 방향 16개입니다. {...}XMFLOAT4집합 초기화(aggregate initialization)로, 중괄호 안 값이 순서대로 x,y,z,w에 들어갑니다. x·y는 아까처럼 [−1,1)이지만 z=0인 점이 핵심입니다.

왜 z=0인가(기하 직관): 이 벡터는 나중에 셰이더에서 표면의 접평면(tangent plane) 안에서 커널을 빙 돌리는 축으로 쓰입니다. 접평면은 법선에 수직인 면이므로, 그 안에 있는 회전 벡터는 법선 성분(z)이 0이어야 합니다. 회전은 "각도"만 필요하니 xy 평면 위의 방향이면 충분합니다.

16개인 이유는 4×4 텍스처 한 장에 담아 화면 전체에 타일처럼 반복해 깔기 위해서입니다. 4×4마다 회전 패턴이 반복되므로, 커널 샘플이 64개처럼 적어도 이웃한 픽셀들이 서로 다른 각도로 훑어 부족한 샘플 수를 공간적으로 벌충합니다(대신 지글거림이 남고, 이건 블러로 지웁니다 — 5절).

2-7. 텍스처 생성 — 각 필드가 하는 일

D3D11_TEXTURE2D_DESC td = {};
td.Width = 4; td.Height = 4; td.MipLevels = 1; td.ArraySize = 1;
td.Format = DXGI_FORMAT_R32G32B32A32_FLOAT;
td.SampleDesc.Count = 1; td.Usage = D3D11_USAGE_IMMUTABLE;
td.BindFlags = D3D11_BIND_SHADER_RESOURCE;
D3D11_SUBRESOURCE_DATA init = { noise.data(), 4 * sizeof(XMFLOAT4), 0 };
device->CreateTexture2D(&td, &init, &noiseTex);
device->CreateShaderResourceView(noiseTex.Get(), nullptr, &noiseSRV);
필드의미
Width / Height4 / 44×4 = 16텍셀. 위에서 만든 16개 벡터와 정확히 일치.
MipLevels1밉맵 없음. 노이즈는 축소하면 안 되므로 원본 한 레벨만.
ArraySize1텍스처 배열이 아닌 단일 텍스처.
FormatR32G32B32A32_FLOAT채널당 32비트 float 4개 — 음수(−1~1)를 손실 없이 담기 위함.
SampleDesc.Count1MSAA 없음(픽셀당 1샘플).
UsageIMMUTABLE생성 후 변경 안 함 → 드라이버 최적화 가능. 그래서 초기 데이터 필수.
BindFlagsSHADER_RESOURCE셰이더에서 Texture2D로 읽기 위한 바인딩.

D3D11_SUBRESOURCE_DATA init = { noise.data(), 4 * sizeof(XMFLOAT4), 0 }의 두 번째 값은 SysMemPitch, 즉 한 행의 바이트 수입니다. 한 행에 4텍셀, 텍셀당 sizeof(XMFLOAT4)=16바이트이므로 4×16 = 64바이트. GPU가 CPU 메모리를 행 단위로 복사할 때 다음 행이 몇 바이트 뒤에 있는지 알려 주는 값입니다. 이걸 틀리면 텍스처가 비스듬히 밀려 깨집니다.

CreateTexture2D로 GPU 텍스처를 만들고, CreateShaderResourceView(..., nullptr, ...)로 셰이더가 읽을 SRV(Shader Resource View)를 만듭니다. 두 번째 인자 nullptr은 "텍스처 포맷 그대로 뷰를 만들라"는 뜻입니다. 이 noiseSRV가 셰이더의 texNoise : register(t2)에 바인딩됩니다.

함정 노이즈 샘플러는 반드시 POINT 필터 + WRAP 주소 모드여야 합니다. WRAP(=REPEAT)이라야 4×4가 화면 전체에 타일링되고, POINT라야 텍셀 사이를 섞지 않아 회전 벡터가 그대로 유지됩니다. LINEAR로 섞으면 회전 벡터가 이상하게 뭉개져 밴딩 제거 효과가 사라집니다. 반면 G-버퍼 샘플러(sampGB)는 CLAMP를 써서 화면 밖을 훑을 때 가장자리 값을 물게 합니다.

3. SSAO 픽셀 셰이더 — 가림 누적 (HLSL)

무엇을 하나: 풀스크린 패스에서 픽셀마다 실행되어, 그 픽셀의 AO 값(1=완전히 열림, 0=완전히 가림)을 한 채널로 출력합니다. 큰 흐름: G-버퍼에서 위치·법선을 읽고 → 노이즈로 회전 축을 세우고 → 커널 64개를 그 픽셀 주변 뷰 공간에 뿌린 뒤 → 화면으로 되투영해 저장 깊이와 비교 → 가려진 샘플 비율을 구합니다.

ssao.hlsl (픽셀 셰이더)hlsl
Texture2D    gPosition : register(t0);  // 뷰공간 위치
Texture2D    gNormal   : register(t1);  // 뷰공간 법선
Texture2D    texNoise  : register(t2);  // 4x4 회전 노이즈
SamplerState sampGB    : register(s0);  // POINT/LINEAR + CLAMP
SamplerState sampNoise : register(s1);  // POINT + WRAP (타일링)

#define KERNEL 64
cbuffer SSAOConstants : register(b0) {
    float4   samples[KERNEL];   // 반구 커널 (.xyz 사용)
    float4x4 projection;        // 뷰 → 클립
    float2   screenSize;        // 화면 해상도
    float2   _pad;
};

static const float radius = 0.5;
static const float bias   = 0.025;

float4 main(float4 posH : SV_Position, float2 uv : TEXCOORD0) : SV_Target {
    float2 noiseScale = screenSize / 4.0;   // 노이즈 타일 스케일

    float3 fragPos = gPosition.Sample(sampGB, uv).xyz;
    float3 normal  = normalize(gNormal.Sample(sampGB, uv).xyz);
    float3 randVec = normalize(texNoise.Sample(sampNoise, uv * noiseScale).xyz);

    // Gram-Schmidt로 픽셀별 회전 TBN 기저 구성
    float3 tangent   = normalize(randVec - normal * dot(randVec, normal));
    float3 bitangent = cross(normal, tangent);
    float3x3 TBN     = float3x3(tangent, bitangent, normal);

    float occlusion = 0.0;
    for (int i = 0; i < KERNEL; ++i) {
        // 커널 샘플을 뷰 공간의 픽셀 주변으로 이동
        float3 samplePos = fragPos + mul(samples[i].xyz, TBN) * radius;

        // 그 샘플을 화면 좌표로 투영해 저장된 깊이를 얻는다
        float4 offset = mul(float4(samplePos, 1.0), projection);
        offset.xyz /= offset.w;
        offset.xy = offset.xy * float2(0.5, -0.5) + 0.5;  // v 뒤집기

        float sampleDepth = gPosition.Sample(sampGB, offset.xy).z;

        // 범위 체크: 깊이 차가 반지름을 넘으면 무시(경계 오검출 방지)
        float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
        // 저장 깊이가 샘플보다 카메라에 가까우면 가려진 것
        occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
    }
    return 1.0 - (occlusion / (float)KERNEL);  // 1=열림, 0=완전 가림
}

3-1. 리소스 선언과 레지스터 슬롯

Texture2D gPosition : register(t0); 같은 줄들은 셰이더가 쓸 입력을 레지스터 슬롯에 묶습니다. t0~t2는 텍스처(SRV) 슬롯, s0~s1은 샘플러 슬롯, b0는 상수 버퍼 슬롯입니다. CPU에서 PSSetShaderResources(0, ...)로 바인딩한 순서가 그대로 t0, t1, t2에 대응합니다.

3-2. 상수 버퍼 — 커널·투영·화면크기

cbuffer SSAOConstants : register(b0) {
    float4   samples[KERNEL];   // 반구 커널 (.xyz 사용)
    float4x4 projection;        // 뷰 → 클립
    float2   screenSize;        // 화면 해상도
    float2   _pad;
};

samples[64]가 2절에서 넘긴 커널입니다(그래서 float4였죠). projection뷰 공간을 클립 공간으로 보내는 투영 행렬입니다 — 샘플 위치를 화면 좌표로 되투영할 때 씁니다. screenSize는 픽셀 단위 해상도(예: 1920×1080), _pad16바이트 정렬을 맞추는 더미입니다. float2 screenSize 뒤에 float2 _pad를 붙여 둘이 합쳐 float4 한 칸(16바이트)을 채워, 다음 데이터가 경계에 딱 맞게 시작하도록 합니다.

함정 projection을 cbuffer로 올릴 때 transpose가 필요할 수 있습니다. DirectXMath는 행벡터 규약이라 mul(v, M)이 자연스럽지만, HLSL의 기본 행렬 팩킹은 열 우선(column-major)이라 CPU에서 XMMatrixTranspose(proj)로 뒤집어 올리는 것이 관례입니다. 이 페이지 규약대로 mul(vector, matrix)(행벡터)를 쓰고 CPU에서 transpose해 올리면 부호·축이 맞습니다. 화면이 뒤집히거나 AO가 안 나오면 십중팔구 이 transpose가 빠진 것입니다.

3-3. 상수 파라미터 — radiusbias

static const float radius = 0.5;
static const float bias   = 0.025;

radius=0.5가림을 조사할 반경(뷰 공간 단위, 보통 미터)입니다. 반경을 키우면 넓은 그늘이 생기고(멀리까지 가림 조사), 줄이면 아주 좁은 틈만 어두워집니다. static const는 "셰이더 안에서 바뀌지 않는 컴파일 타임 상수"라는 뜻입니다.

bias=0.025자기 자신을 가림으로 오판하는 것을 막는 여유값입니다. 평평한 면에서는 샘플 깊이와 표면 깊이가 부동소수 오차로 아주 미세하게 엇갈릴 수 있는데, 이 작은 값을 더해 두면 "진짜로 bias보다 더 앞에 있을 때만 가림"으로 세어 줄무늬 아티팩트(self-occlusion)를 없앱니다.

3-4. main 진입 — SV_Positionuv

float4 main(float4 posH : SV_Position, float2 uv : TEXCOORD0) : SV_Target {
    float2 noiseScale = screenSize / 4.0;

float4 posH : SV_Position시스템이 채워 주는 픽셀의 화면 좌표(픽셀 단위), float2 uv : TEXCOORD0는 풀스크린 삼각형에서 보간된 0~1 텍스처 좌표입니다. 반환 시맨틱 : SV_Target은 "이 float4를 렌더 타깃에 쓴다"는 뜻입니다(AO는 결국 한 채널만 쓰지만 float4로 반환).

noiseScale = screenSize / 4.0은 4×4 노이즈를 화면에 1픽셀당 1텍셀 비율로 타일링하기 위한 배율입니다. 예를 들어 1920×1080이면 noiseScale = (480, 270). 뒤에서 uv * noiseScale로 노이즈를 샘플하면, uv가 0→1로 가는 동안 노이즈 좌표는 0→480까지 올라가 WRAP 덕분에 4×4 패턴이 480번(=1920/4) 반복됩니다.

3-5. G-버퍼 읽기 — 위치·법선·회전 벡터

float3 fragPos = gPosition.Sample(sampGB, uv).xyz;
float3 normal  = normalize(gNormal.Sample(sampGB, uv).xyz);
float3 randVec = normalize(texNoise.Sample(sampNoise, uv * noiseScale).xyz);

Texture.Sample(sampler, uv)는 텍스처를 그 좌표에서 읽어 float4를 돌려주고, .xyz로 앞 세 성분만 취합니다. fragPos이 픽셀의 뷰 공간 위치(가림 조사의 중심), normal뷰 공간 법선입니다. 법선은 normalize로 길이 1을 보장합니다 — G-버퍼에 저장·보간되며 길이가 미세하게 틀어질 수 있기 때문입니다.

randVec은 노이즈 텍스처에서 읽은 이 픽셀 전용 랜덤 회전 방향입니다. uv * noiseScale로 타일링해 읽으므로 4픽셀마다 같은 값이 반복됩니다. z=0인 벡터라 접평면 안의 방향입니다.

3-6. TBN 기저 — Gram-Schmidt로 픽셀마다 다른 축 세우기

float3 tangent   = normalize(randVec - normal * dot(randVec, normal));
float3 bitangent = cross(normal, tangent);
float3x3 TBN     = float3x3(tangent, bitangent, normal);

수학적 의미(Gram–Schmidt 직교화): randVec은 아무 방향이라 법선과 수직이 아닐 수 있습니다. 우리는 법선에 딱 수직인 접선(tangent)이 필요합니다. 그래서 randVec에서 법선 방향 성분을 빼서 남은 접평면 성분만 취합니다.

기하 직관 벽에 비스듬히 꽂힌 화살(randVec)의 그림자를 벽면(접평면)에 드리운 것이 tangent라고 생각하세요. 벽에 파고든 부분(법선 성분)은 잘라내고 벽을 따라 누운 부분만 남깁니다. 이렇게 하면 tangent ⊥ normal이 보장됩니다.

bitangent = cross(normal, tangent)외적으로 두 벡터에 동시에 수직인 세 번째 축을 만듭니다. 이제 tangent, bitangent, normal 셋이 서로 직교하는 정규직교 기저(오른손 규칙으로 배치)가 되어, float3x3(t, b, n)으로 회전 행렬 TBN을 구성합니다. 이 행렬이 "표준 +Z 반구 커널"을 실제 법선 방향으로 돌려 세우는 역할을 합니다. 노이즈로 tangent가 픽셀마다 달라지므로 커널 전체가 픽셀마다 다른 각도로 회전됩니다 — 이것이 적은 샘플로 밴딩을 없애는 핵심입니다.

3-7. 커널 샘플을 뷰 공간에 배치

float3 samplePos = fragPos + mul(samples[i].xyz, TBN) * radius;

mul(samples[i].xyz, TBN)은 표준 반구 커널 벡터를 TBN으로 회전시켜 실제 표면 방향에 맞춥니다. 여기서 인자 순서가 중요합니다 — 이 페이지 규약(행벡터)대로 mul(vector, matrix), 즉 벡터가 왼쪽입니다. 회전한 방향에 * radius로 반경을 곱하고, fragPos(현재 픽셀 위치)에 더해 가림을 찔러 볼 뷰 공간의 실제 점 samplePos를 얻습니다.

직관적으로는 "지금 이 픽셀에서, 표면 위쪽 반구 방향으로 최대 0.5m 떨어진 어떤 점"입니다. 이 점 근처에 다른 표면이 튀어나와 있으면 이 방향은 가려진 것입니다.

3-8. 화면으로 되투영 — 저장 깊이 조회

float4 offset = mul(float4(samplePos, 1.0), projection);
offset.xyz /= offset.w;
offset.xy = offset.xy * float2(0.5, -0.5) + 0.5;  // v 뒤집기
float sampleDepth = gPosition.Sample(sampGB, offset.xy).z;

왜 되투영하나: samplePos는 "내가 조사하려는 이상적 위치"일 뿐입니다. 정말 그 자리에 표면이 있는지 알려면, 그 점이 화면의 어느 픽셀에 찍히는지 계산해 G-버퍼에 실제 저장된 깊이와 비교해야 합니다. 4단계로 진행됩니다:

  1. mul(float4(samplePos,1.0), projection): 위치라 w=1을 붙여 뷰→클립 변환. 결과는 동차좌표 (x,y,z,w).
  2. offset.xyz /= offset.w: 원근 나눗셈. w로 나눠 NDC(정규화 장치 좌표)로 만듭니다. 이래야 원근이 반영된 화면 위치가 나옵니다.
  3. offset.xy * float2(0.5, -0.5) + 0.5: NDC의 [−1,1]을 텍스처 좌표 [0,1]로 변환. x는 ×0.5+0.5, y는 ×(−0.5)+0.5로 뒤집습니다 — D3D 텍스처 V축이 위→아래라 NDC의 위쪽(+y)이 uv의 0이 되어야 하기 때문입니다.
  4. gPosition.Sample(...).z: 그 화면 위치에 실제로 저장된 표면의 뷰 공간 깊이(z)를 읽습니다.
함정 y 뒤집기 float2(0.5, -0.5)의 부호를 빼먹으면 AO가 상하로 뒤집힌 채 나오거나, 위아래 경계에서 가림이 반대로 계산됩니다. GL 코드를 그대로 옮길 때 가장 흔히 틀리는 지점입니다(GL은 V축이 아래→위라 +0.5를 씁니다).

3-9. 범위 체크와 가림 판정 — 핵심 부등호

float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;

범위 체크(rangeCheck): 조사 중인 픽셀(fragPos.z)과 되투영해 읽은 표면 (sampleDepth)의 깊이 차가 반경(radius)보다 크면, 그건 훨씬 멀리 있는 다른 물체라 가림으로 세면 안 됩니다(예: 앞 물체 뒤로 먼 배경이 보일 때 배경에 가짜 그늘이 생기는 것 방지). radius / abs(깊이차)는 차가 작을수록 큰 값, 클수록 작은 값이 되고, smoothstep(0,1,·)이 이를 0~1로 부드럽게 클램프합니다. 차가 반경보다 훨씬 크면 0에 가까워져 이 샘플의 기여를 꺼 버립니다.

가림 판정(부등호): sampleDepth >= samplePos.z + bias 는 이 SSAO의 심장입니다. 왼손 좌표계에서 z가 작을수록 카메라에 가깝습니다. "저장된 표면(sampleDepth)이 내가 찌른 샘플 점(samplePos.z)보다 더 (작은 z)에 있다"는 것은, 그 방향에 다른 표면이 가로막고 있다는 뜻입니다. + bias는 3-3에서 설명한 self-occlusion 여유값입니다. 조건이 참이면 1.0(가림 한 표), 거짓이면 0.0rangeCheck로 가중해 occlusion에 누적합니다.

부호 감각 체크 fragPos.z = 5.0(카메라에서 5m), 어떤 샘플의 samplePos.z = 5.3이라 하죠. 그 화면 위치를 읽었더니 sampleDepth = 5.1이었다면? 표면(5.1)이 샘플(5.3)보다 에 있으므로 5.1 >= 5.3 + 0.025 → 거짓... 이 아니라, 5.1은 5.325보다 작으니 거짓 → 가림 아님? 잠깐, 부등호가 sampleDepth >= samplePos.z이므로 "저장 표면이 샘플보다 뒤(큰 z)일 때 참"입니다. 표면이 샘플보다 앞(5.1 < 5.3)이면 샘플은 표면 로 들어간 것 → 이 코드에서는 가림으로 세지 않습니다. 반대로 sampleDepth = 5.4였다면 5.4 >= 5.325 참 → 가림. 즉 저장 표면이 샘플보다 카메라에서 멀 때 그 사이 공간을 가림으로 간주하는 구현입니다. 부호는 코드 그대로 두고, 다만 이 감각으로 "왜 이 부등호인지"를 이해하는 것이 중요합니다.

3-10. 최종 출력 — 정규화와 반전

return 1.0 - (occlusion / (float)KERNEL);  // 1=열림, 0=완전 가림

occlusion은 64개 샘플 중 가림으로 세어진 (가중) 개수입니다. / KERNEL0~1 비율로 정규화하면 "가려진 정도"가 됩니다. 그런데 우리가 원하는 건 반대로 "열린 정도(밝기 배율)"이므로 1.0 -로 뒤집습니다. 결과: 1이면 완전히 열림(어둡게 안 함), 0이면 완전히 가림(가장 어둡게). 이 한 채널 값이 다음 블러 패스의 입력이 됩니다.

4. 블러 패스 — 노이즈 지우기 (HLSL)

왜 필요한가: 노이즈 회전 덕에 밴딩은 없앴지만 대신 화면이 지글지글합니다(픽셀마다 회전이 달라 AO 값이 튐). 4×4 노이즈 타일과 같은 크기의 박스 블러로 문지르면 이 고주파 잡음이 깔끔히 사라지고 부드러운 그늘만 남습니다.

ssao_blur.hlsl (픽셀 셰이더)hlsl
Texture2D    ssaoInput : register(t0);
SamplerState samp      : register(s0);

// 4x4 박스 블러: 노이즈 텍스처 타일 크기와 같게 맞춰 잔상을 없앤다
float4 main(float4 posH : SV_Position, float2 uv : TEXCOORD0) : SV_Target {
    float2 texel;
    ssaoInput.GetDimensions(texel.x, texel.y);
    texel = 1.0 / texel;
    float result = 0.0;
    [unroll] for (int x = -2; x < 2; ++x)
    [unroll] for (int y = -2; y < 2; ++y)
        result += ssaoInput.Sample(samp, uv + float2(x, y) * texel).r;
    return result / 16.0;
    // 최종 라이팅 패스에서 ambient *= ssaoBlur.Sample(samp, uv).r; 로 곱해 적용
}

4-1. 텍셀 크기 계산

float2 texel;
ssaoInput.GetDimensions(texel.x, texel.y);
texel = 1.0 / texel;

GetDimensions는 텍스처의 픽셀 폭·높이를 받아옵니다(예: 1920, 1080). 여기에 1.0 / texel로 역수를 취하면 텍셀 하나의 uv 크기(예: 1/1920, 1/1080)가 됩니다. 1.0 / texel은 벡터 나눗셈이라 x·y 각각에 적용됩니다. 이 값을 곱해야 "옆 픽셀"로 정확히 uv 한 칸 이동할 수 있습니다.

4-2. 4×4 이웃 평균

[unroll] for (int x = -2; x < 2; ++x)
[unroll] for (int y = -2; y < 2; ++y)
    result += ssaoInput.Sample(samp, uv + float2(x, y) * texel).r;
return result / 16.0;

x, y가 각각 −2, −1, 0, 1을 돌아 4×4 = 16개의 이웃 텍셀을 샘플합니다 (< 2이므로 2는 제외). uv + float2(x,y) * texel로 현재 픽셀 기준 상하좌우 이웃 좌표를 만들고, AO는 .r 채널에 있으니 그것만 더합니다. 16개를 다 더한 뒤 / 16.0으로 단순 평균(박스 블러)을 냅니다.

[unroll]은 컴파일러에게 "이 루프를 펼쳐서 조건 분기 없이 직선 코드로 만들라"는 힌트입니다. 반복 횟수가 컴파일 타임에 고정(16회)이라 펼치는 편이 GPU에서 빠릅니다.

주의 박스 블러 크기(4×4)를 노이즈 타일 크기(4×4)와 일치시키는 것이 핵심입니다. 노이즈 패턴이 4픽셀 주기로 반복되므로, 정확히 한 주기를 평균 내면 회전으로 생긴 잡음이 상쇄됩니다. 크기가 어긋나면 잔상이 남습니다.

5. 합성 — 앰비언트 항에만 곱하기

마지막 단계는 블러된 AO를 라이팅에 반영하는 것입니다. 주석대로 라이팅 패스에서:

float ao = ssaoBlur.Sample(samp, uv).r;   // 0~1 (1=열림)
float3 ambient = ambientColor * albedo * ao;   // 간접광에만 AO
float3 direct  = BRDF(...) * NdotL * lightColor;  // 직접광엔 AO 미적용
float3 color   = ambient + direct;

핵심은 ambient(간접광)에만 ao를 곱하고 direct(직접광)에는 곱하지 않는 것입니다(0절 함정 참고). AO=1인 뻥 뚫린 면은 앰비언트가 그대로, AO≈0인 좁은 틈은 앰비언트가 거의 사라져 자연스러운 접촉 그림자가 완성됩니다.

성능 SSAO 비용은 픽셀당 커널 샘플 수(여기선 64)에 비례합니다. 실무의 표준 절충은 (1) AO를 1/2 해상도로 계산한 뒤 업스케일, (2) 샘플 수를 16~32로 줄이고 노이즈+블러로 벌충, (3) 반드시 블러 패스를 붙이기입니다. 64샘플 풀해상도는 학습·고품질용이고, 게임에서는 대개 16~32샘플 반해상도가 기본값입니다.

6. 대안 — HBAO · GTAO · 베이크드 AO

SSAO는 간단하지만 커널 반경 안의 근사라 한계가 있습니다. 더 정확하거나 더 저렴한 대안을 알아두면 좋습니다.

기법원리장단점
SSAO 반구 커널을 뿌려 깊이 비교로 가림 카운트 구현 간단·저렴. 근사라 노이즈·부정확. 블러 필수.
HBAO
(Horizon-Based)
깊이 버퍼를 따라 지평선 각도를 적분해 열린 하늘 각도를 잼 SSAO보다 물리적으로 정확. 비용은 조금 더 큼.
GTAO
(Ground Truth AO)
가시성 적분을 물리적으로 정합하게 근사(현대 표준) 품질 최고, 멀티바운스·컬러 블리딩 확장 가능. 구현 복잡.
베이크드 AO 오프라인에서 미리 구운 AO를 텍스처로 곱함 정적 지오메트리에 가장 저렴·안정. 동적 물체엔 부적합.

정리하면, 움직이는 씬은 SSAO/HBAO/GTAO 중 예산에 맞게 고르고, 정적 배경은 베이크드 AO 맵이 여전히 가장 가성비가 좋습니다. 실무에서는 정적은 굽고 동적은 실시간 AO로 처리하는 혼합 방식이 흔합니다.

7. DirectXMath / D3D API 대응 정리

준비 단계에서 등장한 DirectXMath·D3D 호출을 원본의 무엇에 대응하는지 1:1로 정리합니다.

DirectXMath / D3D 호출하는 일대응
XMVectorSet(x,y,z,w)4개 float → SIMD 벡터커널 한 샘플의 초깃값 생성
XMVector3Normalize(s)벡터를 길이 1로반구 껍질로 투영
XMVECTOR * float스칼라 곱(성분별)커널 길이 무작위 축소 / 중심 몰기
XMStoreFloat4(&v, s)SIMD → 메모리 구조체커널을 cbuffer용 배열에 저장
XMFLOAT4 (float3 대신)16바이트 정렬 원소cbuffer float4 samples[]와 배치 일치
XMMatrixTranspose(proj)행렬 전치행벡터 규약 cbuffer 업로드
CreateTexture2D + CreateShaderResourceViewGPU 텍스처와 읽기 뷰 생성노이즈 텍스처 → texNoise (t2)

HLSL 쪽 대응도 짚어 두면: mul(v, M)은 CPU의 행벡터 변환과 같은 순서, dot/cross/normalize는 각각 내적·외적·정규화, smoothstep(a,b,x)x[a,b]에서 부드러운 S자 곡선으로 0~1 매핑, Sample(sampler, uv)은 텍스처 조회입니다. 이 페이지의 SSAO는 개념 이해를 위한 학습용 구현이며, 실무에서는 엔진이 제공하는 검증된 AO(대개 GTAO 계열)를 쓰는 것을 권장합니다.