본문 바로가기

Function

10만개의 수를 draw call 한번으로 그리기

(이 글은 shader 작업이 가능한 사람에게만 유용합니다)

 

공간 데이터 시각화를 하다보면 많은 양의 숫자를 렌더링해야 할 경우가 종종 있다.

어떤 경우에는 몇 개의 숫자가 크고 명료하게 보여야 하지만, 때로는 '데이터스러운 느낌'을 주기 위해서 많은 양의 숫자가 필요할 때도 있다. 물론, 그 숫자들은 임의로 생성한 것이 아니라 데이터에서 비롯된 정확한 숫자가 되어야 한다. 인터랙션의 경우를 생각한다면 선택적으로 확대해서 볼 수도 있어야 하기 때문이다.

 

여기서는 많은 양의 숫자들을 빠르게 렌더링하는 간단한 팁을 소개한다. 최적화를 고민했던 누군가는 아래에 소개한 내용을 이미 당연하게 쓰고 있을 것 같기도 한데, 검색해도 잘 나오지 않아서 약간의 코드로 직접 만들어봤다. 셰이더를 통해 텍스쳐 작업을 하는 사람들에게는 너무 당연한 방법일 것 같기도 하고... 그래도 나처럼 몰랐던 사람들은 여전히 잘 모를테니.... 각설하고 설명해보겠다.

 

OpenGL이나 DirectX로 직접 코드를 짜서 그래픽 작업을 하는 사람들은 많지 않겠지만, 여기서 소개하는 방법은 유니티나 언리얼 사용자들도 쓸 수 있을 것 같다. 그런데 많은 숫자들을 렌더링할 필요가 있는 사람들이 과연 얼마나 있을까? 여튼, 숫자를 100개만 쓰더라도 인스턴스처럼 반복되는 개체라면 개체 자체에 value를 함께 태운 후, 개체의 면에다 곧바로 렌더링함으로써 드로우콜을 1회로 줄일 수 있다는 장점이 있다.

 

 

 

OpenGL을 공부할 때, 아래의 사이트가 많이 도움 되었다.  그 동안 나도 역시 아래에서 방식으로 텍스트를 그려왔다.

 

 

LearnOpenGL - Text Rendering

Text Rendering In-Practice/Text-Rendering At some stage of your graphics adventures you will want to draw text in OpenGL. Contrary to what you may expect, getting a simple string to render on screen is all but easy with a low-level API like OpenGL. If you

learnopengl.com

위의 방법으로는, 한 프레임에 몇십개나 몇백개의 글자를 쓸 때까지는 별 문제가 없는데, 몇천개나 몇만개를 써야 할 때는 심각한 성능 저하가 발생한다. 글자 하나하나당 텍스쳐를 입힐 개체를 그리고, 그 작업을 한 프레임 안에서 일일이 텍스쳐 재결착 등의 작업으로 해결하다보니 1만개의 글자를 쓰려면 draw call이 한 프레임에서 1만개 발생한다.

 

그래서 그냥 0부터 9까지의 숫자가 적힌 텍스쳐를 하나 준비해서, 개체를 그릴 때 수를 그 개체 위에다 바로 입혀버리는 방법으로 해결했다. 그런데 방금 저 위의 링크를 다시 들어가보니, 저 링크에서 설명하는 'Classical text rendering' 방식이기도 하다.

 

정확히 세어 보지는 않았는데 십만개 정도도 무리없이 실시간으로 그릴 수 있는 것 같다. 아래 그림에서 보이는 인구 막대와 텍스트들은 단 한번의 드로우콜로 해결했다.

 

 

일단 아래와 같은 이미지를 만든다. 

단, '고정폭 폰트'로 준비해야 한다. 예를 들어 '4237'라는 숫자를 렌더링할 경우, 모든 숫자와 픽셀들을 병렬적으로 써야 하기 때문에, 3이 렌더링되는 위치를 정할 때 십의 자리라는 정보만으로도 렌더링할 좌표를 결정할 수 있어야 하기 때문이다. 고정폭 폰트가 아니라면 끝에서부터 한글자씩 차곡차곡 텍스트의 기준위치를 결정할 수 밖에 없다.

