← 개념 · 데모 가이드 | 구현 허브 | 7장. 실전 렌더링 기법 | 안티앨리어싱 (Anti-aliasing)
IMPLEMENTATION · CHAPTER 7

안티앨리어싱 (Anti-aliasing) — 한 줄 한 줄 직접 구현

기하가 픽셀 격자에 스냅되면서 생기는 계단 현상(jaggies)을 부드럽게 만드는 기법입니다. 이 페이지에서는 Direct3D 11에서 가장 흔한 세 갈래 — MSAA(하드웨어 멀티샘플), FXAA(후처리 셰이더), TAA(시간 누적) — 를 코드 한 줄, API 인자 하나까지 해부합니다. 목적과 비용이 다르므로 왜 그렇게 짜는지, 각 줄이 GPU에서 무엇을 시키는지를 수학 원리 → 기하 직관 → 코드 매핑의 3단으로 풀어 설명합니다.

0. 앨리어싱은 왜 생기나 — 근본 원인부터

코드를 보기 전에 "무엇을 고치려는지"부터 정확히 이해하고 갑시다. 화면은 결국 정수 좌표에 놓인 픽셀 격자입니다. 반면 삼각형의 실제 경계선은 연속적인 수학적 직선이라, 임의의 실수 위치를 지납니다. 래스터라이저는 각 픽셀의 중심점 한 점이 삼각형 안에 있는지만 검사해서 "칠한다 / 안 칠한다"를 이진(binary)으로 결정합니다.

비스듬한 경계선 위에서는, 실제로는 픽셀의 40%만 삼각형에 덮여 있는데도 중심이 안에 있으면 100% 색으로, 밖에 있으면 0%로 칠해집니다. 이 이진 근사가 바로 계단 현상의 정체입니다. 신호처리 용어로는, 경계선이라는 고주파 성분을 픽셀이라는 낮은 샘플링 레이트로 찍어서 생기는 앨리어싱(aliasing)입니다. 원본 신호의 정보가 샘플 격자로 정확히 표현되지 못해 계단·지글거림 같은 가짜 무늬로 "접혀 들어오는(alias)" 현상이지요.

해결의 큰 방향은 두 가지입니다. (1) 더 촘촘히 찍는다 — 픽셀 하나를 여러 하위 위치에서 샘플링해 평균 내면 이진이 아니라 "40% 덮임 → 40% 섞인 색"이 됩니다. MSAA·TAA·SSAA가 이 계열입니다. (2) 다 찍은 뒤 흐릿한 곳을 후처리로 뭉갠다 — 이미 완성된 이미지에서 밝기 차이가 급한 곳(엣지)을 찾아 이웃과 섞습니다. FXAA가 이 계열입니다. 아래에서 세 기법을 이 관점으로 하나씩 뜯어봅니다.

기법원리알파/셰이더 엣지비용대표적 부작용
MSAA지오메트리 엣지에서 픽셀당 여러 서브샘플의 커버리지 평균기본은 못 잡음(A2C 필요)중간메모리 증가
FXAA완성 이미지에서 luma 대비로 엣지 찾아 블렌드모두 잡음매우 낮음전체적으로 약간 흐릿
TAA프레임마다 지터 + 이전 프레임 재투영 누적모두 잡음중간(구현 복잡)움직일 때 유령상(ghosting)

1. MSAA — 가장 쉬운 길 (스왑체인 백버퍼)

무엇을 만드나: 후처리 파이프라인이 없다면, 스왑체인과 깊이 버퍼를 만들 때 SampleDesc.Count만 올리면 안티앨리어싱이 끝납니다. 별도의 셰이더 코드 없이 하드웨어가 엣지를 다중 샘플링해 줍니다.

왜 필요한가: MSAA(Multisample Anti-Aliasing)는 GPU 고정 기능이 알아서 "픽셀 안에 서브샘플 위치를 4개 두고, 삼각형이 그중 몇 개를 덮는지"를 세어서 커버리지를 만들어 줍니다. 즉 0장에서 말한 (1)번 "더 촘촘히 찍기"를 하드웨어가 해 주는 것이라, 우리가 짤 코드는 "버퍼를 멀티샘플로 만들어라"는 선언 몇 줄뿐입니다.

1-1. 큰 흐름

  1. 스왑체인의 DXGI_SAMPLE_DESC.Count를 4로 요청한다.
  2. 깊이/스텐실 텍스처도 동일한 샘플 수로 만든다(불일치 시 렌더 타깃 바인딩 실패).
  3. 평소처럼 그린다 — 하드웨어가 알아서 엣지를 다중 샘플링한다.
msaa_swapchain.cppcpp
using Microsoft::WRL::ComPtr;
const int W = 1280, H = 720, SAMPLES = 4;

// ── 스왑체인을 4x MSAA로 요청 ──
DXGI_SWAP_CHAIN_DESC scd = {};
scd.BufferDesc.Width  = W;
scd.BufferDesc.Height = H;
scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
scd.SampleDesc.Count   = SAMPLES;   // 픽셀당 4 샘플
scd.SampleDesc.Quality = 0;         // 표준 MSAA 패턴
scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
scd.BufferCount = 1;
scd.OutputWindow = hwnd;
scd.Windowed = TRUE;
// (D3D11CreateDeviceAndSwapChain 로 device/context/swapChain 생성)

// 백버퍼로 RTV 생성
ComPtr<ID3D11Texture2D> backBuffer;
swapChain->GetBuffer(0, IID_PPV_ARGS(&backBuffer));
ComPtr<ID3D11RenderTargetView> rtv;
device->CreateRenderTargetView(backBuffer.Get(), nullptr, &rtv);

// ── 깊이/스텐실 텍스처도 반드시 같은 샘플 수로 ──
D3D11_TEXTURE2D_DESC dd = {};
dd.Width = W; dd.Height = H; dd.MipLevels = 1; dd.ArraySize = 1;
dd.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
dd.SampleDesc.Count = SAMPLES;      // ← 렌더타깃과 일치해야 함
dd.Usage = D3D11_USAGE_DEFAULT;
dd.BindFlags = D3D11_BIND_DEPTH_STENCIL;
ComPtr<ID3D11Texture2D> depthTex;
device->CreateTexture2D(&dd, nullptr, &depthTex);
ComPtr<ID3D11DepthStencilView> dsv;
device->CreateDepthStencilView(depthTex.Get(), nullptr, &dsv);

