Skip to the content.

컴퓨터 그래픽스 기초: 01 서론 (2023.05.17)

Home

목차

책에서는 컴퓨터 그래픽스란 컴퓨터를 사용하여 어떤 이미지를 생성하고 조작하는 모든 것을 의미한다고 한다. 하지만 필자의 경우 게임 개발자이므로 조금은 좁은 시선으로 이 책을 바라보려고 한다. 필자가 다루려는 부분들은 게임과 유관한 부분이기 때문에 시각화, 컴퓨터 비전, 2D 등은 다루지 않을 것이다.

컴퓨터 그래픽스를 실제로 다루기 위해서는 추가적인 지식이 필요하다:

필자는 Windows OS, Ubuntu, Intel CPU, NVIDIA GPU의 환경에서 Direct3D 12와 Vulkan을 중심으로 이 책을 다루려고 한다. 이외의 환경은 따로 다루지 않을 것이나, 추후 추가될 수 있다(아마 Android OS, ARM CPU, Adreno GPU, iOS, Apple CPU/GPU).

1.1 그래픽스 분야

기타 다른 분야(UI, VR, 시각화, 영상 처리, 3D 스캐닝, computational photography 등)도 있지만, 게임 개발자의 시선으로 바라볼 것이니 양해를 구한다.

1.2. 주요 어플리케이션

무조건 게임으로 설정할 것이다.

1.3 그래픽스 API

언어는 C++20을 사용할 것이다. (C++23 추가 시 판단하여 올릴 수도 있음)

1.4 그래픽스 파이프라인

3D primitive(사실상 삼각형)를 효율적으로 그리기 위한 단계. 즉, 어떤 가상의 공간과 그 공간 위에 여러 오브젝트와 카메라가 주어 졌을 때, 이 카메라에서 이 공간을 바라보는 영상을 만들어 내는 방법으로 이해할 수 있다.

기본적으로 삼각형을 이루는 3D 공간에서의 정점을 2D 화면(모니터)으로 옮겨주고, 그렇게 2D 화면에 매핑된 삼각형 내부를 shading해주어 사실적인 이미지를 만들어 내고, 혹여나 2D 화면으로 매핑하면서 겹치는 삼각형이 생겼을 시 카메라 가까운 것부터 보이도록 순서대로 잘 그려주는 역할을 담당한다.

마지막에 언급한 순서가 매우 중요한데, 3D 공간에서야 카메라와 나머지 물건들 간의 공간적인 정보가 있으니 누가 누구보다 더 앞에 있는지가 명백해진다. 그러나 결과적으로 사진을 찍으려면 이를 센서라는 2D 화면에 매핑을 해줘야 한다. 3D 공간 상의 오브젝트들을 2D 화면에 직교 투영을 하다보면 순서가 매우 중요하지게 된다. 예를 들어 카메라를 기준으로 두 오브젝트 A, B가 서로 겹치는데, A가 B보다 더 앞에 있다고 가정해보자. 만약 2D 화면으로 투영을 해줄 때 A를 먼저 투영하고 B를 투영하게 되면 B가 A를 가리는 사진이 나오게 될 것이다. 하지만 A는 B보다 앞에 있는 오브젝트이므로 이런 일이 발생해서는 안된다!! 그렇기에 순서가 매우 중요해진다.

이 순서를 해결하기 위해 그릴 오브젝트들을 카메라로부터의 거리(깊이depth라고 부른다. 또한 카메라를 기준으로 좌우로 x축, 위아래로 y축, 앞뒤로 z축이라고 가정하면 z 값에 따라 순서가 달라지므로 depth 값을 z 값이라고도 한다.)에 따라 정렬하는 방법이 사용되어 왔었다. 근데 오히려 현재 업계에서 기본 중에 기본으로 사용하는 방법은 우아한 알고리듬을 사용하는 방법이 아닌, 단순히 모든 오브젝트의 z 값을 버퍼에 저장해주는 z-buffer 방법을 사용한다. B 오브젝트를 먼저 그릴 때, 따로 z 버퍼에 해당 오브젝트의 깊이를 기록해두는 것이다. 나중에 A 오브젝트를 그릴 때 A의 깊이는 B보다는 덜 깊을테니, 이미 투영시킨 B의 정보를 A의 투영된 정보로 덮어 씌우는 것이다!!