한번에 붙여놓았지만, 각 숫자가 차지하는 영역(픽셀 크기)은 모두 동일해야 한다.

숫자가 표시되지 않은 여백 부분은 흰색이 아니라 blank, 즉 투명한 영역으로 두어야 한다. 그러므로 jpg 대신 png같은 형식으로 준비하자.

 

 

 

 

 

 

shader 작업의 경우 vertex shader -> geometry shader -> fragment shader의 절차를 거쳐야 한다.

 

여기서는 지도상에 xy좌표와 수직 높이로 표현할(그리고 써야할 숫자 정보이기도 한)값이 있는 point 데이터를 geometry shader로 보내고, 

geometry shader에서 직육면체를 발생시킨 후,

뚜껑에는 연령정보를 쓰고, 앞면에는 숫자 정보를 기입했다. 연령 렌더링은 숫자정보에 비하면 아주 단순하기 때문에 여기서는 숫자를 렌더링하는 방법만 설명한다.

 

 

 

이건, 설명을 위한 참고 그림

 

 

 

아래에 등장하는 변수들과 숫자들은 위의 그림에 설명하였다.

계산과 설명이 조금 복잡해 보일 수 있지만 사실 원리는 간단하다. 글자가 렌더링될 부분의 텍스쳐 좌표를 위 그림의 빨간 글씨처럼 만들어주기 위해, 정점 네개에 할당할 텍스쳐 좌표값들 texL, texR, tex0, texH를 그에 맞게 계산해주는 과정이다. 5.0은 숫자가 다섯자리이므로 5.0이다. 숫자가 946이라면 위의 텍스쳐 좌표는 3.0이 된다.

 

 

Geometry Shader 에서 다른 것들은 덜어내고 필요한 정보들만 담았다.

하드웨어상에서 보간되어야 할 텍스쳐 좌표들을 미리 계산해서 네개의 점 각각에 할당한 후 Fragment Shader로 보내야 한다.

자세한 내용은 주석으로 설명했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
out GS
{    
    vec4 surface_color;    //면의 색상과    
    vec3 tex; //일부 하드웨어 보간되어야 할 텍스쳐 좌표와
    flat float popu; // 표현할 값을 fragment shader로 보낸다.
    //표현할 값은 보간되면 안되므로 flat으로 보낸다.
} gs;
        
        
        
//텍스쳐 좌표 계산용. popu는 원래 데이터가 갖고 있는 값이다.
int digit = int(log(popu)/log(10))+1
//popu가 1이면 digit이 1, 10은 2, 100은 3, 1미만은 0이나 음수
 
//h는 사각기둥의 총 높이로 계산된 값
//xwide는 사각기둥의 가로폭
//비례식 h: xwide = baseTexCoord : 1.4 에서 변환된 식
float baseTexCoord =  (1.4 * h ) / xwide;
 
 
//수 전체의 총 높이(90도 돌렸으므로)는 자리수에 비례한다(고정폭 폰트이므로)
float texH = 0.2 + float(digit); //0.2는 여유폭
float tex0 = texH-baseTexCoord; //바닥의 텍스 좌표
 
//이 두 숫자를 더하여 위의 1.4가 된 것임
float texL = 1.3//왼쪽 margin
float texR = -0.1//오른쪽 margin
 
