The sea is beautiful, deep and cold.

Yet we have duty, can't sleep until finish it over.


Tool: Corel Painter, Wacom INTUOS ART

벡터 그래픽스는 도형 드로잉을 수행하기 위해 수학 방정식을 이용하여 실시간으로 그림을 그리는 기술이다. 화소 데이터가 저장되어 있는 이미지와 다르게 수식을 통해 이미지 결과물을 동적으로 생성하기 때문에 해상도에 따른 화질의 저하가 발생하지 않는 특장점을 갖는다. 물론, 생성하고자 하는 이미지가 복잡할 수록 연산량이 더 많이 요구되는 단점이 있고 이미지 출력 대비 사용 방법도 복잡한 경향이 있지만, 사실상 벡터를 표현할 수 있는 여러 파일 포맷이 존재하고 최근 하드웨어는 비교적 무난한 성능을 보유하고 있기 때문에 일반적인 경우라면 벡터 그래픽스 사용은 더 이상 문제가 없다고 봐도 무방하다. 벡터 그래픽스는 근본적으로 텍스처 질감을 표현할 수 없기 때문에 이미지와 함께 벡터 그래픽스를 활용하면 훌륭한 UI 결과물을 만들어 낼 수 있다. 디자인 컨셉 상 복잡하고 화려한 것보다는 단조롭지만 정결한 디자인을 선호한다면 UI 출력에 있어서 벡터 그래픽스가 좋은 대안이 될 것이다.

렌더링에 있어서 드로잉의 원초적인 기능은 도형을 그리는 작업이다. 캔버스가 점, 직선, 곡선, 원, 다각형 등 도형을 그릴 수 있는 기능을 제공한다면 이러한 도형을 조합하여 어떠한 형태의 UI 이미지도 생성할 수 있다. 벡터 래스터라이저는 이러한 벡트 그래픽스를 구현한 드로잉 엔진에 해당되며 앞 장에서 살펴본 캔버스의 렌더링을 완성하는 가장 중요한 부분에 해당한다. 이번 장에서는 벡터 래스터라이저가 어떻게 구성되고 이를 통해 렌더링 단계에서 오브젝트가 어떻게 출력되는지 이해해 보는 시간을 가져보도록 하자.


1. 이번 장 목표