via Gfycat

3D 공간에 있는 오브젝트를 2D 공간으로 투영을 하겠다는 것은 곧 3D 공간의 한 정점을 2D 공간에 투영을 하겠다는 것이다. 즉, 수학적으로 바라보자면 벡터와 행렬이 등장할 수 밖에 없는 구조이다. 이때 실제 위치를 의미하는 벡터와 방향을 의미하는 벡터를 구분하기 위해 추가적인 4차원 원소를 추가해주어 4D 좌표를 사용하도록 한다. 이 좌표를 동차좌표계homogeneous coordinate이라 부른다. 이러면 기본 단위가 3차원 벡터에서 4차원 벡터로 증가되었으니 행렬 변환을 해줄 때도 4 × 4 행렬을 써주어야 한다.

결국 파이프라인이 하는 것은 4차원 벡터에 여러 행렬을 곱해주어 2D 화면에 삼각형들을 투영하여 그려주는 것이다. 보통 기능을 구현을 했으면 그 다음엔 자연스럽게 성능 얘기를 할 수 밖에 없다. 왜냐면 지금 필자는 게임 그래픽스를 다루는 것이고, 게임이라는 것은 실시간성이 보장 되어야 하기 때문이다.

파이프라인의 성능을 결정하는 것은 당연하겠지만 그릴 삼각형의 개수가 된다. 파이프라인이 하는 게 삼각형을 2D 화면에 그려주는 거니까. 헌데 성능을 늘리려고 삼각형을 줄이게 되면 시각적인 품질을 떨어뜨릴 수 밖에 없게 된다. 게임의 경우엔 성능이 훨씬 더 중요하기 때문에 보통 삼각형을 줄이는 방법을 택하게 된다. 대표적인 기술이 세밀도level of detail 혹은 LoD이다. (필자: 아무리 복잡하게 생긴 아파트도 멀리서 보면 그냥 하얀색 네모이다. 이렇듯 거리에 따라 삼각형의 개수를 줄이는 방법을 LoD라 부른다.)

1.5 수치 문제

사실상 그래픽스에서 모든 숫자는 IEEE 754에 따르는 단일 정밀도 부동 소수점을 사용하고 있다. 물론 이 표현법이 뭔지도 중요하지만, 사람들이 사실 잘 모르는 중요한 점이 하나 더 있다. 바로 특수값들이 존재한다는 것이다.

  1. 양의 무한대 ∞: 모든 유효한 값들보다 큰 유효한 값
  2. 음의 무한대 -∞: 모든 유효한 값들보다 작은 유효한 값
  3. 숫자가 아님 Not a number(NaN): 0에 의한 나눗셈과 같이 정의되지 않은 결과를 야기하는 연산에 의해 발생한 값

위의 특수값에 대한 연산들:

a는 임의의 양의 실수

+a / (+∞) = +0
-a / (+∞) = -0
+a / (-∞) = -0
-a / (-∞) = +0

∞ + ∞ = +∞
∞ - ∞ = NaN
∞ × ∞ = ∞
∞/∞ = NaN
∞/a = ∞
∞/0 = ∞
0/0 = NaN

논리 연산을 적용했을 때 참인 표현식들:

  1. 모든 유한한 유효한 숫자는 +∞보다 작다
  2. 모든 유한한 유효한 숫자는 -∞보다 크다
  3. -∞는 +∞보다 작다

NaN을 포함한 연산 규칙:

  1. 모든 산술 연산에 대해서 피연산자에 NaN이 존재한다면 연산의 결과는 무조건 NaN이다
  2. 모든 논리 표현식에 대해서 피연산자에 NaN이 존재한다면 표현식의 결과는 무조건 false이다

0에 의한 나눗셈에 대한 처리:

+a / +0 = +∞
-a / +0 = -∞

이러한 IEEE 754의 장점의 예시:

a = f(b, c)이고, f(b, c) = 1 / ((1 / b) + (1 / c))라고 가정할 때,

a = f(b, c);
if (a > 0)
{
    // do something
}

위의 코드에서 if 문의 조건은 a의 값이 NaN이거나 -∞이면 false이고, +∞이면 참이다. 즉, f의 결과가 특수값이 나오더라도 전체 로직의 결과는 일관되게 작동한다는 장점이 있다.

