이 글은 패치 용접·국소 곡률 보정·점광원까지 만든 이전 글의 후속이다. 이번에는 두 AA 경로를 분명하게 분리했다. --aa=fxaa는 완성된 화면 전체에 적용하는 일반 FXAA이고, --aa=adaptive는 렌더링 분석 중 급격하게 꺾이는 기하 지점만 골라 FXAA를 적용하는 절충 모드다.
실행 가능한 핵심 소스(main.cpp, CMakeLists.txt, teapot.bpt, 구현 노트)는 utah-teapot-geometry-guided-aa.zip으로 첨부했다.
1. 두 모드의 역할
전역 FXAA는 색의 휘도 변화로 화면 에지를 찾아 모든 픽셀에서 보간 여부를 판단한다. 구현이 간단하고 실루엣·알파 경계·텍스처 경계까지 한 번에 다룰 수 있지만, 기하학적으로 매끈한 곳도 흐려질 수 있다.
적응형 모드는 이 전역 FXAA를 대체하는 다른 종류의 AA가 아니다. 같은 방향성 FXAA 보간을 더 좁은 후보에만 적용하는 절충안이다. 단, 검은 배경과 객체가 맞닿는 실루엣은 실제 coverage 경계이므로 반드시 후보에 포함한다. 그 외의 내부 픽셀은 depth, normal, curvature G-buffer가 실제로 꺾였는지를 판단할 때만 통과한다.
2. 1단계: G-buffer에서 후보 값을 수집한다
먼저 래스터화 단계에서 색뿐 아니라 깊이, 법선, 곡률 proxy를 G-buffer에 저장한다. 다음으로 각 픽셀의 4방향 이웃을 읽어 후보 판정에 필요한 값을 만든다. 이 순서를 지키면 화면 색만 보고 기하학적 의미를 추측하지 않아도 된다.
새 구현은 한 픽셀 p와 4방향 이웃 q에서 다음 값을 수집한다.
depthJump = max_q |z_p - z_q| / min(z_p, z_q)
normalBend = max_q (1 - dot(N_p, N_q))
backgroundCount = background 이웃의 수
curvature = 래스터화 때 보간해 둔 기하 곡률 proxy
3. 2단계: 배경 경계와 급격한 기하 이벤트를 스텐실에 기록한다
다음 조건 중 하나를 만족하면 적응형 FXAA 후보로 기록한다.
silhouette = backgroundCount >= 1
apertureOrCorner = backgroundCount >= 2
OR (backgroundCount >= 1 AND curvature > 0.07 AND normalBend > 0.04)
abruptBend = curvature > 0.08 AND normalBend > 0.07
occlusionKink = depthJump > 0.035 AND normalBend > 0.04
backgroundCount >= 1이면 검은 배경과 객체 사이의 경계를 부드럽게 하기 위해 적응형 후보가 된다. 다만 기본 실루엣 점수는 낮게 둔다. 주둥이의 구멍처럼 둘 이상의 방향이 배경과 닿는 개구부, 뚜껑 림처럼 배경 접촉과 강한 방향 변화가 함께 있는 부분, 서로 다른 깊이의 표면이 만나는 가림 경계에는 더 높은 점수를 준다. 따라서 두 방법 모두 검은 배경-객체 경계를 처리하지만, 적응형은 기하학적으로 중요한 곳에 더 큰 보간량을 배정한다.
이 값은 정확한 미분기하학적 주곡률도, 진짜 MSAA coverage도 아니다. 대신 CPU 래스터라이저가 이미 계산한 depth, normal, curvature를 이용해 어느 픽셀에만 비용을 쓸지를 보수적으로 정하는 규칙이다.
4. 3단계: 전역 또는 국소 FXAA를 실행한다
single-sample triangle rasterisation
-> colour + depth + normal + curvature G-buffer
-> --aa=fxaa: full-frame directional FXAA
-> --aa=adaptive: object/background silhouette + aperture / abrupt-bend / occlusion-kink analysis
-> sharp-feature stencil -> local directional FXAA
-> PPM / X11
두 모드 모두 최종 색의 휘도로 방향을 계산한다.
L = 0.299 R + 0.587 G + 0.114 B
이번 구현은 단순한 상하 또는 좌우 평균을 쓰지 않는다. 먼저 대각선 4개 픽셀의 휘도에서 에지 진행 방향 dir을 계산한다. 그 방향을 따라 bilinear sample을 네 번 읽고, 가까운 두 sample의 평균 rgbA와 넓은 범위까지 포함한 rgbB를 만든다. rgbB의 휘도가 현재 3x3 범위를 벗어나면 rgbA를 택한다. 이 범위 검사가 좁은 하이라이트를 지나치게 넓히거나 서로 다른 에지를 섞는 일을 줄인다.
--aa=fxaa는 이 directional resolve를 화면 전체에 적용한다. --aa=adaptive는 위 기하 분석을 통과한 픽셀에서만 같은 resolve를 계산한 뒤, 분석 점수에 따라 원본과 결과를 섞는다.
featureScore = max(curvature / 0.16,
normalBend / 0.20,
depthJump / 0.08,
silhouette ? 0.35 : 0.0)
rgbA = 0.5 * (sample(dir * -1/6) + sample(dir * 1/6))
rgbB = 0.5 * rgbA + 0.25 * (sample(dir * -1/2) + sample(dir * 1/2))
result = luma(rgbB) in localRange ? rgbB : rgbA
global FXAA: output = result
adaptive FXAA: output = mix(original, result, 0.45 + 0.55 * featureScore)
둘 다 한 번의 FXAA resolve다. adaptive가 전역 FXAA 뒤에 화면 전체를 다시 블러링하는 두 번째 필터는 아니다. 메시 위치·깊이·법선 자체는 수정하지 않는다.
5. 4단계: 같은 카메라로 결과를 비교한다
AA의 차이를 보려면 카메라가 중요하다. 정면의 낮은 구도에서는 뚜껑과 몸체의 경계가 겹치거나 짧아져서, 보간 전후를 읽기 어렵다. 아래 비교는 위에서 약간 내려다보는 3/4 구도(theta=1.5, phi=0.55, radius=5.4)를 사용했다. 이 시점에서는 뚜껑 림과 몸체 사이의 어두운 틈·밝은 림이 화면을 길게 가로지른다.
패치당 분할 수는 비교를 드러내려고 8로 낮췄다. 이 값은 최종 품질 권장값이 아니라, 단일 샘플 픽셀 계단과 선택적 보간의 차이를 확인하기 위한 시험 조건이다.
단일 샘플 (off) |
전역 FXAA | 급격한 지점의 적응형 국소 FXAA |
|---|---|---|
![]() |
![]() |
![]() |
아래는 새 directional resolve로 다시 렌더한 뚜껑 림을 세 배 확대한 결과다. 왼쪽부터 off, 전역 fxaa, 적응형 adaptive다. 가운데는 화면 전체 FXAA의 결과이고, 오른쪽은 객체-배경 실루엣과 림의 기하 변화로 판정된 픽셀만 보간한 결과다.