// ── 렌더 루프: 별도 코드 없이 엣지가 부드러워진다 ──
MSG msg = {};
while (msg.message != WM_QUIT) {
    if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) {
        TranslateMessage(&msg); DispatchMessage(&msg); continue;
    }
    const float clear[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
    context->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get());
    context->ClearRenderTargetView(rtv.Get(), clear);
    context->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
    drawScene();
    swapChain->Present(1, 0);
}

1-2. 줄 단위 해부 — 스왑체인 서술자

using Microsoft::WRL::ComPtr; — WRL의 ComPtr은 COM 객체(D3D 리소스)를 위한 스마트 포인터입니다. D3D11의 모든 인터페이스(ID3D11...)는 참조 카운트로 수명을 관리하는 COM 객체라, 직접 Release()를 부르면 실수하기 쉽습니다. ComPtr은 스코프를 벗어나면 자동으로 Release()를 호출해 누수를 막아 줍니다. using은 매번 Microsoft::WRL::ComPtr이라 길게 쓰지 않으려는 별칭 선언입니다.

DXGI_SWAP_CHAIN_DESC scd = {}; — 스왑체인의 모든 속성을 담는 구조체입니다. = {}값 초기화(value-initialization)로, 모든 멤버를 0으로 채웁니다. D3D의 서술자(desc) 구조체는 멤버가 수십 개라, 이렇게 0으로 밀어 두고 필요한 것만 덮어쓰는 것이 정석입니다. 초기화를 빠뜨린 멤버에 쓰레기 값이 들어가면 디바이스 생성이 조용히 실패하거나 정의되지 않은 동작을 하니, = {}는 습관처럼 붙이세요.

scd.SampleDesc.Count = SAMPLES; — 이 한 줄이 MSAA의 핵심입니다. SampleDescDXGI_SAMPLE_DESC 타입으로 멤버가 CountQuality 둘뿐입니다. Count = 4는 "픽셀 하나마다 서브샘플을 4개 둬라"는 뜻입니다. 래스터라이저는 삼각형 커버리지를 이 4개 서브샘플 위치에서 판정하고, 깊이 테스트도 서브샘플별로 합니다. 그래서 경계 픽셀은 "4개 중 2개가 삼각형 안 → 커버리지 50%"처럼 계조(gradation)를 갖게 됩니다.

scd.SampleDesc.Quality = 0; — 같은 샘플 수 안에서도 하드웨어가 여러 서브샘플 배치 패턴(품질 레벨)을 제공할 수 있습니다. 0은 표준(가장 널리 지원되는) 패턴입니다. 실무에서는 ID3D11Device::CheckMultisampleQualityLevels(format, count, &numLevels)로 "이 포맷에서 4x가 지원되는지, Quality를 몇까지 쓸 수 있는지"를 먼저 물어보고 정해야 합니다. 지원 안 되는 조합을 넣으면 생성이 실패합니다.

함정 Count2의 거듭제곱(1, 2, 4, 8)만 의미가 있고, 하드웨어가 지원하는 값이어야 합니다. 대부분의 데스크톱 GPU가 4x는 확실히 지원하지만 8x는 포맷에 따라 다릅니다. 또 스왑체인을 MSAA로 만들면 플립 모델(DXGI_SWAP_EFFECT_FLIP_*)과는 함께 못 씁니다. 최신 데스크톱 앱에서 플립 모델을 쓰려면 뒤(2절)의 오프스크린 MSAA 방식을 택하고 백버퍼는 단일샘플로 둬야 합니다.

1-3. 줄 단위 해부 — 깊이 버퍼는 왜 같은 샘플 수여야 하나

dd.SampleDesc.Count = SAMPLES; — 여기가 가장 흔히 실수하는 지점입니다. D3D11은 렌더 타깃(컬러)과 깊이/스텐실의 샘플 수가 정확히 같아야 둘을 동시에 바인딩할 수 있습니다. 이유는 물리적입니다. MSAA에서 깊이 테스트는 서브샘플 단위로 일어나야 커버리지가 제대로 나오는데, 그러려면 깊이 버퍼도 픽셀당 4개의 깊이 값을 저장할 공간, 즉 4x 멀티샘플 텍스처여야 합니다. 컬러는 4샘플인데 깊이가 1샘플이면 서브샘플별 깊이 비교가 불가능하므로 API가 바인딩을 거부합니다.

dd.Format = DXGI_FORMAT_D24_UNORM_S8_UINT; — 24비트 정규화 깊이 + 8비트 스텐실을 한 텍셀에 담는 표준 깊이 포맷입니다. dd.BindFlags = D3D11_BIND_DEPTH_STENCIL;로 "이 텍스처는 깊이/스텐실로 쓴다"고 선언해야 CreateDepthStencilView가 성공합니다. MipLevels = 1인 이유는 깊이 버퍼에는 밉 체인이 필요 없기 때문입니다(화면 크기 한 장이면 됨).

1-4. 줄 단위 해부 — 렌더 루프

루프 본문에서 MSAA를 위해 추가로 하는 일은 전혀 없습니다. 이것이 MSAA의 매력입니다. OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get())로 멀티샘플 RTV와 DSV를 묶어 걸고, 평소처럼 drawScene()을 호출하면 커버리지 계산은 고정 기능이 처리합니다. rtv.GetAddressOf()ID3D11RenderTargetView* const*(포인터의 주소)를 돌려줍니다. 이 함수가 RTV 배열을 받기 때문에, RTV가 하나라도 "그 하나의 주소"를 넘겨야 합니다.

swapChain->Present(1, 0); — 첫 인자 1SyncInterval로, "수직 동기 1회를 기다렸다 표시"(=VSync 켜짐)를 뜻합니다. 0을 넣으면 즉시 표시(테어링 가능, 최대 프레임레이트)입니다. 화면에 보여지는 순간에 백버퍼가 그대로 나가는데, 백버퍼 자체가 이미 MSAA로 해상된(resolved) 결과입니다 — 스왑체인 백버퍼의 경우 Present 시점에 하드웨어가 자동으로 멀티샘플을 단일샘플로 리졸브해 스캔아웃합니다.

