포스트

비전을 위한 그래픽스 파이프라인

비전을 위한 그래픽스 파이프라인

CV 연구를 하다 보면 3D 데이터를 2D 이미지로 투영하거나, 합성 데이터(synthetic data)를 만들거나, NeRF처럼 미분 가능한 렌더링을 다루는 순간이 와요. 이때 그래픽스 파이프라인의 흐름을 알고 있으면 많은 것이 선명해져요. 이 글에서는 OpenGL을 처음 다루는 분을 위해 파이프라인의 기본 개념을 차근차근 정리해봐요.

그래픽스 파이프라인은 어떻게 흘러갈까

3D 모델 하나가 화면에 픽셀로 찍히기까지는 여러 단계를 거쳐요. 크게 CPU가 하는 일과 GPU가 하는 일로 나뉘어요.

  1. 데이터 준비 (CPU): 정점(vertex) 데이터와 텍스처를 메모리에 올려요.
  2. 셰이더 컴파일 (GPU): GPU에서 실행될 작은 프로그램을 컴파일해요.
  3. MVP 변환 (GPU): 3D 좌표를 화면 좌표로 옮겨요.
  4. 텍스처 매핑 (GPU): 표면에 이미지를 입혀요.
  5. 조명 계산 (GPU): 빛의 영향을 반영해요.
  6. 깊이 테스트 + 블렌딩 (GPU): 앞뒤 관계와 투명도를 정리해요.
  7. 더블 버퍼링 출력 (CPU): 완성된 뒷 버퍼를 앞으로 보내요.

CPU는 재료를 준비하고, GPU는 수백만 개의 픽셀을 병렬로 처리해요. 이 역할 분담이 실시간 렌더링을 가능하게 해줘요.

렌더 루프는 무엇을 반복할까

게임이나 실시간 시뮬레이션은 매 프레임마다 같은 작업을 반복해요. 이 반복 구조를 렌더 루프라고 불러요. 한 프레임 안에서 일어나는 일은 이래요.

  • poll_events(): 키보드와 마우스 입력을 처리해요.
  • get_time(): 경과 시간을 측정해서 애니메이션을 갱신해요.
  • glClear() + Draw: 이전 프레임을 지우고 새로 물체를 그려요.
  • swap_buffers(): 완성된 화면을 한번에 출력해요.

핵심은 이중 버퍼링(double buffering)이에요. 화면에 그려지는 동안 또 다른 버퍼에서 다음 프레임을 준비해요. 그리기가 끝나면 두 버퍼를 교체해요. 덕분에 사용자는 끊김 없는 화면을 보게 돼요.

정점 데이터는 어떻게 구성할까

삼각형 하나를 그리려면 세 개의 정점이 필요해요. 각 정점에는 위치만 있는 게 아니에요. 법선 벡터(normal)와 UV 좌표도 함께 담아야 조명과 텍스처를 계산할 수 있어요.

일반적인 정점 하나는 8개의 float로 구성돼요. 32바이트예요.

구성 항목 크기
위치 px, py, pz 3 floats (12B)
법선 nx, ny, nz 3 floats (12B)
UV u, v 2 floats (8B)

이런 구조를 인터리브(interleaved) 배치라고 불러요. 속성을 하나의 버퍼에 번갈아 배치해요. 분리해서 저장하는 방법도 있지만, 인터리브 방식이 캐시 효율 면에서 유리해요.

VBO와 VAO는 뭘까

정점 데이터를 GPU에 올리고 해석하려면 두 가지 객체가 필요해요.

  • VBO (Vertex Buffer Object): 실제 정점 데이터가 담긴 GPU 메모리 버퍼예요. 숫자들의 덩어리라고 생각하면 돼요.
  • VAO (Vertex Array Object): 속성의 위치와 크기 정보를 담은 객체예요. “이 버퍼의 0~11바이트는 위치, 12~23바이트는 법선”이라는 설명서 역할이에요.

VBO가 데이터라면, VAO는 그 데이터를 읽는 규칙이에요. 이 둘이 있어야 셰이더가 데이터를 올바르게 해석할 수 있어요.

MVP 변환은 왜 필요할까

