OpenGL를 기반으로 UI 렌더러를 만들 때 고민 중 하나가 효율적으로 도형을 그리는 방법이 뭘까...였다.

 

1. OpenGL 기본 배경

 일단 기본 배경을 설명하자면, OpenGL에는 도형을 그리는 여러 방법이 있다.

 

 첫 번째는 OpenGL 1.0 Version에서 사용하던 'glBegin()-glVertex()-glEnd()' 방식을 사용한 방법인데, 이 방법의 경우에는 그리고자 하는 도형(Premitive)을 glBegin()을 통해 정의하고, 해당 도형의 모든 정점을 glVertex()을 호출하여 정의 하면 된다. 모든 정점을 다 정의한 후에는 glEnd()을 호출하여 정점 정의가 끝났음을 알리면 된다. 이 방법의 장점은 작성 난이도는 낮다는 것이고, 단점은 쓸대없이 많은 양의 CPU Call로 인한 성능 저하이다. (ex. 원의 경우 최소 360개의 정점에 대해 glVertex()로 정의해야한다.)

 

//사각형 그리기
glBegin(GL_QUADS);

glVertex2d(0, 0);
glVertex2d(width, 0);
glVertex2d(width, height);
glVertex2d(0, height);

glEnd();

 

(물론 이 외에도 Fixed Function만 이용 가능하기 때문에 사용자가 원하는 특수 처리를 못한다는 점이 있으나, 현재 포스팅하는 내용은 단순히 도형을 그리는 것에 초점이 맞춰져 있기 때문에 자세한 설명은 제외.)

 

 두 번째는 OpenGL 2.0 Version부터 등장한 Shader를 사용하는 방법이다. 이 방법은 간단하게 말하자면, 개발자가 GLSL 이라는 쉐이더 언어를 통해 화면 상의 각 픽셀의 색상값을 지정하는 것이다. (사실 이 부분은 Fragment Shader에 대한 설명이다) OpenGL은 각각의 픽셀에 대해 Shader Program을 실행시켜 컬러값을 도출한다. 이때 수 많은 Pixel에 대해 하나하나 CPU를 통해 호출하여 처리하면 느리겠지만, OpenGL은 GPU 위에서 병렬연산을 통해 빠르게 처리한다. 이 방법의 장점은 적은 양의 CPU Call이 필요하다는 점이며, 단점은 입문자가 접근하기 어렵게 느낄 수 있다는 점이다.

 

// 원 렌더링 Shader 예시
#version 330 core

uniform vec4 color;
in vec2 pos; // pixel 좌표 (0~1 범위로 normalize한 좌표)

float drawCircle() {
    float radius = length(pos);
    return step(radius, 1.0); // radius 값이 1.0 이하면 해당 pixel의 alpha 값을 1로 설정하여 원을 그린다.
}

void main() {
    float alpha = drawCircle();
    gl_FragColor = vec4(color.xyz, color.w * alpha);
}

 

(또한 Shader를 사용하면 각 Pixel을 자유롭게 조작할 수 있기 때문에 Motion Blur, Anti-Aliasing, Lighting등과 같은 특수처리를 쉽게 할 수 있다.)

 

2. 문제 발단

 나는 두가지 방법 중 유연성과 성능이 좋은 Shader를 사용하는 방법을 선택했다. 여기까지는 다들 납득이 갈 것이다.

문제는 직접 GLSL을 통해 각 도형에 대한 Shader를 만들고 난 후에 생겼다.

 

 도형을 그리는 알고리즘 자체가 비효율적이었던 것이다. (너무 별 생각없이 짰다..) 간단히 Round Rectangle을 그리는 로직으로 예를 들자면, 첫 번째로 Pixel 위치가 모서리 범위(주황색 범위) 안에 있는지 검사하고, 만약 안에 있으면 둥근 모서리 안 쪽(노랑색 범위)에 위치해 있는지를 검사하는 식이었다. 결과적으로 하늘색 + 노란색 부분만 칠하면 원했던 RoundRectangle을 그릴 수 있었다.

 

RoundRectangle 렌더링 알고리즘

 아무래도 Shader를 작성해보지 않은 분들은 '이게 왜 비효율적이야?'라고 생각할 수도 있다. 하지만 Shader의 경우 GPU 상에서 각 Pixel에 대해 병렬로 실행되는 만큼, 적은 분기(if 문)와 코드 양이 중요하다. 문제는 내 알고리즘은 각 도형을 그릴 때 무조건 한 두개 이상의 분기가 존재했고, 실제 구현 시 코드 길이도 길면 15줄까지 되었다.. (수학을 못해서...) 물론 이 자체로도 첫 번째 방법 보다야 성능이 좋았지만 나는 만족하지 못했다. 

 

3. SDF(Signed Distance Field)를 통한 문제 해결

 로직을 개선하고자 방법을 찾아보던 중 SDF(Signed Distance Field)라는 것을 발견했다. SDF는 테두리로 부터의 거리를 연산하는 알고리즘인데, 이때 앞에 부호화가 붙은 걸 보면 알 수 있듯이 테두리 안쪽에서의 거리는 마이너스로, 바깥에서의 거리는 플러스로 연산한다.

 

 따라서 도형의 SDF만 연산할 수 있다면, 도형을 채울 때는 SDF 값이 0 이하인 Pixel은 색을 칠하면 되고, 도형의 테두리를 그릴 때는 SDF값이 0인 Pixel만 칠하면 된다. (테두리 두께도 SDF를 통해 조작할 수 있다.)

 

// 사각형 SDF
float rectangleSDF( in vec2 pos, in vec2 size ) {
    vec2 distance = abs(pos) - size ;
    return length(max(distance, 0.0)) + min(max(distance.x,distance.y), 0.0);
}

// 둥근 모서리 사각형 SDF
float rectangleSDF( in vec2 pos, in vec2 size, float radius) {
    vec2 distance = abs(pos) - size + radius;
    return length(max(distance, 0.0)) + min(max(distance.x,distance.y), 0.0) - radius;
}

// SDF 사용 예제
void main() {
    float alpha = 1.0;
    float sdf = shapeSDF(); // 그리고자 하는 도형의 sdf 계산 (인자는 생략함)

    if( fill == 1.0 ) {
        // sdf가 0.0 보다 작으면 alpha 값을 1로 설정 (채우기)
    } else {
        // sdf가 0.0이면 alpha 값을 1로 설정 (테두리 그리기)
    }   
    gl_FragColor = vec4(color.xyz, color.w * alpha);
}

 

4. 마무리

 실제로 SDF를 적용했더니 코드양도 대폭 줄고 성능도 개선이 되어서 만족했다. 일단 지금 내 수준에서는 여기까지가 최선이라고 생각된다. 이후에 만약 더 좋은 방법을 찾는다면, 추가로 포스팅할 생각이다.

 

오타나 잘못된 내용 있으면 코멘트 부탁드립니다.

 

참고 링크

https://www.iquilezles.org/www/articles/distfunctions2d/distfunctions2d.htm

+ Recent posts