성능 MSAA는 지오메트리 엣지에만 효과가 있고, 픽셀 셰이더는 픽셀당 한 번만 돕니다 (샘플당이 아님 — 이것이 SSAA 대비 싸게 먹히는 이유입니다). 그래서 비용 대비 화질이 좋지만, 알파 텍스처의 잘린 엣지(나뭇잎 등)에는 효과가 없습니다 — 그건 블렌드 스테이트의 AlphaToCoverageEnable이나 FXAA가 필요합니다. 메모리는 컬러+깊이가 대략 샘플 수배로 늘어나므로(4x면 약 4배) 고해상도에서는 VRAM을 크게 먹습니다.
주의 AlphaToCoverage(A2C)는 MSAA와 함께 쓰는 저비용 기법으로, 픽셀 셰이더가 낸 알파를 MSAA 커버리지 마스크로 변환해 나뭇잎 같은 알파 컷아웃 엣지도 부드럽게 만듭니다. 블렌드 서술자에서 bd.AlphaToCoverageEnable = TRUE; 한 줄로 켭니다. 단, MSAA가 켜져 있을 때만(멀티샘플 렌더 타깃) 의미가 있습니다.

2. MSAA — 오프스크린 렌더 타깃에 직접 (후처리와 함께)

무엇을 만드나: HDR·블룸 같은 후처리를 하려면 오프스크린 렌더 타깃에 그려야 하는데, 그 텍스처도 멀티샘플이어야 합니다. 그런데 멀티샘플 텍스처는 셰이더에서 바로 Sample()할 수 없습니다. 그래서 멀티샘플 텍스처에 그린 뒤, 단일샘플 텍스처로 리졸브(ResolveSubresource)하는 2단계 패턴을 씁니다.

왜 이렇게까지 하나: 셰이더의 Texture2D.Sample()은 픽셀당 색 하나가 있는 일반 텍스처를 전제로 합니다. 멀티샘플 텍스처는 픽셀당 값이 4개(Texture2DMS)라 "어느 서브샘플을 읽으라는 건지" 정의가 안 됩니다. 리졸브는 이 4개를 평균 내 픽셀당 1개로 합쳐, 후처리가 다룰 수 있는 평범한 텍스처로 바꾸는 과정입니다.

msaa_offscreen.cppcpp
using Microsoft::WRL::ComPtr;
const int W = 1280, H = 720, SAMPLES = 4;
const DXGI_FORMAT FMT = DXGI_FORMAT_R8G8B8A8_UNORM;

// ── (A) 멀티샘플 컬러 텍스처: 여기에 씬을 그린다 ──
D3D11_TEXTURE2D_DESC cd = {};
cd.Width = W; cd.Height = H; cd.MipLevels = 1; cd.ArraySize = 1;
cd.Format = FMT;
cd.SampleDesc.Count = SAMPLES;          // 멀티샘플
cd.Usage = D3D11_USAGE_DEFAULT;
cd.BindFlags = D3D11_BIND_RENDER_TARGET;
ComPtr<ID3D11Texture2D> msColor;
device->CreateTexture2D(&cd, nullptr, &msColor);
ComPtr<ID3D11RenderTargetView> msRTV;
device->CreateRenderTargetView(msColor.Get(), nullptr, &msRTV);

// 멀티샘플 깊이/스텐실 (같은 샘플 수)
D3D11_TEXTURE2D_DESC dd = cd;
dd.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
dd.BindFlags = D3D11_BIND_DEPTH_STENCIL;
ComPtr<ID3D11Texture2D> msDepth;
device->CreateTexture2D(&dd, nullptr, &msDepth);
ComPtr<ID3D11DepthStencilView> msDSV;
device->CreateDepthStencilView(msDepth.Get(), nullptr, &msDSV);

// ── (B) 단일샘플 텍스처: 리졸브 결과를 담아 셰이더에서 샘플링 ──
D3D11_TEXTURE2D_DESC rd = {};
rd.Width = W; rd.Height = H; rd.MipLevels = 1; rd.ArraySize = 1;
rd.Format = FMT;
rd.SampleDesc.Count = 1;                // ← 단일샘플
rd.Usage = D3D11_USAGE_DEFAULT;
rd.BindFlags = D3D11_BIND_SHADER_RESOURCE;
ComPtr<ID3D11Texture2D> resolved;
device->CreateTexture2D(&rd, nullptr, &resolved);
ComPtr<ID3D11ShaderResourceView> screenSRV;
device->CreateShaderResourceView(resolved.Get(), nullptr, &screenSRV);