3D 공간의 점을 화면에 찍으려면 여러 단계를 거쳐야 해요. 이때 사용하는 것이 MVP 행렬이에요. Model, View, Projection 세 행렬의 곱이에요.

\[gl\_Position = \mathbf{P} \cdot \mathbf{V} \cdot \mathbf{M} \cdot vec4(aPos, 1.0)\]

각 행렬의 역할은 이래요.

  • Model: 물체의 이동, 회전, 크기를 담당해요. 물체를 월드 공간에 배치해요.
  • View: 카메라의 위치와 방향을 반영해요. 카메라 기준 좌표로 바꿔요.
  • Projection: 원근감을 부여해요. 3D 공간을 2D 평면에 투영해요.

카메라 캘리브레이션과 뭐가 같을까

Vision에서 사진을 찍는 과정을 수식으로 쓰면 이런 모습이에요.

\[\mathbf{x}_{pixel} = \mathbf{K} \cdot [\mathbf{R} | \mathbf{t}] \cdot \mathbf{X}_{world}\]

여기서 $\mathbf{X}_ {world}$는 월드 좌표의 3D 점이고, $\mathbf{x}_ {pixel}$은 이미지의 픽셀 좌표예요. 중간에 두 개의 행렬이 있어요.

  • Extrinsic parameter $[\mathbf{R} \mid \mathbf{t}]$: 카메라의 회전 $\mathbf{R}$과 위치 $\mathbf{t}$를 담은 외부 파라미터예요. 월드 좌표를 카메라 기준 좌표로 바꾸는 역할이에요.
  • Intrinsic parameter $\mathbf{K}$: 초점 거리($f_x, f_y$)와 주점(principal point, $c_x, c_y$)을 담은 내부 파라미터예요. 카메라 좌표를 픽셀 좌표로 바꿔요.

이 식을 MVP와 비교하면 대응 관계가 보여요.

Vision Graphics 하는 일
(없음, $\mathbf{X}_{world}$가 이미 월드 좌표) Model $\mathbf{M}$ 물체 로컬 → 월드
Extrinsic $[\mathbf{R} \vert \mathbf{t}]$ View $\mathbf{V}$ 월드 → 카메라
Intrinsic $\mathbf{K}$ Projection $\mathbf{P}$ 카메라 → 이미지

두 분야가 같은 변환을 다른 이름으로 부르고 있었어요. Vision에서는 “찍힌 사진으로부터 $\mathbf{K}$와 $[\mathbf{R} \vert \mathbf{t}]$를 복원하는 것”이 캘리브레이션이고, 그래픽스에서는 “$\mathbf{M}, \mathbf{V}, \mathbf{P}$를 직접 지정해서 이미지를 만드는 것”이 렌더링이에요. 방향만 반대예요.

왜 3D Reconstruction이 “역문제”일까

이 대응 관계를 알면 SfM(Structure from Motion), SLAM, Multi-view geometry가 왜 어려운지 보여요. 그래픽스는 $\mathbf{M}, \mathbf{V}, \mathbf{P}$와 3D 점을 입력으로 받아 이미지를 출력해요. 정답이 하나로 정해져요.

반대로 Vision은 이미지 여러 장을 입력으로 받아 $\mathbf{R}, \mathbf{t}, \mathbf{X}_{world}$를 복원해요. 문제는 이 해가 유일하지 않다는 거예요. 같은 이미지를 만들어내는 카메라 위치와 3D 점의 조합이 무수히 많아요. 예를 들어 모든 3D 점을 두 배로 키우고 카메라를 두 배 멀리 두면 똑같은 사진이 나와요. 이를 scale ambiguity라고 해요.

그래서 Vision 쪽 알고리즘은 “어떤 조건을 추가해야 해가 유일해지는가”를 고민해요. Essential matrix, Bundle adjustment, epipolar geometry 같은 개념이 다 이 맥락에서 나온 거예요. MVP 변환의 구조를 알면 이런 제약 조건이 왜 필요한지 자연스럽게 이해돼요.

미분 가능한 렌더링은 어떻게 연결될까

