컴퓨터 그래픽스에서 3차원 곡면을 화면에 렌더링하기 위해서는 수학적 수학 공식(수학적 곡면)을 컴퓨터가 계산할 수 있는 작은 다각형(삼각형 메시)으로 쪼개야 한다. 이 글은 OpenGL 같은 외부 라이브러리 없이, C++과 X11 라이브러리만 사용하여 유타 티팟(Utah Teapot)의 Bézier 패치를 직접 삼각형으로 변환하고 CPU에서 픽셀 단위로 색을 계산(소프트웨어 렌더링)한 기록이다.
렌더링 파이프라인 구축은 기본 수학 알고리즘과 기하적 제약을 먼저 분석한 뒤, AI 코더에게 각 단계별(난수 제거 -> 곡률 스파이크 보정 -> 점광원 적용 -> 전역 정점 용접)로 코드를 구현하도록 지시하는 방식으로 진행되었다.
소스 코드와 입력 데이터는 utah-teapot-software-renderer.zip에서 확인할 수 있다. (main.cpp, CMakeLists.txt, teapot.bpt 포함)
1. 기초 개념: 3D 곡면에서 픽셀 색상까지
그래픽스 전공자가 아닌 독자를 위해, 3D 모델이 2D 화면에 그려지는 기본 원리부터 차근차근 살핀다.
(1) Bézier 패치와 테셀레이션(Tessellation)
teapot.bpt 파일에는 티팟의 형태가 32개의 Bézier 패치(Bézier Patch)로 정의되어 있다. Bézier 패치는 16개의 조종점(Control Point)으로 만들어지는 매끄러운 3차원 곡면 수식이다. 하지만 모니터에 그림을 그리기 위해서는 이 수식을 정점(Vertex)과 삼각형(Triangle)의 집합인 메시(Mesh)로 잘게 쪼개야 한다. 이 과정을 테셀레이션(Tessellation)이라 부른다.
(2) 법선 벡터(Normal Vector)와 조명
물체 표면의 한 점에서 수직으로 뻗어나가는 방향 벡터를 법선(Normal Vector)이라 한다. 빛이 표면에 부딪혀 반사될 때, 빛의 방향과 법선 방향의 각도에 따라 표면의 밝기가 결정된다.
- Diffue(난반사): 빛과 법선이 이루는 각도가 좁을수록 환하게 빛난다.
- Specular(정반사): 빛이 반사되어 관찰자의 눈(카메라)으로 들어오는 방향과 가까울수록 날카로운 하이라이트(빛 번짐)가 생긴다.
(3) 정점 연결성(Adjacency Graph)
3D 물체는 수많은 정점으로 이루어진다. 두 삼각형이 만나서 만나는 경계 정점을 서로 “공유”하고 있다면, 표면의 이웃 관계(Adjacency Graph)가 형성된다. 이 이웃 관계가 올바르게 연결되어 있어야 정점 간의 법선을 부드럽게 섞거나 곡률을 계산할 수 있다.
2. 렌더링 파이프라인과 파라미터 탐색 과정
2.1 첫 단계: X11 프레임버퍼 렌더링 경로 검증