아래 확대 비교는 검은 배경과 티팟 몸체의 외곽이다. 가운데의 전역 FXAA와 오른쪽의 적응형 FXAA 모두 배경-객체 경계의 날카로운 계단을 보간한다. 차이는 적용 범위다. 전역 FXAA는 화면 전체의 휘도 에지를 검사하고, 적응형은 이 검은 배경 접촉과 분석된 기하 후보에만 계산을 집중한다.

6. 5단계: 실행 명령으로 재현한다
cmake -S . -B build-aa -DCMAKE_BUILD_TYPE=Release
cmake --build build-aa -j
# X11 창에서 보기
./build-aa/teapot --unify=yes --aa=adaptive
# 본문과 같은 비교용 정지 이미지
./build-aa/teapot --still off.ppm --unify=yes --tess 8 --size 512x384 \
--camera 1.5 0.55 5.4 --aa=off
./build-aa/teapot --still fxaa.ppm --unify=yes --tess 8 --size 512x384 \
--camera 1.5 0.55 5.4 --aa=fxaa
./build-aa/teapot --still adaptive.ppm --unify=yes --tess 8 --size 512x384 \
--camera 1.5 0.55 5.4 --aa=adaptive
옵션의 의미는 다음과 같다.
--aa=off: 후처리 없이 단일 샘플 래스터화만 사용한다.--aa=fxaa: 화면 전체의 휘도 에지에 고정 강도의 일반 FXAA를 적용한다.--aa=adaptive: 검은 배경과 객체의 실루엣, 그리고 곡률·법선 변화·깊이 변화로 찾은 급격한 기하 후보에만 FXAA를 적용한다. 분석 점수가 클수록 혼합량을 조절한다. 기본값이다.
7. 6단계: 실제 비용을 반복 측정한다
품질 비교만으로 적응형 경로의 목적이 달성됐다고 판단하면 안 된다. 따라서 같은 장면에서 applyPostAA() 구간만 따로 반복 측정했다. 이 값에는 메시 생성, 정점 변환, 삼각형 래스터화, PPM 저장, X11 표시 시간은 들어가지 않는다. 즉 아래 표는 전체 프레임 시간이 아니라 AA 후처리 자체의 비용이다.
측정 조건은 Release 빌드, 1024×768, --unify=yes, --tess 12다. 각 모드는 먼저 5회 워밍업했고, 그 뒤 25회를 기록했다.
| 모드 | 평균 | 중앙값 | 최솟값 | 최댓값 |
|---|---|---|---|---|
off |
0.000 ms | 0.000 ms | 0.000 ms | 0.000 ms |
전역 fxaa |
35.835 ms | 35.556 ms | 35.000 ms | 38.443 ms |
적응형 adaptive |
9.960 ms | 9.889 ms | 9.223 ms | 11.429 ms |
적응형 경로는 전역 FXAA의 평균 post-AA 비용을 약 72.2% 줄였다. 따라서 이번 구현에서는 비용 절감에 성공했다. 원인은 적응형이 전체 프레임의 모든 픽셀에서 대각선 bilinear sample을 수행하지 않고, 검은 배경-객체 실루엣과 depth/normal/curvature 분석을 통과한 픽셀에서만 directional resolve를 수행하기 때문이다.
다만 이 수치만으로 전체 FPS가 72.2% 좋아진다고 해석하면 안 된다. 실제 프레임에는 CPU 삼각형 래스터화와 조명 계산도 포함된다. 후처리만 보면 적응형이 유리하다는 결론이며, 전체 프레임 속도는 장면 해상도, 테셀레이션 밀도, 화면에서 객체가 차지하는 면적에 따라 별도로 측정해야 한다.
8. 7단계: FXAA와 적응형에 각각 2x 다운샘플을 적용한다
이번에는 참조 이미지만 고해상도로 만들지 않았다. FXAA와 적응형 모두에 다음 두 파이프라인을 실제로 적용했다.
native: 512x384 render -> AA -> output
2x: 1024x768 render -> AA -> Lanczos downsample -> 512x384 output
품질 참조는 같은 구도에서 2048x1536, --aa=off으로 렌더한 뒤 Lanczos로 512x384로 축소한 4x 이미지다. 따라서 아래의 2x 결과는 고해상도 렌더링과 AA를 둘 다 적용한 뒤 같은 출력 해상도로 내린 결과다.