NeRF나 Gaussian Splatting 같은 최근 방법은 이 파이프라인을 미분 가능하게 다시 작성한 거예요. 전통적인 Vision은 “이미지 → 3D”를 직접 풀려고 했어요. 반면 미분 가능한 렌더링은 이렇게 접근해요.

  1. 3D 표현(NeRF의 경우 신경망, Gaussian Splatting의 경우 3D 가우시안 집합)을 임의로 초기화해요.
  2. 이 3D 표현을 MVP 변환으로 렌더링해서 이미지를 만들어요.
  3. 렌더링된 이미지와 실제 사진의 차이를 손실(loss)로 계산해요.
  4. 이 손실을 3D 표현에 대해 역전파해서 업데이트해요.

즉, 그래픽스를 순방향으로 돌리고 그 안에서 경사하강을 수행하는 거예요. 이게 가능하려면 MVP 변환, Rasterization, 조명 계산이 전부 미분 가능해야 해요. 그래서 이 분야의 논문들은 “어떤 단계를 어떻게 미분 가능하게 근사했는가”에 집중해요.

결국 그래픽스 파이프라인을 이해한다는 건 Vision의 역문제를 정방향에서 바라보는 것과 같아요. 한쪽을 알면 반대쪽이 훨씬 선명해져요.

셰이더는 무엇을 하는 프로그램일까

셰이더(shader)는 GPU에서 병렬로 실행되는 작은 프로그램이에요. 파이프라인 중에서 프로그래머가 직접 작성하는 부분이에요. 두 가지 종류가 가장 중요해요.

Vertex Shader: 정점의 위치를 결정한다

VBO에서 정점 하나를 받아서 MVP 변환을 적용해요. 3D 위치를 화면 좌표로 바꾸는 일이 주 역할이에요. 이 단계에서 법선과 UV 좌표를 Fragment Shader로 넘겨줘요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aUV;

uniform mat4 uM, uV, uP;

out vec3 vNormal;
out vec2 vUV;

void main() {
    gl_Position = uP * uV * uM * vec4(aPos, 1.0);
    vNormal = aNormal;
    vUV = aUV;
}

Fragment Shader: 픽셀의 색을 결정한다

Rasterization 단계에서 삼각형이 픽셀(fragment) 조각으로 쪼개져요. Fragment Shader는 각 픽셀마다 실행돼요. Vertex Shader에서 넘어온 법선과 UV를 받아서 텍스처를 샘플링하고 조명을 계산해요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#version 330 core
in vec3 vNormal;
in vec2 vUV;

uniform sampler2D uTex;
uniform vec3 uLightDir;

out vec4 FragColor;

void main() {
    vec3 base = texture(uTex, vUV).rgb;
    float ambient = 0.2;
    float diff = max(dot(normalize(vNormal), normalize(uLightDir)), 0.0);
    vec3 color = base * (ambient + (1.0 - ambient) * diff);
    FragColor = vec4(color, 1.0);
}

정점 하나하나에 대해 Vertex Shader가 돌고, 그다음 픽셀 하나하나에 대해 Fragment Shader가 돌아요. GPU는 이걸 수백만 번 병렬로 처리해요.

텍스처와 UV는 어떻게 맞물릴까

텍스처 매핑은 2D 이미지를 3D 표면에 입히는 작업이에요. 이때 3D 표면 위의 점이 이미지의 어느 위치를 참조할지 알려주는 좌표가 UV 좌표예요.

UV 좌표는 이미지의 좌하단이 (0, 0), 우상단이 (1, 1)이에요. (1, 1)을 넘어가면 이미지가 타일처럼 반복돼요.

Mipmap은 왜 필요할까

멀리 있는 물체를 원본 해상도로 그리면 어떻게 될까요? 한 픽셀이 여러 텍셀(텍스처의 픽셀)을 덮어서 깜빡임(aliasing)이 생겨요. 이를 방지하려고 mipmap을 사용해요. 원본 텍스처의 축소본을 여러 단계로 미리 만들어 두고, 거리에 따라 적절한 크기를 골라 써요. 딥러닝의 이미지 피라미드와 비슷한 개념이에요.

조명은 어떻게 계산할까

실감 나는 화면을 만들려면 빛의 방향에 따라 밝기가 달라져야 해요. 가장 기본적인 모델이 Lambert 조명이에요.