1.6 효율성

효율성은 결국 tradeoff다. 다만, 경험적으로 비추어 봤을 때 실제 연산의 수보다 더 중요한 것은 메모리 접근 패턴인 듯 하다. 이는 프로세서의 속도가 램의 속도보다 훨씬 빠르기 때문이다.

효율적인 코드를 짜는 팁:

  1. 가장 단순한 방법으로 코드를 작성한다.
  2. 최적화 모드로 컴파일한다.
  3. 프로파일러(뭐든지)를 통해 주요한 병목점을 찾아낸다.
  4. 지역성을 개선할 수 있는 방향으로 자료구조를 살펴본다. 가능하다면 타겟 아키텍처의 캐시/페이지 크기에 알맞게 데이터 단위 크기를 설정한다.
  5. 수치 연산 쪽에 병목점이 발생한다면 컴파일러가 생성한 어셈블리 코드를 보고 비효율적일 수 있는 부분을 확인한다. 문제를 찾아냈다면 코드를 다시 작성하여 해결한다.

가장 중요한 순서는 단연 1번이다. 대부분의 “최적화”는 실제 시간이 줄어 들지도 않고 코드의 가독성만 해칠 뿐이다. 게다가 그런 최적화에 시간 쓸 바엔 같은 시간에 버그를 고치거나 새 기능을 추가하는 것이 훨씬 낫다. 참고로 옛문헌에 나오는 최적화 팁들은 좀 조심할 필요가 있다. 대부분 하드웨어의 발전으로 이미 해결된 문제인 경우가 많다. 여튼 최적화를 했다면 프로파일링을 통해 얼마나 최적화가 잘 되었는지를 확인해주자.

1.7 그래픽스 프로그램 설계 및 개발

1.7.1 클래스 설계

KISS. Keep It Simple, Stupid (P.S.)

점과 벡터는 분리해서 보는 것이 좋다! (S.M.)

기본적으로 최대한 공통된 부분을 찾아야 한다:

struct Vector2f
{
    float X;
    float Y;
};

struct Vector3f
{
    float X;
    float Y;
    float Z;
};

struct HomogeneousVector
{
    float X;
    float Y;
    float Z;
    float W;
};

struct Rgb
{
    float R;
    float G;
    float B;
};

typedef float[4][4] Transform;

class Image final
{
public:
    ...

private:
    uint32 mWidth;
    uint32 mHeight;
    Rgb** mRgbData;
}

단위 벡터 전용 클래스 따로 두는 거 생각보다 괜찮음. (P.S.)

1.7.2 float vs double

모던 아키텍처 기준으로는 메모리 사용을 줄이고 일관된 메모리 접근이 효율성의 핵심이므로 float을 사용.

기하 연산 땐 double, 색 연산 땐 float. 삼각형 메쉬처럼 메모리 많이 먹는 애들의 경우 저장은 float으로 하되 멤버 함수 등으로 접근할 땐 double로 변환. (P.S.)

double이 꼭 필요한 순간 아니면 float만 사용. (S.M.)

1.7.3 그래픽스 프로그램 디버깅

과학적 방법

결과를 보고 무엇이 문제일지 가설을 세우고 테스트를 해본다.

코딩의 결과를 영상으로 출력하기

가장 간단한 방법 중 하나. 예를 들어 어떤 값이 제대로 들어오는 지 확인하기 위해 값이 양수면 빨간색, 0이면 초록색, 음수면 파란색 등으로 출력하거나, normalize한 값으로 RGB로 출력하는 방법 등이 있다.

디버거 사용

게임 그래픽스의 경우 GPU에서 모든 처리를 하기 때문에 전통적인 디버거 사용이 어렵다. 이 책에서는 그러한 점을 언급하진 않는 듯.

실제로 사용해본 결과 D3D12 환경에선 PIX가 매우 유용하고, Vulkan은 RenderDoc이 유용하다. D3D12에서 device removed가 발생하면 Nvidia의 Nsight Graphics가 유용하다.

디버깅을 위한 데이터 시각화

프로그램 내부적으로 결과를 만들기 위한 중간 값들이 상당히 많이 사용되는데, 이러한 데이터들을 잘 수집하여 시각화하는 것 또한 하나의 좋은 디버깅 방법이다.