| 파이프라인 | 전체 RMSE | 뚜껑 림 RMSE | 실루엣 RMSE | 평균 end-to-end 시간 |
|---|---|---|---|---|
| 네이티브 전역 FXAA | 0.006583 | 0.011683 | 0.002883 | 25.200 ms |
| 네이티브 적응형 | 0.006460 | 0.011310 | 0.002912 | 16.267 ms |
| 2x 전역 FXAA 후 축소 | 0.002915 | 0.005092 | 0.001597 | 135.600 ms |
| 2x 적응형 후 축소 | 0.002995 | 0.005126 | 0.001584 | 105.467 ms |
시간은 Release 빌드, --unify=yes, --tess 8, 같은 카메라에서 각 파이프라인을 3회 워밍업한 뒤 15개 표본으로 측정했다. 표본 하나는 5회 렌더의 평균이다. 이 end-to-end 값에는 실행 파일 시작, 메시 준비, 래스터화, AA, PPM 저장이 포함된다. 2x 행에는 Lanczos 축소 변환도 포함된다. 따라서 위 7절의 post-AA 단독 시간과 섞어 해석하면 안 된다.


9. 8단계: 가성비를 판단한다
품질만 보면 2x 전역 FXAA가 전체와 뚜껑 림에서 가장 낮은 RMSE를 냈다. 2x 적응형은 전체 오차가 0.000080 더 높지만, 2x 전역 FXAA보다 약 22.2% 빠르고 실루엣 crop에서는 가장 낮은 RMSE를 냈다. 고품질 정지 이미지나 느린 오프라인 출력이라면 2x 적응형이 더 균형 잡힌 선택이다.
실시간 CPU 렌더러에서는 네이티브 적응형이 더 낫다. 네이티브 전역 FXAA보다 전체·림 RMSE가 모두 낮고, end-to-end 시간도 약 35.4% 적다. 2x 적응형은 네이티브 적응형보다 약 6.5배 오래 걸리므로, 2x 품질 이득이 프레임 시간 예산을 정당화할 때만 선택해야 한다. 이 결론은 4x 다운샘플 참조와 RMSE에 근거한 화면 공간 판단이며, 진짜 sub-pixel coverage나 인간 지각 품질의 완전한 측정은 아니다.
정리하면 네 파이프라인의 권장 용도는 다음과 같다.
- 네이티브 전역 FXAA: 단순한 화면 후처리와 모든 색상 에지의 일괄 완화가 필요한 빠른 확인용 출력에 적합하다. 실루엣은 가장 매끈하지만, 이 CPU 렌더러에서는 적응형보다 비용이 크다.
- 네이티브 적응형 AA: X11 실시간 미리보기의 기본값으로 적합하다. 검은 배경 실루엣과 중요한 기하 꺾임을 보정하면서도, 네이티브 전역 FXAA보다 더 적은 시간이 든다.
- 2x 전역 FXAA 후 축소: 시간보다 정지 이미지 품질을 우선할 때 적합하다. 전체와 뚜껑 림 RMSE가 가장 낮으므로, 블로그용 대표 캡처나 오프라인 결과물에 쓸 수 있다.
- 2x 적응형 AA 후 축소: 고품질 프리뷰와 캡처 사이의 절충안이다. 2x 전역 FXAA와 거의 같은 품질을 유지하면서 비용은 더 낮고, 검은 배경 실루엣 수치는 가장 좋다.
10. 마지막으로 MSAA와의 차이를 확인한다
이 구현은 실제 MSAA가 아니다. MSAA는 래스터화 때 픽셀 내부의 여러 샘플 위치에서 coverage와 깊이를 판정한다. 여기서는 이미 확정된 단일 샘플 색을 국소적으로 보간할 뿐이므로, 가는 선·알파 컷아웃·서브픽셀 기하에 대한 coverage 정보는 되찾지 못한다.
다만 이번 sharp-feature stencil은 다음 단계의 선택적 MSAA에도 그대로 쓸 수 있다. 후보가 있는 8×8 타일만 2×2 sub-pixel coverage로 재래스터화하고 나머지는 단일 샘플로 유지하면, 그때는 실제 선택적 MSAA가 된다. 현재 단계는 그 비용을 들이기 전에 후보 위치와 필터 범위를 검증하는 구현이다.