\[Color = base \times \big(ambient + (1 - ambient) \times \max(\mathbf{N} \cdot \mathbf{L}, 0)\big)\]
  • Ambient: 환경에서 오는 최소 밝기예요. 빛이 직접 닿지 않는 부분도 완전히 검지 않도록 해줘요.
  • Diffuse (Lambert): 표면의 법선 벡터 $\mathbf{N}$과 광원 방향 $\mathbf{L}$의 내적으로 계산해요. 빛을 수직으로 받을수록 밝고, 비스듬하게 받을수록 어두워요.

법선과 빛 방향의 내적이 등장하는 이유는 표면이 빛을 얼마나 “정면으로” 받고 있는지 측정하기 때문이에요. 두 벡터가 평행하면 내적은 1이 되고, 수직이면 0이 돼요. 음수는 빛이 반대 방향에서 오는 경우라 0으로 잘라줘요(max 함수).

깊이 테스트와 블렌딩은 뭘 해결할까

여러 물체가 겹쳐 있을 때 어떤 픽셀이 앞에 보일지 결정해야 해요. 이때 사용하는 것이 Z-buffer예요. 모든 픽셀에 대해 깊이 값을 저장해 두고, 새로 그리려는 픽셀이 더 가까우면 교체해요. 화가가 앞에서부터 그릴 필요 없이, 순서와 상관없이 정확한 결과가 나와요.

투명한 물체는 깊이 테스트만으로 처리할 수 없어요. 뒤에 있는 픽셀의 색과 섞어야 하거든요. 이를 alpha blending이라고 해요. 투명도(alpha)를 가중치로 두 색을 섞어요.

OpenGL 생태계는 어떻게 생겼을까

OpenGL을 쓰려고 하면 비슷한 이름의 라이브러리가 많아서 헷갈려요. 역할을 분리해서 정리해봐요.

라이브러리 역할
GL (Graphics Library) 실제 그리기 명령을 담당하는 코어 라이브러리예요.
GLU (GL Utility) 텍스처, 기하 도형 생성 같은 고수준 기능을 제공해요.
GLUT (Utility Toolkit) 창 관리, 입력 이벤트 같은 UI를 담당해요. 오래된 툴킷이에요.
GLFW GLUT를 대체하는 가벼운 창/입력 관리 라이브러리예요. 요즘 표준이에요.
GLEW OpenGL의 확장 기능을 크로스 플랫폼으로 불러오는 역할이에요.

간단히 정리하면, GL이 그리기 엔진, GLFW가 창과 입력, GLEW가 확장 로더예요. 이 세 가지 조합으로 OpenGL 프로그램을 시작하는 경우가 많아요.

Vision 연구에 이걸 왜 알아야 할까

CV 연구자 입장에서 그래픽스 파이프라인을 아는 것이 왜 중요한지 짚어볼게요.

첫째, 카메라 모델링의 기초예요. MVP 변환의 View와 Projection은 그대로 카메라 extrinsic/intrinsic에 대응해요. 3D reconstruction, SLAM, Multi-view geometry는 모두 이 투영 과정을 뒤집거나 최적화하는 문제예요.

둘째, 합성 데이터 생성에 직결돼요. Unity나 Blender 같은 툴은 내부적으로 이 파이프라인을 돌려요. 라벨 자동화된 학습 데이터를 만들고 싶다면 셰이더를 직접 건드려야 할 때가 있어요.

셋째, 미분 가능한 렌더링의 출발점이에요. NeRF, Gaussian Splatting, 3D-aware GAN 같은 모델은 “렌더링 과정을 미분 가능하게 만든” 것이에요. Rasterization, 조명 계산, 깊이 테스트를 미분 가능한 형태로 다시 쓰는 것이 핵심이에요. 원래 파이프라인을 알아야 무엇을 바꿔야 하는지 보여요.

넷째, GPU 최적화 직관이 생겨요. VBO, VAO, 셰이더의 병렬 실행 모델은 CUDA 커널 최적화 감각과 맞닿아 있어요. 딥러닝 모델도 결국 GPU 위에서 돌아가니 이 직관은 두고두고 쓸모가 있어요.

그래픽스는 Vision의 쌍둥이예요. Vision이 이미지에서 3D를 복원하는 역문제라면, 그래픽스는 3D에서 이미지를 만들어내는 정문제예요. 한쪽을 알면 반대쪽이 자연스럽게 보여요.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.