벡터 그래픽스(Vector Graphics)는 수식을 이용하여 도형 이미지를 생성하는 기술이다. 화소 데이터가 저장된 이미지와 달리 도형 이미지를 동적으로 생성하기 때문에 해상도에 따른 화질 저하가 발생하지 않고 데이터 크기가 작은 특장점을 갖는다. 물론 일반 이미지 포맷 대비 구현이 복잡하고 생성하고자 하는 도형 이미지가 복잡할수록 더 많은 연산을 요구하지만, 요즘은 벡터를 표현할 수 있는 다양한 파일 포맷이 존재하고 최근 프로세서의 성능이 고수준으로 향상하였기 때문에 일반적으로는 벡터 그래픽스를 실시간으로 처리하는 작업은 큰 문제가 없다고 봐도 무방하다. 한편, 벡터 그래픽스는 근본적으로 텍스처 질감을 표현할 수 없기 때문에 이미지와 함께 벡터 그래픽스를 활용하면 훌륭한 UI 결과물을 만들어 낼 수 있다. 디자인 컨셉 상 복잡하고 화려한 것보다는 단조롭지만 정결한 디자인을 선호한다면 UI 출력에 있어서 벡터 그래픽스가 좋은 대안이 될 것이다. 


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



1. 학습 목표

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


  • 벡터 그래픽스 역사를 살펴본다.
  • SVG 파일 포맷을 이용하여 벡터 그래픽스를 표현하는 방법을 이해한다.
  • 도형을 그리는 주요 수식과 알고리즘을 이해한다.
  • 단색, 그래디언트 색상 채우기, 스트로크 기능과 구현 방법을 살펴본다.
  • RLE 알고리즘을 이용한 데이터 최적화 기법을 살펴본다.


  • 2. 벡터 그래픽스 역사

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

    그림 11950년대 항공 경로 레이더 시스템 (FAA News)

    이후, 벡터 그래픽스는 거듭 발전하여 드로잉 명령어 목록을 통해 순차적으로 렌더링을 수행하는 방식으로 전형화되었다. 이러한 방식은 Digital 사의 GT40 기계에 적용되었고 Vectrex 벡터 그래픽스 시스템을 탑재한 가정용 게임 시스템은 물론, 스페이스 워즈, 애스터로이드(Asteroids) 등의 게임에서도 벡터 그래픽스가 적용되었다. 

    한편, 90년대 초 필자가 즐겼던 게임 어나더 월드(Another World)는 화면 전체가 벡터 그래픽스를 통해 실시간으로 출력된 벡터 그래픽스의 대표 게임 중 하나였다. 당시 어나더 월드는 멋진 배경과 부드러운 애니메이션으로 게임 개발자의 관심을 모았다. 특히, 당시에는 하드웨어 제약이 다소 컸기 때문에 어나더 월드는 온전히 벡터 그래픽스만으로도 멋진 게임 비주얼을 만들어 낸 성공적인 게임 사례 중 하나였다.

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

    오늘날 벡터 그래픽스는 3D가 아닌 2D를 지칭하는 기술로 통용된다. 엄밀히 말하자면 3D 그래픽스 또한 벡터 그래픽스의 연장선에 존재하나 3D 그래픽스는 벡터 그래픽스보다 1차원을 더 표현하는 것 이상으로 여러 고급 렌더링 기술을 추가한다. 90년대 컴퓨터 게임 대중화와 함께 3D 그래픽스가 급속도로 확산하면서 3D 전용 그래픽스 칩셋이 거듭 발전하였고 칩셋 제조사와 소프트웨어 산업 업계의 표준화 작업이 진행되면서 3D 그래픽스는 렌더링 파이프라인 및 셰이더 등 3D 특화 기술을 정립하여 이제는 벡터 그래픽스와는 완전히 다른 기술로 간주되고 있다.

    벡터 그래픽스가 2D 영역에 자리함으로써 벡터 그래픽스는 UI뿐만 아니라 일러스트 같은 산업 디자인에서도 유용하게 사용된다. 이 중 그래프 및 차트는 사용자 데이터를 기반으로 UI 이미지를 실시간으로 생성하기 때문에 벡터 그래픽스가 적절하다.

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


    3. SVG (Scalable Vector Graphics)

    3.1 SVG 개요


    1999년 월드 와이드 웹 컨소시엄(W3C)은 웹 페이지에서 벡터 그래픽스를 출력하기 위해 SVG(Scalable Vector Graphics) 포맷을 정의하였다. W3C는 당시의 벡터 그래픽스 사양을 토대로 SVG 포맷을 정의했기 때문에 SVG 포맷을 이해하면 벡터 그래픽스를 이해하는 데 큰 도움이 된다. SVG는 XML 기반 구조화된 텍스트 형식 데이터이므로 가독성은 물론 필요에 따라 데이터를 바이너리로 압축할 수도 있다. 하지만 실질적인 내용은 벡터 드로잉을 위한 수식 인자 집합에 가까워서 전문 디자인 도구를 다루지 않고서는 SVG를 직접 작성하기란 쉽지 않다. SVG를 제작할 수 있는 대표 벡터 디자인 도구로는 어도비(Adobe)의 일러스트레이터(Illustrator)와 그놈(Gnome) 프로젝트의 잉크스케이프(Inkscape)가 있으며 현재 모든 인터넷 브라우저에서는 SVG 출력 기능을 지원한다. 

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


    SVG는 기본적으로 벡터를 표현하기 위한 도형 및 속성을 정의한다. 기술 가능한 도형으로는 사각형, 원, 선, 폴리곤 그리고 경로(Path)가 있으며 그 외로 이미지와 텍스트도 기술할 수 있다. 또한 SVG는 도형 효과를 위한 여러 속성을 정의하며 이러한 속성으로는 스트로크(Stroke), 필터(Filter), 그림자, 단색 및 그래디언트(Gradient) 채우기 등이 있다. 기본적으로 SVG는 출력 요소와 속성을 같이 기술함으로써 원하는 이미지를 표현한다. 


    SVG는 벡터 요소를 기술하는 방법을 정의할 뿐 실제 벡터 요소를 출력하는 기능을 제공하지 않는다. 대신 SVG 파일로부터 실제 렌더링 결과물 출력해주는 여러 오픈소스 프로젝트가 있으며 그 중 대표적인 프로젝트로 librsvg가 있다. 따라서 플랫폼 독자적인 SVG 출력 기능을 제공하지 않다면 librsvg 라이브러리를 활용할 수 있다. 그리고 SVG 보다 범용적인 목적의 벡터 드로잉 엔진으로 카이로(Cairo) 오픈소스 프로젝트가 있으니 참고하길 바란다. SVG에 대해 더욱 자세한 기능과 명세서를 살펴보고 싶다면 SVG 공식 튜토리얼 사이트( www.w3schools.com/graphics/svg_intro.asp)를 참고한다.



    3.2 SVG 예제


    • 그래디언트 채우기와 텍스트
    다음은 그래디언트 채우기를 적용한 타원과 단색 텍스트를 기술하는 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>
    
    코드 1SVG 그래디언트 채우기 및 텍스트 예제

    그림 5코드 1 출력 결과물

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

    <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>
    
    코드 2SVG 스트로크 예제

    그림 6코드 2 출력 결과물

    • 다각형
    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: 코드 3 출력 결과물

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

    <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>
    
    코드 4SVG 경로 예제

    그림 8코드 4 출력 결과물


    3.3 SVG 효과


    SVG를 통해 복잡한 이미지도 표현할 수 있다. 다음 그림은 이를 증명한다.

    그림 9: SVG로 표현한 타이거 (Ghostscript Tiger)

    그림 9는 SVG 대표 리소스 중 하나로서 타이거 이미지를 표현한다. 만약 동일 이미지를 PNG 파일로 저장한다면 512x512 해상도 기준 약 156KB 저장 공간이 필요하다. 더욱더 높은 1024x1024 해상도로 저장한다면 약 356KB 저장 공간이 필요하다. 다양한 해상도의 기기를 위해 스케일러블(Scalable) UI를 지원한다면 해상도 별 이미지를 여러 벌 갖추고 있어야 하므로 실질적으로 사용하는 PNG 저장 공간은 더욱 커질 것이다. 반면 그림 9의 SVG는 해상도에 상관없이 6KB의 저장 공간만 있어도 충분하다. 

    이처럼 SVG는 다양한 해상도의 기기에서 단일 리소스만으로 최상의 품질을 보여줄 수 있는 장점은 물론 파일 크기 측면에서도 훨씬 더 효율적이다. 이러한 SVG 장점은 앞으로 살펴볼 벡터 그래픽스의 고유 특성을 대변한다.


    4. 벡터 기능 정의

    SVG 파일로부터 데이터를 파싱(Parsing)하면 디자인이 어떠한 벡터 요소로 구성되어 있는지 알 수 있을 뿐 이를 출력하는 작업은 별개의 문제이다. 따라서 벡터 렌더링 엔진에서는 SVG 요구사항을 만족할 수 있는 벡터 드로잉 기능을 갖추는 것이 필요하다. UI 엔진에서 벡터 드로잉 기능을 갖추면 SVG뿐만 아니라 여러 벡터 리소스도 직접 출력할 수 있다.

    벡터 렌더링 기능을 구축하기에 앞서 기능 정의를 해보자. 앞 절에서 언급했지만, 오늘날 벡터 그래픽스 사양은 정형화되었으므로 대표 포맷인 SVG를 기반으로 벡터 그래픽스 기능을 분류해 보자. 벡터 그래픽스 사양은 크게 세 부분으로 나눌 수 있다.


    4.1 도형

    도형(Shape)은 점(Point), 선(Line), 사각형(Rectangle), 원(Circle) 등 여러 형태의 단순 도형부터 곡선(Curve), 경로(Path),  폴리곤(Polygon) 등 복잡한 기하 도형까지 정의할 수 있다. 사실 선, 곡선 그리고 폴리곤을 이용하면 원은 물론 다각형 모두 표현할 수 있지만, 원과 사각형과 같은 범용적인 도형은 개별 인터페이스로 제공하는 것이 사용자 편의성 측면에서 좋다.

    그림 10: 벡터 도형


    4.2 채우기

    채우기(Fill)는 도형 색상을 지정하며 기본적으로 단색(Solid)과 그래디언트(Gradient) 효과를 정의한다. 단색은 도형을 단일 색상으로 칠한다. 그래디언트는 두 가지 이상 색상을 지정하여 도형 색상을 칠한다. 그래디언트에서 지정한 다수의 색상 사이는 보간(Interpolation)법을 통해 색상을 결정할 수 있다. 그래디언트 채우기 방향은 여러 선택 사항을 가지는데 기본적으로 선형(Linear) 수직과 수평이 있으며 원형(Radial)과 앵귤러(Angular) 방식을 제공할 수도 있다. 일반 UI에서는 선형 수직, 수평 그리고 원형 그래디언트를 많이 활용한다. 

    채우기 방법으로 텍스처(Texture)도 고려할 수 있다. 텍스처는 특정 이미지뿐만 아니라 무늬 및 패턴을 통해 도형 질감을 표현한다. 이미지 리소스는 해상도 대비 화질 저하가 없는 벡터 그래픽스의 장점에 반하기 때문에 텍스처 이용 시 이점을 유념해야 한다.

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

    그림 12: 텍스처 채우기


    4.3 스트로크

    스트로크(Stroke)는 선 또는 도형의 외곽선을 표현한다. 도형의 채우기 색상과 별개로 스트로크는 자체 색상과 함께 넓이를 정의한다. 추가로 대쉬(Dash) 속성을 통해 실선 내지 점선을 표현하는데 패턴은 고정이거나 가변적이다. 가변적인 경우 사용자가 패턴값을 직접 입력한다. 예를 들어 사용자가 {4, 3} 패턴을 입력한 경우 4픽셀 선과 3픽셀 여백으로 구성된 점선을 가리킬 수 있다. {5, 2, 3, 1} 패턴을 입력했다면  5픽셀 선과 2픽셀 여백 이어서 3픽셀 선과 1픽셀 여백으로 구성된 점선을 가리킨다.

    그림 13: 스트로크 대쉬

    스트로크는 선의 연결점(Join)과 끝점(Linecap) 속성도 정의할 수 있다. 대표적인 연결점 속성은 마이터(miter), 라운드(round), 베벨(bevel)이 있고 끝점 속성은 버트(butt), 라운드(round), 스퀘어(square)가 있다.

    그림 14: 스트로크 조인

    그림 15 스트로크 라인캡


    4.4 클래스 설계

    앞서 살펴본 기능을 토대로 우리는 벡터 기능을 클래스 다이어그램으로 정의한다. 본 절에서 소개하는 클래스 다이어그램은 이후에 소개하는 벡터 기능 구현 방침을 보여준다.


    그림 16: UIShape을 확장한 도형 클래스


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

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


    UIShape는 UIFill과 UIStroke로부터 정보를 전달받고 이와 관련된 작업을 수행한다. 벡터 그래픽스 사양에 따라 채우기와 스트로크는 기능을 독립적으로 행사할 수 없으며 반드시 UIShape을 통해 동작한다. 채우기는 단일 및 선형, 원형 그래디언트 색상 채우기로 세분화하므로  UIFill은 인터페이스로서 연결고리 역할만 수행하고 실제 구현은 이를 확장한 파생 클래스에서 수행한다. 그래디언트의 복수 색상을 지정하기 위해 UIFillColor를 도입한다.

    그림 18단일 및 그래디언트 색상 채우기를 위한 클래스 정의


    5. 도형 그리기

    도형 그리기에 앞서 미리 언급하자면, 실용 엔진에서는 고성능 벡터 래스터 작업을 위해 도형 수식 연산 과정을 프로그래밍적 기교, 알고리즘 트릭으로 단순화하여 이를 최적화한다. 그뿐만 아니라 병렬화 및 하드웨어 가속을 활용하기 위해 렌더링 알고리즘을 설계한다. 일반적으로 고급 벡터 래스터라이저는 GPU 가속을 활용하지만, CPU 연산에서는 SIMD(Single Instruction Multiple Data) 벡터 연산을 활용할 수도 있다. 하지만 여기서는 여러분의 이해를 돕기 위해 기본 정석을 토대로 도형 그리기를 완성한다. 어렵고 난해한 대수학이 아닌 고등 수준의 수학 지식으로도 잘 동작하는 도형 드로잉 알고리즘을 완성할 수 있다.


    우선 4절에서 살펴본 벡터 기능을 토대로 벡터 렌더러(Vector Renderer) 구현부를 살펴본다. 벡터 렌더러는 캔버스에 추가된 도형 객체로부터 채우기 및 스트로크 정보를 전달받고 래스터 작업을 수행한다. 캔버스 엔진은 객체 렌더링 시 캔버스 버퍼인 NativeBuffer를 전달하고 벡터 오브젝트는 이를 벡터 렌더러에게 전달하여 정보를 공유한다. 벡터 렌더러는 NativeBuffer를 대상으로 실제 픽셀 데이터를 기록하는 래스터 작업을 수행할 수 있다. 


    UICanvas.render():
        if(self.dirty == false) return;
    
        /* 활성 객체를 대상으로 출력 버퍼에 UI를 그리는 작업을 수행. 이 때 각 개체가 그릴
           대상 버퍼와 필요 정보를 인자로 전달한다. self.buffer는 코드 2.7 참고 */
        foreach(self.activeObjs, obj)
            obj.render(self.buffer, ...);
        ...
    
    /* UIObject의 파생 클래스인 UIRect는 UIVectorRenderer로 NativeBuffer를 전달하고
       UIVectorRenderer는 NativeBuffer를 대상으로 래스터 작업을 수행하여 벡터 이미지를
       완성한다. */
    UIRect.render(buffer, ...):
        ...
        UIVectorRenderer.drawRect(buffer, ...);
        ...
    
    코드 5UIVectorRenderer 호출 과정


    5.1 사각형

    도형 중에서도 가장 기본인 사각형부터 접근해 보자. 사각형은 대표적으로 여백을 채우는 기능으로 활용할 수 있다. 사각형은 위치와 크기 정보로 구성할 수 있는데 사각형을 그리기 위해서는 위치를 기준으로 사각형의 크기만큼 반복문을 수행하며 색상 데이터를 채우는 작업을 수행한다.

    /*
     * 사각형 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p rect: Geometry
    */
    UIVectorRenderer.drawRect(buffer, rect, ...):
        Pixel bitmap[] = buffer.map();               //버퍼 메모리 접근
        scanLineSize = buffer.scanlineSize();        //bitmap 버퍼 가로 길이
    
        //bitmap에서 사각형을 그릴 시작 위치
        bitmap += (rect.y * scanLineSize) + rect.x;
    
        //사각형 그리는 실제 로직! 색상은 임의로 흰색으로 지정
        for(y = 0; y < rect.h; ++y)
            for(x = 0; x < rect.w; ++x)
                bitmap[y * scanLineSize + x] = 0xffffffff;
    
    코드 6사각형 드로잉 로직


    5.2 클리핑

    도형을 그릴 때 드로잉 영역의 유효성을 검증하는 작업은 선행되어야 한다. 올바르지 않은 위치로 버퍼 메모리에 접근하면 데이터 훼손 내지 프로세스가 강제 중단될 수 있다. 사각형 위칫값이 음수이거나 사각형 크기가 버퍼보다 큰 경우를 가정해 보자. 이 경우 버퍼 영역을 벗어난 영역을 잘라내는 작업을 수행한다. 사각형과 캔버스 버퍼 두 영역을 비교하고 겹치는 영역, 즉 교집합을 구하여 사각형의 새로운 위치와 크기를 구하는 작업을 선행한다. 이 작업을 클리핑(Clipping)이라고 한다.

    /* * 두 사각 영역의 교집합 영역 계산 * @p rect1: Geometry * @p rect2: 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를 이용한다. clipped = clipRects(rect, Geometry(0, 0, buffer.width(), buffer.height())); ...

    코드 7클리핑 로직

    한편, 사용자가 지정한 도형의 드로잉 영역이 UIObject 경계 영역(Geometry)을 벗어난 경우를 고려해 보면 UIObject를 뷰포트(Viewport)로서 도형의 클리핑에 활용할 수 있다. 이 경우 온전한 도형을 그리기 위해서는 객체 영역은 도형보다 크거나 같아야 한다. 이 동작을 토대로 객체 영역 정보를 벡터 렌더러에 추가로 전달하여 클립 계산에 활용한다.

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

    /*
     * 사각형 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p rect: Geometry
     * @p clipper: Geometry 
    */
    UIVectorRenderer.drawRect(buffer, rect, clipper, ...):
        ...
        //버퍼 영역을 벗어나지 않도록 클리핑 수행 
        clipped = clipRects(rect, Geometry(0, 0, buffer.width(), buffer.height()));
    
        //제시된 clipper를 대상으로 추가 클리핑 수행
        clipped = clipRects(clipped, clipper);    
        ...
    
    UIRect.render(buffer, ...):
        ...
        /* 사각 영역과 오브젝트 영역 모두 벡터 렌더러에 전달. 
           오브젝트 영역은 클립 영역으로 활용된다. */
        UIVectorRenderer.drawRect(buffer, self.rect, self.geometry(), ...);
        ...
    
    코드 8드로잉 영역 계산 과정

    오브젝트와 도형 그리고 버퍼 간 교집합 계산은 여러 드로잉 과정에서 빈번히 발생하기 때문에 세 영역의 교집합을 구하는 clipRects(rect1, rect2, rect3); 같은 계산 로직을 추가하여 코드를 조금 더 최적화할 수 있으며 이후에 다루는 다른 도형에서도 이러한 드로잉 영역의 유효성을 검증하고 재계산하는 클리핑 작업이 모두 동일하게 적용되어야 한다.


     클리핑과 컬링


    그래픽스 시스템에서 클리핑과 컬링은 드로잉 영역을 최적화하고 성능을 향상하는 목적으로 활용된다. 클리핑은 드로잉 대상으로부터 가시 영역을 벗어난 부분을 제거하여 래스터 영역을 최소화한다. 결과적으로 실제 화면에 보이는 부분만 추려내기 때문에 드로잉 대상 객체의 기하 정보와 뷰포트 영역 간 교집합을 계산하는 것이 알고리즘 핵심이다. 일반적으로 클리핑은 래스터 단계 직전에 수행한다. 반면, 컬링(Culling)은 보다 추상적인 개념으로서 오브젝트 단위로 계산을 수행할 수 있다. 때문에 컬링은 응용 단계에서 수행하며 오브젝트가 뷰포트 내지 카메라 가시 영역에 존재하는지 판단함으로써 드로잉 대상 후보를 사전에 결정한다. 대표적으로 3D 그래픽스 시스템에서는 프러스텀(Frustum), 오클루전(Occlusion) 그리고 후면(Back-face) 컬링 기법을 언급할 수 있다. 프러스텀 컬링은 드로잉 대상 객체가 3차원 공간상에서 카메라의 가시 영역 내에 있는지 판단하여 드로잉 후보를 결정한다. 오클루전 컬링은 객체가 다른 객체에 의해 완전히 가려졌는지 여부를 통해 판단하고 후면 컬링은 객체를 구성하는 폴리곤 면이 바라보는 방향을 통해 폴리곤을 드로잉 대상에서 제외하는데 이는 폴리곤을 구성하는 정점의 연결 방향이 시계(CW) 또는 반시계(CCW)인지 여부로 판단한다. 2장에서 살펴본 씬그래프 기반 렌더링에서는 부모 노드를 통해 장면을 구성하는 자식 노드가 드로잉 대상인지 아닌지 빠르게 판단할 수 있다. 이 역시 하나의 컬링 작업에 해당한다.



    5.3 직선

    직선을 그리기 위해서는 선분이 지나가는 두 점 pt1(x1,y1), pt2(x2,y2)의 정보를 알아야 한다. 두 점으로부터 직선 방정식을 이용하여 기울기 m을 구하면, 직선을 구성하는 픽셀의 위칫값을 구할 수 있다. 

    그림 20: 직선 방정식


    직선 드로잉을 구현하기 위해 x1 ~ x2 사이의 x 값을 1씩 증가하면서 직선 방정식에 대입한다. 그러면 x에 해당하는 y 값을 구할 수 있다. 이 때의 x, y의 값은 선을 구성하는 픽셀 위치에 해당한다.

    /*
     * 직선 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p pt1: Point 직선 시작점
     * @p pt2: Point 직선 끝점
     * @p clipper: Geometry
    */
    UIVectorRenderer.drawLine(buffer, pt1, pt2, Geometry clipper, ...):
        ...
        m = (pt2.y - pt1.y) / (pt2.x - pt1.x);        //기울기 값
    
        /* 직선의 x축 클리핑. 여기서는 pt1은 pt2보다 값이 작다고 가정한다. */    
        sx = pt1.x < clipped.x ? clipped.x : pt1.x;
        ex = pt2.x > (clipped.x + clipped.w - 1) ? (clipped.x + clipped.w - 1) : pt2.x;
    
        /* x 값을 인자로 y 값을 도출. 직선 기울기가 y축에 더 가깝다면 x 값을 도출한다. */
        for(x = sx; x < ex; x++)
            /* 정수형 경우 반올림(rounding) 처리에 주의 */
            y = m * (x - pt1.x) + pt1.y;  
            /* 직선의 y축 클리핑 */
            if(y < clipped.y || y >= (clipped.y + clipped.h)) continue;
    
            bitmap[y * scanLineSize + x] = 0xffffffff;
    
    코드 9: 직선 드로잉 로직

    코드 9 10번째 줄에서 직선의 x축 클리핑하는 과정을 보면 pt1의 x 값이 pt2보다 작다고 가정하지만 실제로는 x 값이 작은 점이 pt1이 되도록 조정하는 작업이 선행되어야 한다. 또한, 선의 기울기가 x축에 가까운지 또는 y축에 가까운지에 따라 for() 문 조건을 다르게 설정해야 한다. 이는 기울기 m 값으로 쉽게 판단할 수 있다. 선이 x축에 가깝다면, 다시 말해 수평으로 긴 직선이라면 x 값을 증가하면서 그에 해당하는 y 값을 구하고 반대의 경우 y 값으로부터 x 값을 도출한다. 그렇지 않으면 픽셀이 손실되어 점선 형태의 출력 결과가 나타날 수 있다.


    5.4 원

    원 둘레는 원의 중심(cx, cy)과 반지름(r) 정보를 이용하여 구할 수 있다.

    그림 21: 원의 방정식

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

    1사분면의 둘레를 구했다면 2사분면의 둘레는 1사분면의 x 위치로부터 중점 cx 까지 거리를 구한 후 그 값의 두 배를 더함으로써 구할 수 있다. 3사분면은 x 대신 y를, 4사분면은 x, y 모두 같이 처리함으로써 원 전체 둘레를 구한다.

    /* * 원 그리는 작업 수행 * @p buffer: NativeBuffer * @p center: Point 원 중점 * @p radius: Var 원 반지름 */ UIVectorRenderer.drawCircle(buffer, center, radius, ...): ... //1사분면에 한하여 수행 for(y = center.y - radius; y <= center.y; y++) x = sqrt(pow(radius * 2) - pow(y - center.y, 2)) + center.x; sx = x + (center.x - x) * 2; //x 값 대칭 sy = y + (center.y - y) * 2; //y 값 대칭 bitmap[sy * scanLineSize + x] = 0xffffffff; //1사분면 bitmap[sy * scanLineSize + sx] = 0xffffffff; //2사분면 bitmap[y * scanLineSize + x] = 0xffffffff; //3사분면 bitmap[y * scanLineSize + sx] = 0xffffffff; //4사분면

    코드 10: 원 드로잉 로직

    10번 째 줄에서 확인할 수 있듯이 도형을 그릴 때는 가능하다면 y축을 우선으로 반복문을 수행하며 x 좌푯값을 도출한다. 그 이유는 7절에서 다룰 RLE 데이터를 쉽게 구축하는 목적도 있지만, 기본적으로 CPU가 메모리에 접근하는 방식과도 관련이 있다. 일반적으로 물리 메모리는 1차원 선형 공간을 띄기 때문에 x 좌표를 증가하며 색상을 채워나가는 방식이 CPU 캐싱 적용률이 뛰어나다.

    타원은 원과 다를바가 없다. 원 대신 타원의 공식을 적용하기만 하면 된다.

    그림 22: 타원의 방정식


    5.5 부채꼴

    부채꼴은 앞서 살펴본 선과 원의 조합에서 크게 벗어나지 않는다. 부채꼴을 그리기 위해서는 원 중점과 반지름, 시작과 끝 지점의 방위각 정보가 필요하다. 이 정보를 알면 원의 둘레가 어디서 시작하고 끝나는지 알 수 있으며 이 둘레는 원의 몇 사분을 지나는지도 계산할 수 있다. 원의 방정식을 통해 원둘레를 구하고 그 둘레의 시작과 끝점을 원 중점과 연결한 직선을 그리면 부채꼴이 완성된다.

    그림 23: 부채꼴 도식

    angle1이 가리키는 좌표점은 원 중심을 원점으로 반지름 r 길이의 vStart 벡터를 통해 구할 수 있다. angle2가 가리키는 좌표점은 vStart 벡터를 사잇각 𝛳만큼 회전한 결과이므로 vStart 벡터에 2차원 회전 행렬을 곱하여 구할 수 있다. 그 결과는 vEnd 벡터에 해당한다. 벡터 회전을 위해 코사인(cos), 사인(sin)값이 필요하며 각도(degree)를 라디안(radian) 단위로 변환해야 한다. 이를 위해 Math 함수 집합에서 유틸리티 함수를 제공하면 편하다. POSIX를 포함한 표준 라이브러리에서는 수식 처리를 위해 cos(), sin(), tan()과 같은 기본 삼각함수 기능을 제공하므로 이를 참고한다.

    /*
     * 원 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p center: Point 원 중점
     * @p radius: Var 원 반지름
    */
    UIVectorRenderer.drawCircle(buffer, center, radius, ...):
        ...    
         //1사분면에 한하여 수행
        for(y = center.y - radius; y <= center.y; y++)
            x = sqrt(pow(radius, 2) - pow(y - center.y, 2)) + center.x;
            sx = x + (center.x - x) * 2;      //x 값 대칭
            sy = y + (center.y - y) * 2;      //y 값 대칭
    
            bitmap[sy * scanLineSize + x] = 0xffffffff;         //1사분면
            bitmap[sy * scanLineSize + sx] = 0xffffffff;        //2사분면
            bitmap[y * scanLineSize + x] = 0xffffffff;          //3사분면
            bitmap[y * scanLineSize + sx] = 0xffffffff;         //4사분면
    
    코드 11: 각도 - 라디안 변환

    한편, 벡터(Vector)와 변환(Transform)은 그래픽스 작업에서 필수 도구에 해당한다. 여기서는 필요 기능만 확인한다.

    /* * 2D 벡터 회전 * @p angle: Var */ Vector2.rotate(angle): radian = degreeToRadian(angle); self.x = cos(radian) * self.x - sin(radian) * self.y; self.y = sin(radian) * self.x + cos(radian) * self.y;

    코드 122D 벡터 회전

    다음은 부채꼴을 그리는 주요 로직이다.

    /*
     * 부채꼴 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p center: Point 원 중점
     * @p radius: Var 원 반지름
     * @p angle1: Var 부채꼴 시작점 각도
     * @p angle2: Var 부채꼴 끝점 각도
    */
    UIVectorRenderer.drawArc(buffer, center, radius, angle1, angle2, ...):
        ...
        //시작, 끝 각도 차가 360 이상이면 원과 동일
        if(abs(angle2 - angle1) >= 360)
            return UIVectorRenderer.drawCircle(buffer, center, radius, ...);
        
        /* angle1과 angle2가 가리키는 벡터 */
        Vector2 vStart(0, -center.y);
        vStart.rotate(angle1);
        Vector2 vEnd(0, -center.y);
        vEnd.rotate(angle2);
    
        /* 계산할 y 값 범위를 결정. 만약 부채꼴이 1, 2 사분면에 걸쳐 있다면 원의 상단,
           3, 4 사분면에 걸쳐있다면 원의 하단을 y 범위에 포함한다. */         
        yStart = center.y, yEnd = center.y;
    
        if((270 <= angle1 < 360) || (0 <= angle1 < 90) ||
           (270 <= angle2 < 360) || (0 <= angle2 < 90))
            yStart = center.y - radius;
    
        if((90 <= angle1 < 270) || (90 <= angle2 < 270))
            yEnd = center.y + radius;
    
        Vector2 vZero(0, -radius);  //0도를 가리키는 벡터
    
        /* 드로잉 시작 */
        for(y = yStart; y < yEnd; y++)
            sx[0] = sqrt((radius * radius) - pow(y - center.y, 2)) + cx;
            sx[1] = x + (center.x - x) * 2;     //x 값 대칭
    
            foreach(sx, x)
                //중점으로부터 (x,y) 벡터를 구하고
                Vector2 vDir(x - center.x, y - center.y);
                //vZero와 내적을 유도하여 사잇각을 구한다. (그림 3.24)
                cos = vZero.dotProduct(vDir) / (vZero.length() * vDir.length());
                theta = acos(cos);
                angle = radianToDegree(theta);
                //내적 방향 고려
                if (vZero.crossProduct(vDir) < 0) angle = 360 - angle;
    
                //시작 각과 끝 각 사이에 존재하면 해당 위치는 부채꼴 둘레에 포함된다.
                if (angle1 <= angle < angle2) 
                    bitmap[y * scanLineSize + x] = 0xffffffff;
    
        //마지막으로 원의 중점과 둘레의 두 끝점을 직선으로 연결한다.
        UIVectorRenderer.drawLine(buffer, center, Point(vStart.x, vStart.y), ...);
        UIVectorRenderer.drawLine(buffer, center, Point(vEnd.x, vEnd.y), ...);
    
    코드 13: 부채꼴 드로잉 로직

    코드 13에서는 부채꼴이 원의 네 사분면 중 어느 사분면에서 시작하여 어느 사분면에서 끝나는지를 확인한다. 이후, y축을 중심으로 반복문을 수행하며 원의 방정식을 통해 현재 y에 해당하는 sx 좌푯값을 구한다. y축을 중심으로 원의 둘레는 양방향 대칭이어서 sx 값은 두 개다. 이후 원 중심을 원점으로 두 좌표를 가리키는 벡터를 구한 후, vOrigin와 내적(Dot Product) 식을 이용하여 사잇각을 구한다. 벡터 내적은 0 - 180 범위 값만 도출할 수 있어서 두 벡터의 외적을 통해 방향 정보까지 추가한다. 만일 외적 값이 음수이면 시계 반대 방향으로 간주하고 각도가 180를 넘어간 것으로 간주하여 계산한 각도를 뒤집는 작업을 수행한다. 최종적으로 계산한 각도가 부채꼴의 두 각도 사이에 존재하면 해당 좌표는 부채꼴의 둘레에 존재하므로 드로잉 작업을 수행한다.


    그림 24: 부채꼴 벡터 내적 식 유도

    벡터의 연산은 수식을 그대로 구현한다. 수식 증명 등 보다 자세한 이해가 필요하면 선형대수를 참고한다.

    /*
     * 2D 벡터 내적
     * @p v: Vector2
    */
    Vector2.dotProduct(v):
        return (self.x * v.x) + (self.y + v.y);
    
    /*
     * 2D 벡터 외적
     * @p v: Vector2
    */
    Vector2.crossProduct(v):
        return (self.x * v.y) - (v.x * self.y);
    
    /*
     * 2D 벡터 길이
    */
    Vector2.length():
        return sqrt((self.x * self.x) + (self.y * self.y));
    
    코드 14벡터 연산 구현부


    5.6 둥근 사각형

    둥근 사각형은 사각형과 원의 각 사분면을 그리는 로직을 그대로 활용할 수 있다. 다만 사각형 모서리에 위치할 원의 반지름을 사용자가 결정할 수 있도록 인터페이스만 고려하면 된다.

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

    사각형의 각 모서리에 있는 가상의 원의 반지름을 통해 사각형 모서리의 둥근 정도를 결정한다. 이 원의 반지름 d는 절댓값이나 사각형 넓이 w의 비율 값으로 결정할 수 있다. d가 1일 때 사각형 가로, 세로 크기가 같다면 둥근 사각형의 외양은 완전한 원이 된다. 반면 d가 0이면 사각형은 완전한 직사각형(또는 정사각형)이다.

    /*
     * 둥근 사각형 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p rect: Geometry
     * @p cornerRadius: Var 모서리에 위치한 원의 반지름 비율 [0 ~ 1]
    */
    UIVectorRenderer.drawRoundRect(buffer, rect, cornerRadius, ...)
    
    코드 15: 둥근 사각형 메서드 프로토타입


    구현 핵심은 각 모서리에 원을 사각형 내에 같은 크기로 배치한다. 따라서 둥근 사각형은 네 모서리의 부채꼴과 사각형 두 부분으로 나눠서 드로잉을 수행할 수 있다. 특히 모서리에 있는 네 부채꼴의 합은 하나의 완전한 원과 같다. 그러므로 원을 사분면으로 나눠서 그리되 중심 위치만 바꿔주면 된다. 이는 앞서 배웠던 원 드로잉 로직과 완전히 같다. 한편 사각형은 모서리를 제외한 영역에 해당하며 사각형을 세 부분으로 나눈다면 매우 쉽게 사각 영역을 완성할 수 있다. 그림 3.26은 이를 도식화한다.

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


    만약 사각형 넓이 또는 높이 대비 원의 반지름이 커서 원이 서로 교차한다면 둥근 사각형의 외양은 훼손될 수 있다. 따라서 원의 반지름 크기에 제약이 필요하다. 사각형의 짧은 면의 길이가 두 원의 반지름 합보다 짧은 경우 원의 반지름은 사각형의 길이가 짧은 면을 기준으로 크기가 0.5배로 재조정되어야 한다. 


    5.7 곡선

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


    • 스플라인 곡선

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

    스플라인 곡선은 보간법에 따라 생성한 선이 구간 점을 정확히 통과할 수도 있지만, 구간 점을 근사하게 지나칠 수도 있다. 결과적으로 두 경우 모두 곡선을 생성하는 점에서는 같지만, 구간 점을 정확히 지나치는 곡선은 때에 따라 문제 풀이에 유용하다. 핵심은 구간을 다항식으로 정의하고 구간의 연결점을 가리키는 양쪽 함수의 값은 같아야 한다. 또한, 매끄러운 곡선에 불연속성이 없다고 가정하면 연결 부위에서의 도함수 값은 같으며 이를 통해 다항식으로부터 미지수를 구할 수 있다. 시작과 끝점은 그 점의 위치 특성상 2차 도함수 값이 0이라고 전제한다.

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

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

    그림 29: 베지어 곡선

    수식을 정리한 3차 베지어 곡선의 함수는 다음과 같다. 

    그림 30: 베지어 곡선 함수

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

     위 식을 기반으로 베지어 곡선을 코드로 옮기면 다음과 같다.

    /*
     * 곡선을 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p start: Point
     * @p end: Point
     * @p control1: Point
     * @p control2: Point
    */
    UIVectorRenderer.drawCurve(buffer, start, end, control1, control2, ...):
    
        //긴 축을 찾아 그 길이만큼 반복문을 수행한다.
        sx = abs(end.x - start.x);
        sy = abs(end.y - start.y);
        segment = sx > sy ? sx : sy;
    
        prev = start;        //이전 좌표값
    
        for(t = 1; t < segment; t++)
            //계수 구하기
            a = pow((1 - t), 3);
            b = 3 * pow((1 - t), 2) * t;
            c = 3 * pow((1 - t), 2) * pow(t, 2);
            d = pow(t, 3);
    
            Point cur;    //현재 좌표값
            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;
    
            UIVectorRenderer.drawLine(buffer, prev, cur, ...);
    
            prev = cur;
    
    코드 16곡선 드로잉 로직

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


    5.8 경로와 폴리곤

    연속된 곡선과 직선을 하나의 집합으로 구성하면 경로를 표현할 수 있다. 만약 여기서 경로의 첫 지점과 끝 지점을 연결하면 우리는 이를 다각형으로도 표현할 수 있다. 경로를 표현하기 위해 앞서 배운 벡터 드로잉 기능을 명령어(Command) 목록으로써 구성한다. 가령 다음 코드를 보면 이해가 쉽다.

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

    그림 31: 경로를 이용한 도형 구축

    • 경로
    경로에서 명령어는 앞서 살펴본 선, 곡선 등 도형 기능과 1:1 대응한다. 따라서 UIPath는 드로잉을 수행할 때 명령어를 하나씩 확인하면서 그에 해당하는 도형 드로잉 메서드를 호출하기만 하면 된다. 경로 자체가 가지는 고유한 도형 로직은 존재하지 않음으로 moveTo(), lineTo(), curveTo(), arcTo() 명령어를 리스트나 스택으로 구축하고 이들을 실행하는 과정이 경로 구현의 핵심이다.

    /*
     * UIPath는 도형의 외곽선을 명령어로 전달받고 이들을 그리는 작업을 수행한다.
     * 폴리곤을 완성하기 위해서는 경로의 시작점과 끝을 정확히 연결하면 된다.
     * close()는 끝점과 시작점을 연결해주는 기능을 제공한다.
    */
    UIPath extends UIShape:
        UIPathCommand cmds[];         //명령어 목록
        ...
        /* 이하 명령어 추가 메서드. 각 명령어는 UIPathCommand 인터페이스를 확장하여
           데이터를 정의하고 이를 cmds 목록에 추가한다. */
    
        moveTo(x, y):
            self.cmds.push(UIPathCommandMoveTo(x, y));
    
        lineTo(x, y):
            self.cmds.push(UIPathCommandLineTo(x, y));
    
        curveTo(control1, control2, end):
            self.cmds.push(UIPathCommandCurveTo(ctrl1, ctrl2, end));
    
        close():
            self.cmds.push(UIPathCommandClose());
        ...
    
        /*
         * 큐잉(queueing)된 명령어를 하나씩 꺼내어 드로잉 수행
        */
        override render(...):
            ...
            Point begin = cur = (0,0);
    
            foreach(self.cmds + offset, cmd)
                switch(cmd.type())
                    UIPathCommandMoveTo:
                        begin = cur = cmd.get();
                    UIPathCommandLineTo:
                        to = cmd.get();
                        UIVectorRenderer.drawLine(buffer, cur, to, ...);
                        cur = to;
                    UIPathCommandCurveTo:
                        ctrl1, ctrl2, end = cmd.get();
                        UIVectorRenderer.drawCurve(buffer, cur, end, ctrl1, ctrl2, ...);
                        cur = end;
                    UIPathCommandClose:
                        UIVectorRenderer.drawLine(buffer, cur, begin, ...);
                ...
    
    코드 18UIPath 명령어 수행 로직 

    코드 18에서 UIPath는 요청받은 명령어를 UIPathCommand 목록에 추가하고 렌더링 시점에 이들을 순차적으로 실행하는 과정을 수행한다. 경로 명령어는 타입별 독자적 데이터를 구축하므로 UIPathCommand 인터페이스를 정의하고 각 타입별로 이를 확장하여 데이터를 구축할 수 있게 한다. 다음 코드는 이 중 곡선 명령어 데이터를 구현하는 예시를 보여준다.

    /*
     * 곡선 경로 명령어를 구축하고 get() 호출 시 디스패치(dispatch)할 곡선 데이터를 제공
     * UIPathCommand 인터페이스를 구현하여 UIPath의 명령어 목록에 추가 가능하다. 
    */
    UIPathCommandCurveTo extends UIPathCommand:
        Point ctrl1, ctrl2, end;   // CurveTo 데이터
    
        constructor(ctrl1, ctrl2, end):
            self.ctrl1 = ctrl1;
            self.ctrl2 = ctrl2;
            self.end = end;
     
        /* CurveTo 데이터 반환 */
        override get():
            return self.ctrl1, self.ctrl2, self.end;
    
    코드 19UIPathCommand 데이터 구현

    경로의 명령어를 이용하면 벡터 리소스에 기록된 여러 도형 정보도 하나의 시퀀스로서 입력할 수 있기 때문에 효율적이다. SVG처럼 하나의 벡터 리소스에는 여러 도형 정보가 입력되므로 이들을 해석하여 명령어 목록으로 구축하면 UIPath로 바로 전달할 수 있다. 추가로 UI 엔진이 벡터 리소스를 다루기 위한 벡터 클래스(UIVector)를 제공한다면 앱 개발자는 리소스 내 도형들을 개별 객체로 다룰 필요 없이 하나의 집합 객체 또는 이미지로서 다룰 수 있다. 실제로 앱 개발자가 벡터 드로잉 인터페이스를 직접 호출하여 화면을 구성하기보다는 벡터 리소스로부터 데이터를 읽어오는 것이 효율적이므로 UI 엔진은 벡터 리소스 파일을 불러오는 기능을 제공해야 한다.

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


    그림 32의 UIVector 클래스는 SVG 해석기를 통해 SVG 원시 데이터로부터 유효한 경로 명령어를 생성한다. 이후 이를 UIPath의 입력 커맨드로서 전달한다. 이러한 동작 절차는 SVG뿐만 아니라 다른 벡터 리소스에 대해서도 같이 적용할 수 있다. UIVector는 도형을 출력하는 기능 외에 벡터 리소스를 불러올 수 있는 인터페이스 제공함으로써 벡터 결과물을 바로 출력할 수 있도록 도와준다.

    • 폴리곤
    한편 폴리곤은 기하학적 형태를 띠기 때문에 앞서 배운 도형처럼 정규화된 수식을 통하여 그리기에는 제약이 많다. 대안으로 완성한 경로에 색상을 채우는 방식을 이용하면 의외로 문제는 쉬워진다. 단순하지만 효과적인 방법은 경로의 y축 최상단부터 최하단까지 루프를 돌며 가로로 도형을 그려나가는 방법이다. 왼쪽에서 오른쪽으로 픽셀을 그리는 과정에서 현재 픽셀의 위치가 도형의 내부인지 외부인지를 판단할 수 있다면 픽셀을 출력할지 말지를 결정할 수 있다.

    그림 33은 이 방법을 도식화한다. y축을 기준으로 반복문을 수행하며 y 위치의 수평선(빨간 선)과 교차하는 경로를 간추려낸다. 이때 교차하는 경로와 충돌하는 지점 x 값을 도출한 후 이들을 오름차순으로 정렬하면 우리는 해당 선 중 어느 구간에 색상을 채워야 하는지 결정할 수 있다. 가장 먼저 교차하는 지점은 도형의 외곽선에 해당하므로 여기서부터 색상을 채우면 된다. 첫 번째 교차점(x1)부터 x 좌푯값을 증가시키면서 픽셀을 출력하다가 두 번째 충돌 지점(x2)에 도달하면 도형 외각에 해당하므로 색상 채우기를 멈추고 다음 지점(x3)으로 건너뛴다. 이 작업을 반복하면 도형의 한 줄이 완성된다.

    그림 33충돌탐지를 이용한 폴리곤 완성

    결국, 그림 33의  y 좌표에서 드로잉을 수행할 영역은 (x1 ~x2) , (x3 ~ x4), (x5 ~x6) 세 부분으로 정할 수 있다. 이때 우리에게 주어진 인수는 y 값에 해당하며 경로마다 y 값으로부터 x 값을 도출할 수 있는 연산이 필요하다. 이는 앞서 살펴본 수식의 역산에 해당한다. 

    다음 코드는 폴리곤을 그리는 로직의 핵심을 보여준다.

    /*
     * 폴리곤을 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p commands: UIPathCommand
    */
    UIVectorRenderer.drawPolygon(buffer, commands, ...):
        ...
        //경로 명령어로부터 y의 min, max 값을 찾아 yBegin, yEnd에 기록
        foreach(commands. cmd)
            ...
    
        for(y = yBegin; y < yEnd; y++)
            //a. 경로로부터 y 위치에서 충돌하는 점들을 찾고 이들의 x 좌표 목록 반환
            xList = findIntersectsAtY(commands, y);
    
            //b. x 값 오름차순 정렬
            sortAscending(xList);
    
            //c. 준비한 x 값을 이용하여 드로잉 수행
            for(i = 0; i < xList.size() - 1; i+=2)
                xBegin = xList[i];
                xEnd = xList[i+1];
    
                //(x, y)에 해당하는 픽셀 그리기
                for(x = xBegin; x < xEnd; x++)
                     bitmap[y * scanLineSize + x] = 0xffffffff;
    
    코드 20폴리곤 드로잉 로직

    코드 20은 폴리곤 드로잉 로직을 개략적으로 보여준다. 사실 드로잉 시점에 충돌 점을 찾는 작업은 다소 비효율적인 부분에 해당하므로 도형의 형태가 변하지 않는다고 가정하면 충돌 점은 사전에 계산하여 캐싱하는 것이 바람직하다. 그리고 엔진은 캐싱한 좌표 목록을 UIVectorRenderer에 전달함으로써 추가 부담 없이 래스터라이징을 곧바로 수행할 수 있다. 이러한 방식은 이후 살펴볼 RLE(Run Length Encoding) 알고리즘과 개념이 비슷하므로 여기서는 자세한 설명은 생략한다.

    여기까지 우리는 도형을 그리는 기본 수식과 구현 로직에 대해 살펴보았다. 트릭과 최적화보다는 이해와 기본 구현 방안에 충실했기 때문에 성능 개선면에서는 고민할 여지가 남아있다. 참고로 Bresenham의  알고리즘을 이용하면 정수 연산으로도 도형을 완성할 수 있는 최적화된 구현이 가능하다. Bresenham은 화면에 매핑될 픽셀이 정수라는 점에 기안하며 수학적 계산으로부터 픽셀 배치의 패턴을 정규화하여 더욱더 빠른 픽셀 위치 계산이 가능한 도형 래스터 기법이다.

    추가로  앨리어싱(Aliasing) 현상은 렌더링 품질을 저하하는 부분이며 이는 반드시 다뤄야 할 부분에 해당한다. 앨리어싱은 흔히 계단 현상으로 불리며 앤티에일리어싱(Anti-Aliasing) 기법을 적용하면 개선이 가능하다. 앤티에일리어싱은 4장에서 다룬다.

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



    5. 채우기

    5.1 단색

    색상을 채우는 방법은 크게 단색과 그래디언트 두 개로 구분할 수 있다. 여기서 단색은 하나의 색상을 도형 전체에 적용하기 때문에 특별히 어려운 구현을 요구하지 않는다. 사용자가 설정한 색상을 전달받고 드로잉 단계에서 이를 적용하면 된다. 사용자가 색상을 지정하기 위해 단일 색상 정보를 가진 UIFilllSolidColor 같은 클래스를 제공할 수 있다. UIFillSolidColor 객체를 UIShape에 전달한다면 UIShape는 드로잉 시점에서 UIFillSingleColor로부터 색상 정보를 전달받을 수 있을 것이다.

    //사각형 생성
    UIRect shape;
    shape.geometry(100, 100, 200, 200);
    
    //도형을 채울 색상 지정
    UIFillSolidColor fill;
    fill.color(UIRGBA(100, 100, 255, 255));
    
    //채우기 적용
    shape.fill(fill);
    
    코드 21: 단색 채우기 예제 

    코드 21처럼 색상을 결정하기 위해 fill.color()에 R, G, B, A 채널 값을 직접 지정할 수도 있지만, 범용적으로 사용되는 색상의 경우 UIRGBA.Blue, UIRGBA.Pupple 등 명시적 색상 명을 통해 사용자 편의를 제공할 수도 있다.

    UIShape이 UIFill과 연관 관계를 맺은 후에는 렌더링 단계에서 색상 정보를 벡터 렌더러로 전달할 수 있다. 색상 정보를 전달받은 UIVectorRenderer는 색상 값을 사각형의 픽셀 데이터로 결정한다.

    UIRect.render(buffer, ...):
        ...
        /* UIRect는 사용자가 지정한 색상을 채우기 위해 UIVectorRenderer로 색상 정보(fill)를
           추가로 전달한다. */
        UIVectorRenderer.drawRect(buffer, self.rect, self.geometry(), self.fill(), ...);
    
    /*
     * 사각형 그리는 작업 수행
     * @p buffer: NativeBuffer
     * @p rect: Geometry
     * @p clipper: Geometry
     * @p fill: UIFill
    */
    UIVectorRenderer.drawRect(buffer, rect, clipper, fill, ...):
        ...
        //사각형을 그리는 로직
        for(y = 0; y < rect.h; ++y)
            for(x = 0; x < rect.w; ++x)
                //색상 정보는 fill로부터 얻어온다.
                bitmap[y * scanLineSize + x] = fill.color();
    
    코드 22: 단색 채우기 드로잉 로직

    한편 그래디언트 채우기는 지정된 복수의 색상을 보간법을 통해 채우는 효과를 보여준다. 

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


    5.2 선형 그래디언트

    선형 그래디언트(Linear Gradient 또는 Axial Gradient)를 표현하기 위해서는 그래디언트 효과가 펼쳐질 공간을 잇는 두 점 그리고 이 두 점 사이를 보간할 색상 정보가 필요하다. 쉬운 이해를 위해 우측을 향하는 두 색상을 보간하는 그래디언트를 생각해 보자. 이 경우 그래디언트의 시작점과 끝점(p1, p2) 그리고 두 색상 정보 (C1, C2)가 주어지는 데 두 색상을 선형 보간하기 위해서는 시작점으로부터 끝점 사이의 특정 위치(p3)의 정규값(p)을 구한 후 이 값을 이용하여 색상을 보간한다(C3).

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

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

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

    참고로 cosA는 두 벡터의 내적으로부터 도출할 수 있다.

    그림 38: 벡터 내적 식


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

    //둥근 사각형
    UIRoundRect shape;
    shape.geometry(0, 0, 200, 150);
    shape.radius(0.1);
    
    //선형 그래디언트
    UIFillLinearGradient fill;
    
    //선형 그래디언트 영역 지정 (시작점, 끝점)
    fill.region(0, 0, 200, 150);
    
    //선형 그래디언트 색상 지정 (위치, RGBA 색상). 색상 위치는 0 ~ 1 범위로 제한
    fill.color(0.0, UIRGBA(100, 100, 255, 255));
    fill.color(0.5, UIRGBA(255, 255, 255, 255));
    fill.color(1.0, UIRGBA(255, 255, 0, 255));
    
    //채우기 적용
    shape.fill(fill);
    
    코드 23:선형 그래디언트 채우기 사용 예제

    그림 39: 세 개의 색상이 개입한 선형 그래디언트

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

    그림 41: 선형 그래디언트 개입 색상 수에 따른 출력 결과


    세 개 이상 색상이 그래디언트에 개입하는 경우 인접한 두 색상끼리만 색상 보간을 수행한다. 그림 3.39 경우에는 [Stop1, Stop2]와 [Stop2, Stop3] 두 쌍의 선형 보간이 발생한다. 이때 그래디언트 방향 벡터는 공유하되 정규값 범주는 그래디언트 전체가 아닌 두 점 사이로 결정한다. 결과적으로 복수 색상의 선형 그래디언트에서 한 점의 색상을 구하기 위해서는 앞선 배운 로직을 구간별로 적용한다.

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

    UIVectorRenderer.drawRect(buffer, rect, clipper, fill, ...):
        ...
        //사각형을 그리는 로직
        for(y = 0; y < rect.h; ++y)
            for(x = 0; x < rect.w; ++x)
                //색상 정보를 구하기 위해 color()에 좌표를 추가로 전달한다.
                bitmap[y * lineLength + x] = fill.color(x, y);
    
    /*
     * 선형 그래디언트 색상 정보 반환
     * @p x: Var
     * @p y: Var
    */
    UIFillLinearGradient.color(x, y):
        //개입하는 색상이 한 개이면 단일 색상과 동일
        if(self.colors.count() == 1) return self.colors[0].color;
    
        /* 그래디언트 방향 벡터 (V). 
           start와 end는 fill.region()에서 전달받은 시작과 끝점 */
        Vector2 vDir(self.end - self.start).normalize();
        vDir.normalize();
        
        //현재 구하고자 하는 픽셀 위치를 가리키는 벡터 (V1)
        Vector2 vCur(x - self.begin.x, y - self.begin.y);
    
        //그림 3.37 선형 그래디언트 구현식을 이용하여 V2 벡터 계산
        cosA = vDir.dotProduct(vCur) / (vDir.length() * vCur.length());
        vCur = (vDir / vDir.length()) * (vCur.length() * cosA);
    
        //전체 구간
        progress = vCur.length() / Vector2(self.end - self.start).length();
    
        //반복문을 수행하며 vCur가 속한 구간을 찾아서 최종 색상 계산
        for(i = 0; i  < self.colors.count() - 1; ++i)
    
            //UIFillColor는 색상(color)와 위치(pos) 정보를 가짐
            UIFillColor color1 = self.colors[i];
            UIFillColor color2 = self.colors[i + 1];
    
            //vCur가 속한 구간인지 판단
            if(progress < color1.pos || progress >= color2.pos) continue;
    
            //p 값 계산
            vSegment = vDir * (color2.pos - color1.pos);
            vCur -= (vDir * color1.pos);
            p = vCur.length() / vSegment.length();
    
            //두 색상을 보간한 데이터 반환
            return {color1.color.r * p + color2.color.r * (1 - p),
                    color1.color.g * p + color2.color.g * (1 - p),
                    color1.color.b * p + color2.color.b * (1 - p),
                    color1.color.a * p + color2.color.a * (1 - p)};  
    
    코드 24선형 그래디언트 색상 결정부


    코드 24는 앞서 살펴본 수학적 풀이를 그대로 코드로 옮겨놓은 것에 불과하므로 이해하기 그리 어렵지 않다. 다만 픽셀마다 UIFillLinearGradient.color()를 수행하기 부담스러울 수 있음으로 가능한 작업은 사전에 처리하는 것도 고려해볼 만하다. 특히 그래디언트 색상 테이블을 미리 구축한다면 단순 테이블 인덱싱 수준으로 계산 로직을 단순화할 수 있다. 이때 테이블의 크기가 너무 크다면 이를 미니맵(minimap)처럼 크기를 축소하여 메모리를 절약하는 것도 하나의 방법이다. 



    5.3 원형 그래디언트

    선형 그래디언트를 이해하면 원형 그래디언트는 어려운 문제가 아니다. 원형 그래디언트 역시 선형과 마찬가지로 그래디언트 방향과 임의의 색상 위치를 가리키는 정규값을 계산하면 된다. 다만 선형과 달리 원형은 초점(focal)을 중심으로 전방위로 그래디언트가 펼쳐지는 차이가 있다. 따라서 원형 그래디언트의 방향 벡터는 초점으로부터 현재 그리고자 하는 색상 위치의 차(difference)를 통해 계산하면 된다. 다만 이 경우 픽셀마다 독립적인 방향 벡터를 구해야 하므로 초점으로부터 현재 색상 위치를 가리키는 벡터를 구한 뒤, 이 위치가 어느 색상 구간에 속한지 확인해야 한다. 이후 선형 그래디언트와 동일하게 해당 구간을 연결하는 두 색상을 보간하여 최종 색상을 결정한다.


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

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

    //원 도형 생성
    UICircle shape;
    shape.position(200, 200);
    shape.radius(100);
    
    //원형 그래디언트
    UIFillRadialGradient fill;
    
    //원형 그래디언트의 반경
    fill.radius(100);
    
    //원형 그래디언트의 초점 위치. 그래디언트 범위(0 ~ 1) 내로 제한
    fill.focal(0.5, 0.5);
    
    /* 원형 그래디언트 색상 지정 (위치, RGBA 색상). 
       색상 위치는 0 ~ 1 범위로 제한 0은 초점, 1은 원형 외곽 경계점 */   
    fill.color(0.0, UIRGBA(100, 100, 255, 255));
    fill.color(0.5, UIRGBA(255, 255, 255, 255));
    fill.color(1.0, UIRGBA(100, 100, 255, 255));
    
    //채우기 적용
    shape.fill(fill);
    
    코드 25: 원형 그래디언트 채우기 사용 예제

    /*
     * 원형 그래디언트 색상 정보 반환
     * @p x: Var
     * @p y: Var
    */
    UIFillRadialGradient.color(x, y):
        //개입하는 색상이 한 개이면 단일 색상과 동일
        if(self.colors.count() == 1) return self.colors[0].color;
    
        //현재 구하고자 하는 픽셀 위치를 가리키는 벡터 
        Vector2 vCur = Vector2(x - self.focal.x, y - self.focal.y);
    
        //방향 벡터
        Vector2 vDir = vCur.Normalize();
    
        //전체 구간에서 현재 픽셀의 위치 비율
        progress = vCur.length() / self.radius;
    
        //반복문을 수행하며 vCur가 속한 구간을 찾아서 최종 색상을 계산
        for(i = 0; idx  < self.colors.count() - 1; ++i)
    
            //UIFillColor는 색상(color)와 위치(pos) 정보를 가짐
            UIFillColor color1 = self.colors[i];
            UIFillColor color2 = self.colors[idx + 1];
    
            //vCur가 속한 구간인지 판단
            if(progress < color1.pos || progress >= color2.pos) continue;
    
            //p 값 계산
            Vector2 vSegment = vDir * (color2.pos - color1.pos);
            vecCur -= (vDir * color1.pos);
            p = vCur.length() / vSegment.length();
    
            //두 색상을 보간한 데이터 반환
            return {color1.color.r * p + color2.color.r * (1 - p),
                    color1.color.g * p + color2.color.g * (1 - p),
                    color1.color.b * p + color2.color.b * (1 - p),
                    color1.color.a * p + color2.color.a * (1 - p)};
    
    코드 26: 원형 그래디언트 색상 결정부


    6. 스트로크

    원래 스트로크(Stroke) 용어의 의미는 붓 터치 기법에서 유래하지만, 벡터 그래픽스의 스트로크는 선 스타일을 지정할 수 있는 비교적 제한된 수준으로 제공된다. 실제로 여러 벡터 그래픽스 엔진에서는 몇 가지 정형화된 스트로크 패턴을 제공하며 사용자는 이를 통해 선 내지 도형 외곽선에 그 효과를 적용하기도 한다. 

    벡터 엔진에서 스트로크를 사용하기 위해서는 스트로크 객체를 생성하고 일부 속성을 설정한 후 그리고자 하는 도형에 생성한 스트로크를 지정한다. 빠른 이해를 위해 스트로크 사용 예제를 살펴보자.

    UILine line;                              //선 생성
    line.from(100, 100);                      //시작점
    line.to(150, 150);                        //끝점
    
    UIStroke stroke;                          //스트로크 객체 생성
    stroke.width(10);                         //스트로크 넓이
    stroke.join(UIStroke.JoinMiter);         //스트로크 연결 부위 스타일 (그림 3.14 참조)
    stroke.lineCap(UIStroke.LineCapButt);     //스트로크 끝 부위 스타일 (그림 3.15 참조)
    stroke.color(UIRGBA.Black);               //스트로크 색상
    
    line.stroke(stroke);                      //스트로크를 도형에 적용
    

    코드 27UIStroke 예제

    또는 스트로크 속성을 UIShape에 통합한 디자인도 고려해볼 수 있으며 두 디자인 방식에 큰 차이는 없다.

    UILine line; 
    line.from(100, 100);                      
    line.to(150, 150);                       
    line.strokeWidth(10);
    line.strokeJoin(UIStroke.JoinMiter);
    line.strokeCap(UIStroke.LineCapButt); 
    line.strokeColor(UIRGBA.Black); 
    

    코드 28UILine 스트로크 속성 예제

    스트로크 드로잉은 사실상 선 구현의 연결 선상에 있다. 앞서 살펴본 선 드로잉 로직에 선 넓이, 조인, 라인캡, 대쉬 스타일 기능을 보강한다면 스트로크 기본 기능을 완성할 수 있다. 사실 이러한 기능은 앞서 다룬 도형 드로잉 기법에서 모두 다루고 있어서 여기서는 선 넓이를 표현하는 방법에 좀 더 집중한다. 


    6.1 스트로크 넓이

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

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

    그림 45 상단 출력 결과를 보면 단순 평행 이동으로는 선의 끝이 잘린 것처럼 표현될 수 있기 때문에 적절히 균형을 맞추며 수직, 수평으로 번갈아 이동하면서 선을 여러 번 출력하는 것이 필요하다는 것을 알 수 있다. 따라서 1픽셀 넓이의 선을 여러 번 출력하여 더욱더 넓은 넓이를 표현하는 방식에서는 선의 시작점과 끝점을 잘 계산하는 것이 중요하다. 하지만 선을 단순 평행 이동 하는 것만으로는 안된다는 사실은 곡선에서 금방 드러난다.

    그림 46넓이를 표현하기 위해 곡선(점선)을 평행 이동한 결과


    그림 46을 보면 알겠지만, 곡선의 경우 단순 위치 변경은 결코 깔끔하게 마무리되기 어렵다. 심지어 이러한 접근법은 때에 따라 로직이 더욱 복잡해진다. 물론 경우의 수를 모두 보완한다면 기능적으로 완성은 가능하지만 대쉬 속성을 결합한다면 선은 오히려 연속된 도형 집합에 가까워진다는 점에서 생각을 다르게 할 필요가 있다. 여기서 짚어볼 핵심은 넓이가 존재하는 선을 선이 아닌 다각형으로 간주할 수 있다는 점이다.

    그림 47넓이가 있는 대쉬 선


    그림 47은 점선의 한 조각을 확대해서 보여준다. 조각의 외곽 정보를 추출할 수 있다면 네 개의 직선으로 구성된 UIPath를 만들고 이로써 도형을 완성할 수 있다. 만약 이러한 직선이 대각선이 아니라 수직 또는 수평선이라면 점선 조각은 정확히 사각형과 일치하기 때문에 사각형의 네 꼭짓점을 찾는 작업은 매우 간단하다. 그리고 직사각형(또는 정사각형)을 중심으로 회전 각도를 구할 수 있다면 대각선의 경우도 계산할 수 있다.


    이를 확인하기 위해 대쉬 스타일은 배제한 채 하나의 직선을 구현해 보자. 코드 27은 넓이 10, 길이가 70.71 (정확히는 70.710678. 두 점의 사이의 거리를 구하는 식을 통해 계산)인 사각형에 해당한다. 이때 사각형 중심을 원점으로 각 꼭짓점의 상대 위치를 구한다. 이후 실제 그리고자 하는 대각선과 하나의 축(x 또는 y축)과의 사잇각 A를 구하는데 사잇각을 구하면 회전 행렬을 이용하여 각 꼭짓점의 회전 위치 좌표를 구할 수 있다.



    그림 48사각형 꼭지점 계산

    곡선의 경우를 살펴보자. 원이나 부채꼴의 경우 중점으로부터 방향을 결정하고 중점에서 둘레까지의 거리를 계산함으로써 구할 수 있는데 이 거리에 스트로크의 넓이의 절반을 더하고 빼면 곡선의 양변 위치를 계산할 수 있다. 하지만 베지어, 스플라인 곡선의 경우는 더욱 복잡하다. 이 경우 곡선의 방향 벡터로부터 법선 벡터 또는 90도 회전 벡터 계산해야 하며 이 법선 벡터에 선의 넓이를 반영하면 곡선의 변에 있는 점을 찾을 수 있다. 

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

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

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


    6.2 스트로크 대쉬

    대쉬 스타일이 적용된 선의 경우에는 선의 생략 구간을 결정하는 것이 필요한데 생략 구간은 사용자가 직접 지정할 수 있도록 대쉬 패턴으로 입력받는다.

    /* 대쉬 패턴은 배열을 통해 사용자가 직접 지정 */
    dashPattern = {50.0,   //선 구간
                   10.0,   //생략 구간
                   10.0,   //선 구간
                   10.0};  //생략 구간            
    
    /* 스트로크에 적용. 4는 dashPattern 갯수 */
    stroke.dash(dashPattern, 4);
    
    코드 30스트로크 대쉬 패턴 적용

    그림 51: 코드 30 출력 결과

    대쉬 선을 구현하는 엔진부 코드는 생략한다. 핵심은 대쉬 패턴의 입력 단위가 픽셀이라고 가정하고 전체 길이 중에서 어느 구간이 선이고 어느 구간이 생략 구간인지 파악하는 것이다. 이후  6.1절에서 살펴본 방식을 토대로 생략 구간만 제외하고 직선과 곡선을 출력하면 된다.


    6.3 스트로크 조인

    스트로크 조인은 두 선의 연결점을 표현하는 방법으로 정의한다. 대표적인 조인 속성으로는 마이터, 라운드, 베벨이 있으며 속성마다 조금씩 시각적 차이가 존재한다. 스트로크 조인을 구현하기 위해서는 두 선이 만나는 점을 어떻게 연결할지를 고민해야 한다.


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

    • 마이터(Miter)
    마이터는 두 선이 교차하는 지점까지 선을 확장한다. 연결된 두 스트로크 선의 방정식을 이용하여 두 선이 만나는 지점을 찾은 뒤 그 구간까지 선을 확장하여 마이터를 위한 도형 조각을 채워야 한다. 그림 3.52 마이터의 흰색 영역은 추가로 채워야 할 도형 영역에 해당한다. 

    • 라운드(Round)
    라운드의 경우에는 두 선이 만나는 꼭짓점과 두 선의 모서리를 기반으로 부채꼴 도형을 추가하면 된다. 좀 더 자세히 설명하자면, 두 선이 만나는 점을 원의 중심으로 하고 선 넓이의 절반을 원의 반지름으로 지정한다. 원의 중심으로부터 각 선의 직각을 이루는 방향 벡터 구하면 부채꼴이 그려야 할 각도를 계산할 수 있다. 부채꼴을 완성하는 방법은 4.5절을 참고한다. 

    • 베벨(Bevel)
    마지막으로 베벨은 두 선의 끝점 외곽 모서리를 직선으로 연결한다. 

    래스터 단계에서 조인을 구현하는 가장 쉬운 방법은 만나는 두 선을 합친 후 새로운 폴리곤으로 완성하는 것이다. 주의할 부분은 두 선이 만나기 전 영역은 보존하고 겹치는 끝점에서는 교집합 영역만을 보존해야 한다는 점이다. 따라서 합쳤을 때 각 모서리에 해당하는 점을 찾는 것이 핵심이며 라운드의 경우에는 곡선이 새롭게 추가되어야 한다. 이렇게 새롭게 구성된 도형은 하나의 폴리곤으로서 출력할 수 있다.

    라인캡 구현 방법은 조인과 크게 다르지 않기 때문에 여기서는 언급하지 않고 넘어간다.

    스트로크는 도형을 그리는 과정에서 수행할 수 있다. 여기서는 도형 합병(Merge)에 대해서 언급하진 않지만, 만약 여러 도형 간 합병이 발생하면 스트로크 경로도 달라질 수 있다. 도형마다 개별적으로  스트로크를 다루는 대신 합병으로 완성된 폴리곤을 대상으로 스트로크를 그려야 한다. 반대의 경우도 마찬가지이다. 이러한 도형 조작은 객체의 update() 시점에 미리 처리하고 가공된 정보를 유지함으로써 렌더링의 성능을 더욱 높일 수 있다.

    마지막으로 다음은 코드 UIPath를 기준으로 스트로크를 추가로 그리는 로직을 주석 코드로 대변한다. 

    UIPath.update():
        /* 도형(UIPath)을 업데이트하는 시점에 stroke로부터 그려야 할 스트로크 경로 
           명령어를 추가로 구축한다. render() 시점에 이 명령어를 UIVectorRenderer로
           전달하여 도형과 함께 드로잉을 수행한다. 전 프레임과 비교하여 stroke의 정보가
           변경되지 않았다면 strokeCmds를 다시 갱신할 필요 없다. */
        self.strokeCmds = self.stroke.update(this);
    
    UIStroke.update(UIShape shape):
        /* stroke를 적용한 도형의 타입(사각형, 원, 곡선, 경로 등)을 토대로 UIPathCommand를
           구축한다. 이때 앞에서 살펴본 개념을 기반으로 대쉬, 조인, 라인캡 등 
           스트로크 특성을 추가로 반영한다. */
        UIPathCommand cmds[];
        ...
    
        return cmds;
    
    UIPath.render(...):
       //닫힌 도형이며 채우기 존재, 즉 폴리곤
        if (self.closed && self.fill())
            //폴리곤을 먼저 그린다. drawPolygon()은 코드 3.20 참고
            UIVectorRenderer.drawPolygon(buffer, self.commands, clipper, fill, ...);
    
        /* strokesCmds로부터 스트로크를 그리기 위한 벡터 커맨드 리스트를 얻어와 드로잉을 
           수행하며 drawPolygon() 메서드를 그대로 이용 */
        self.drawPolygon(buffer, self.strokeCmds, clipper, self.stroke.fill(), ...);
    
    코드 31스트로크를 포함한 폴리곤 드로잉 로직


    7. RLE 최적화

    RLE(Run-Length Encoding)는 데이터 압축 기법의 하나로 연속된 동일 데이터를 하나의 데이터와 그 횟수로만 기재한다. 예를 들면  ‘aaaabbbbbccccddd’ 열네 개 크기의 데이터를 RLE로 압축하면 ‘a4b5c4d3’와 같이 변환할 수 있다. 이는 여덟 개의 데이터로서 원본 대비 절반의 데이터 크기를 갖는다. RLE는 알고리즘이 단순하여 적용하기 쉽고 비소실(Lossless) 이라는 특징을 가지고 있어서 원본 데이터 훼손이 없는 장점이 있다. 이 압축 방식은 극단적으로 데이터가 무한히 반복될 경우 최대 99%의 압축률을 보여줄 수도 있지만, 데이터가 세 번 이상 반복되는 경우가 없다면 실질적으로 압축의 효과가 없거나 오히려 데이터가 더 커질 수도 있어서 압축 대상은 다소 제약적이다. 특히 원본 데이터가 반복적이지 않고 불규칙적으로 나열된 경우에는 RLE를 사용하기 적절치 않다.

    벡터 그래픽스에서는 RLE를 사용해야 할 이유가 다분하다. 특히 UI에서 벡터 그래픽스로 출력한 이미지는 텍스처가 단순하고 동일 색상이 반복되는 경우가 많은데 이러한 특성은 RLE 압축과 잘 부합한다.

    그림 53RLE를 이용한 이미지 압축 (Shimi Volkovich)

    예로, 그림 3.53은 620픽셀 넓이의 벡터 이미지에서 특정 y 위치의 픽셀 데이터를 RLE를 이용하여 압축한 결과를 도식화한다. 1픽셀 크기를 4바이트로 가정하면 원본의 경우 620 x 4이므로 2,480바이트가 필요하지만 RLE 압축의 경우에는 12 x 4이기 때문에 48바이트로 감소한다. 이는 원본 대비 0.01% 크기이다.

    벡터 그래픽스는 이미지를 생성하는 데 있어서 경우 따라  많은 계산 작업을 요구하기 때문에 다소 부담이 발생할 수 있다. 만약 한번 계산한 도형 이미지를 RLE로 저장하여 재활용한다면 도형 계산 단계를 건너뛸 수 있음으로 성능 향상에 도움이 된다. 물론 생성한 도형 비트맵을 바로 캐싱할 수도 있다. 하지만 보다 많은 저장 공간이 있어야 하는 점에서 절충안이 필요하다. 특히 앱 화면에 벡터 UI 요소가 많다면 캐싱 메모리는 많이 증가할 것이다.

    엄밀히 말하자면 벡터 렌더링 엔진에서 RLE는 꼭 필요한 요소가 아니다. 특히 그래디언트 채우기를 한다면 도형을 채우는 픽셀마다 다른 데이터 정보가 필요하기 때문에 RLE는 적절치 않다. 다만 여기서는 벡터 렌더링 엔진의 최적화 방법 중 하나로서 RLE를 적용할 수 있음을 보여준다. 실제로 단색 도형에 매우 효과적이고 그래디언트 채우기를 적용한 경우일지라도 색상 정보를 제외한 도형의 형태 정보만 저장함으로써 RLE 기법은 유효하다. 실제로 폰트의 글리프를 생성하는 프리타입(FreeType) 벡터 엔진에서도 RLE를 통해 벡터 글리프를 드로잉한다. 그리고 때에 따라 RLE를 다른 최적화 알고리즘과 함께 잘 응용한다면 더욱 효과적인 결과를 보여줄 수도 있다.

    그림 3.53 벡터 이미지의 경우 배경, 구름, 태양 크게 세 부분의 벡터 요소(설명을 단순화하기 위해 태양 주위의 광선은 생략)로 구성됨을 알 수 있다. 달리 말하면 배경을 위한 하나의 사각형과 두 개의 폴리곤을 이용하는데 폴리곤마다 RLE를 적용할 수 있다. 여기서는 하나의 예로서 구름을 집중해서 살펴보자. 일단 색상 정보를 제외하더라도 구름의 지오메트리를 기록하기 위해서는 줄마다 구름의 시작점과 끝점 위치 정보가 필요하다. 이 줄을 데이터화하여 RLESpan으로 정의한다면 하나의 RLESpan은 시작점(x)과 길이 정보(length)를 보유할 수 있다. 또한 RLESpan은 y축 선상에서 연속된 데이터이기 때문에 y 정보를 따로 보유하지 않으며 대신 RLESpan 배열을 보유하는 RLEData가 y의 시작점과 y 길이 정보를 보유한다. 단색이라는 가정하에 RLEData는 해당 폴리곤의 색상 정보도 추가로 보유할 수 있다.

    그림 54폴리곤 RLE 데이터


    RLESpan 기반으로 도형의 이미지 정보를 데이터화하면 도형의 지오메트리 정보만을 기록하기 때문에 일반 이미지와 달리 불필요한 여백 정보(그림 3.54의 회색 영역)를 따로 할당할 필요가 없다. 또한 이해를 위해 단순화했지만 한 줄에 기록해야 할 도형이 하나라고 가정할 수 없기 때문에 RLESpan은 가변 개수의 도형 영역을 기록할 수도 있어야 한다.


    그럼 RLE를 벡터 렌더링 엔진에 적용하기 위해 데이터를 인코딩하고 디코딩하는 핵심 로직을 살펴본다.


    /* * 기존 폴리곤 메서드를 수정하고 RLE를 적용한다. * RLEData를 구축하는 것이 핵심이다. */ UIPath.update(): /* 1. UIPath 도형 */ //UIPathCommand로부터 y의 min, max 값을 찾아 yBegin, yEnd에 기록 foreach(self.cmds, cmd) ... //RLEData 구축 RLEData rleData; rleData.y = yBegin; rleData.length = yEnd - yBegin; //RLESpan 구축 for(y = yBegin, i = 0; y < yEnd; y++, i++) //a. 경로로부터 y 위치에서 충돌하는 점들을 찾고 이들의 x 좌표 목록 반환 xList = findIntersectsAtY(self.cmds, y); //b. x 값 오름차순 정렬 sortAscending(xList); //c. 준비한 x 값을 이용하여 RLESpan 구축 for(x = 0; x < xList.size() - 1; x+=2) RLESpan span; span.x = xList[x]; span.length = xList[x + 1] - xList[x]; rleData.spans[i].append(span); self.rleData = rleData; /* 2. 스트로크 커맨드 */ self.strokeCmds = self.stroke.update(this); //위와 동일한 방법으로 스트로크를 위한 RLE 데이터를 구축 ... self.stroke.rleData = rleData; /* * update()에서 구축한 RleData를 UIVectorRenderer로 전달 */ UIPath.render(...): //폴리곤을 먼저 그린다. if (self.closed && self.fill()) UIVectorRenderer.drawRLE(buffer, self.rleData, clipper, fill, ...); //다음으로 스트로크를 그린다. if (self.stroke) UIVectorRenderer.drawRLE(buffer, self.stroke.rleData, clipper ...); /* * RLE 데이터를 이용하여 폴리곤을 그리는 작업 수행 * @p buffer: NativeBuffer * @p rleData: RLEData * @p clipper: Geometry * @p fill: UIFill */ UIVectorRenderer.drawRLE(buffer, rleData, clipper, fill, ...): yBegin = rleData.y; yEnd = yBegin + rleData.length; //y 영역 클리핑 수행 ... for(y = yBegin; y < yEnd; y++) //Span을 가져와 드로잉을 수행한다. foreach(rleData.spans[y - rleData.y], span) xBegin = span.x; xEnd = xBegin + span.length; //x 영역 클리핑 수행 ... //(x, y)에 해당하는 픽셀 그리기 for(x = xBegin; x < xEnd; x++) bitmap[y * scaneLineSize + x] = fill.color(x, y);

    코드 32RLE을 적용한 폴리곤 드로잉 로직


    8. 정리하기

    이번 장에서는 벡터 그래픽스의 역사와 개념을 살펴보았고 그동안 산업 표준으로 사용된 SVG 포맷의 스펙을 간략히 살펴보았다. 비록 SVG는 여기서 다룬 내용보다 더 넓은 명세를 가지고 있지만, 사각형, 원, 선, 곡선, 경로, 폴리곤 등 벡터의 핵심 도형을 출력하는 방법을 살펴봄으로써 벡터 그래픽스의 기본 원리와 개념을 이해할 수 있었다. 그뿐만 아니라 이들을 직접 코드로 옮겨봄으로써 범용적인 벡터 렌더링을 구현할 수 있는 실용 지식을 갖출 수 있다. 추가로 도형의 색상을 채우기 위한 단일 색상 채우기, 선형, 원형 그래디언트 채우기 기법을 살펴보았고 스트로크를 통해 선 스타일 및 도형의 외곽선 출력 방안도 살펴보았다. 마지막으로 더욱 나은 벡터 렌더링 성능을 위해 RLE 압축 기법을 이용하여 벡터 이미지 데이터를 최적화할 수 있는 기법도 함께 배웠다.