//렌더링할 박스가 너무 작아서
//텍스트의 기본 크기를 담지 못하는 경우
//텍스트를 그에 맞게 줄인다.
if (tex0>0
{
tex0 = -0.1f;
float baseTexCoord2 = float(digit+0.3)*xwide/h;
texL = baseTexCoord2-0.1;            
}
 
//fragment shader로 보내는 정보 중 tex에는 여러가지 정보들을 담는다.
//gs.tex = vec3(tex0,texL,21.0); //21은 이 면이 숫자를 기입할 앞면이라는 신호
 
//앞면을 위한 네개의 점(2개의 삼각형, traingle_strip)을 방출한다.
gs.surface_color = vec4(southColor, transparency+0.5);    
gs.tex = vec3(tex0,texL,21.0); 
gl_Position = p101; //앞면 중 좌하단 좌표
EmitVertex();
 
gs.surface_color = vec4(southColor, transparency);    
gs.tex = vec3(texH,texL,21.0);
gl_Position = p1h1; //앞면 중 좌상단 좌표
EmitVertex();
 
gs.surface_color = vec4(southColor, transparency+0.5);    
gs.tex = vec3(tex0,texR,21.0);
gl_Position = p301; //앞면 중 우하단 좌표
EmitVertex();
 
gs.surface_color = vec4(southColor, transparency);    
gs.tex = vec3(texH,texR,21.0);
gl_Position = p3h1; //앞면 중 우상단 좌표
EmitVertex();
EndPrimitive();
cs

 

 

 

그 다음은 프래그먼트 셰이더

역시 필요한 내용은 주석으로 적었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#version 460
 
layout (location = 0) out vec4 color;
 
layout (binding = 1) uniform sampler2D ageTex;
layout (binding = 2) uniform sampler2D digitTex;
 
in GS
{    
    vec4 surface_color;    
    vec3 tex;
    flat float popu;
} fs;
 
//빠른 계산을 위한 참고용 배열
//사용하는 숫자가 1000000000을 넘어가면 32bit 정수형을 사용하는 것 부터 바꿔야 한다.
int ref[] = {0,1,10,100,1000,10000,100000,1000000,10000000,100000000,1000000000};
 
void main() {    
 
    if (fs.tex.z <0) { //육면체 중 그냥 면 색깔만 칠하는 경우
        color = fs.surface_color;
        
    } else if (fs.tex.z<20.0) { //육면체 중 위의 뚜껑. 여기서는 연령 정보를 썼다.
        float ageOffset = (fs.tex.z+1.0)/20.0//원래 이미지의 x좌표는 00, 10, 20, ..., 90 까지 들어있다.
        vec2 texcoord = vec2(ageOffset + fs.tex.x/20.0, fs.tex.y);
        vec4 figureTex = texture(ageTex, texcoord);
        color = mix(fs.surface_color, figureTex,  figureTex.w);
        
    } else { //육면체 중 숫자를 쓰는 면. 앞에서 fs.tex.z = 21로 할당했었음
        
        //1은 1, 10은 2, 100은 3, 1미만은 0이나 음수
        int digit = int(log(fs.popu)/log(10))+1
        
         //현재 fragment에 할당된 texCoord
        int texCoordNow = int(fs.tex.x);
        
        if (fs.tex.x<0 || fs.tex.x>=digit) { //자리수를 벗어나면 그냥 그대로 면만 칠한다.
            color = fs.surface_color;
            
        } else {
            
            //각 숫자를 렌더링할 때 가져올 텍스쳐 x좌표
            //하나의 숫자 텍스쳐 가로 좌표를 0.0 ~ 1.0 으로 간주
            float texNewX = fract(fs.tex.x);
            
             //몇의 자리를 써야 하는지 본다.
            int whichDigit = digit - texCoordNow;
            
             //소수점 이하는 버린다.
            int popuInt = int(fs.popu);
            
            //해당 자리수의 숫자를 구한다.
            //3248 이라는 숫자에서 이 fragment에 할당된 부분이 십의 자리라는 것을
            //whichDigit에서 구했다면,
            //여기서는 숫자 4를 결정한다.
            int figure = (popuInt - popuInt/ref[whichDigit+1]*ref[whichDigit+1])/ref[whichDigit]; 
           
            //전체 텍스쳐에서 취할 픽셀 좌표를 계산한다.
            vec2 texcoord = vec2((float(figure)+texNewX)/10.0, fs.tex.y);
            
            //텍스쳐에서 픽셀을 취한다.
            vec4 figureTex = texture(digitTex, texcoord);            
            
            //최종 아웃풋. 
            //준비한 텍스쳐(01234...89)에서 숫자가 칠해지지 않은 부분은
            //투명하므로 figureTex.w = 0.0 이다. 
            //이 경우는 mix 함수에 의해 본래 막대 색상이 칠해진다.
            color = mix(fs.surface_color, figureTex,  figureTex.w);
            
        }
 
        
 
    }
 
 
}
 
cs
 
 
 

 

 

 

 

 

렌더링되는 장면은 다음과 같다. 박스가 작을 경우 숫자를 축소해서 보여준다.

 

대표

 

 

 

 

 

 

아래에는 간단한 영상. 실시간으로 동작한다.