도입 배경

Monument Gallery에는 페이지에서 추출한 사진을 파편처럼 띄워놓고, 사진 파편을 클릭하면 사진이 합쳐지게 하는 기능이 존재합니다. 해당 기능을 구현하기 위해서는, 각 픽셀 하나마다 삼각형이 2개씩, 50x50 픽셀 기준으로 5000개의 삼각형이 일시불란하게 움직여야 합니다. Monument Gallery에서는 최대 85개의 갤러리까지 존재하므로, 하나의 갤러리당 하나의 사진 파편이 존재한다면, 최악의 경우 42만 5000개의 오브젝트를 렌더링해야 하는 불상사가 일어나게 됩니다.

많은 오브젝트 렌더링, 왜 안 좋은가?

위의 사진은 단순히 박스 2500개가 화면에 보였을 때 어떻게 보이는지를 테스트한 사진입니다.

위의 사진은 단순히 박스 2500개가 화면에 보였을 때 어떻게 보이는지를 테스트한 사진입니다.

가장 단순하게 생각하자면, 사진 파편을 구현하기는 각 삼각형을 메시로 구성하고, 삼각형이 클릭되면 모든 메시에 대해 애니메이션을 걸어주는 방식을 생각할 수 있지만, 이렇게 구현하면 매우 성능이 떨어지게 됩니다. 그 이유는 3D 컴퓨터 그래픽스에서 오브젝트 하나가 그려질 때마다, 드로우 콜이라는 것을 요청하기 때문으로, 많은 양의 드로우 콜을 요청하면 그만큼 CPU에 부하가 커지기 때문입니다.

드로우 콜?

드로우 콜은 쉽게 말해, CPU가 GPU에게 오브젝트를 그리라고 명령하는 것을 의미합니다.

더 자세하게 말하자면, 3D 장면을 렌더링할 때 CPU에 메모리로 저장되어 있는 오브젝트들은 먼저 카메라 영역 내부에 존재하는가를 먼저 거칩니다.(이 과정은 매우 빠르게 계산됩니다) 카메라 영역 바깥에 있으면 CPU는 해당 오브젝트를 렌더링을 시도하지 않습니다. 오브젝트가 카메라 안쪽에 존재한다면 CPU는 GPU에게 오브젝트의 정보(각 버텍스의 속성, 오브젝트의 위치/크기/회전 상태)를 전달해줘야 합니다. GPU는 CPU의 값을 바로 읽어들일 수 없기 때문에, CPU에 저장된 오브젝트의 상태를 GPU 메모리로 복사하는 과정을 거치게 됩니다. 모든 변경된 상태를 GPU가 복사했으면 CPU는 GPU에게 메시를 그리라는 명령을 내립니다.

문제는, CPU와 GPU는 다른 기기고, 그렇기 때문에 CPU는 GPU에게 명령을 내리기 위해 GPU에 맞는 신호로 명령을 변환해야 한다는 겁니다. 명령을 GPU가 알아들을 수 있는 신호로 변환하는 과정에서 오버헤드가 생기게 되고, 즉 많은 드로우 콜이 많은 오버헤드를 일으키게 되는 것(=CPU에 부담을 많이 일으킨다)이죠.

드로우 콜을 줄이는 방법?

드로우 콜은 일반적으로는 하나의 메시 당 1번 호출됩니다. 즉, 메시를 줄이면 드로우 콜을 줄일 수 있다는 의미죠. 사진 파편의 각 삼각형을 메시로 선언하지 않고, 모든 사진 파편을 메시로 하고, 각 삼각형을 메시의 지오메트리를 구성하는 삼각형으로 만들면 드로우 콜을 줄일 수 있습니다.

그렇다면 애니메이션은 어떻게 구성하느냐는 문제가 발생하게 되는데, 2가지 방법을 생각할 수 있습니다. 첫 번째 방법은 애니메이션이 발생할 때마다 1초에 60번 지오메트리의 각 버텍스의 좌표를 계산하여 이를 동기화시키는 방법이며, 두 번째 방법은 셰이더 프로그래밍을 이용하여 애니메이션 중 버텍스의 좌표 계산을 GPU에 위임하는 방법입니다.

버텍스를 CPU에서 계산

Pros

Cons

셰이더 프로그래밍 이용

Pros

Cons

이 중 2번째 방법인 셰이더 프로그래밍을 이용한 애니메이션 버텍스 계산 위임을 채택했으며, 저희 프로젝트의 주된 성능 지표인 높은 프레임(45~60fps) 유지에 도움이 될 것이라고 생각했기 때문입니다. CPU에서 버텍스를 계산하는 장점 중 하나인 애니메이션 도중 클릭 이벤트 발생은 애니메이션이 500ms 이내에 종료되며, 사용자 관점에서 움직이는 사진 파편을 클릭할 일이 있지 않을 것이라고 생각했기에 사소한 것이라고 판단했습니다.

셰이더 프로그래밍

간단하게 셰이더 프로그래밍이라고 말했지만, 정확히 말하자면 어떠한 메시가 GPU 상에서 어떻게 그려지는지를 직접 프로그래밍하는 것에 가깝습니다. 셰이더 프로그래밍은 버텍스 셰이더, 프래그먼트 셰이더로 구성되며, 버텍스 셰이더는 메시의 정점 위치를 계산하며, 프래그먼트 셰이더는 메시의 색상을 계산합니다.

// vertex shader
attribute float intensity;
varying vec3 vPosition;

void main() {
	vec3 newPosition = position * intensity;
	vPosition = modelViewMatrix * vec4( position, 1.0 );
	gl_Position = projectionMatrix * vPosition;
}
// fragment shader
uniform vec3 color;
varying vec3 vPosition;

void main() {
	gl_FragColor = vec4( vPosition * color, 1.0 );
}

간단한 glsl 코드를 예제로 가져왔습니다. 보는 바와 같이 C언어와 비슷한 생김새를 가졌으나, 이 코드가 반복문도 없는데 어떻게 버텍스를 연산하고 색상을 연산하는지 감이 안 잡힐 겁니다. 쉽게 이해하자면, 각 정점이 버텍스 셰이더의 main 함수를 거쳐서 화면에 보이는 버텍스로 변환되고, 그것이 프래그먼트 셰이더의 main 함수를 거쳐서 색상을 가진다고 생각하시면 됩니다. 명시되지 않은 정점들은 알아서 선형보간되어서 위치와 색상이 계산됩니다. 기존의 코드가 컴퓨터에 순차적으로 명령을 내리는 방식이라면, 셰이더 프로그래밍은 버텍스의 집합을 다른 버텍스와 색상으로 통째로 변환해주는 함수를 만드는 것이라고 생각하면 쉽습니다.

이걸로 어떻게 애니메이션을 만드나요?