이번 장을 통해 다음 사항을 학습해 보자.

  • 벡터 그래픽스의 발전 역사를 살펴본다.
  • SVG 파일 포맷을 통한 벡터 그래픽스를 표현하는 방법을 이해한다.
  • 도형을 그리는 수식과 알고리즘을 이해한다.
  • 그래디언트 채우기, 스트로크의 기능과 구현 방법을 살펴본다.
  • 벡터 래스터라이저를 완성하고 캔버스 엔진과 연동한다.
  • RLE(Run-Length Encoding) 알고리즘을 적용한 최적화 기법을 살펴본다.


  • 2.벡터 그래픽스 개요

    초창기 컴퓨팅 시절 그래픽스 시스템은 벡터 그래픽스 시스템이 일반적이었다. 최초의 벡터 그래픽스 사용 사례를 조사해보면 군사 또는 특수 시스템을 위해 개발되었는데, 실제로 최초의 벡터 그래픽스 사용 사례는 미국의 SAGE 방공 시스템이었다. 벡터 그래픽스는 항공 시스템의 항공 경로 조작을 위한 출력장치에 적용되었고 1963년 컴퓨터 그래픽스 선구자인 MIT 이반 수더랜드(Ivan Sutherland) 박사는 TX-2 머신에서 벡터 그래픽스를 사용하기도 하였다.


    그림 1: 1950년대 항공 경로 레이더 시스템 (faanews)

    이후, 벡터 그래픽스는 실시간으로 구축 가능한 드로잉 명령어 리스트을 통해 순차적으로 렌더링을 수행하는 방식으로 전형화되었다. 이러한 방식은 Digital 사의 GT40 머신에 적용되었고 Vectrex 벡터 그래픽스 시스템을 탑재한 가정용 게임 시스템은 물론, 스페이스 워즈, 애스터로이드(Asteroids) 등의 게임에서도 벡터 그래픽스가 사용되었다. 90년대 초반 필자가 즐겨했던 도스 게임 중 하나인 어나더 월드(Another World)는 화면 전체가 벡터 그래픽스를 통해 실시간으로 출력된 벡터 그래픽스의 대표 게임 중 하나였다. 당시 어나더 월드는 벡터로 출력하기엔 믿기 어려울 만큼 멋진 배경과 부드러운 애니메이션으로 개발자의 큰 관심을 모았다. 특히 컴퓨터 하드웨어 사양 중 메모리 제약이 큰 시절인 만큼 어나더 월드는 메모리 공간을 많이 차지하는 이미지 리소스를 사용하지 않고 순수히 벡터 드로잉 명령만으로 멋진 게임 비주얼을 실시간으로 만들어 내는데 성공한 하나의 사례이다.


    그림 2: 어나더 월드 (1991)

    오늘날 벡터 그래픽스는 관행적으로 3D가 아닌 2D를 지칭하는 기술로 여긴다. 90년대 초반 컴퓨터 하드웨어 성능이 향상함에 따라 3D 그래픽스가 게임 및 일반 애플리케이션에 보편화되면서 벡터 그래픽스의 위치가 애매해진 이유가 있기도 하다. 사실상 3D 그래픽스 역시 개념적으로 벡터 그래픽스와 다를바 없지만 3D 그래픽스는 벡터 그래픽스보다 차원을 하나 더 표현하기 위해 훨씬 더 복잡한 연산과 렌더링 기술을 필요로 하는 고급 기술에 해당한다. 일찍이 3D 그래픽스를 지원하기 위한 하드웨어 그래픽스 칩셋이 발전하고 칩셋 제조사와 SW 산업 업계의 표준화 작업이 진행되면서 3D 그래픽스는 렌더링 파이프라인 및 셰이더 등의 3D 분야의 기술을 정립하였고 이제는 벡터 그래픽스와는 완전히 다른 기술로 간주되고 있다.

    벡터 그래픽스가 2D 영역에 자리잡음으로써 앱 UI나 일러스트 또는 다양한 산업 디자인 툴에서도 유용하게 사용된다. 실제로 실시간 데이터 기반의 그래프나 차트 UI는 사용자 데이터를 기반으로 실시간으로 UI 이미지를 생성해야 하기 때문에 벡터 그래픽스를 활용하기 좋은 경우이다.


    그림 3: 차트 & 그래프 디자인 (brusheezy.com)

    1999년 월드 와이드 웹 컨소시엄(W3C)에서는 웹에서 2D 벡터 그래픽을 표현하기 위해 SVG(Scalable Vector Graphics) 파일 포맷을 정의하였다. 오늘날 벡터 그래픽스의 사양 및 기술은 정형화되어 있으며 SVG는 그러한 기술 트렌드를 밑바탕으로 벡터 그래픽스의 표준화 작업을 진행해왔기 때문에 SVG를 이해하면 벡터 그래픽스를 이해하는데 큰 도움이 된다. SVG는 XML 기반이며 텍스트 형식에 데이터가 구조화되어 있어서 가독성 있으며 필요에 따라 데이터를 바이너리로 압축할 수도 있다. 하지만, 말이 텍스트 형식이지 실질적으로 데이터 자체는 벡터 드로잉을 위한 수식 인자의 정보 집합에 더 가까웠기 때문에 전문 디자인 툴을 다루지 않고서는 SVG를 직접 손으로 작성하기는 쉽지 않다. SVG를 제작할 수 있는 벡터 디자인의 대표 툴로는 어도비(Adobe)의 일러스트레이터(Illustrator)와 무료 소프트웨어인 그놈(Gnome) 프로젝트의 잉크스케이프(Inkscape)가 있으며 대부분의 인터넷 브라우저에서는 SVG 출력 기능을 지원한다.


    그림 4: 어도비 일러스트레이터(좌), 그놈 잉크스케이프(우)


    3. SVG (Scalable Vector Graphics)

    SVG는 벡터를 표현하기 위한 기본 도형 및 속성을 정의한다. 기본 출력 대상은 사각형, 원, 타원, 선, 폴리곤, 경로(Path), 그리고 텍스트 등이 있다. 추가로 이들을 출력할 때 적용할 효과에 대한 속성도 정의한다. 이러한 속성에는 스트로크(Stroke), 필터, 블러(Blur), 그림자, 그래디언트(Gradient)을 이용한 색상 채우기 등이 있다. 다음은 그래디언트 효과를 적용한 타원과 텍스트를 정의하는 SVG 예제이다.

    <!-- svg 선언, 기본 사이즈 세로:150, 가로:400 -->

    <svg height="150" width="400">

     <defs>

       <!-- 선형 그래디언트, 좌측에서 우측 방향 -->

       <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">

         <!-- 그래디언트 지점 및 색상. 좌측 끝:노란색 -->

         <stop offset="0%" style="stop-color:rgb(255,255,0);stop-opacity:1" />

         <!-- 그래디언트 지점 및 색상. 우측 끝:빨간색 -->

         <stop offset="100%" style="stop-color:rgb(255,0,0);stop-opacity:1" />

       </linearGradient>

     </defs>

     <!-- 타원, 중심 좌표:(200, 80), 가로 반지름:85, 세로 반지름:55. 색상:grad1 참조 -->

     <ellipse cx="200" cy="70" rx="85" ry="55" fill="url(#grad1)" />

     <!-- 텍스트 SVG, 색상:흰색, 폰트 크기:45, 폰트명: Verdana, 위치 좌표:(150, 86) -->

     <text fill="#ffffff" font-size="45" font-family="Verdana" x="150" y="86">SVG</text>

    </svg>

    코드 1: SVG 그래디언트 예제


    그림 5: SVG 그래디언트 예제 출력 결과

    스트로크를 이해하기 쉽게 설명하면, 선을 그리는 붓터치 속성과 유사하다. 스트로크는 선을 그리거나 도형의 외곽 선을 표현하는데 사용된다. 스트로크를 통해 선의 두께 및 색상은 물론, 선이 실선인지 점선인지, 선의 연결점 및 끝지점은 어떻게 표현할지 등을 세부적으로 지정할 수 있다.

    <svg height="80" width="300">

     <!-- 이하 스트로크 동일 적용. 색상:검정, 스트로크 넓이:6 -->

     <g fill="none" stroke="black" stroke-width="6">

       <!-- 경로, 시작점:(5,20), 이동 거리:(215, 0), 스트로크 라인캡 스타일:butt -->

       <path stroke-linecap="butt" d="M5 20 l215 0" />

       <!-- 경로, 시작점:(5,40), 이동 거리:(215, 0), 스트로크 라인캡 스타일:round -->

       <path stroke-linecap="round" d="M5 40 l215 0" />

       <!-- 경로, 시작점:(5,60), 이동 거리:(215, 0), 스트로크 라인캡 스타일:square -->

       <path stroke-linecap="square" d="M5 60 l215 0" />

     </g>

    </svg>

    코드 2: SVG 스트로크 예제


    그림 6: SVG 스트로크 예제 출력 결과

    SVG에서는 정점을 연결하여 다각형을 정의한다.

    <svg height="210" width="500">

     <!-- 폴리곤, 정점 목록 (100,10), (40,198), (190,78), (10,78), (160,198) 채우기 색상:라임, 스트로크 색상:보라, 스트로크 넓이:5  -->

     <polygon points="100,10 40,198 190,78 10,78 160,198" style="fill:lime;stroke:purple;stroke-width:5;" />

    </svg>

    코드 3: SVG 폴리곤 예제


    그림 7: SVG 폴리곤 예제 출력 결과

    다음 예제는 보다 복잡한 벡터 출력 예제이다.

    <svg height="400" width="450">

     <!-- 경로 (A-B), 시작점:(100,350), 이동 거리:(150, -300), 스트로크 색상:빨강, 스트로크 넓이:3  -->

     <path d="M 100 350 l 150 -300" stroke="red" stroke-width="3" />

     <path d="M 250 50 l 150 300" stroke="red" stroke-width="3" />

     <path d="M 175 200 l 150 0" stroke="green" stroke-width="3" />

     <!-- 경로, 시작점:(100,350), 2차 베지어 곡선 P1:(150, -300), P2(300, 0) 스트로크 색상:파랑, 스트로크 넓이:5  -->

     <path d="M 100 350 q 150 -300 300 0" stroke="blue" stroke-width="5" />

     <!-- 이하 스트로크 동일 적용. 색상:검정, 스트로크 넓이:3 -->

     <g stroke="black" stroke-width="3" fill="black">

       <!-- 원(A): 중심 좌표:(100, 350), 반지름:3 -->

       <circle cx="100" cy="350" r="3" />

       <circle cx="250" cy="50" r="3" />

       <circle cx="400" cy="350" r="3" />

     </g>

     <!-- 이하 텍스트 동일 적용. 폰트 크기:30, 폰트명: sans-serif, 색상:검정, 정렬:가운데 -->

     <g font-size="30" font-family="sans-serif" fill="black" text-anchor="middle">

       <!-- 텍스트:A, 좌표:(100 - 30, 350) -->

       <text x="100" y="350" dx="-30">A</text>

       <text x="250" y="50" dy="-10">B</text>

       <text x="400" y="350" dx="30">C</text>

     </g>

    </svg>

    코드 4: SVG 베이지어 경로 예제


    그림 8: SVG 베이지어 경로 예제 출력 결과

    SVG를 통해서 도형 이상으로 복잡한 이미지 역시 표현 가능하다. 다음 그림은 SVG를 통해 보다 복잡한 이미지를 표현할 수 있다는 것을 증명한다.


    그림 9: SVG로 표현한 호랭이 (Ghostscript Tiger)

    그림 9은 무료 SVG 리소스 중 하나인, 매우 유명한 타이거 이미지이다. 위의 이미지를 배경의 투명 정보를 보유하기 위해 png 파일로 저장한다면, 512x512 크기 기준으로 약 156 KB의 메모리 공간이 필요하다. 더 나은 해상도를 위해 1024x1024크기로 저장한다면 두 배 이상인 약 356 KB의 메모리 공간이 필요하다. 다양한 기기, 다양한 해상도의 스케일러블한 UI를 지원하는 경우라면, png의 경우 각 해상도에 적합한 이미지를 여러벌 갖추고 있어야 하므로 실질적으로 사용하는 저장디스크의 공간은 더욱 커질 수 있다. 압축률이 뛰어난 대표적인 이미지 포맷인 jpeg 역시 상황은 별반 다르지 않다. 반면 SVG의 경우 위 타이거 이미지라면, 해상도에 상관없이 67 KB의 메모리 공간만 있어도 충분하다. SVG는 일반 이미지 포맷 대비, 다양한 해상도의 기기에서 단일 리소스만으로 퀄리티 저하 없는 이미지를 보여줄 수 있는 장점은 물론, 디스크 메모리 사용 측면에서도 더 효율적임을 보여준다.

    SVG 파일로부터 렌더링 결과물을 출력해주는 대표 오픈소스 라이브러리로 그놈 프로젝트의 librsvg가 있으므로 플랫폼 독자적인 SVG 벡터 드로잉 기능이 없다면 이 라이브러리를 활용할 수 있다. svg가 아닌, 보다 범용적인 목적의 오픈소스 벡터 드로잉 엔진으로는 카이로(Cairo)가 있으니 참고하길 바란다. SVG의 보다 자세한 기능과 명세서를 살펴보고 싶다면, SVG 공식 튜토리얼 사이트( www.w3schools.com/graphics/svg_intro.asp)를 참고하도록 하자.


    3. 벡터 기능 정의

    대표적으로 벡터 포맷인 SVG에 대해서 살펴보았지만, SVG 뿐만 아니라 다양한 벡터 리소스를 출력하기 위해서는 UI 엔진에서 벡터 드로잉 기능이 요구된다. 벡터 파일 데이터를 파싱하면 어떠한 벡터 요소가 출력되어야 하는지 알 수 있을 뿐, 실질적으로 벡터 렌더링 엔진에서는 그에 요구되는 드로잉 기능을 갖추고 있어야만 벡터 파일에서 정의하는 도형을 출력할 수 있다.

    벡터 렌더링 기능을 개발하기에 앞서, 벡터 렌더링 엔진이 제공할 기능 정의가 필요하다. 앞 절에서 설명한 대로 오늘날 벡터 그래픽스의 사양 및 기술은 정형화되어 있기 때문에 대표적인 SVG를 기반으로 벡터 그래픽스 기능을 분류해 보자. 크게 세 부분으로 나눌 수 있다.

  • 도형(Shape): 점(Point), 선(Line), 곡선(Curve), 경로(Path), 사각형(Rectangle), 둥근 사각형(Round Rectangle), 부채꼴(Pie), 원(Circle), 타원(Ellipse), 폴리곤(Polygon), 폴리스타(Polystar) 정도로 정의 가능하다. 사실 폴리곤 기능을 제공하면 이를 통해 삼각형, 사각형을 비롯한 임의의 다각형을 모두 표현 가능하다. 하지만, 그나마 많이 사용되는 사각형 및 둥근 테두리의 사각형, 스타 정도는 사용하기 쉬운 인터페이스를 정의하는 것도 좋은 방법이다. 추가로 텍스트 요소도 고려해야 하지만, 텍스트는 별도의 장에서 다루도록 한다.


  • 그림 10: 벡터 도형

  • 채우기(Fill): 도형의 색상을 지정할 수 있는 메커니즘이 필요하다. 기본적으로 단색과 그래디언트 효과를 많이 사용하며 이를 위해 채우기(Fill) 기능을 제공할 수 있다. 단색은 도형 내부의 색을 단일(solid) 색상으로 채운다. 그래디언트의 경우에는 두 가지 이상의 색상을 지정하여 도형 내 색상을 채운다. 지정한 복수의 색상 간에는 보간(Interpolation)이 발생하며, 수식을 통해 도형 내 채울 색상을 결정할 수 있다. 그래디언트 채우기는 수직(linear-vertical) 또는 수평(linear-horizontal) 방향으로 동작하며 추가로 원형(radial) 또는 앵귤러(angular) 그래디언트 방식을 제공할 수도 있다. 일반적인 UI에서는 수직, 수평, 원형 그래디언트를 많이 사용한다. 필요에 따라 채우기 옵션에 텍스처를 입히는 기능을 제공할 수 있다. 텍스처는 도형의 질감을 표현하며 예쁜 벽지처럼 도형 내에 특정 무늬를 반복해서 출력하는 타일 효과를 기대할 수 있다. 하지만 타일 매핑이 아닌, 기하 텍스처 매핑 수준을 고려한다면 과대 기능은 아닌지 다시 한번 검토해 볼 필요가 있다. 텍스처의 경우 이미지 리소스를 사용하므로 화질 저하 없이 다양한 해상도를 지원할 수 있는 벡터 그래픽스의 장점에 반하는 디자인 요구에 해당된다. 도형에 이미지를 채워야 한다면, 이미 만들어진 도형 이미지 자체를 출력하는 것이 성능이나 기능 구현 면에서 더 바람직하다.


  • 그림 11: 그래디언트 채우기


    그림 12: 이미지 타일 채우기

  • 스트로크(Stroke): 스트로크는 선 또는 도형의 외곽 선을 그리는데 영향을 준다. 도형의 채우기 색상과 별개로 스트로크 스스로 색상을 가지며 두께를 지정할 수 있다. 스트로크를 통해 실선 혹은 점선을 표현할 수 있으며 추가로 선의 연결점(Join)과 끝지점(Linecap)을 어떻게 표현할 지를 지정 가능하다. 연결점은 미터(miter), 라운드(round), 베벨(bevel)이 있으며 선의 끝지점은 버트(butt), 라운드(round), 스퀘어(square) 속성을 정의한다.


  • 그림 13: 스트로크 조인(Join) 속성


    그림 14: 스트로크 라인캡(Linecap) 속성

  • 스트로크에서 대쉬(dash) 패턴을 통해 점선을 표현한다. 패턴은 고정일 수도 있지만 가변적일 수도 있다. 가변적인 경우, 사용자가 패턴 값을 입력하면 해당 패턴에 맞춰 점선이 출력될 것이다. 예를 들면, 사용자가 {4, 3} 패턴을 입력했다면, 4픽셀 길이의 선과 3픽셀 여백의 점선이 출력될 수 있다. {5, 2, 3, 1} 패턴 값을 입력했다면, 5픽셀 길이의 선과 2픽셀 여백, 다음으로 3픽셀 길이의 선과 1픽셀 여백의 점선이 반복적으로 출력될 것이다.


  • 그림 15: 패턴값을 통한 점선 스타일 지정

    앞서 살펴본 기능을 토대로 우리는 벡터 오브젝트를 다음과 같이 정의한다. 기저 클래스를 정의하고 이를 확장하여 기능을 세분화 해보자.


    그림 16: UIShape를 확장한 다양한 도형 클래스

    UIShape는 기본적으로 UIObject의 특성을 물려받고 이하 사각형, 선, 폴리곤 등 실체가 존재하는 도형은 UIShape를 확장하여 구현한다. 공통된 특성을 UIShape에 정의함으로써 각 도형은 스트로크 및 채우기 기능과 자연스럽게 통합될 수 있다. UIShape 자체는 실체하지 않는 도형이기 때문에 추상 클래스로 선언하는 것이 바람직하다. UIShape 파생클래스는 각 도형을 그릴 수 있는 수식을 정의하고 필요한 인자를 외부로 노출하여 값을 입력받도록 한다. 전달받은 입력값을 토대로 각 도형의 update()와 render()에서는 드로잉 작업을 수행할 수 있다. 이와 관련된 부분은 3.4 절에서 하나씩 살펴보도록 하자.


    그림 17: UIShape, UIFill, UIStroke 클래스 다이어그램

    UIShape는 기본적으로 UIFill과 UIStroke를 통해 채우기 및 스트로크 정보를 전달받고 이와 관련된 작업을 수행한다. 개념적으로 채우기와 스트로크는 엔진에서 독립적으로 기능을 행사할 수 없으며 반드시 UIShape를 통해서만 작동한다. 채우기는 단일 색상 또는 선형, 원형, 앵귤러 그래디언트 등으로 세분화 되므로 UIFill 자체는 연결고리 역할만 수행하고 실제 구현은 이를 확장한 파생 클래스에서 처리할 수 있다.


    그림 18: UIFill 클래스 확장


    UIFill 클래스는 5절에서 구체적으로 살펴본다.


    4. 도형 그리기

    앞 절에서 살펴본 벡터 기능을 토대로 벡터 렌더러(Vector Renderer)를 구현해 보자. 벡터 렌더러는 캔버스에 추가된 오브젝트로부터 도형, 채우기 그리고 스트로크 정보를 전달받아 실제로 래스터라이즈 작업을 수행하는 기능을 담당한다. 캔버스 엔진은 오브젝트를 렌더링할 때, 드로잉 대상 버퍼로서 NativeBuffer를 전달하고 벡터 오브젝트의 경우에는 이를 벡터 렌더러에게 고스란히 전달한다. 이 후, 벡터 렌더러는 NativeBuffer를 통해 실제 픽셀 정보를 기록하는 작업을 수행할 수 있다.

    UICanvas.render() { /* 캔버스 버퍼에 UI를 그리는 작업을 수행한다. 이 로직이라면, 생성된 모든 오브젝트를 그리게 된다. 오브젝트가 그릴 대상 버퍼 외 필요한 인자도 전달한다. self.buffer가 기억이 안나면 2장을 참고하자. */ foreach(self.objs, obj) obj.render(self.buffer, ...); self.flush(); //그림 완료 신호를 보낸다. } /* UIObject로부터 파생된 UIRect 역시 벡터 렌더러로 NativeBuffer를 전달함으로써 드로잉을 완성할 수 있다. */ UIRect.render(buffer, ...) { ... UIVectorRenderer.drawRect(buffer, ...); ... }

    코드 5: UICanvas로부터 UIVectorEngine으로 NativeBuffer를 전달하는 과정

    도형 그리기에 앞서 미리 언급하자면, 실제 여러 실용 엔진에서는 보다 고성능의 벡터 래스터라이저를 위해 도형의 수식을 프로그래밍적 기교, 알고리즘 트릭, 심지어는 수식의 증명을 보다 단순화하여 로직을 최적화하고 병렬화를 통해 가속화를 수행할 수 있다. 추가로 하드웨어 가속을 활용하기 위해 하드웨어 인터페이스에 적합하도록 알고리즘을 변경하기도 한다. 기본적으로 래스터라이저는 그래픽스 하드웨어를 활용하지만 SIMD(Single Instruction Multiple Data) 벡터 연산을 수행할 수도 있다.

    사실상, 수학적 이해가 기반이 되어 있어야 트릭과 최적화도 가능하므로 여기서는 여러분의 기본 이해를 돕기 위해 도형의 기초 정석을 기반으로 도형 그리기를 완성한다. 보다 어렵고 난해한 대수학이 아닌, 고등 수준의 수학 지식으로도 충분히 잘 동작하는 알고리즘을 완성할 수 있다.


    4.1 사각형

    도형의 기본은 사각형이다. 사각형은 사각 영역에 색상을 채우는 매우 단순한 기능을 제공하지만, UI 앱의 여백을 채우는 가장 기본적인 기능을 제공하기도 한다.

    사각형은 위치(pos)와 크기(size) 정보를 통해 구성할 수 있다. 기본적으로 사각형의 좌측 상단 위치(pos)와, 크기(size)를 지정하여 사각형의 정보를 구한다. 크기를 알기 때문에 pos를 기준으로 루프를 돌며 색상을 채울 수 있다.

    /* * 사각형을 그리는 메서드 * buffer: NativeBuffer * rect: 드로잉할 사각 영역 (타입: Geometry) */ UIVectorRenderer.drawRect(buffer, rect, ...) { Pixel bitmap = buffer.map(); //버퍼 메모리 접근 //bitmap의 가로 한 줄의 크기를 구한다. => buffer width * sizeof(Pixel) Var lineLength = buffer.lineLength(); //bitmap에 그리기 위한 시작 위치를 찾아간다. bitmap += (rect.y * scanLineSize) + rect.x; //여기가 사각형을 그리는 실제 로직! 색상은 임의로 흰색으로 지정했다. for (y = 0; y < rect.h; ++y) { for (x = 0; x < rect.w; ++x) bitmap[y * lineLength + x] = 0xffffffff; } }

    코드 6: 사각형 드로잉 메서드

    매우 기본적인 사항이지만, 사각형의 드로잉 영역이 유효성을 검증하는 작업은 필수이다. 올바르지 않은 사각형의 위치 및 크기로 인해 NativeBuffer로부터 얻어온 bitmap 메모리의 영역을 벗어난다면 데이터 훼손이 발생하거나 최악의 경우 프로세스가 중단될 수도 있다. 예를 들면, 사각형의 위치가 음수 영역이거나 사각형의 크기가 캔버스 버퍼 크기보다 클 경우에 해당된다. 이 경우에는 벗어난 영역을 잘라내는 작업을 수행한다. 사각형과 캔버스 버퍼 두 영역을 비교하여 겹치는 영역, 즉 교집합을 구하여 사각형의 새로운 위치 및 크기를 구하는 작업이 반드시 선행되어야 한다. 전문 용어로 이러한 작업을 클리핑(Clipping)이라고 한다.

    /* * 두 사각 영역의 교집합 계산을 계산 후 반환 * rect1: 사각 영역 1 (타입: Geometry) * rect2: 사각 영역 2 (타입: Geometry) */ clipRects(rect1, rect2) { Geometry result; result.x = max(rect1.x, rect2.x); result.y = max(rect1.y, rect2.y); result.w = min(rect1.x + rect1.w, rect2.x + rect2.w); result.h = min(rect1.y + rect1.h, rect2.y + rect2.h); return result; } UIVectorRenderer.drawRect(buffer, rect, ...) { //사각형이 그려질 영역 재계산. 이후 rect 대신 clipped를 이용한다. Geometry clipped = clipRects(rect, Geometry(0, 0, buffer.width(), buffer.height())); ... }

    코드 7: 클리핑 계산 로직

    한편, 사각형의 위치와 크기가 오브젝트 영역을 벗어나면 어떠할까? 기본적으로 오브젝트 영역을 드로잉 사각 영역과 일원화 하는 것도 하나의 방법이며 다른 방편으로는 사각 영역과 별개로 오브젝트 영역을 클립(clip) 영역으로 활용할 수도 있다.

     클리핑과 컬링


    그래픽스 시스템에서 클리핑과 컬링은 드로잉 영역을 최적화하여 성능을 향상시키는 목적으로 사용된다. 두 기법 간 궁극적인 목적이 동일하기 때문에 둘 간의 차이가 간혹 애매모호하기도 하지만 분명한 기술적 차별점이 존재한다. 클리핑(clipping)은 보통 래스터라이제이션 단계 직전에 드로잉 대상 영역이 가시영역 즉, 뷰포트(Viewport)를 벗어난 경우를 판단하고 벗어난 영역을 잘라내어 드로잉 영역을 최소화 한다. 알고리즘의 핵심은 잘려진 영역을 계산하고 실제 보이는 부분을 추려내기 때문에 드로잉 대상 객체의 폴리곤 정보를 토대로 작업을 수행한다. 방금 전에 학습한 clipRects()도 클리핑의 작업에 해당한다. 반면, 컬링(culling)은 좀 더 추상적인 오브젝트 단위로 계산을 수행한다. 해당 오브젝트가 뷰포트 또는 카메라의 가시 영역에 존재하는지 여부를 판단하고 드로잉 결정 여부를 판단하기 때문에 응용 또는 변환 단계에서 작업을 수행한다. 일반적으로 3D 그래픽스 시스템에서는 프러스텀(Frustum), 오클루전(Occlusion) 그리고 후면(Back-face) 컬링 세 기법을 기본적으로 적용한다. 프러스텀 컬링은 드로잉 대상 객체가 카메라의 가시 영역에 존재하는지를 식별한다. 반면 오클루전 컬링은 대상 객체가 다른 객체에 의해 완전히 가려졌는지 여부를 판단한다. 후면 컬링은 폴리곤을 구성하는 정점 순서가 시계방향(cw) 또는 반시계 방향(ccw)인지를 판단하여 객체가 뒤집힌 면인지 아닌지를 판단할 수 있으며 이를 통해 컬링 작업을 추가로 수행하기도 한다. 메쉬(Mesh), 즉 부피가 있는 오브젝트인 경우 뒷면은 보이지 않는게 당연하다. UI 엔진에서는 2장에서 살펴본 씬그래프 기법을 활용한다면 해당 씬이 그려질 대상인지 아닌지 사전에 판단하여 드로잉 작업을 추려낼 수 있으며 이는 프러스텀 컬링 기법에 해당된다고 볼 수 있다. 이 후의 장에서 컬링 기법을 적용하는 방법에 대해서 자세히 다루도록 한다.


    사실, 정답이 없는 개념 문제이기 때문에 여기서는 오브젝트 영역을 클립 영역으로 활용하기로 하자. 이 경우, 드로잉 영역이 오브젝트 영역을 벗어나지 않도록 제한하는 것이 개발 관점에서 더 유리하다. 객체의 출력 결과물을 오브젝트의 경계로 제한한다면 오브젝트의 출력 영역 일관성이 확보되기 때문에 계산이 용이하고 결과물 예상 관점에서 보다 이해하기가 쉽다. 그렇지 않다면, 어떤 오브젝트의 출력 이미지가 해당 오브젝트 영역을 벗어나 다른 오브젝트 위에 겹쳐 그려질 수 있으며, 최악의 경우에는 오류가 발생했을 때 어떤 이미지의 출력 결과물이 어떤 오브젝트로부터 비롯된 것인지를 예측하기가 어려워진다. 오브젝트의 위치와 크기는 오브젝트가 출력할 이미지의 영역을 반드시 확보해야 한다.

    클리핑을 수행하기 위해 오브젝트 자체 영역을 클립(clipper) 영역으로 벡터 렌더러에 추가로 전달하여 영역 계산에 활용한다.


    그림 19: 클리핑 후 드로잉 영역

    /* * 사각형을 그리는 메서드 * buffer: NativeBuffer * rect: 드로잉할 사각 영역 (타입: Geometry) * clipper: 클립 영역 (타입: Geometry) */ UIVectorRenderer.drawRect(buffer, rect, clipper, ...) { //사각형이 그려질 영역 재계산. 버퍼 영역과 사각영역 수행. Geometry clipped = clipRects(rect, Geometry(0, 0, buffer.width(), buffer.height())); //오브젝트 영역에 대해서도 수행한다. 이후 rect 대신 clipped를 이용한다. Geometry clipped = clipRects(clipped, clipper); ... } UIRect.render(buffer, ...) { ... //UIRect는 드로잉 사각 영역과 오브젝트 영역을 벡터 렌더러에게 모두 전달한다. UIVectorRenderer.drawRect(buffer, self.rect, self.geometry(), ...); ... }

    코드 8: 드로잉 영역 계산

    사실 오브젝트와, 도형 그리고 버퍼 세 영역 간의 교집합을 구하는 작업은 여러 드로잉 과정에서 빈번히 발생하기 때문에 원한다면, 세 개의 사각 영역의 교집합을 구하는 clipRects(rect1, rect2, rect3);와 같은 로직을 추가하여 코드를 조금 더 최적화할 수 있다.

    앞으로 구현하는 모든 도형에는 이러한 드로잉 영역의 유효성을 검증하고 좌표를 재계산하는 클리핑 작업이 모두 동일하게 적용되어야 한다. 예시로, 사각형과 직선에 대해서만 적용 방법을 보여준다.


    4.2 직선

    직선의 방정식을 이용하면 픽셀을 그릴 위치를 정확하게 계산할 수 있다. 직선을 그리기 위해서는 선분이 지나치는 두 점 pt1(x1,y1), pt2(x2,y2)의 정보만 알면 된다. 두 점을 이용해 기울기 m을 구한 후, x 좌표값에 곱하여 y 좌표값을 구할 수가 있다.


    그림 20: 직선의 방정식


    구현부에서는 x1 ~ x2 사이의 x 값을 1씩 증가하면서 그림 20 수식에 대입한다. 그러면 x에 해당하는 y 값을 구할 수 있으며 이 때의 x, y의 값은 벡터 렌더러가 그려야할 선의 픽셀 위치에 해당한다.

    /* * 직선의 방정식을 이용한 직선 드로잉 * pt1: 선의 시작점 (타입: Point) * pt2: 선의 끝점 (타입: Point) */ UIVectorRenderer.drawLine(buffer, pt1, pt2, Geometry clipper, ...) { ... Var m = (pt2.y - pt1.y) / (pt2.x - pt1.x); /* 주의: 여기서는 x값을 인자로 y값을 도출한다. 만약 직선이 y축에 더 가깝다면 x값을 도출해야 한다. */ //x축 클리핑 계산 Var sx = pt1.x < clipped.x ? clipped.x : pt1.x; Var ex = pt2.x > (clipped.x + clipped.w - 1) ? (clipped.x + clipped.w - 1) : pt2.x; for (x = sx; x < ex; x++) { /* 정수형의 경우 반올림(rounding) 처리에 주의하자. */ Var y = m * (i - pt1.x) + pt1.y; //y축 클리핑 계산 if (y < clipped.y || y >= (clipped.y + clipped.h)) continue; bitmap[y * lineLength + x] = 0xffffffff; } }

    코드 9: 직선 드로잉 로직

    코드에서 표현하지 않았지만, 선의 방향이 x축에 가까운지 아니면 y축에 가까운지에 따라 for ()문의 기준을 다르게 해야 한다. 이는 absolute(pt2.x - pt1.x) - absolute(pt2.y - pt1.y); 로 판단할 수 있다. 선이 y축보다 x축에 더 가깝다면 즉, 수평으로 더 긴 직선이라면 x 값을 하나씩 증가하면서 그에 해당하는 y값을 구하고 반대의 경우 y값을 증가하면서 x값을 도출해야 한다. 그렇지 않으면 픽셀을 손실하여 점선과 같은 출력 결과가 나타날 수 있다. 직선 역시 클립 영역 내서만 드로잉을 수행한다.


    4.3 원

    원은 원의 중심(cx, cy)과 반지름(r)을 통해 원의 둘레의 좌표를 구할 수 있다.


    그림 21: 원의 방정식

    선을 제외한 도형을 그릴 때는 y축을 기준으로 x좌표 값을 도출한다. 이유는 추후에 다룰 RLE 캐싱 메커니즘을 보다 용이하게 구축하기 위한 것도 있지만 기본적으로 CPU가 메모리에 접근하는 방식과도 관련이 있다. 물리적으로 메모리는 1차원 선형 공간을 띄며 y좌표를 구한 후 x좌표를 반복하며 색상을 채워나가는 방식이 캐싱 적용률이 훨씬 뛰어나다.

    여기서 재미있는 사실은 원은 중점으로부터 사대면 대칭인 특성이 있다. 달리 말하면, 방정식을 통해 한면의 둘레 좌표를 구하는 것만으로 원의 전체 둘레를 모두 계산할 수 있으며 이는 비교적 부하가 큰 루트 연산을 피할 수 있으므로 효율적이다.

    1사분면의 둘레를 구했다면, 2사분면은 1사분면의 현재 x값으로부터 원의 중심 좌표 cx값까지 거리를 구한 후, 그 값의 두배를 더함으로써 구할 수 있으며 3사분면은 x가 아닌 y값을, 4사분면은 x, y 모두 동일한 방식을 처리함으로써 둘레의 위치값을 구할 수 있다.

    /* * 원의 방정식을 이용한 원 드로잉 * center: 원의 중심 좌표 (타입: Point) * radius: 원의 반지름 */ UIVectorRenderer.drawCircle(buffer, center, radius, ...) { ... //1사분면에 한하여 반복 for (y = center.y - radius; y < center.y; y++) { Var x = sqrt((radius * radius) - pow(y - center.y, 2)) + cx; Var sx = x + (cx - x) * 2; //x축 대칭 위치 Var sy = y - (y - cy) * 2; //y축 대칭 위치 bitmap[sy * lineLength + x] = 0xffffffff; //1사분면 bitmap[sy * lineLength + sx] = 0xffffffff; //2사분면 bitmap[y * lineLength + x] = 0xffffffff; //3사분면 bitmap[y * lineLength + sx] = 0xffffffff; //4사분면 } }

    코드 10: 원 드로잉 로직

    타원은 원과 크게 다를바가 없으며, 원 드로잉 로직에 타원의 공식을 적용하기만 하면 된다.


    그림 22: 타원의 방정식


    4.4 부채꼴

    부채꼴을 그리는 방식은 앞서 살펴본 라인과 원을 조합하여 구현할 수 있다. 부채꼴을 그리기 위해서는 원의 중심과 반지름, 시작 각도와 끝 각도 이 네 정보가 필요하다. 이들의 정보를 알면, 원의 둘레가 어떤 점에서 시작해서 어떤 점에서 끝나는지 알 수 있으며 이 둘레가 원의 몇사분을 차지하는지 계산이 가능하다. 계산한 사분을 통해 그려야할 둘레를 구하고 그 둘레의 시작점과 끝점을 각각 원의 중심과 연결한 직선을 그리면 실제로 부채꼴이 완성된다.


    그림 23: 부채꼴 수식

    angle1을 가리키는 꼭지점은 원의 중심을 원점으로 angle1을 가리키는 반지름의 길이의 벡터를 통해 구할 수 있으며 angle2 - angle1 사이각 만큼 vStart 벡터를 원의 중심을 기준으로 회전하여 angle2를 가리키는 벡터 vEnd를 구한다. 벡터는 2차원 회전 행렬을 곱하여 구할 수 있다. 회전을 위해 코사인(cos), 사인(sin) 값 도출은 물론, 각도를(degree)를 라디안(radian)으로 변환해야 하는데 이를 위해 Math 함수집단에서 유틸리티 함수를 제공하면 편하다. 범용적인 시스템에서는(POSIX를 포함하여) 수학 처리를 위한 cos, sin, tan와 같은 삼각함수를 제공하므로 따로 작성할 필요가 없다.

    UIMath.PI = 3.141592653589; /* * 각도를 라디안 값으로 변환 */ UIMath.degreeToRadian(degree) { return degree / 180 * PI; } /* * 라디안을 각도로 변환 */ UIMath.radianToDegree(radian) { return radian * 180 / PI; }

    코드 11: 각도 <-> 라디안 변환 함수

    한편, 벡터와 변환은 추후 여러 그래픽스 처리 작업에 활용가능하므로 기본 클래스를 제공하는 것도 나쁘지 않다. 여기서는 당장 필요한 기능만 확인한다.

    /* * 2D 벡터의 회전. 앞서 작성한 수학 함수를 이용한다. */ UIVector2.rotate(angle) { Var radian = UIMath.degreeToRadian(angle); self.x = UIMath.cos(radian) * self.x - UIMath.sin(radian) * self.y; self.y = UIMath.sin(radian) * self.x + UIMath.cos(radian) * self.y; }

    코드 12: 회전을 위한 2D 벡터 클래스

    다음은 부채꼴을 그리는 로직이다. 코드가 다소 복잡해 보이지만 실제 로직 자체는 그렇지 않으니 집중해서 읽어보자.

    /* * 부채꼴 드로잉 * center: 원의 중심 좌표 (타입: Point) * radius: 원의 반지름 * angle1: 시작 각도 * angle2: 끝 각도 */ UIVectorRenderer.drawArc(buffer, center, radius, angle1, angle2, ...) { ... //만약 시작과 끝 각도의 차가 360이상이면 원과 동일하다... if (angle2 - angle1 >= 360) return UIVectorRenderer.drawCircle(buffer, center, radius, ...); //angle1과 angle2가 가리키는 꼭지점을 구한다. Vector2 vStart = {0, -center.y}; Vector2 vEnd = vStart; vEnd.rotate(angle2 - angle1); //부채꼴의 시작과 끝 각도를 통해 원의 둘레가 위치하는 사분면의 차를 구한다. Var squad; //부채꼴이 시작하는 사분면 if (angle1 >= 0 && angle1 < 90) squad = 1; else if (angle1 >= 90 && angle1 < 180) squad = 1; else if (angle1 >= 180 && angle1 < 270) squad = 2; else if (angle1 >= 270 && angle1 < 360) squad = 3; Var equad; //부채꼴이 끝나는 사분면 if (angle2 >= 0 && angle2 < 90) equad = 1; else if (angle2 >= 90 && angle2 < 180) equad = 1; else if (angle2 >= 180 && angle2 < 270) equad = 2; else if (angle2 >= 270 && angle2 < 360) equad = 3; Var diff = equad - squad; if (diff < 0) diff += 4; /* 이제 squad에서 시작해서 시계방향으로 diff 크기만큼 사분면을 드로잉한다. diff가 0이면 하나의 사분면 내에서 원의 둘레가 존재하고, 1이면 두 사분면에 걸쳐 원의 둘레가 존재, 3이면 네 사분면에 대해서 원의 둘레를 구한다. */ /* 스캔할 y값의 범위를 결정한다. 만약 부채꼴이 1,2 사분면에만 걸쳐있다면 원의 상단, 3, 4사분면에만 걸쳐있다면 원의 하단이 범위가 된다. */ var yStart, yEnd; if (squad =< 2 || equad =< 2) yStart = center.y - radius; else yStart = center.y; if (squad > 2 || equad > 2) yEnd = center.y + radius; else yEnd = center.y; //드로잉 시작. 원의 y축을 스캔 for (y = yStart; y < yEnd; y++) { Var x = sqrt((radius * radius) - pow(y - center.y, 2)) + cx; Var sx = x + (cx - x) * 2; //x축 대칭 위치 //거쳐가야할 사분면을 확인하면서 x, y 위치에 매칭하는 점을 그린다. for(i = 0; i < diff + 1; i++) { var quad = (squad + i) % 4; //현재 사분면 /* 부채꼴이 시작하는 사분면 (A 영역)*/ if (i == 0) { //1사분면 if (quad == 0 && y < center.y) { //드로잉 영역이 1사분면에서 끝남 if (diff == 0 && (x >= vStart.x && x < vEnd.x)) bitmap[y * lineLength + x] = 0xffffffff; //드로잉이 다음 사분면까지 이어진다. else if (x >= vStart.x) bitmap[y * lineLength + x] = 0xffffffff; //2사분면 else if (quad == 1 && y < center.y) { //드로잉 영역이 2사분면에서 끝남 if (diff == 0 && (sx >= vStart.x && sx < vEnd.x)) bitmap[y * lineLength + sx] = 0xffffffff; //드로잉이 다음 사분면까지 이어진다. else if (sx >= vStart.x) bitmap[y * lineLength + sx] = 0xffffffff; } //3사분면 else if (quad == 2 && y >= center.y) { //드로잉 영역이 3사분면에서 끝남 if (diff == 0 && (sx < vStart.x && sx >= vEnd.x)) bitmap[y * lineLength + sx] = 0xffffffff; //드로잉이 다음 사분면까지 이어진다. else if (sx < vStart.x) bitmap[y * lineLength + sx] = 0xffffffff; } //4사분면 else if (quad == 3 && y >= center.y) { //드로잉 영역이 4사분면에서 끝남 if (diff == 0 && (x < vStart.x && x >= vEnd.x)) bitmap[y * lineLength + x] = 0xffffffff; //드로잉이 다음 사분면까지 이어진다. else if (x < vStart.x) bitmap[y * lineLength + x] = 0xffffffff; } } /* 부채꼴 시작과 끝 사이에 존재하는 사분면. 이 영역은 분리되지 않기 때문에 어느 사분면인지만 판단하여 그려넣자. (B 영역) */ if (i > 0 && i < diff) { switch (quad) { 0: bitmap[y * lineLength + x] = 0xffffffff; break; 1: bitmap[y * lineLength + sx] = 0xffffffff; break; 2: bitmap[y * lineLength + x] = 0xffffffff; break; 3: bitmap[y * lineLength + sx] = 0xffffffff; break; } } /* 부채꼴이 끝나는 사분면 (C영역) */ if (i == diff) { //이하 생략. 부채꼴이 시작하는 사분면과 로직이 크게 다르지 않다. } } } //마지막으로, 원의 중점과 둘레의 두 꼭지점을 선으로 연결한다. UIVectorRenderer.drawLine(buffer, center, Point(vStart.x, vStart.y), ...); UIVectorRenderer.drawLine(buffer, center, Point(vEnd.x, vEnd.y), ...); }

    코드 13: 부채꼴 드로잉 로직

    부채꼴 드로잉의 핵심은 부채꼴이 원의 네 사분면 중 어느 사분면에서 시작하여 어느 사분면에서 끝나는지를 확인하는 것이다. 그것을 판단하면, 원의 y축 최상단에서 최하단까지 루프를 돌며 원의 방정식을 통해 현재 y에 해당하는 x좌표를 구한다. 이 x, y 좌표는 원의 둘레의 한 지점을 가리키는데 이 점이 부채꼴의 범위 내에 포함되는지 판단하여 드로잉 여부를 결정한다. 이해를 돕기 위해 다소 코드를 풀어서 작성하였지만, 굳이 y축의 범위를 모두 스캔하지 않고 기존 원 드로잉과 마찬가지로 한 사분면 범위만 루프를 돌면서 부채꼴을 완성할 수 있을 것이다. 또다른 최적화 방법으로는 1, 2 사분면과 3, 4사분면을 나눠서 별도로 처리한다. 애초에 1,2 사분면 영역에서 3, 4분면의 드로잉 가능성 여부를 확인할 필요가 없으며 반대 상황 역시 마찬가지이다. y축을 기준으로 원의 영역을 상단과 하단으로 분리하면, 드로잉 대상을 점검하는 과정 역시 상, 하단으로 분리되어 보다 효율적일 것이다.


    그림 24: 부채꼴 드로잉 도식화


    4.5 둥근 사각형

    다행히도 둥근 사각형은 기존 사각형과 원의 각 사분면을 그리는 로직을 그대로 활용해도 구현이 가능하다. 둥근 사각형에서 새롭게 고민해야 할 부분은 오로지 사각형의 각 모서리의 지름을 결정하는 것뿐이다.


    그림 25: 둥근 사각형 드로잉 도식화

    사각형의 각 모서리에 위치한 가상의 원의 지름을 통해 사각형 모서리의 완만한 정도를 결정한다. 이 원의 지름 d는 사각형의 넓이 w 대비 비율값으로도 결정할 수 있다.

    /* * 둥근 사각형을 그리는 메서드 * rect: 드로잉할 사각 영역 (타입: Geometry) * cornerRadius: 모서리에 위치한 원의 지름 비율 [0 ~ 1] */ UIVectorRenderer.drawRoundRect(buffer, rect, cornerRadius, ...)

    코드 14: 둥근 사각형 메서드 프로토타입


    각 모서리의 원은 사각형 내 최소 네 개가 동일한 크기로 배치된다. 실제 사각형 크기 대비 원의 크기가 너무 커서 원이 서로 교차한다면 둥근 사각형의 외양은 훼손된다. 이 경우 지름 크기에 제약이 필요하다. 사각형의 높이가 가로 크기보다 작은 경우에도 문제가 될 수 있다. 사각형의 짧은 한 면의 길이가 원의 지름보다 짧은 경우 원의 지름은 사각형의 길이가 짧은 면을 기준으로 값이 1로서 재조정되어야 한다. 실제로 cornerRadius가 1이면 둥근 사각형의 외양은 원과 완전히 동일하다.

    둥근 사각형은 각 네 모서리와 사각형 몸체 두 부분으로 나눠서 드로잉을 수행할 수 있다. 특히 각 모서리의 부채꼴의 합은 하나의 완성된 원과 동일하다. 그러므로 원의 각 사분면을 나눠서 그리되 각 중심 위치만 바꿔주면 된다. 이는 앞서 배웠던 원 드로잉의 로직과 거의 다를 바가 없다. 한편, 그 외의 사각 영역은 모서리를 제외한 영역을 그려야 하는데 사각 영역을 크게 상, 중, 하 세 부분으로 나눈다면 매우 쉽고 빠르게 사각 영역을 완성할 수 있다. 다음 그림은 이를 도식화 한다.


    그림 26: 둥근 사각형 부분 드로잉 도식화

    둥근 사각형의 구현부는 앞서 살펴본 원과 사각형 드로잉 로직의 조합과 다를바 없으므로 여기서는 생략한다.


    4.6 곡선

    곡선을 구하는 방식은 여러 있다. 쉽게는, 제한적이지만 앞서 배운 타원의 일부 구간을 통해서 곡선을 구할 수도 있고 사인, 코사인 등의 수식을 응용해도 부드러운 곡선을 표현할 수 있다. 만약 일정 구간별로 위치 정보를 가지고 있다면 각 구간별로 다항식 보간(Interpolation)을 이용하여 스플라인 곡선을 구할 수 있으며 , B-스플라인, Nurbs(Non-Uniform Rational B-Spline), 에르미트(Hermite) 또는 큐빅(Cubic) 스플라인 보간법 등이 주로 활용된다. 추가로, 시작과 끝점 그리고 두 개의 제어점을 이용하는 베지어(Bezier) 곡선 역시 널리 알려진 곡선 표현 방법 중 하나이다. 특히 베지어 곡선은 트루타입(Truetype) 폰트 표현 및 김프(Gimp) 툴 등에서 실제로 사용되는 등 컴퓨터 그래픽스에서 대표적으로 활용되는 곡선 표현 방법에 해당한다.


    그림 27: 스플라인 보간 곡선 표현 (tools.timodenk)

    대표적으로 스플라인 곡선을 살펴보면, 보간을 통해 구간별 지점을 통과할 수도 있지만 지점을 근사하게 지나칠 수도 있다. 두 경우 모두 곡선을 생성하는 점에서는 동일하지만, 구간을 지나치는 보간의 경우 곡선의 예상 결과가 보다 직관적인 부분은 존재한다.

    스플라인 보간의 핵심은 각 구간을 다항식으로 정의하고 구간 지점에서의 양쪽 함수의 값이 같아야 한다. 또한, 매끄러운 곡선에 불연속성이 없다고 가정했을 때 시작과 끝점을 제외한 내부 점에서의 도함수 값이 동일하다는 가정하에 다항식으로부터 미지수를 구할 수 있으며 시작과 끝점은 그 점의 위치 특성상 2차 도함수 값이 0이라고 전제한다.


    그림 28: 2차 스플라인 곡선 함수

    스플라인은 세 점 이상의 위치 값이 주어졌을 때 위 함수로부터 각 구간별 계수를 구할 수 있으며 x값을 증가시키면서 y값을 도출 가능하다.

    베지어 곡선은 시작점(Start point)과 끝점(Endpoint) 그리고 두 제어점(Control point)을 통해서 구한다. 제어점은 시작점과 끝점으로부터 접선을 이루어 곡선의 기울기를 결정한다.


    그림 29: 베지어 곡선

    베지어와 관련된 수학 풀이는 온라인을 통해 학습할 수 있으며 이미 수식을 정리한 3차 베지어 곡선의 함수는 다음과 같다.


    그림 30: 베지어 곡선 함수

    네 개의 점 P0 ~ P3의 위치와 선의 선형 위치값 t를 통해 x, y값을 구할 수가 있다. t는 정규된 값을 의미하기 때문에 t가 0인 경우는 시작점을 의미하고 1인 경우는 끝점을 의미한다. 우리는 긴 축을 중심으로 루프를 돌면서 x, y를 구하며 그 점을 선으로 이어서 곡선을 완성할 수 있다.

    위 수식을 기반으로 베지어 곡선 함수를 코드로 구현하면 다음과 같다.

    /* * 베지어 곡선을 그리는 메서드 * start: 시작점 * end: 끝점 * control1: 제어점 1 * control2: 제어점 2 */ UIVectorRenderer.drawCurve(buffer, start, end, control1, control2, ...) { ... //긴 축을 찾아서 그 길이만큼 루프를 돈다. Var sx = absolute(end.x - start.x); Var sy = absolute(end.y - start.y); Var segment = sx > sy ? sx : sy; Point prv = start; //이전 좌표 Point cur; //현재 좌표 for (t = 1; t < segment; t++) { //계수 구하기 Var a = pow((1 - t), 3); Var b = 3 * pow((1 - t), 2) * t; Var c = 3 * pow((1 - t), 2) * pow(t, 2); Var d = pow(t, 3); cur.x = a * start.x + b * control1.x + c * control2.x + d * end.x; cur.y = a * start.y + b * control1.y + c * control2.y + d * end.y; drawLine(buffer, prev, cur, ...); prev = cur; } }

    코드 15: 베지어 곡선 드로잉

    참고로, 연산의 부담을 줄이기 위해 2차 다항식의 베지어 곡선을 사용하는 것도 가능하며 실제 MS의 트루타입 폰트가 이를 이용하는 사례에 해당한다.


    4.7 경로와 폴리곤

    사실상 여기까지 학습을 하였다면, 도형을 그리기 위한 원시적인 요소는 모두 확인한 셈이다. 하지만 추가로 연속된 곡선과 직선 집합을 이용한다면 우리는 보다 길고 복잡한 경로를 표현할 수 있다. 이 경로의 첫 지점과 끝 지점이 연결된 닫힌 형태이면 사실상 폴리곤으로 간주할 수도 있다. 경로를 표현하기 위해서는 기존의 벡터 드로잉 기능을 명령 집합으로써 묶는다면 가능하다. 가령 다음 코드를 보면 이해가 쉽다.

    /* 일반적으로 경로는 이전 명령어를 원점으로 새로운 경로를 추가하여 구축한다. moveTo()를 통해 원점을 이동한다면 새로운 경로가 시작된다. */ UIPath path; //패스 객체 생성 path.moveTo(x, y); //시작 지점을 x, y로 이동 path.lineTo(x2, y2); //x2, y2까지 직선 그리기 path.curveTo(ctrlPt1, ctrlPt2, endPt); //x2, y2 에서 endPt까지 곡선 그리기 /* close()를 호출하면 마지막 지점에서 처음 x, y까지 자동으로 선이 연결되어 닫힌 도형이 완성된다. close()를 호출하지 않으면 직전 위치에서 경로로써 끝난다. */ path.close(); path.show();

    코드 16: 경로 구축 예


    그림 31: 경로를 이용한 도형 완성 (예시)

    경로 클래스에서 제공하는 경로 명령어는 실제로 앞서 살펴본 선, 곡선 등의 메서드와 1:1 매칭되기 때문에 이들의 명령어를 명령어 집합으로 구축한 후, update()나 render() 시점에 명령어를 하나씩 확인하면서 그에 해당하는 도형 드로잉 메서드를 호출하면 된다. 경로 자체가 가지는 고유한 드로잉 알고리즘은 존재하지 않으므로 moveTo(), lineTo(), curveTo(), arcTo() 와 같은 커맨드(Command)를 리스트나 스택으로 구축하고 이들을 디스패치(dispatch)하는 과정이 경로의 핵심에 해당한다.

    /* * 벡터 드로잉 요소 커맨드 집합 */ UIPath extends UIShape { list commands; //커맨드 목록 ... /* * 이하 moveTo(), lineTo()와 같은 커맨드 추가 메서드 */ moveTo(x, y) { ... self.commands.append(UIPathCommandMoveTo(x, y)); } lineTo(x, y) { ... self.commands.append(UIPathCommandLineTo(x, y)); } curveTo(control1, control2, end) { self.commands.append(UIPathCommandCurveTo(ctrl1, ctrl2, end); } close() { self.commands.append(UIPathCommandClose()); } ... /* * 큐잉(queueing)된 커맨드를 하나씩 디스패치하며 드로잉을 수행한다. */ render(buffer, ...) { Point cur = begin = {0, 0}; Point cur = begin; foreach(self.commands. cmd) { switch(cmd.type()) { UIPathCommand.MoveTo: begin = cur = cmd.get(); break; UIPathCommand.LineTo: to = cmd.get(); UIVectorRenderer.drawLine(buffer, cur, to, self.geometry(), ...); cur = to; break; UIPathCommand.CurveTo: ctrl1, ctrl2, end = cmd.get(); UIVectorRenderer.drawCurve(buffer, cur, end, ctrl1, ctrl2, self.geometry()), ...); cur = end; break; UIPathCommand.Close: UIVectorRenderer.drawLine(buffer, cur, begin, self.geometry(), ...); break; ... } } } /* * 벡터 커맨드를 구축하고 디스패치(dispatch)하는 기능을 제공한다. * UIPathCommand는 구현부가 없는 인터페이스에 해당하며 어떤 커맨드를 제공하는지 타입을 * 정의한다. * UIPathCommand를 구현하는 이하 클래스에서는 해당하는 데이터와 get()를 구현한다 */ UIPathCommandCurveTo extends UIPathCommand { Point ctrl1, ctrl2, end; // CurveTo에 해당하는 데이터 constructor(ctrl1, ctrl2, end) { self.ctrl1 = ctrl1; self.ctrl2 = ctrl2; self.end = end; super(UIPathCommand.CurveTo); } /* CurveTo에 해당하는 데이터를 반환한다. */ get() { return self.ctrl1, self.ctrl2, self.end; } }

    코드 17: UIPath 클래스 핵심 구현부

    경로를 이용하면, svg와 같은 벡터 리소스에 기록되어 있는 여러 도형 조합을 하나의 입력 시퀀스로서 구축할 수 있기 때문에 보다 효율적이다. 이는 벡터 리소스 출력물을 하나의 결과물로서 간주할 수 있으며 사용자는 여러 도형을 일일히 구축하거나 조작할 필요가 없으며 어떤 속성 변환을 경로 내에 존재하는 모든 도형에 공통적으로 적용할 수도 있다. 실제로 UI 앱 개발자가 벡터 드로잉 인터페이스를 직접 호출하여 화면을 구성하기 보다는 벡터 리소스로부터 데이터를 읽어오는 것이 구현 관점에서 더 바람직하기 때문에 벡터 리소스 파일을 불러오는 기능을 제공하는 것이 필요하다.


    그림 32: UIPath를 통한 SVG 벡터 드로잉 수행

    이론적으로, Vector 클래스는 SVG 파서를 이용하여 SVG 원시 데이터로부터 유효한 데이터를 가공한 후, 이를 UIPath의 커맨드로 입력할 수 있다. 이는 SVG 뿐만 아니라 다양한 벡터 리소스에 대해서도 동일하게 적용가능하다. UIVector는 사용자가 직접 구축해야할 다양한 벡터 도형을 감추고 하나의 벡터 리소스를 불러올 수 있는 인터페이스 제공함으로써 벡터 결과물을 출력할 수 있도록 도와준다. UIVector 클래스는 파일을 지정하는 path() 메서드를 기본적으로 제공한다.

    한편, 폴리곤을 그리기 위해서는 완성한 경로를 이용하여 색상을 채우는 작업이 필요하다. 폴리곤은 기하학적인 형태를 구성하고 있기 때문에 다른 도형과 같이 수식을 통하여 그리기는 어렵다. 가장 단순하지만 효율적인 방법은 경로의 y축 최상단부터 최하단까지 반복하면서, 가로로 도형을 그려나가는 방법이다. 각 선을 왼쪽에서 오른쪽으로 그리는 과정에서 현재 픽셀의 위치가 도형의 내부인지 외부인지를 판단할 수 있다. 한 선이 시작되면, 해당 선의 y 위치를 포함하는 선들을 우선 간추려낸다. 이 때, y 위치에 해당하는 x 값을 도출할 수 있으며 이들을 정렬하면 우리는 해당 선의 어디서부터 어디까지 색상을 채워야 하는지를 결정할 수 있다. 가장 먼저 교차하는 선은 도형의 외곽선에 해당하기 때문에 여기서 픽셀을 그리기 시작한다. 이 후 x 값을 증가시키다가 두 번째 선과 교차한다면, 이제는 도형을 벗어나기 때문에 픽셀 그리기를 멈추고 이 후 다시 다른 선과 교차한다면 다시 도형에 진입하기 때문에 색상을 채울 수 있다. 이를 반복하면 다음 그림과 같이 도형을 완성할 수 있다.


    그림 33: 충돌탐지를 이용한 기하 도형 드로잉 예시

    그림 33의 경우 앞서 언급한 방식을 이용한다면 높이 y에서의 드로잉을 수행할 영역은 (x1 ~x2) , (x3 ~ x4), (x5 ~x6) 세 부분으로 정할 수 있다. 여기서 문제는, 우리에게 주어진 인수는 y값에 해당하므로 경로의 각 커맨드에서는 y값으로부터 x값을 도출할 수 있는 기능이 필요하다. 이는 앞서 살펴본 수식의 역산에 해당된다.

    다음 코드는 이 방식의 핵심 로직의 뼈대를 보여준다.

    /* * 폴리곤을 그리는 메서드 * commands: UIPathCommand */ UIVectorRenderer.drawPolygon(buffer, commands, ...) { ... //도형의 시작과 끝 y좌표 Var startY, endY; foreach(commands. cmd) { //각 커맨드가 도달할 수 있는 y의 min,max 값을 도출하여 startY, endY를 구한다. ... } for (y = startY; y < endY; y++) { list xList; //a. 현재 y위치에서 충돌하는 선들의 x좌표값을 구한다. xList = findIntersects(commands); //b. a에서 구한 x 좌표값을 오름차순으로 정렬한다. sortAscending(xList); //c. b에서 준비된 x좌표값을 이용하여 드로잉을 수행한다. for (i = 0; i < xList.size() - 1; i+=2) { Var xStart = xList[i]; Var xEnd = xList[i+1]; //(x, y)에 해당하는 픽셀을 그린다. for (x = xStart; x < xEnd; x++) bitmap[y * lineLength + x] = 0xffffffff; } } }

    코드 18: UIPath 클래스 핵심 구현부

    위 코드는 정상적으로 동작은 하지만, x, y의 충돌위치를 구하는 작업은 다소 비효율적인 부분에 해당된다. 도형의 형태가 변하지 않는다고 가정하면 x, y의 충돌위치는 캐싱하는 것이 바람직하며 이후에는 캐싱된 좌표를 UIVectorRenderer에 바로 전달함으로써 별다른 오버헤드 없이 래스터라이징을 수행할 수 있다. 이러한 방식은 이후에 살펴볼 RLE(Run Length Encoding) 알고리즘 구현에 해당되므로 여기서는 설명을 생략하도록 한다.

    여기까지 우리는 도형을 그리는 기본 수식과 구현 로직에 대해서 살펴보았다. 트릭과 최적화보다는 기본 구현 방식에 충실하였기 때문에 성능 개선 부분에 대해서는 고민할 여지가 남아있다. 추가로, 앨리어싱(Anti-Aliasing)은 렌더링 퀄리티에 큰 영향을 미치는 부분에 해당하므로 이 역시, 반드시 개선해야 할 부분에 해당된다. 앨리어싱은 흔히 계단 현상으로 불리며 보간을 통해 픽셀 사이의 중간 픽셀을 생성하여 보다 부드러운 모서리를 생성하는 안티 앨리어싱 기법을 적용하면 이 역시 개선이 가능하다.


    그림 34: 안티앨리어싱을 통한 렌더링 품질 향상 (좌:미적용, 우:적용)

    안티 앨리어싱 기법에 대해서는 이후에 별도로 다루도록 한다.

    잘 알려진 Bresenham의 알고리즘을 이용하면 정수 연산으로도 도형을 완성할 수 있는 최적화된 구현이 가능하다. Bresenham은 화면에 매핑될 픽셀이 정수라는 점에 기안하며 수학적 계산으로부터 픽셀 배치의 패턴을 정규화하여 보다 빠른 픽셀 위치 계산이 가능하다. Bresenham의 알고리즘은 안티 앨리어싱 효과까지 표현이 가능하다. Bresenham의 구현법이 궁금하면 다음 링크를 참조하자. http://members.chello.at/~easyfilter/bresenham.html


    5. 채우기

    5.1 단색 채우기

    도형 그리기를 완성하면 이제 색상을 채울 차례다. 색상을 채우는 메커니즘은 크게 단색과 그래디언트 두 부분으로 나눌 수 있다. 단색은 지정된 하나의 색상을 도형 전체에 적용하기 때문에 특별한 구현이 필요가 없다. 사용자가 지정한 색상을 얻어와서 드로잉 단계에서 적용하기만 하면 된다. 색상을 지정하기 위해 UIFilllSingleColor와 같은 클래스를 제공할 수 있으며 생성한 객체를 UIShape에 전달해 준다면 UIShape는 드로잉 시점에서 UIFillSingleColor로부터 색상 정보를 전달받을 수 있을 것이다.

    //사각형 생성 UIRect shape; shape.geometry(100, 100, 200, 200); shape.show(); //도형을 채울 색상을 지정 UIFillSingleColor fill; fill.color(UIRGBA(100, 100, 255, 255)); //도형에 색상 지정 shape.setFill(fill);

    코드 19: 채우기 사용 예제

    fill.color()에 지정한 색상은 R, G, B, A 각 채널별 값을 직접 지정할 수도 있지만, 범용적으로 사용되는 색상에 대해서는 UIRGBA.Blue, UIRGBA.SkyBlue 등의 이름을 미리 정의하여 편의를 제공할 수도 있다.

    UIRect.render(buffer, ...) { ... //UIRect는 드로잉 사각 영역과 오브젝트 영역을 벡터 렌더러에게 모두 전달한다. UIVectorRenderer.drawRect(buffer, self.rect, self.geometry(), self.fill(), ....); ... } /* * 사각형을 그리는 메서드 * buffer: NativeBuffer * rect: 드로잉할 사각 영역 (타입: Geometry) * clipper: 클립 영역 (타입: Geometry) * fill: 채우기 색상 (타입: UIFill) */ UIVectorRenderer.drawRect(buffer, rect, clipper, fill, ...) { ... /* 여기가 사각형을 그리는 실제 로직! 픽셀 색상은 fill로부터 얻어온다. 보다 나은 성능을 위해서 색상을 루프 밖에서 미리 구할 수도 있다. */ for (y = 0; y < rect.h; ++y) { for (x = 0; x < rect.w; ++x) bitmap[y * lineLength + x] = fill.color(); } }

    코드 20: 채우기 구현 로직

    보다 복잡한 부분은 그래디언트 채우기에 있다. 여러 그리기 툴에서 팔레트를 보았다면 조금 더 이해가 쉬울 것이다. 그래디언트는 연속된 이웃 색깔을 특정 영역 내에 채우는 효과를 보여주는데 여기서는 주로 많이 사용되는 선형과 원형 그래디언트를 구현해 보도록 한다.


    그림 35: 그래디언트 효과 대표적인 예 (Corel Painter)


    5.2 선형 그래디언트

    선형 그래디언트(Linear Gradient 또는 Axial Gradient)는 그래디언트 효과가 펼쳐질 공간의 두 꼭지점 그리고 보간할 다수의 색상의 정보가 필요하다. 일단은 이해를 쉽게 하기 위해 오른쪽 방향의 두 색상을 보간하는 그래디언트를 생각해 보자. 이 경우 그래디언트의 시작점과 끝점(p1, p2) 그리고 두 색상 정보 (C1, C2)가 주어진다. 두 색상을 선형 보간하기 위해서는 시작 지점부터 현재 그릴 픽셀의 위치(p3)에 해당하는 이동지점 0 ~ 1 사이의 정규값(p)을 구한 후, 이 값을 이용하여 색상을 보간한다(C3).


    그림 36: 선형 그래디언트 보간 수식

    문제를 조금 더 응용해 보자. 임의의 방향을 이루는 선형 그래디언트의 경우에는 어떻게 해야 할까? 이 경우, 현재 그리는 픽셀 위치(p4)로부터 정규값(p)을 구하는 것이 핵심이다. 컴퓨터 명령어 처리 방식과 메모리의 구조 상 픽셀값을 기록하는 방향의 우선 순위는 일반적으로 가로축에 해당한다. 이를 고려한다면, 정규값을 구하기 위해서는 현재 픽셀 위치로부터 벡터(V1)을 구한 후, 이를 그래디언트 방향의 방향 벡터(V)에 투영하여 도출한 벡터(V2)로부터 실질적인 위치 p3를 구할 수 있다. 그리고 앞서 살펴본 선형 그래디언트 구현식을 통해 정규값과 색상 값을 구할 수 있다. p3에 위치한 색상 값은 우리가 구하고자 하는 현재 픽셀 위치의 색상 값과 사실상 동일하다.


    그림 37: 선형 그래디언트 구현식

    참고로, cosA 값은 두 벡터의 내적으로부터 추론할 수 있다.


    그림 38: 벡터의 내적

    세 개의 색상을 조합하기 위해서는 앞서 두 색상을 보간하는 방식을 반복적으로 수행하면 된다. 그래디언트를 채울 공간은 동일하게 하나지만, 개입하는 색상의 정보가 늘어나기 때문에 각 색상이 개입할 위치 정보 역시 추가적으로 필요하다. 이러한 개입되는 색상의 위치는 좌표 값으로 지정할 수도 있지만 정규값으로 지정하는 것도 가능하며 이론상 개입할 수 있는 색상의 개수는 제한이 없다.

    //둥근 사각형 도형 생성 UIRoundRect shape; shape.geometry(0, 0, 200, 150); shape.cornerRadius(0.075); shape.show(); //도형을 채울 선형 그래디언트 UIFillLinearGradient fill; //선형 그래디언트의 영역 지정 (시작점, 끝점) fill.region(0, 0, 200, 150); /* 선형 그래디언트의 색상 지정 (위치, RGBA 색상). 각 색상의 위치값을 주의깊게 보자. 위치는 0 ~ 1 사이의 범위로 제한한다. */ fill.addColorStop(0.0, UIRGBA(100, 100, 255, 255)); fill.addColorStop(0.5, UIRGBA(255, 255, 255, 255)); fill.addColorStop(1.0, UIRGBA(255, 255, 0, 255)); //도형에 채우기 지정 shape.fill(fill);

    코드 21:선형 그래디언트 채우기 사용 예제


    그림 39: 세 개의 색상이 개입된 선형 그래디언트 출력 도식화


    그림 40: 그래디언트 방향에 따른 출력 결과


    그림 41: 개입 색상 수에 따른 출력 결과


    세 개 이상의 색상이 그래디언트에 개입하는 경우, 인접한 두 색상끼리 색상 보간을 수행한다. 그림 39의 경우에는 {Stop1, Stop2}과 {Stop2와 Stop3} 이렇게 두 쌍의 선형 보간이 발생한다. 이 때 그래디언트 방향 벡터는 공유하되 정규값의 대상은 그래디언트 전체 공간이 아닌 두 점 사이가 된다. 결과적으로, 선형 그래디언트에서 한 픽셀의 색상을 구하기 위해서는 다음과 같은 로직을 구현한다.


    그림 42: 복수 색상의 선형 그래디언트 구현식

    /* * 사각형을 그리는 메서드 * buffer: NativeBuffer * rect: 드로잉할 사각 영역 (타입: Geometry) * clipper: 클립 영역 (타입: Geometry) * fill: 채우기 색상 (타입: UIFill) */ UIVectorRenderer.drawRect(buffer, rect, clipper, fill, ...) { ... /* 여기가 사각형을 그리는 실제 로직! 픽셀 색상을 구하기 위해 그릴 위치 좌표를 추가로 전달한다. */ for (y = 0; y < rect.h; ++y) { for (x = 0; x < rect.w; ++x) bitmap[y * lineLength + x] = fill.color(x, y); } } /* * 선형 그래디언트 픽셀값 구하는 메서드 */ UIFillLinearGradient.color(x, y) { //예외처리: 개입하는 색상이 한 개이므로 이경우 단일 색상과 동일하다. if (self.colorStop.length() == 1) return self.colorStop[0].color; //그래디언트 방향 벡터 (V) UIVector2 vecDir = UIVector2(self.end - self.start); vecDir.normalize(); // (x, y) / Square Root(x * x + y * y) //구하고자 하는 픽셀의 벡터 (V1) UIVector2 vecCur = UIVector2(x - self.start.x, y - self.start.y); //벡터 투영을 통해, 그래디언트 방향 벡터로부터 V1의 위치를 구한다. Var cosA = (vecDir.x * vecCur.x + vecDir.y * vecCur.y); cosA /= (vecDir.length() * vecCur.length()); vecCur = (vecDir / vecDir.length()) * (vecCur.length() * cosA); Var progress = vecCur.length() / UIVector2(self.end - self.start).normalize(); //루프를 돌며 vecCur가 속한 색상의 세그먼트를 찾아서 최종 색상을 계산한다. for (idx = 0; idx < self.colorStop.length() - 1; idx++) { UIFillColorStop stop1 = self.colorStop[idx]; UIFillColorStop stop2 = self.colorStop[idx + 1]; //vecCur가 속한 색상 세그먼트인지? if (progress < stop1.pos || progress >= stop2.pos) continue; //색상 세그먼트 공간에서의 p 값을 구한다. UIVector2 vecSegment = (vecDir * stop2.pos) - (vecDir * stop1.pos); vecCur -= (vecDir * stop1.pos); Var p = vecCur.length() / vecSegment.length(); //두 색상을 보간한 값을 반환 return {stop1.color.r * p + stop2.color.r * (1 - p), stop1.color.g * p + stop2.color.g * (1 - p), stop1.color.b * p + stop2.color.b * (1 - p), stop1.color.a * p + stop2.color.a * (1 - p)}; } }

    코드 22: 선형 그래디언트 픽셀 계산 로직


    앞서 살펴본 수학적 풀이를 그대로 코드로 작성하고 있으므로 이해하기는 쉽지만 매 픽셀마다 코드 22와 같은 로직을 수행하기엔 다소 부담스러울 수도 있다. 여유가 된다면 캐싱 및 여러 최적화 기법을 검토해 볼 수 있지만 여기서는 원초적인 방법을 이해하는 것으로 일단 만족하도록 하자.


    5.3 원형 그래디언트

    선형 그래디언트를 이해하면 원형 그래디언트는 더 이상 어려운 문제가 아니다. 원형 역시 선형과 마찬가지로 그래디언트 방향 벡터와 현재 픽셀 위치로부터 정규값을 구하기만 하면 된다. 다만 선형과 달리 원형은 원을 중심으로 360도 전 방향으로 그래디언트가 펼쳐지는 차이가 있으며 원형 그래디언트의 방향 벡터는 원형 그래디언트의 초점(focal)로부터 현재 픽셀 위치 좌표의 차를 구해주는 것만으로도 구할 수 있기 때문에 어려운 계산은 딱히 존재하지 않는다. 대신, 매 픽셀마다 독립적인 방향 벡터를 구해야 하며, 초점으로부터 현재 픽셀까지의 거리를 구한 뒤 픽셀이 어느 색상 세그먼트에 속한지를 계산해야 한다. 이 후 선형 그래디언트와 똑같이 해당 세그먼트에 걸쳐 있는 두 색상을 이용하여 보간을 수행한다.


    그림 43: 원형 그래디언트 구현식


    그림 44: 초점 변화에 따른 출력 결과

    //원 도형 생성 UICircle shape; shape.position(200, 200); shape.radius(100); shape.show(); //도형을 채울 원형 그래디언트 UIFillRadialGradient fill; //원형 그래디언트의 초점. 그래디언트 공간 내 (0 ~ 1, 0 ~ 1) 사이의 범위로 제한한다 fill.focal(0.5, 0.5); //원형 그래디언트의 반경 fill.radius(100); /* 원형 그래디언트의 색상 지정 (위치, RGBA 색상). 각 색상의 위치값을 주의깊게 보자. 위치는 0 ~ 1 사이의 범위로 제한하며, 0은 초점, 1은 원형의 외곽 경계점을 가리킨다. */ fill.addColorStop(0.0, UIRGBA(100, 100, 255, 255)); fill.addColorStop(0.5, UIRGBA(255, 255, 255, 255)); fill.addColorStop(1.0, UIRGBA(100, 100, 255, 255)); //도형에 채우기 지정 shape.fill(fill);

    코드 23: 원형 그래디언트 채우기 사용 예제

    /* * 원형 그래디언트 픽셀값 구하는 메서드 */ UIFillRadialGradient.color(x, y) { //예외처리: 개입하는 색상이 한 개이므로 이경우 단일 색상과 동일하다. if (self.colorStop.length() == 1) return self.colorStop[0].color; //구하고자 하는 픽셀의 벡터 UIVector2 vecCur = UIVector2(x - self.focal.x, y - self.focal.y); //방향 벡터 UIVector2 vecDir = vecCur.Normalize(); Var progress = vecCur.length() / self.radius; //루프를 돌며 vecCur가 속한 색상의 세그먼트를 찾아서 최종 색상을 계산한다. for (idx = 0; idx < self.colorStop.length() - 1; idx++) { UIFillColorStop stop1 = self.colorStop[idx]; UIFillColorStop stop2 = self.colorStop[idx + 1]; //vecCur가 속한 색상 세그먼트인지? if (progress < stop1.pos || progress >= stop2.pos) continue; //색상 세그먼트 공간에서의 p 값을 구한다. UIVector2 vecSegment = (vecDir * stop2.pos) - (vecDir * stop1.pos); vecCur -= (vecDir * stop1.pos); Var p = vecCur.length() / vecSegment.length(); //두 색상을 보간한 값을 반환 return {stop1.color.r * p + stop2.color.r * (1 - p), stop1.color.g * p + stop2.color.g * (1 - p), stop1.color.b * p + stop2.color.b * (1 - p), stop1.color.a * p + stop2.color.a * (1 - p)}; } }

    코드 24: 원형 그래디언트 픽셀 계산 로직


    6. 스트로크

    원래 스트로크 용어는 붓터치 기법에서 유래한다. 유화 캔버스에서 붓터치 기법을 통해 얼마나 다양한 효과를 표현할 수 있는지 이해하고 있다면 벡터 그래픽스 엔진에서의 스트로크는 명색이 다소 초라할 수준일 수도 있다. 실제로 일반 벡터 그래픽스 엔진에서는 정형화된 몇 가지 패턴을 제공함으로써 도형의 외곽선을 부각하는 정도로 사용된다. 하지만 어도비 포토샵, 코렐 페인터 등의 전문 드로잉 툴에서 제공하는 실생활 미술에 가까운 다양한 붓터치 효과는 앱 자체 또는 별도의 엔진을 통해 제공하는 것으로 보인다. 붓터치 효과는 특수 페인팅 기술에 가깝기 때문에 UI 벡터 엔진에서 이를 제공하는 것은 그다지 효율적이지 못하다.


    그림 45: 다양한 붓 터치 효과 (Corel Painter)

    UI 벡터 엔진에서 스트로크를 사용하기 위해서는 스트로크 객체를 생성하고 관련 옵션을 지정한 후 그리고자 하는 도형에 생성한 스트로크를 연결해 줌으로써 가능하다. 우선 빠른 이해를 위해 스트로크 사용자 코드를 살펴보자.

    UILine line; //선 생성 line.from(100, 100); //시작점 line.to(150, 150); //끝점 line.show(); UIStroke stroke; //스트로크 객체 생성 stroke.width(10); //스트로크 두께 stroke.join(UIStroke.JoinMiter); //스트로크 연결점 (그림 13 참조) stroke.lineCap(UIStroke.LineCapButt); //스트로크 끝지점 (그림 14 참조) stroke.color(UIRGBA.Black); //스트로크 색상 line.stroke(stroke); //스트로크를 도형에 적용 //이 후 fill 설정 ...

    코드 25: 스트로크 사용 예

    실제로 도형, 채우기 그리고 스트로크 이 세 가지 요소가 벡터 드로잉 구성 요소로서 하나의 컨텍스트를 갖춘다. 카이로와 같은 이미디어트 모드의 벡터 엔진의 경우, 채우기와 스트로크 등의 드로잉 정보를 사용자가 직접 드로잉할 시점의 대상에 맞게 변경해 주면서 컨텍스트를 관리해야 하는 반면, 리테인드 모드를 모방하는 본 예제는 최초 대상 객체에 한번 지정해 주는 것만으로 충분하다.

    스트로크 드로잉은 사실상 선 구현의 연결선 상에 존재한다. 앞서 살펴본 선 드로잉 로직 기반으로 선의 두께, 조인, 대쉬 스타일과 정렬 기능을 확장한다면 스트로크의 기본은 완성된다. 다행히도 이러한 부분은 앞서 살펴본 도형 그리는 기법에서 벗어나지 않기 때문에 여기서는 선의 두께를 표현하는 방법에 좀 더 집중한다.

    스트로크 드로잉의 알고리즘은 Bresenham의 기법을 더 연구한 후 응용할 수 있다. 다른 접근법으로는 도형 그림을 먼저 완성한 후, 도형이 그려진 드로잉 버퍼를 스캔하면서 색상이 존재하는 픽셀과 존재하지 않는 픽셀, 즉 도형의 외곽선을 기준으로 스트로크를 추가로 덧대어 구축할 수도 있다. 하지만 여기서는 앞 절에서 살펴본 각 도형을 그리는 단계에서 도형의 테두리는 스트로크를 적용하여 별도로 처리하는 방식으로 접근해 보고자 한다.

    스트로크를 그리기 위해 선의 두께를 표현하는 방법을 일단 살펴보자. 직선 드로잉에서 살펴본 1픽셀 두께의 직선을 그리는 방법을 이해했다면 그렇게 어렵게 다가오지 않을 수도 있다. 가장 쉬운 접근법은 연속된 좌표로부터 위치를 바꿔가며 동일한 직선을 여러번 그림으로써 두께가 존재하는 직선을 표현하는 방법이다. 예를 들자면, 두께가 2인 선을 그리는 작업은 두께가 1인 선을 두 번 그리는 것과 동일하다. 하지만, 두께가 점점 증가한다면?

    선을 여러겹 출력하여 두께를 표현하는 방식에서는 선의 시작점과 끝점을 잘 계산하는 것이 중요하다. 하지만, 선의 각 꼭지점을 단순 평행 이동 하는 것만으로는 안된다는 점에서 다소 회의적이다. 특히 곡선에서 이러한 제약은 쉽게 드러난다.


    그림 46: 선 두께 표현 결과 (상: 단순 평행 이동, 하: 실제 원하는 결과)


    그림 47: 두께를 표현하기 위한 곡선(점선)의 단순 평행 이동 결과

    그림 46 상단 출력 결과를 보면, 단순 평행 이동으로는 선의 끝지점이 잘린 것처럼 표현될 수 있기 때문에 적절히 균형을 맞추며 수직, 수평으로 번갈아 이동하면서 선을 여러번 출력하는 것이 필요하다는 것을 알 수 있다. 하지만 그림 47을 보면 곡선의 경우 단순 패턴을 통한 위치 계산은 결코 깔끔하게 마무리 되기 어렵다. 심지어, 이러한 접근법은 경우의 수에 따라 로직이 훨씬 더욱 복잡해 진다. 물론, 경우의 수를 모두 보완하면 충분히 기능적으로 완성 가능하지만, 또다른 문제는 선의 두께가 두꺼울 수록 성능은 그다지 효율적이지 않게 된다는 점에 있다. 우리는 가급적 메모리 캐시(Cache) 효율을 활용할 수 있는 픽셀 출력 방식을 고민해야 하고 특히 대쉬 선의 경우, 선은 더이상 선이 아닌 연속된 도형의 집합에 가까워진다.


    그림 48: 두께가 있는 대쉬 선


    결국, 여기서 짚어볼 핵심은 우리는 두께가 존재하는 선을 선이 아닌 또다른 다각형으로 간주할 수 있다는 점이다.

    그림 48는 점선의 한 조각을 확대해서 보여준다. 점선의 각 조각을 하나의 폴리곤으로 간주한다면 어떨까? 조각의 외곽선 정보를 추출할 수 있다면, 네 개의 직선으로 구성된 UIPath를 만들고 이를 폴리곤으로서 도형을 완성할 수 있다. 만약, 이러한 직선이 대각선이 아니라 수직 또는 수평선이라면 점선 조각은 정확히 사각형과 일치하기 때문에 사각형의 네 꼭지점을 찾는 것은 매우 간단하다. 그리고 직사각형(또는 정사각형)을 중심으로 회전 각도를 구할 수 있다면 대각선의 최종 꼭지점의 좌표도 계산을 통해 구할 수 있다.

    우선은 이를 확인하기 위해 대쉬 스타일은 배제한 채, 하나의 직선을 구현해 보자. 코드 25는 넓이 10, 길이가 70.71 (정확히는 70.710678. 두 점의 사이의 거리를 구하는 식을 통해 계산)인 사각형에 해당한다. 이 사각형의 중심을 원점으로서 각 꼭지점의 상대 위치를 구할 수 있으며 실제 그리고자 하는 대각선을 가리키는 벡터를 투영과 내적 수식을 통해 사이각 A도 구할 수 있다. 사이각을 구하면, 회전 행렬을 이용하여 각 꼭지점의 회전 후 위치 좌표를 구한다.


    그림 49: 회전 사각형의 꼭지점 좌표 구하기

    사이각의 경우, 각도가 시계 방향 또는 반시계 방향인지에 대한 정보가 없기 때문에 이 경우 내적하고자 하는 V3 또는 V4 벡터의 x 좌표값이 양수인지 음수인지를 통해 구분하도록 하자. 이와 관련된 구현부는 단순한 수식의 표현에 그치지 않으므로 여기서는 생략한다.

    곡선의 경우를 살펴보자, 원이나 부채꼴의 둘레는 중점으로부터 반지름의 값을 조정함으로써 쉽게 구할 수 있다. 하지만 베지어, 스플라인 곡선의 경우에는 원래 곡선으로부터 부피를 표현하는 곡선의 양변을 구해야 한다. 이는 곡선의 방향 벡터로부터 법선 벡터 또는 90도 회전 벡터를 통해 가능하며 이 법선 벡터에 선의 두께를 반영하면 곡선의 각 변에 위치한 점을 정확히 찾을 수 있다.


    그림 50: 곡선의 방향 벡터로부터 변을 가리키는 법선 벡터 (V1 ~ Vn)

    이 때 양 변에 위치한 점을 구하기 위해 이를 계산하는 시점과 횟수를 결정하기 위한 간격(interval)을 결정할 필요가 있는데 간격이 좁을수록 두 변의 정확도는 증가하지만 그만큼 계산양도 많아진다. 하지만, 일반적인 환경이라면 매 1픽셀마다 변의 위치를 계산해도 별 무리가 없다. 다만 곡선의 진행 방향에 있어서 방향 벡터가 변하지 않은 경우에는 이러한 계산을 생략하는 것도 최적화에 도움이 된다. 계산을 통해 구한 각 변의 점을 연결하면 곡선의 테두리는 완성된다.

    /* 곡선의 정확한 표현을 위해서는 interval를 더 낮게 설정한다. 여기서는 0.01로 가정.*/ for (i = 0; i < 1; i += interval) { /* 베지어 곡선 공식(그림 30 참조)을 이용하여 곡선의 전체 구간 0 - 1 중 인자로 전달한 구간의 좌표 값을 반환 받는다. */ Point p1 = bezierCurvePos(i); Point p2 = bezierCurvePos(i + interval); /* 두 점으로부터 방향 벡터를 계산한다. */ Vector dir = p2 - p1; dir.normalize(); /* 방향 벡터을 90회전하여 법선 벡터를 구한다. */ v * mRotate; v.normalize(); /* 법선 벡터를 선 두께의 절반만큼 증가시키면 변에 위치한 점을 구할 수 있다. 이 벡터를 뒤집으면 정확히 반대편 변을 가리킨다. */ v *= (lineWidth * 0.5); /* 현재 곡선의 위치에서 구한 양변 점의 위치. v1, v2를 이전 루프의 v1, v2와 각각 연결해 나가면 두께가 존재하는 곡선이 완성된다. */ v1 = p1 + v; v2 = p1 - v1; }

    코드 26: 곡선의 변을 찾기 위한 주요 로직

    대쉬 스타일이 적용된 선의 경우, 선의 연속된 지점의 끝 즉, 생략 구간을 파악하는 것이 중요한데 선 전체 길이를 0 ~ 1 사이로 정규화시키면 계산이 보다 간단해진다.

    대쉬 스타일은 스타일을 미리 규격화하는 대신 사용자가 임의의 패턴을 자유롭게 지정할 수 있도록 인터페이스를 제공하는 것이 더욱 유연하다.

    /* 대쉬 패턴은 배열을 통해 사용자가 임의로 만들 수 있다. */ dashPattern = {50.0, //선 구간 10.0, //생략 구간 10.0, //선 구간 10.0}; //생략 구간 /* 스트로크에 적용, 4는 dashPattern 요소 길이 */ stroke.dash(dashPattern, 4);

    코드 27: 스트로크 대쉬 스타일 적용 예


    그림 51: 코드 27 출력 결과

    대쉬 선을 구현하기 위한 엔진 코드는 여기서는 생략한다. 핵심은 대쉬 패턴으로 지정한 값의 단위가 픽셀이라고 가정하고 전체 길이 중에서 어느 구간이 선이고 어느 구간이 생략 구간인지를 계산하는 것이다. 이를 계산한 후, 앞서 살펴본 개념에 적용하여 직선과 곡선의 대쉬 패턴을 구현하는 것이 가능하다.

    경로를 구축하는 과정에서 스트로크의 연결지점을 표현하는 방법으로 조인 속성을 지정할 수 있다. 그림 3.13을 확인해 보면 대표적인 조인 속성으로 miter, round, bevel이 있으며 이러한 특성을 시각적으로 표현하기 위해서는 스트로크 두 선의 끝 모서리를 연결하는 방식에 집중해야 한다.


    그림 52: 조인 특성에 따른 스트로크 연결 결과

    miter는 두 선이 교차하는 지점까지 선을 확장한다. 연결된 두 스트로크 선의 방정식을 이용하여 두 선이 만나는 지점을 찾은 뒤, 그 구간까지 선을 확장하여 miter를 위한 도형 조각을 완성해야 한다. 그림 3.52 miter의 비어있는 영역이 추가로 그려야 할 도형에 해당하며 스트로크를 우선 그린 후, miter를 위한 폴리곤을 추가로 그린다.

    round의 경우, 두 선의 끝 외각 꼭지점을 토대로 부채꼴을 추가로 완성하면 된다. 앞 절에서 살펴본 부채꼴 그리는 로직을 그래도 활용가능하다. 원래 두 선이 끝나는 점을 원의 중심으로 하여 선의 넓이의 절반을 원의 반지름으로 지정한다. 원의 중심으로부터 두 꼭지점을 가리키는 벡터를 통해 부채꼴이 그려야할 각도를 계산한다. 스트로크를 그린 후에는 round를 위한 부채꼴을 추가로 그린다.

    마지막으로 bevel은 두 선 끝 외각 꼭지점을 직선으로 그래도 연결해 준다. miter와 마찬가지로 bevel을 위한 도형 조각을 추가로 완성해야 하는데 기존 폴리곤 로직을 그래도 활용할 수 있다.

    조인의 구현 방식을 이해한다면 라인캡 역시 크게 다를바 없기 때문에 여기서는 별도로 다루지 않도록 한다.

    스트로크는 도형을 그는 과정에서 수행가능하다. 여기서는 도형의 합병에 대해서 언급하진 않았지만 만약 여러 도형간 합병(merge)이 발생하면 스트로크의 경로도 달라질 수 있다. 각 도형마다 스트로크를 그리는대신 합병으로 완성된 최후의 폴리곤을 대상으로 스트로크가 완성되어야 한다. 반대의 경우도 마찬가지이다. 이러한 도형의 조작은 오브젝트의 update() 시점에 미리 처리하고 가공된 정보를 유지함으로써 렌더링의 성능을 높일 수 있다.

    마지막으로 다음은 코드 17를 보강하여 벡터 드로잉에서 스트로크를 추가로 그리는 로직을 의사 코드 수준으로 보여준다. 대표적으로 UIPath에 적용해 보자.

    UIStroke.update(UIShape shape) { List pathList; /* stroke가 적용된 shape의 타입(사각형, 원, 곡선, 경로 등)을 토대로 stroke 폴리곤을 구축한다. 폴리곤 정보를 UIPathCommand로 구축하되, 앞서 살펴본 개념을 기반으로 대쉬, 조인, 라인캡 등의 특성을 반영하고 최종적인 UIPathCommand를 구축하여 반환한다. */ ... return pathList; } UIPath.update() { /* 도형(UIPath)가 업데이트되는 시점에 stroke로부터 그려야할 스트로크 폴리곤 커맨드를 구축한다. 이 커맨드 목록은 render()에서 UIVectorRenderer로 전달되며, 또다른 폴리곤으로서 도형 위에 추가로 그리기 위한 리소스로 활용된다. 전 프레임과 비교하여 UIPath 또는 UIStroke의 정보가 변경되지 않았다면, strokeCmdList도 다시 갱신할 필요가 없다. */ self.strokeCmdList = self.stroke.update(this); } UIPath.render(buffer, ...) { //UIPath 도형 자체를 우선 그린다. drawPolygon()은 코드 18를 참고하자. UIVectorRenderer.drawPolygon(buffer, self.commands, clipper, fill, ...); /* strokesCmdList로부터 스트로크를 그리기 위한 벡터 커맨드 리스트를 얻어와 드로잉을 수행하며 drawPolygon() 메서드를 그대로 이용한다. */ foreach(self.strokeCmdList, strkCmds) self.drawPolygon(buffer, strkCmds, clipper, self.stroke.fill(), ...); }

    코드 28: 스트로크를 추가한 드로잉 로직


    7. RLE 최적화

    RLE(Run-Length Encoding)은 데이터 압축 기법 중 하나로 연속된 동일 데이터를 하나의 데이터와 그 횟수로만 기입한다. 예를 들면, ‘aaaabbbbbccccddd’ 라는 14개의 연속된 데이터를 RLE 방식으로 압축하면 ‘a4b5c4d3’와 같이 변환되며, 이는 8개의 데이터로서 원본 대비 절반가량의 데이터 크기를 갖는다. RLE은 압축 방식 중에서도 알고리즘이 단순하여 적용하기도 쉽고 비손실이라는 특성을 가지고 있기 때문에 원본 훼손이 없는 장점을 지니고 있다. 이 압축 방식은 극단적으로 데이터가 무한히 반복될 경우 최대 99퍼센트의 압축률을 보여줄 수도 있지만, 데이터가 세 번 이상 반복되는 경우가 없다면 실질적으로 압축의 효과가 없거나 반대로 데이터가 더 커질 수도 있기 때문에 사용이 다소 제약적이다. 특히 원본 데이터에 패턴이 없고 불특정한 경우에는 RLE는 사용하기 적절하지 않다.

    벡터 그래픽스에서 RLE는 사용성이 다분하다. 최근 UI에서 벡터 그래픽스로 출력한 이미지들은 텍스처가 단순하여 동일한 색상의 픽셀이 반복되는 경향이 있는데 이러한 특성은 RLE 압축과 잘 부합한다.


    그림 53: RLE를 이용한 이미지 압축 

    그림 53은 가로 620 크기 벡터 이미지에서 특정 라인의 픽셀 데이터를 RLE를 이용하여 압축한 결과를 도식화한다. 1픽셀의 크기가 4바이트 인 경우 620 * 4 = 2480 바이트를 요구하는 반면 RLE 압축을 할 경우 12 * 4 = 48바이트로 감소하며 이는 0.01% 크기로 메모리를 절약한 셈이다.

    그럼 우리가 앞서 이야기한 벡터 렌더링 엔진에서 RLE을 어떻게 활용할 수 있을까? 벡터 UI의 경우 복잡한 계산을 통한 실시간 이미지를 생성한다는 점에서 계산량이 많을 수 있다. 만약, 한번 계산한 도형 이미지를 RLE로 저장하여 재활용한다면, 동일한 도형을 다시 그리는 과정에서 계산을 건너뛸 수 있으므로 성능 향상에 도움이 된다. 물론 생성한 벡터 비트맵 이미지를 그대로 캐싱할 수도 있다. 이 경우에는 성능상 얻는 이점도 있지만 그만큼 많은 메모리를 필요로 하기 때문에 다소 절충점이 필요하다. 특히 앱 화면에 벡터 UI 요소가 많다면 메모리 사용량은 크게 증가할 수 있다.

    사실 벡터 렌더링 엔진에서 RLE가 절대 해답은 아니다. 특히 그래디언트 채우기를 적용하는 경우라면, 도형의 매 픽셀마다 다른 데이터 정보가 필요하기 때문에 RLE는 적절하지가 않다. 대신 여기서는 벡터 엔진의 하나의 최적화 방법으로서 RLE을 적용할 수 있음을 보여주며, 그래디언트 채우기가 아닌 단색 도형의 경우, 또는 그래디언트 채우기 일지라도 색상 정보를 제외한 도형의 형태 정보만 저장함으로써 이 RLE 기법은 유효하다. 실제로 폰트의 글리프를 생성하는 프리타입(FreeType) 벡터 엔진에서도 RLE를 적용한 벡터 글리프를 드로잉하고 있으며 경우에 따라 RLE를 다른 최적화 알고리즘과 함께 잘 버물려 응용한다면 더욱 막강한 효과를 보여줄 수 있을 것이다.

    그림 53의 벡터 이미지의 경우 사실 하나의 이미지를 위해 크게 세 부분의 벡터 요소(배경, 구름, 태양. 설명을 단순화 하기 위해 태양 주위의 광선은 생략한다. )가 존재함을 알 수 있다. 달리 말하면, 배경을 위한 하나의 사각형과 두 개의 폴리곤을 이용하는데, 각 도형마다 RLE를 적용할 수 있다. (물론 사각형의 경우에는 도형 계산이 원래 단순함으로 RLE 적용을 생략하는 것이 더 효율적이다.)

    여기서는 하나의 예로서 구름을 대상으로 살펴보자. 색상 정보를 제외하고 도형의 지오메트리 자체를 기록하기 위해 매 라인마다 도형의 시작점과 끝점 정보가 필요하다. 하나의 라인 정보를 RLESpan으로서 정의하면 하나의 RLESpan은 시작점(x)와 그 길이 정보(length)를 보유하게 된다. 이러한 RLESpan은 y축으로 연속적이기 때문에 y 정보는 필요하지 않으며 대신 RLESpan을 배열을 보유하는 RLEPolygon이 y와 y축 길이 정보를 보유한다. 단색이라는 가정 하에, RLEPolygon은 해당 폴리곤의 색상 정보도 추가로 보유할 수도 있다.


    그림 53: RLE를 이용한 하나의 도형 정보 구축


    Span 기반으로 도형의 이미지 정보를 구축하면 실제 도형이 위치 정보만을 기록하기 때문에 일반 이미지와 달리 불필요한 여백 정보(그림 54의 회색 영역)를 따로 기록할 필요가 없다. 여기서는 이해를 위해 생략하였지만, 하나의 Span이 기록해야할 도형 영역은 반드시 하나라고 가정할 수 없기 때문에 RLESpan은 가변 개수의 영역을 기록할 수 있어야 한다.

    마지막으로, RLE를 벡터 렌더링 엔진에 적용하기 위해서 데이터를 인코딩하고 디코딩하는 핵심 로직을 살펴보도록 하자.

    /* * 기존 폴리곤을 그리는 메서드를 수정하여 RLE를 적용한다. * RLEData를 구축하는 것이 핵심이다. */ UIPath.update() { /* 도형에 변화가 없다면 update()를 수행할 필요가 없다. 불필요한 계산을 막는다. UIPath의 command에 변화가 있었다면 changed의 값은 true일 것이다. */ if (self.changed == false) return; /* 1. UIPath 도형 */ //도형의 시작과 끝 y좌표 Var startY, endY; foreach(commands. cmd) { //각 커맨드가 도달할 수 있는 y의 min,max 값을 도출하여 startY, endY를 구한다. ... } //RLE Data 구축 RLEData rleData; rleData.y = startY; rleData.length = endY - startY; //RLESpan을 구축한다. for (y = startY, i = 0; y < endY; y++, i++) { list xList; //a. 현재 y위치에서 충돌하는 선들의 x좌표값을 구한다. xList = findIntersects(commands); //b. a에서 구한 x 좌표값을 오름차순으로 정렬한다. sortAscending(xList); //c. b에서 준비된 x좌표값을 이용하여 Span을 구축한다. for (i = 0; i < xList.size() - 1; i+=2) { RLESpan span; span.x = xList[i]; span.length = xList[i + 1] - xList[i]; rleData.spans[i].append(span); } } self.rleShapeData = rleData; /* 2. 스트로크 커맨드 */ //동일한 방법으로 스트로크 RLE 데이터를 추가로 구축한다. ... self.rleStrokeData = rleData; self.changed = false; } /* * update()에서 구축한 RLEData를 UIVectorRenderer로 전달한다. */ UIPath.render(buffer, ...) { //UIPath 도형 자체를 우선 그린다. UIVectorRenderer.drawRLE(buffer, self.rleShapeData, clipper, fill, ...); //다음으로 UIPath의 스트로크를 그린다. UIVectorRenderer.drawRLE(buffer, self.rleStrokeData, clipper, self.strokefill(), ...); } /* * RLE 데이터로부터 드로잉을 수행하는 메서드 * buffer: NativeBuffer * rleData: RLE 데이터 (타입: RLEData) * clipper: 클립 영역 (타입: Geometry) * fill: 채우기 색상 (타입: UIFill) */ UIVectorRenderer.drawRLE(buffer, rleData, clipper, fill, ...) { Var startY = rleData.y; Var endY = startY + rleData.length; //y 영역에 대해서 클리핑을 수행 ... for (y = startY; y < endY; y++) { //Span을 가져와 드로잉을 수행한다. foreach (rleData.spans[y - rleData.y], span) { Var startX = span.x; Var endX = startX + span.length; //x 영역에 대해서 클리핑을 수행 ... //(x, y)에 해당하는 픽셀을 그린다. for (x = xStart; x < xEnd; x++) bitmap[y * lineLength + x] = fill.color(x, y); } } }

    코드 29: RLE을 적용한 UIPath 드로잉


    8. 정리하기

    벡터 그래픽스는 UI 엔진의 기본 핵심 기능으로서 UI를 구성하는 기본 도형부터 기하학적인 다각형을 출력하는 메커니즘을 수행한다. 이러한 도형을 조합하면 다양한 형태의 UI를 생성할 수 있는데, 보다 화려한 효과를 제공하기 위해 도형의 색상을 채우는 기능은 물론 선과 도형의 외곽선 스타일을 지정하는 기능도 제공한다.

    이번 장에서는 벡터 그래픽스의 역사와 컨셉을 살펴보았고 그동안 산업 표준으로 사용된 SVG 포맷의 스펙을 간략히 살펴보았다. 비록 SVG는 여기서 다룬 내용보다 더 광범위한 스펙을 제공하지만, 사각형, 원, 선, 곡선 등 주요 도형을 출력하기 위한 수식과 원리를 이해하였고 이들을 직접 코드로 옮겨봄으로써 SVG는 물론, 벡터 렌더링을 구현할 수 있는 기반을 다졌을 것으로 생각한다. 그뿐만 아니라, 도형의 색상을 채우기 위한 단일 색상 채우기, 선형, 원형 그래디언트 채우기 기법을 살펴보았고 스트로크를 통해 선의 스타일 및 도형의 외곽선을 출력하는 방식도 살펴보았다. 마지막으로, 보다 나은 벡터 렌더링 성능을 위해 RLE 압축 기법을 이용하여 한번 생성한 도형 정보는 캐싱하고 이를 재활용하여 출력하는 기법도 배웠다.



    Tool: Corel Painter, Wacom INTUOS ART