위 사진(teapot_realtime_preview.png)은 파이프라인의 극초기 단계다. 3D 공간의 패치 표본과 접선(Tangent) 방향이 올바르게 계산되는지 점과 선으로 창에 출력해보았다. 이로써 teapot.bpt 파싱 및 CPU 프레임버퍼를 XPutImage를 통해 X11 화면으로 뿌리는 기초 뼈대가 올바르게 동작함을 확인했다.
2.2 노이즈 제거와 Bilateral 법선 필터의 비교
| Bilateral OFF | Bilateral ON |
|---|---|
![]() |
![]() |
초기 설계 구상에서는 법선 벡터에 약간의 난수를 섞은 뒤 Bilateral 필터(거리와 법선 방향 차이를 동시에 고려하는 가중 평균 필터)로 smoothing하는 방안을 고려했다. 그러나 무작위 노이즈를 주입하는 것 자체가 불필요한 왜곡을 만든다는 점을 파악하고 난수 생성을 제거했다.
수학적으로 명확히 구한 법선(raw normal)을 사용한 결과가 clean_off.png이며, 여기에 Bilateral 필터를 적용한 결과가 clean_on.png이다. 노이즈를 뺀 깨끗한 상태이므로 두 이미지의 차이는 매우 미미하다. Bilateral 필터는 원래의 표면 형태를 뭉개는 필터가 아니라, 수치적 불안정성으로 튀는 법선만 제한적으로 정돈해야 하기 때문이다.
하지만 두 그림 모두 몸통 부분에 세로 방향의 어두운 접합선(음영 경계)이 여전히 나타난다. 이는 법선 필터의 강도 문제가 아니었다.
2.3 테셀레이션 분할 수(Tessellation) 증가의 한계
| 32 분할, Bilateral OFF | 32 분할, Bilateral ON |
|---|---|
![]() |
![]() |
패치 하나를 $32 \times 32$개 삼각형으로 더 조밀하게 쪼개어 보았다(smooth32_off.png, smooth32.png). 삼각형이 미세해지면서 곡면 내부의 음영은 부드러워졌으나, 패치와 패치가 만나는 세로 접합선은 사라지지 않았다.
분할 수를 아무리 늘려도 경계선이 생기는 원인은 32개의 Bézier 패치가 독립된 메시로 존재하여, 경계선상의 정점들이 위치만 같을 뿐 서로 연결되지 않은 별개의 정점으로 다루어졌기 때문이다.
2.4 거리 감쇠 및 곡률 기반 점광원(Point Light) 모델
| 과도한 점광원 세기 (포화) | 조정한 점광원 세기 (최종) |
|---|---|
![]() |
![]() |
무한히 먼 곳에서 평행하게 들어오는 방향광(Directional Light) 대신, 공간상 한 점에 위치하는 점광원(Point Light) 모델을 적용했다.
- 역제곱 감쇠(Inverse-square Falloff): 광원과 픽셀 사이의 거리 $r$의 제곱($r^2$)에 비례하여 빛이 약해진다.
- Beer-Lambert 대기 투과: 매질(공기)에 의한 흡수를 $\exp(-\text{airExtinction} \cdot r)$로 계산한다.
physical_check.png는 점광원의 세기(Light Intensity)를 과도하게 설정하여 픽셀 색상이 최대값(255)으로 포화(Whiteout)된 상태다. 세기를 정밀하게 다듬은 physical_final.png에서는 정반사 하이라이트와 난반사의 밝기 구배가 부드럽게 살아난다.
main.cpp 1008-1024행의 핵심 구현 코드:
Vec3 toLight = sp.lightPos - P;
const double distance2 = std::max(0.04, dot(toLight, toLight));
const double distance = std::sqrt(distance2);
Vec3 L = toLight / distance;
const double transmission = std::exp(-sp.airExtinction * distance);
const double radiance = sp.lightIntensity * transmission / distance2;
// 국소 곡률을 거칠기(Roughness) 항으로 활용
const double roughness = std::clamp(
sp.baseRoughness + sp.curvatureRoughness * curvature, 0.08, 0.85);
const double effectiveShininess = std::max(2.0,
sp.shininess / (1.0 + 5.0 * roughness * roughness));
여기서 curvature는 표면 곡면이 얼마나 가파르게 변하는지를 나타내는 기하학적 수치다. 평평하거나 순만한 부위는 하이라이트를 또렷하게 유지하고, 꺾임이 심한 부위는 거칠기를 높여 하이라이트를 퍼뜨린다.
3. 국소 곡률 EWMA 보정과 정점 용접(Unification)
3.1 EWMA(지수 가중 이동 평균) 기반 곡률 스파이크 정돈
표면을 일률적으로 평탄화(Blurring)하면 림(Rim)이나 주둥이 같은 실제 기하학적 특징까지 뭉개진다. 따라서 주변 정점 곡률의 지수 가중 이동 평균(EWMA) 추세를 구한 뒤, 추세보다 유난히 크게 튀는 ‘스파이크’ 정점만 제한적으로 보정하도록 지시했다.
main.cpp 851-906행의 EWMA 보정 로직:
const double neighbourTrend = weighted / weight;
ewma[v] = alpha * curvature[v] + (1.0 - alpha) * neighbourTrend;
// 주변 추세보다 18% 이상 튀는 스파이크 정점만 선별
const double excess = curvature[v] - ewma[v] * 1.18;
if (excess <= 0.0) continue;
// 중심 방향으로 최대 3.5%만 이동
const double amount = std::clamp(excess * 0.025, 0.0, 0.035);
updated[v] = m.pos[v] * (1.0 - amount) + centroid[v] * amount;
이 알고리즘 설계의 기본 프레임은 동아시아 고전 산학서인 《구장산술(九章算術)》과 《구일집(九一集)》 문제를 반복 풀이하며 형성된 계산 사고 체계에 바탕을 두고 있다.
- 《구장산술》적 분해와 누적: 복잡한 대상을 무리하게 한꺼번에 다루기보다, 계산 가능한 기본 단위(패치 → 격자 표본 → 삼각형 → 픽셀)로 분해한 뒤 z-buffer와 정점 인접 그래프(Adjacency)에 중간 계산 결과를 점진적으로 누적하는 접근 방식이다.
- 《구일집》 및 증승개방술(增乘開方術)적 국소 근사: 완벽한 해를 단번에 요구하거나 전체를 일괄 평균(Blurring)내지 않고, 현재 값과 이웃의 추세를 비교하여 오차가 발생하는 극소 부위(스파이크)에만 보정을 적용하는 반복적 한계 보정 태도다.
현대의 EWMA나 Bilateral 필터 자체가 고전 산학에서 온 것은 아니지만, 파이프라인을 구축할 때 어떤 중간값을 먼저 누적하고 어디에 국소적 보정을 적용할지 지시하는 판단 기준에는 이러한 계산 훈련 경험이 자연스럽게 배어있다.
3.2 핵심 해결책: 정점 용접(--unify=yes)
패치별 독립 정점 (--unify=no) |
전역 정점 용접 (--unify=yes) |
|---|---|
![]() |
![]() |
split.png와 unified.png 비교가 이 파이프라인의 핵심 결과다.
--unify=no(기본값): 32개 패치가 각각 정점들을 소유한다. 3D 공간상 좌표가 완전히 동일한 경계선 정점이라도 서로 다른 인덱스를 가지므로 이웃 관계(Adjacency)가 끊긴다. 이 때문에 법선 보정이나 곡률 필터가 패치 경계를 넘지 못해 정면에 십자형 접합선이 그어진다.--unify=yes: 3D 공간 위치가 0.0002(weldTolerance) 이내로 가까운 정점들을 하나의 전역 정점으로 합친다(Welding).
main.cpp 761-775행의 정점 용접 해시 검사:
constexpr double weldTolerance = 2e-4;
// 동일 공간 좌표의 정점이 존재하는지 주변 격자 공간 탐색
if (it != weldedVertex.end() && length(m.pos[it->second] - p) <= weldTolerance)
return it->second; // 기존 정점 재사용
용접을 거친 후 main.cpp 829-836행에서 이웃 목록(Adjacency)을 전역 정점 기준으로 재구성하고 중복을 제거한다.
connect(grid[idx(i, j)], grid[idx(ni, nj)]);
std::sort(neighbours.begin(), neighbours.end());
neighbours.erase(std::unique(neighbours.begin(), neighbours.end()), neighbours.end());
m.nbrList.insert(m.nbrList.end(), neighbours.begin(), neighbours.end());
전역 정점 그래프가 연결되자, 법선 및 곡률 필터가 패치 경계를 넘어서 부드럽게 전달되었으며 unified.png와 같이 십자 접합선이 완벽하게 제거되었다.
4. 빌드 및 실행 방법
컴파일 및 실행 절차는 다음과 같다.
cmake -S . -B build
cmake --build build -j
cd build
# 창화면으로 실시간 실행 (정점 용접 적용)
./teapot --unify=yes
# PPM 이미지 파일로 바로 저장
./teapot --still teapot.ppm --unify=yes
키보드/마우스 제어
- 마우스 드래그: 3D 물체 회전 (Orbit)
- 마우스 휠: 확대/축소 (Zoom)
b키: Bilateral 법선 필터 On/Off 토글1/2키: Spatial Sigma 감소/증가3/4키: Range Sigma 감소/증가5/6키: Shininess (정반사 광택) 감소/증가
5. 결론
화면에 원치 않는 선이나 왜곡이 발생할 때, 무작정 필터의 강도를 올리거나 삼각형 분할 수만 늘리는 것은 근본적인 해결책이 되지 못한다.
이번 유타 티팟 소프트웨어 렌더러 구축 과정에서 확인했듯, 문제는 법선 계산이나 필터 알고리즘이 아니라 동일 위치 정점이 서로 끊어져 있던 메시 연결성(Topology) 구조에 있었다. 정점을 용접하고 전역 그래프를 구축한 뒤 국소 스파이크만 보정함으로써, 깨끗하고 부드러운 3D 음영을 구현할 수 있었다.