// ── 렌더 루프 ──
void renderFrame() {
    // 1) 멀티샘플 렌더 타깃에 씬 그리기
    const float clear[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
    context->OMSetRenderTargets(1, msRTV.GetAddressOf(), msDSV.Get());
    context->ClearRenderTargetView(msRTV.Get(), clear);
    context->ClearDepthStencilView(msDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
    drawScene();

    // 2) 멀티샘플 → 단일샘플 리졸브
    context->ResolveSubresource(resolved.Get(), 0, msColor.Get(), 0, FMT);

    // 3) 이제 screenSRV를 후처리 셰이더 입력으로 사용해 화면에 출력
    context->OMSetRenderTargets(1, backRTV.GetAddressOf(), nullptr);
    context->PSSetShaderResources(0, 1, screenSRV.GetAddressOf());
    drawFullscreenQuad(postShaders);
}

2-1. 줄 단위 해부 — 세 개의 텍스처, 각자의 역할

이 코드에는 텍스처가 세 개 등장합니다. 역할을 BindFlagsSampleDesc.Count로 구분하는 것이 핵심입니다.

텍스처SampleDesc.CountBindFlags역할
msColor4RENDER_TARGET씬을 그려 넣는 멀티샘플 캔버스
msDepth4DEPTH_STENCIL서브샘플별 깊이 테스트용(컬러와 샘플 수 일치)
resolved1SHADER_RESOURCE리졸브 결과, 후처리가 Sample() 가능

cd.BindFlags = D3D11_BIND_RENDER_TARGET;msColor는 오직 그리기 대상이라 RENDER_TARGET만 켭니다. 여기에 SHADER_RESOURCE까지 켜고 싶어질 수 있지만, 멀티샘플 텍스처를 셰이더에서 읽으려면 Texture2DMS로 선언하고 서브샘플을 명시적으로 Load()해야 하므로 일반 후처리에는 맞지 않습니다. 그래서 별도의 단일샘플 resolved를 두는 것입니다.

D3D11_TEXTURE2D_DESC dd = cd; — 깊이 서술자를 컬러 서술자에서 복사해 시작합니다. 이렇게 하면 Width, Height, SampleDesc.Count가 자동으로 컬러와 같아져 "샘플 수 일치" 실수를 원천 차단합니다. 그다음 FormatBindFlags만 깊이용으로 덮어씁니다. 이런 "복사 후 두 필드만 수정" 패턴은 실무에서 샘플 수 불일치 버그를 줄이는 좋은 습관입니다.

rd.SampleDesc.Count = 1; — 리졸브 대상은 반드시 단일샘플입니다. 그리고 rd.BindFlags = D3D11_BIND_SHADER_RESOURCE;로 셰이더 입력임을 선언해 CreateShaderResourceView가 성공하게 합니다. 이 SRV(screenSRV)가 3단계에서 후처리 셰이더의 t0에 들어갑니다.

2-2. 줄 단위 해부 — ResolveSubresource

context->ResolveSubresource(resolved.Get(), 0, msColor.Get(), 0, FMT); — 이 한 줄이 멀티샘플 → 단일샘플 변환의 전부입니다. 인자를 하나씩 봅시다.

  1. resolved.Get()대상(dst) 리소스. 단일샘플 텍스처.
  2. 0 — 대상의 서브리소스 인덱스(밉 0, 배열 0). 밉이 하나뿐이니 0.
  3. msColor.Get()소스(src) 리소스. 멀티샘플 텍스처.
  4. 0 — 소스의 서브리소스 인덱스.
  5. FMT — 리졸브에 사용할 포맷. 소스·대상과 호환되어야 함.

GPU는 이 호출에서 msColor의 픽셀마다 4개 서브샘플 색을 평균resolved의 한 텍셀로 씁니다. 바로 이 평균이 커버리지 50% 엣지 픽셀을 "반쯤 섞인 색"으로 만들어 계단을 매끈하게 하는 것입니다.

함정 ResolveSubresource는 소스와 대상의 크기·포맷이 같아야 하며, 대상은 반드시 단일샘플(SampleDesc.Count == 1) 텍스처여야 합니다. 리사이즈나 포맷 변환을 겸할 수는 없습니다 — 그런 처리는 별도의 후처리 패스로 하세요. 또 HDR 파이프라인에서는 이 리졸브가 평균을 선형 공간에서 하느냐가 중요합니다. sRGB 인코딩된 값을 그냥 평균하면 물리적으로 틀리므로, 컬러 포맷을 ..._SRGB로 두거나 HDR(float) 포맷으로 두고 리졸브해야 엣지 색이 정확합니다.

2-3. 줄 단위 해부 — 후처리로 넘기기

context->OMSetRenderTargets(1, backRTV.GetAddressOf(), nullptr); — 이제 출력 대상을 실제 화면 백버퍼(backRTV)로 바꿉니다. 깊이는 후처리 풀스크린 쿼드에는 필요 없으므로 nullptr을 넘겨 DSV를 떼어냅니다.

context->PSSetShaderResources(0, 1, screenSRV.GetAddressOf()); — 리졸브된 텍스처를 픽셀 셰이더의 슬롯 0(t0)에 바인딩합니다. 첫 인자 0이 시작 슬롯, 둘째 1이 개수입니다. 이후 drawFullscreenQuad가 화면을 덮는 삼각형/쿼드를 그리며 이 텍스처를 샘플링해 후처리(톤매핑·블룸·그리고 다음 절의 FXAA)를 적용합니다.

주의 다음 프레임에 msColor를 렌더 타깃으로 다시 걸기 전에, 그것을 SRV로 물고 있던 바인딩을 반드시 풀어야 합니다(반대도 마찬가지). D3D11은 같은 리소스를 입력(SRV)과 출력(RTV)으로 동시에 바인딩하는 것을 금지하며, 디버그 레이어가 경고를 뿜고 해당 스테이지를 무력화합니다. 실무에서는 프레임 끝에 PSSetShaderResourcesnullptr을 바인딩해 슬롯을 비워 둡니다.

3. FXAA — 후처리 셰이더 (luma 기반 엣지 블렌딩)

무엇을 만드나: FXAA(Fast Approximate Anti-Aliasing)는 이미 렌더된 이미지를 입력으로 받아, 밝기(luma) 차이로 엣지를 찾고 그 방향으로 이웃 픽셀을 섞는 순수 화면공간 기법입니다. MSAA와 달리 알파 엣지·셰이더 앨리어싱까지 부드럽게 하지만, 약간 흐릿해집니다. 아래는 개념을 담은 간결한 자작 버전입니다(실무 FXAA 3.11은 훨씬 정교합니다).

왜 필요한가: MSAA는 커버리지 정보를 렌더 중에만 알 수 있어 지오메트리 엣지에만 통합니다. 반면 FXAA는 완성된 픽셀 값만 보고 판단하므로, 그 픽셀이 삼각형 엣지든 알파 컷아웃이든 노멀맵 스페큘러 반짝임이든 가리지 않고 "밝기가 급변하는 곳"을 모두 처리합니다. 비용도 픽셀 셰이더 패스 한 번이라 매우 쌉니다. 대가는 실제 커버리지를 모른 채 추정하므로 전체적으로 살짝 뿌예진다는 점입니다.

fxaa.hlsl (Pixel Shader)HLSL
Texture2D    uScene : register(t0);   // 리졸브된(또는 후처리 전) 씬 컬러
SamplerState uSamp  : register(s0);

cbuffer FXAAParams : register(b0) {
    float2 uTexel;     // 1.0 / 화면해상도
    float2 _pad;
};

struct PSIn {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;
};

// RGB → 인지 밝기(luma)
float luma(float3 c) { return dot(c, float3(0.299, 0.587, 0.114)); }

float4 main(PSIn i) : SV_Target {
    float2 vUV = i.uv;
    // 중앙과 상하좌우 4방향의 밝기 샘플
    float3 rgbM = uScene.Sample(uSamp, vUV).rgb;
    float lM = luma(rgbM);
    float lN = luma(uScene.Sample(uSamp, vUV + float2(0.0, -uTexel.y)).rgb);
    float lS = luma(uScene.Sample(uSamp, vUV + float2(0.0,  uTexel.y)).rgb);
    float lW = luma(uScene.Sample(uSamp, vUV + float2(-uTexel.x, 0.0)).rgb);
    float lE = luma(uScene.Sample(uSamp, vUV + float2( uTexel.x, 0.0)).rgb);

    // 국소 대비: 엣지가 아니면 그대로 통과 (성능 + 흐림 방지)
    float lMin = min(lM, min(min(lN, lS), min(lW, lE)));
    float lMax = max(lM, max(max(lN, lS), max(lW, lE)));
    float contrast = lMax - lMin;
    if (contrast < max(0.0312, lMax * 0.125)) {
        return float4(rgbM, 1.0);
    }

    // 엣지 방향 추정: 수직/수평 밝기 기울기 비교
    float gradV = abs(lN + lS - 2.0 * lM);   // 수직 방향 변화
    float gradH = abs(lW + lE - 2.0 * lM);   // 수평 방향 변화
    bool horizontal = gradH >= gradV;
    float2 dir = horizontal ? float2(uTexel.x, 0.0) : float2(0.0, uTexel.y);

    // 엣지를 가로질러 양쪽 이웃과 평균 → 계단 완화
    float3 a = uScene.Sample(uSamp, vUV + dir).rgb;
    float3 b = uScene.Sample(uSamp, vUV - dir).rgb;
    float3 blended = (rgbM + a + b) / 3.0;

    // 대비가 클수록 강하게 섞는다
    float amount = clamp(contrast * 2.0, 0.0, 1.0);
    return float4(lerp(rgbM, blended, amount), 1.0);
}

3-1. 리소스 선언 해부

Texture2D uScene : register(t0); — 입력 씬 컬러 텍스처를 텍스처 레지스터 0번(t0)에 바인딩합니다. C++ 쪽 PSSetShaderResources(0, 1, ...)의 슬롯 0과 이 t01:1로 짝이 됩니다. HLSL의 register(tN)은 "이 리소스가 몇 번 슬롯에서 온다"는 계약입니다.

SamplerState uSamp : register(s0); — 샘플러는 별도 레지스터 공간 s를 씁니다. C++ PSSetSamplers(0, 1, ...)와 짝입니다. FXAA에서는 텍셀 정확히를 읽는 게 중요할 때도 있지만, 여기서는 이웃과의 부드러운 섞임을 위해 보통 선형(bilinear) 샘플러를 씁니다.

cbuffer FXAAParams : register(b0) { float2 uTexel; float2 _pad; }; — 상수 버퍼입니다. uTexel1픽셀의 UV 크기, 즉 (1/width, 1/height)입니다. UV는 0~1로 정규화된 좌표라, "옆 픽셀"로 가려면 1/width만큼 움직여야 하기 때문입니다. float2 _pad;16바이트 정렬 패딩입니다 — D3D11 상수 버퍼는 16바이트 경계 규칙이 있어, float2(8바이트) 하나만 두면 나머지 8바이트를 어차피 채워야 합니다. 명시적으로 _pad를 두면 C++ 구조체와 크기가 어긋날 위험이 줄어듭니다.

함정 uTexel을 잘못 넣으면(예: 해상도를 그대로 넣거나 폭/높이를 뒤바꾸면) 이웃 샘플이 엉뚱한 곳을 찍어 FXAA가 화면을 흐리기만 하고 엣지는 못 잡습니다. 반드시 uTexel = (1.0f / renderWidth, 1.0f / renderHeight)로, FXAA를 도는 실제 해상도 기준으로 넣으세요(하프해상도 후처리라면 그 하프 해상도로).

3-2. luma — 왜 RGB가 아니라 밝기인가

float luma(float3 c) { return dot(c, float3(0.299, 0.587, 0.114)); }

수학적 의미: 이것은 RGB 3차원 벡터를 밝기라는 1차원 스칼라로 사영하는 내적(dot product)입니다. dot(c, w) = c.r*w.r + c.g*w.g + c.b*w.b이므로, 결과 = 0.299·R + 0.587·G + 0.114·B입니다. 이 가중치는 사람 눈이 초록에 가장 민감하고(0.587) 파랑에 가장 둔감하다(0.114)는 실측(ITU-R BT.601)에서 온 값입니다.

기하 직관: RGB 색공간을 3차원 공간으로 보면, 이 내적은 모든 색을 "밝기 축(회색 방향에 가까운 특정 방향)"으로 그림자처럼 투영한 길이를 재는 것입니다. 두 색이 밝기 축 상에서 멀리 떨어져 있으면 사람 눈에 뚜렷한 엣지로 보입니다.

왜 밝기로 엣지를 찾나: 엣지는 결국 "밝기가 급변하는 선"입니다. 색상(hue)만 다르고 밝기가 같은 경계(예: 같은 밝기의 빨강↔초록)는 사람 눈에 덜 두드러지고, 계단도 덜 보입니다. 그래서 세 채널을 다 비교하는 대신 밝기 하나로 줄이면 계산이 1/3로 줄고 인지적으로도 타당한 엣지 검출이 됩니다. 이것이 FXAA가 luma를 쓰는 이유입니다.

주의 FXAA는 sRGB로 인코딩된(감마 적용된) 컬러 위에서 돌리는 것이 원칙입니다. luma가 인지 밝기 기준이라야 엣지가 잘 잡히기 때문입니다. 즉 톤매핑·감마 보정 이후의 이미지를 입력으로 넣으세요. 선형 HDR 값 위에서 돌리면 밝은 영역의 대비가 과장되고 어두운 영역은 엣지를 놓칩니다.

3-3. 십자 샘플과 국소 대비 — 조기 종료

중앙(M)과 상하좌우(N/S/W/E) 다섯 지점의 luma를 뽑습니다. 이웃 좌표를 만들 때 vUV + float2(0.0, -uTexel.y)처럼 uTexel을 더하는 것이 바로 "1픽셀 이동"입니다. 텍스처 V가 위→아래로 증가하는 D3D 규약이라, -uTexel.y가 화면상 위쪽(N), +uTexel.y아래쪽(S)입니다. 이 방향 감각을 헷갈리면 엣지 방향 판정이 틀어집니다.

float contrast = lMax - lMin;는 십자 이웃 중 가장 밝은 것과 어두운 것의 차, 즉 이 픽셀 주변의 국소 대비입니다. 그다음:

if (contrast < max(0.0312, lMax * 0.125)) return float4(rgbM, 1.0); — 대비가 문턱값보다 작으면 엣지가 아니라고 보고 원본을 그대로 반환합니다. 문턱값이 두 겹인 이유가 중요합니다.

  1. 0.0312(절대 문턱): 아주 어두운 영역에서 미세한 노이즈를 엣지로 착각해 뭉개는 것을 막는 바닥값.
  2. lMax * 0.125(상대 문턱): 밝은 영역일수록 눈이 대비에 둔감하므로, 밝기에 비례해 문턱을 올림.
  3. max(...)로 둘 중 큰 값을 택해, 두 조건을 동시에 만족(=진짜 엣지)할 때만 처리.

이 조기 종료는 성능(대부분의 픽셀은 엣지가 아니라 곧장 반환)과 화질(엣지 아닌 곳을 흐리지 않음)을 동시에 잡는 핵심 트릭입니다. 화면의 평평한 면 90%는 여기서 빠져나갑니다.

3-4. 엣지 방향 추정 — 2차 미분(라플라시안)의 직관

float gradV = abs(lN + lS - 2.0 * lM);
float gradH = abs(lW + lE - 2.0 * lM);

수학적 의미: lN + lS - 2·lM은 세로 방향의 2차 차분(discrete second derivative)입니다. 연속함수의 2계도함수 f''를 격자에서 근사한 f(x-1) - 2f(x) + f(x+1) 공식 그대로입니다. 밝기가 세로로 꺾이는(엣지가 가로로 누운) 곳에서 이 값의 절댓값이 커집니다.

기하 직관: 만약 위-중앙-아래 luma가 0.1, 0.5, 0.9처럼 일정하게 증가하면 2차 차분은 0.1 + 0.9 - 1.0 = 0 — 완만한 그라디언트는 엣지가 아닙니다. 반대로 0.9, 0.9, 0.1처럼 한쪽에서 급히 꺾이면 0.9 + 0.1 - 1.8 = -0.8, 절댓값이 커 "여기 세로로 급변이 있다 = 가로 엣지"로 잡힙니다.

bool horizontal = gradH >= gradV; — 수평 방향 변화(gradH)가 더 크면, 밝기가 가로로 급변하는 것이므로 엣지는 세로선에 가깝습니다. 이때 계단을 완화하려면 가로로 이웃을 섞어야 하므로 dir = (uTexel.x, 0)(수평 방향)을 고릅니다. 반대면 수직 방향을 고릅니다. 즉 "변화가 큰 축을 가로질러 평균한다"가 원리입니다.

함정 변수명 horizontal과 실제 이동 방향의 관계를 헷갈리기 쉽습니다. 여기서 horizontal == true는 "밝기의 수평 기울기가 크다 → 엣지는 세로 → 이웃을 수평으로 섞는다"는 뜻입니다. 이름을 "섞는 방향"으로 읽으면 맞고, "엣지 방향"으로 읽으면 반대라 혼동합니다.

3-5. 블렌딩 — 얼마나 섞을지

float3 blended = (rgbM + a + b) / 3.0; — 중앙과, 엣지를 가로지르는 양쪽 이웃 a, b단순 평균입니다. 이 평균이 커버리지 정보 없이도 계단을 "번지게" 해 매끈해 보이게 만듭니다. MSAA의 서브샘플 평균을 사후에 근사하는 셈입니다.

float amount = clamp(contrast * 2.0, 0.0, 1.0); — 얼마나 섞을지를 국소 대비에 비례시킵니다. 대비가 크면(뚜렷한 엣지) 강하게, 약하면 약하게. clamp(.., 0, 1)로 0~1 범위에 가둬 과도한 섞임을 막습니다. * 2.0은 감도(강도) 조절 상수로, 화질을 보며 튜닝하는 값입니다.

return float4(lerp(rgbM, blended, amount), 1.0); — 최종적으로 원본 rgbM과 평균 blendedamount 비율로 선형 보간(lerp)합니다. lerp(x, y, t) = x*(1-t) + y*t이므로, amount=0이면 원본 그대로, amount=1이면 완전 평균입니다. 알파는 불투명 1.0으로 출력합니다.

손계산 예: 검은(0.0)과 흰(1.0)이 만나는 세로 엣지 위 픽셀에서 rgbM=1.0, 한쪽 이웃 a=1.0(흰), 반대쪽 b=0.0(검정)이라 하면 blended=(1+1+0)/3≈0.667. 대비 contrast≈1.0이라 amount=clamp(2.0,0,1)=1.0. 결과 = lerp(1.0, 0.667, 1.0)=0.667 — 새하얗던 계단 픽셀이 중간 회색으로 부드러워집니다.

성능 이 자작 FXAA는 픽셀당 최대 7번의 텍스처 샘플(중앙+십자 4 + 대각 방향 2)로, 풀스크린 패스 하나면 끝나 매우 쌉니다. 실무 FXAA 3.11은 여기에 엣지를 따라 여러 스텝을 탐색해 서브픽셀 정밀도를 높인 로직이 더해져 화질이 좋지만, 원리는 같습니다(luma 대비 → 방향 → 블렌드). MSAA와 달리 메모리를 거의 안 먹는 것이 큰 장점입니다.

4. TAA — 시간적 안티앨리어싱 (개념)

무엇을 만드나: TAA(Temporal Anti-Aliasing)는 매 프레임 카메라 투영에 서브픽셀 크기의 지터(jitter)를 주어 샘플 위치를 흩뿌린 뒤, 이전 프레임 결과를 현재 화면에 재투영(reprojection)해 지수 이동평균으로 누적합니다. 즉 "여러 프레임에 걸친 슈퍼샘플링"이라, 정지 화면에서는 MSAA보다 훨씬 매끈하고 셰이더 앨리어싱까지 잡습니다.

왜 강력한가: MSAA가 한 프레임 안에서 4개 서브샘플로 커버리지를 재는 대신, TAA는 시간축으로 샘플을 모읍니다. 프레임마다 카메라를 픽셀보다 작게 미세하게 흔들면(지터), 같은 표면이 매 프레임 조금씩 다른 하위 위치에서 샘플링됩니다. 이 결과들을 여러 프레임에 걸쳐 평균하면 사실상 무한에 가까운 서브샘플 슈퍼샘플링 효과가 납니다. 지오메트리 엣지뿐 아니라 스페큘러 반짝임·알파 엣지 같은 셰이더 앨리어싱까지 잡는 이유입니다.

4-1. 세 가지 필수 요소

  1. 지터(jitter): 서브픽셀 오프셋(예: Halton 수열)을 투영 행렬에 더해 매 프레임 샘플 위치를 흩뿌린다.
  2. 모션 벡터(motion vector): 픽셀별로 "이 표면이 이전 프레임 어디에 있었나"를 저장해, 히스토리 텍스처의 정확한 위치를 찾는다.
  3. 히스토리 클램프(neighborhood clamp): 이동/디스오클루전 시 유령상(ghosting)을 막기 위해, 재투영해 온 이전 색을 현재 이웃 색상 범위(AABB)로 제한한다.

4-2. 지터는 왜 투영 행렬에 더하나

수학적 의미: 클립공간에서 x, y는 뒤에 원근분할될 w에 스케일된 좌표입니다. 투영 행렬의 마지막 행(행벡터 규약에서 _31, _32 부근, 정확히는 클립 x/y에 w를 더하는 성분)에 jitterX·2/width, jitterY·2/height를 더하면, NDC로 환산됐을 때 화면상 서브픽셀만큼 장면 전체가 평행이동합니다. 즉 기하는 그대로 두고 샘플 격자만 흔드는 효과입니다.

기하 직관: 카메라를 물리적으로 옮기는 게 아니라, "픽셀 격자를 0.25픽셀 오른쪽, 다음 프레임엔 0.5픽셀 위로…" 하는 식으로 격자만 미끄러뜨리는 것입니다. 그래야 같은 엣지가 프레임마다 다른 하위 위치에서 커버리지 판정을 받고, 그 결과를 평균하면 계조가 생깁니다. 지터 수열로 Halton(2,3) 같은 저불일치(low-discrepancy) 수열을 쓰는 이유는, 랜덤보다 샘플이 골고루 퍼져 적은 프레임으로도 균일한 커버리지를 얻기 때문입니다.

함정 지터를 UI·텍스트·후처리 UV에까지 흘리면 안 됩니다. 지터는 오직 3D 씬의 투영에만 적용하고, 재투영·블렌딩 후에는 히스토리 좌표에서 지터를 다시 빼서 정합을 맞춰야 합니다. 또 지터 오프셋을 상수 버퍼로 셰이더에 넘겨 모션 벡터 계산에서 반영해야 정확한 재투영이 됩니다.

4-3. 재투영과 히스토리 클램프 — 유령상을 막는 법

재투영: 현재 픽셀의 모션 벡터 mv를 이용해 histUV = curUV - mv로 이전 프레임에서 같은 표면의 위치를 찾아 히스토리 텍스처를 샘플링합니다. 카메라나 물체가 움직여도 "같은 표면 점"을 계속 추적해 평균에 누적할 수 있게 해 줍니다.

지수 이동평균: 최종 색 = lerp(history, current, α)로, α는 보통 0.1 안팎입니다. 즉 매 프레임 현재 색을 10%만 섞고 90%는 과거를 유지해, 여러 프레임에 걸쳐 샘플이 누적되며 매끈해집니다.

히스토리 클램프: 문제는 물체가 빠르게 움직이거나 가려졌다 드러날 때(디스오클루전), 재투영해 온 과거 색이 현재 장면과 안 맞아 잔상(ghosting)이 생긴다는 것입니다. 이를 막기 위해, 현재 픽셀의 3×3 이웃 색으로 만든 색공간 AABB(min~max 박스) 안으로 히스토리 색을 clamp합니다. 과거 색이 현재 이웃 범위를 벗어나면(=장면이 바뀜) 그 범위 경계로 끌어당겨 잔상을 잘라 냅니다.

주의 TAA는 구현 난도가 높고(모션 벡터 G-버퍼, 히스토리 핑퐁 텍스처, 클램프·샤프닝 튜닝) 움직임에서 번짐·잔상이 생기기 쉬워, 실무에서는 대개 엔진이 제공하는 TAA를 튜닝해 씁니다. 직접 구현은 학습 목적이 크며, 프로덕션에서는 검증된 구현(엔진 내장, 또는 TAAU/DLSS/FSR 같은 상위 기법)을 쓰는 것이 안전합니다.

5. 지터 오프셋을 DirectXMath로 — API 1:1 대응

TAA 지터를 직접 짤 때 실제로 만지는 것은 투영 행렬입니다. DirectXMath로 어떻게 더하는지, 규약 통일 관점에서 정확히 짚어 봅니다. 규약은 행벡터 v' = v·M, 행 우선 저장, 왼손 좌표계, 클립 z [0,1]입니다.

taa_jitter.cppcpp
#include <DirectXMath.h>
using namespace DirectX;

// Halton(base) 수열의 index번째 값 (0~1). 지터 샘플 위치 생성용.
float halton(int index, int base) {
    float f = 1.0f, r = 0.0f;
    while (index > 0) {
        f /= (float)base;
        r += f * (float)(index % base);
        index /= base;
    }
    return r;
}

// 프레임마다 서브픽셀 지터를 더한 투영 행렬을 만든다.
XMMATRIX jitteredProjection(float fovY, float aspect, float zn, float zf,
                            int frame, int width, int height) {
    // 왼손 좌표계 · 클립 z [0,1] 표준 투영
    XMMATRIX proj = XMMatrixPerspectiveFovLH(fovY, aspect, zn, zf);

    // Halton(2,3)로 [-0.5, 0.5) 픽셀 범위의 서브픽셀 오프셋 생성
    float jx = halton(frame + 1, 2) - 0.5f;   // 픽셀 단위
    float jy = halton(frame + 1, 3) - 0.5f;

    // 픽셀 오프셋 → NDC 오프셋 (화면 폭/높이가 NDC로 2에 대응)
    float ndcX = jx * 2.0f / (float)width;
    float ndcY = jy * 2.0f / (float)height;

    // 행벡터 규약: 클립 x += w*ndcX 는 투영 행렬의 (row3, col0/1)에 더함
    XMFLOAT4X4 m;
    XMStoreFloat4x4(&m, proj);
    m._31 += ndcX;   // 클립.x 에 w*ndcX 를 더하는 성분
    m._32 += ndcY;   // 클립.y 에 w*ndcY 를 더하는 성분
    return XMLoadFloat4x4(&m);
}

5-1. 줄 단위 해부 — Halton과 지터 변환

halton(index, base)는 저불일치 수열 값을 만드는 표준 루틴입니다. indexbase 진법으로 뒤집어 소수부로 만드는 것으로, base 2·3을 쓰면 두 축의 샘플이 골고루 퍼집니다. frame + 1을 넣는 이유는 index 0이 항상 0을 뱉어(지터 없음) 첫 프레임이 흔들리지 않는 것을 피하기 위해서입니다. 실무에서는 8~16 프레임 주기로 순환시킵니다.

XMMatrixPerspectiveFovLH(fovY, aspect, zn, zf) — 이름의 LH가 왼손 좌표계, DirectXMath의 이 함수가 만드는 클립 z 범위가 [0,1]이라 우리 규약과 정확히 맞습니다 (GL 계열의 [-1,1]과 다릅니다). 반환은 행벡터 규약의 행렬이라, 셰이더에서 mul(v, M)로 곱하면 됩니다.

float ndcX = jx * 2.0f / width;수학적 의미: NDC의 x는 화면 왼쪽 끝 -1부터 오른쪽 끝 +1까지, 즉 폭 전체가 2에 대응합니다. 따라서 "1픽셀"은 NDC로 2/width이고, jx픽셀만큼 흔들려면 jx·2/width를 더해야 합니다. y도 같은 논리로 2/height입니다.

5-2. 왜 _31, _32에 더하나 — 행벡터 규약의 핵심

수학 → 코드 매핑: 행벡터 규약에서 정점은 clip = v · M으로 변환됩니다. 여기서 입력 v = (x, y, z, 1)의 마지막 성분(w=1)에 곱해지는 것이 행렬의 3행(0-기준, _31 _32 _33 _34)입니다(행벡터·행 우선 저장 규약). 원근 투영에서 클립 w는 대략 뷰공간 깊이에 비례하므로, clip.xw·ndcX를 더하려면 3행의 x열, 즉 m._31ndcX를 더하면 됩니다. 원근분할(clip.xy / clip.w) 후 정확히 ndcX만큼의 NDC 평행이동이 남습니다.

기하 직관: "장면 전체를 화면상 jx픽셀만큼 옆으로 민다"는 것을, 카메라를 옮기지 않고 투영 단계에서 상수 오프셋으로 처리하는 것입니다. 깊이에 무관하게 화면상 동일한 픽셀 수만큼 이동하도록 w에 비례한 항으로 넣는 것이 포인트입니다.

함정 열벡터 규약(v' = M·v, GLM 기본)에서는 같은 오프셋을 _13, _23 (마지막 )에 더합니다. 규약을 헷갈려 엉뚱한 성분에 더하면 지터가 깊이에 따라 어긋나거나 전혀 안 흔들립니다. 이 페이지의 규약은 행벡터·행 우선이므로 _31, _32가 맞습니다. 또 cbuffer로 올릴 때는 전치(transpose)가 필요할 수 있습니다(HLSL column_major 기본과의 정합).

5-3. DirectXMath 함수 ↔ 원본 개념 대응표

DirectXMath / D3D원본 개념비고
XMMatrixPerspectiveFovLH왼손 · 클립 z [0,1] 원근 투영행벡터 규약 행렬 반환
XMStoreFloat4x4 / XMLoadFloat4x4SIMD XMMATRIX ↔ 저장용 XMFLOAT4X4_ij 성분 직접 접근하려면 Store 필요
m._31, m._32클립 x/y에 w·offset을 더하는 성분행 우선 저장(_rc = row r, col c)
ResolveSubresource멀티샘플 → 단일샘플 평균MSAA 오프스크린 리졸브
CheckMultisampleQualityLevelsMSAA 샘플/품질 지원 조회생성 전 반드시 확인
HLSL dot(c, (0.299,0.587,0.114))RGB → luma 사영BT.601 가중치
HLSL lerp(x, y, t)선형 보간 x(1-t)+ytFXAA/TAA 블렌딩 공통
주의 직접 지터·리졸브를 짜는 것은 학습용입니다. 실무에서는 DirectXMath/검증된 라이브러리와 엔진 제공 AA(내장 MSAA/TAA, 또는 DLSS·FSR·XeSS 같은 상위 기법)를 쓰는 것이 안정적입니다. 이 페이지 코드는 각 단계가 GPU에서 무엇을 시키는지 이해하기 위한 최소 원형으로 읽어 주세요.

6. 정리 — 언제 무엇을 쓰나

  1. 후처리가 없고 지오메트리 엣지만 문제다 → MSAA 스왑체인(1절). 가장 간단.
  2. HDR·블룸 등 후처리가 있다 → 오프스크린 MSAA + 리졸브(2절) 후 후처리 체인.
  3. 메모리가 빡빡하거나 알파/셰이더 엣지까지 싸게 잡고 싶다 → FXAA(3절)를 후처리 끝에.
  4. 정지·저속 화면 품질을 극대화하고 셰이더 앨리어싱까지 잡아야 한다 → TAA(4절), 단 엔진 제공 권장.
  5. 실무 조합: MSAA(또는 TAA) + 톤매핑 후 FXAA 살짝이 흔한 절충입니다.

세 기법 모두 뿌리는 같습니다 — "이진 커버리지를 계조로 바꾼다". MSAA는 공간에서 서브샘플로, TAA는 시간에서 지터로, FXAA는 사후에 luma 대비로 그 계조를 만들어 냅니다. 이 관점을 잡고 있으면 어떤 AA 기법을 만나도 "이건 계조를 어디서 얻지?"라는 한 질문으로 구조를 빠르게 파악할 수 있습니다.