캔버스(Canvas)란 회화 표면에 유화를 그릴때 쓰이는 평직물을 뜻한다. 이와 유사한 개념으로 UI 엔진에서 캔버스는 UI 엔진에서 UI 객체를 화면에 출력하기 위한 드로잉 영역으로서 활용된다. 하지만 단순히 드로잉 영역을 제공하는 평직물 이상으로, UI 엔진에서의 캔버스는 이보다 훨씬 더 복잡한 기능을 제공한다. 특히 리테인드(Retained) 그래픽 시스템의 캔버스의 경우 앱 화면을 구성하는 UI 객체의 라이프 사이클을 관리하고 이들이 적절한 시점에 화면에 출력될 수 있도록 도움을 준다. 그뿐만 아니라, UI 객체를 캔버스 내부에서 관리하기 때문에 최적의 렌더링을 위한 복잡한 알고리즘을 내부적으로 수행하기도 한다. 추가로 UI 객체를 관리하는 역할 특성상, 단순히 그래픽 출력 뿐만 아니라 사용자 이벤트 처리 방식까지 관여를 하게 되며 캔버스에 배치된 UI 객체가 사용자와 상호작용을 수행할 수 있도록 도와준다.

이처럼 캔버스는 UI 엔진에서 매우 중요한 역할을 수행한다. 개발자로서 UI 캔버스의 내부 동작을 이해한다면 UI 프레임워크의 전반적인 동작 이해에 큰 도움이 된다. 그뿐만 아니라, 앱 개발 관점에서도 앱 개발 중 발생한 UI 동작 오류는 물론, 보다 최적의 앱을 구현할 수 있는 고급 지식과 이해를 갖추는데 도움이 된다. 이번 장에서는 UI 엔진의 핵심인 캔버스 모델을 자세히 살펴보고 캔버스가 제공하는 기본 기능은 물론, 핵심 기능 구현 방법도 살펴보도록 하자. 이번 장을 학습하고나면 UI 객체가 화면에 출력되는 과정을 전반적으로 이해하는데 도움이 될 것이다.


1. 이번 장 목표

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

  • 렌더링의 개념과 동작 구조를 이해한다.
  • 리테인드 모드와 이미디어트 모드의 차이를 이해한다.
  • 캔버스 모델의 특징과 캔버스 엔진의 핵심 기능을 구현한다.
  • UIObject 모델을 이해하고 핵심 기능을 구현한다.
  • 씬그래프 기반의 캔버스 렌더링 메커니즘을 이해한다.
  • 레이어를 통한 객체의 렌더링 순서를 조정하는 방안을 살펴본다.


  • 2. 렌더링의 이해

    렌더링(Rendering)이란 컴퓨터 프로그램을 이용하여 입력 데이터로부터 최종 영상을 만들어내는 과정을 의미한다. UI 엔진 역시 UI 요소를 화면에 출력하기 위해 렌더링 엔진을 요구한다. 보다 복잡한 화면 구성일수록 렌더링 과정 역시 복잡하다. 벡터 그래픽스나 3차원 그래픽스처럼 기하학 정보로부터 최종 픽셀을 생성하는 과정의 경우 렌더링 과정에서 많은 연산이 필요하기 때문에 그래픽스 하드웨어 장치의 도움을 받기도 하며 최근의 시스템에서는 UI 출력 역시 그래픽스 하드웨어 장치를 활용하여 렌더링을 수행하기도 한다. 우선은 이해하기 쉽게 단순한 시나리오로 전체적인 맥락을 짚어 보자. UI를 화면에 출력하기 위해 어떠한 렌더링 절차가 필요할까? 다음 그림은 렌더링 과정을 하나의 예로서 간략하게 도식화한다.


    그림 1: UI 렌더링 과정

    UI 렌더링 엔진의 기능과 설계 방식에 따라 구조 및 동작 방식에 차이가 있을 수 있지만 일반적으로 UI 렌더링 엔진은 그림 1와 같이 이미지를 출력하는 이미지 렌더러(Image Renderer), 텍스트를 출력하는 텍스트 렌더러(Text Renderer), 벡터 드로잉을 수행하는 벡터 렌더러(Vector Renderer) 크게 세 부분의 렌더링을 수행한다. 이 세 렌더링 과정을 거치면, 이들이 만들어낸 결과물을 조합(Composition)하여 최종 결과물을 화면에 출력할 수 있다.

    이미지 렌더러는 jpeg, png 포맷과 같은 이미지 파일로부터 데이터를 읽어와 비트맵 이미지를 생성하는 역할을 담당하며 이미지 스케일링(Scaling), 변환(Transform), 색상 모델(Colorspace) 변환 등을 추가로 수행한다. 이 때 이미지 포맷에 따라 데이터 구성 및 디코딩 방식이 다르며 이미지를 불러오는 과정도 다르다. 이미지 로더에서 수행하는 디코딩 작업은 이미지 포맷에 의존하며, 디코딩 작업의 로직도 복잡한 편이다. 그렇기 때문에, 이미지 로더를 UI 렌더링 엔진에서 직접 구현하기 보다는 각 포맷에 해당하는 외부 모듈로 구성하는 것이 더 효율적이다.

    텍스트 렌더러는 폰트 데이터로부터 폰트 정보를 읽어와 글리프(Glyph)를 생성하며 이러한 글리프를 조합/배열하여 출력할 텍스트를 완성한다. 글리프는 텍스트를 구성하는 하나의 문자 이미지를 가리킨다. 글리프를 생성하는 폰트 엔진은 폰트 정보가 저장되어 있는 폰트 파일로부터 폰트 그래픽 디자인은 물론 위치, 문자 사이의 자간 정보 등을 해석하여 최종 텍스트 이미지를 완성한다. 대중적인 폰트 포맷으로 OTF(Open Type Format), TTF(True Type Format), Fnt(Font) 등이 있다. 일반적으로 폰트 엔진 역시 벡터 렌더링을 수행하며 그 자체로도 방대한 기능을 완성하기 때문에 UI 렌더링 엔진과는 별개의 모듈로서 구성하는 것이 설계 관점에서 더 바람직이다. 참고로 프리타입(freetype) 오픈소스 프로젝트는 텍스트를 출력하는 안정적이고 성능이 우수한 무료 소프트웨어를 제공한다.


    그림 2: 폰트 파일에는 글리프(문자)를 그리기 위한 벡터 정보가 기록되어 있다. (FontForge)


    벡터 렌더러는 선, 원, 다각형의 이미지를 그리는 역할을 수행하며 대표적으로 SVG와 같은 벡터 리소스 데이터를 이용하거나 사용자가 입력한 기하 정보로부터 수식을 통해 이미지를 실시간으로 생성한다. UI를 완성하기 위해 이미지와 텍스트를 이용할 수도 있지만 벡터 드로잉 방식을 이용하면 해상도에 영향을 받지 않는 최적의 이미지를 생성할 수 있으며 리소스 데이터 역시 이미지에 비해 매우 작은 편이다. 게다가, 이미지로는 구현하기 어려운 다소 복잡한 형상의 애니메이션을 동적으로 만들어 낼 수 있는 장점도 있다. 다만 벡터 드로잉은 이미지를 생성하는 연산 과정에서 부하가 발생하기도 한다. 이를 보완하기 위해 그래픽스 하드웨어 도움을 받는 것도 고려할 부분이다. 안드로이드의 머티리얼(Material) UI가 벡터 기반 UI의 대표적인 예이다.


    그림 3: 안드로이드 머티리얼 UI

    안드로이드 머티리얼 UI도 이에 해당되지만, 최근에는 마이크로 인터렉션(Micro Interaction) UX 개념이 유행하면서 과거에 비해 벡터 그래픽스가 여러 시스템에서 범용적으로 활용되고 있다. 최근에는 벡터 기반의 애니메이션을 지원하기 위해 바디무빈(Bodymovin)으로 불리는 json 포맷이 활용되고 있으며 이 포맷을 출력하기 위해 에어비엔비(Airbnb)에서 제작한 로띠(Lottie)라는 오픈소스도 존재한다. 기본적으로 바디무빈 데이터는 어도비(Adobe)의 애프터이펙트(After Effect) 툴에서 작업한 벡터 데이터를 추출한 결과물이다.


    그림 4: Airbnb의 오픈소스 프로젝트 Lottie

    사실상, 그동안 업계에서는 벡터 UX의 애니메이션을 지원하기 위한 별다른 표준 포맷이 존재하지 않았다. SVG의 Smil이라는 확장 포맷이 있었지만 이는 SVG의 공식 포맷이 아니었기 때문에 여러 UI 시스템에서 범용적으로 활용되지 못했다. 반면, 바디무빈은 Hernan Torrisi이라는 개인 개발자가 개발한 포맷이며 로띠 프로젝트를 통해 커뮤니티가 매우 잘 활성되었다. 실제로 LottieFiles 사이트에만 가더라도 무료로 활용할 수 있는 벡터 애니메이션 샘플 리소스를 다운받을 수 있으며 마이크로 인터렉션 디자인 트렌드와 함께 안드로이드, iOS, 자마린 등 여러 플랫폼에서 지원하기 시작했다.


    그림 5: LottieFiles에서 다운받을 수 있는 방대한 벡터 애니메이션 리소스

    UI 렌더링 엔진은 호출자에서 요구한 UI 구성 정보 내지 드로잉 구성 정보를 재해석하여 각각의 렌더러로 작업을 전달하며 각각의 렌더러는 요청한 이미지를 생성한다. 생성된 이미지는 사용자가 입력한 UI의 위치, 크기 정보를 토대로 재배치, 조합하여 최종 결과물을 만들어내는 컴퍼지션 작업을 거치게 된다. 완성된 결과물은 32비트 비트맵으로서, 비트맵을 구성하는 각 픽셀(점) 정보는 RGBA(24비트의 경우 RGB) 색상 정보를 갖춘다. 예를 들어, 400x400 크기 이미지의 비트맵이라고 가정하면 400x400x32비트 크기의 비트맵이 필요하며 여기서 하나의 픽셀 데이터 크기는 32비트이다. 이 한 픽셀은 다시 R(적색), G(녹색), B(청색), A(알파) 채널 정보로 구성되는데 각 채널당 8비트의 크기를 구성하며 이는 28 크기에 해당되므로 한 채널당 0 ~ 255의 색상 정보를 보유할 수 있다. 0 ~ 255 값의 각 채널을 조합하면 실제로 하나의 픽셀이 나타낼 수 있는 색상의 수는 16,581,375개가 된다. 결국 이러한 픽셀은 모니터 화소에 1:1로 매핑됨으로서 하나의 색상 점으로서 출력되며 최종 결과물인 비트맵을 생성하는 과정까지가 렌더링 엔진이 담당한다. 이 후의 비트맵은 디스플레이 출력을 담당하는 드라이버로 전달됨으로로써 최종적으로 화면에 보여질 수 있다.


    그림 6: 비트맵을 구성하는 픽셀 도식화

    비트맵을 구현하기 위해서는 일련의 메모리 공간을 필요로 하는데, 비트맵에 기록하는 각 픽셀의 채널 순서는 시스템 환경에 따라 다르게 지정될 수도 있다. 일반적으로 픽셀 채널은 R, G, B, A 순으로 구성한다.

    /* bitmap은 4바이트 크기의 데이터 타입 배열이라고 가정하자. 이 경우, 가로 10, 세로 10 크기의 비트맵 데이터를 가리킨다. */
    bitmap[10][10];
    bitmap[0][0] = 0xff0000ff;   //첫 번째 라인의 첫 번째 픽셀 색상은 적색
    bitmap[0][1] = 0x00ff00ff;   //첫 번째 라인의 두 번째 픽셀 색상은 녹색
    bitmap[0][2] = 0x0000ffff;   //첫 번째 라인의 세 번째 픽셀 색상은 청색
    ...
    bitmap[1][0] = 0xffff00ff;   //두 번째 라인의 첫 번째 픽셀 색상은 황색
    bitmap[1][1] = 0xff00ffff;   //두 번째 라인의 첫 번째 픽셀 색상은 보라색
    bitmap[1][2] = 0xffffffff;   //두 번째 라인의 세 번째 픽셀 색상은 흰색
    ...
    bitmap[7][9] = 0x000000ff;   //열 번째 라인의 여덟 번째 픽셀 색상은 검정
    bitmap[8][9] = 0x00ffffff;   //열 번째 라인의 아홉 번째 픽셀 색상은 청록색
    bitmap[9][9] = 0xffffffff;   //열 번째 라인의 열 번째 픽셀 색상은 흰색
    
    코드 1: 비트맵에 직접 색상 값을 채우는 예

    UI를 출력하기 위한 렌더링은 앱의 호출에 의해 발생할수도 있지만 보다 고급의 엔진에 가깝다면 UI 엔진 스스로 렌더링을 호출하기도 한다. 전자의 경우를 이미디어트(Immediate) 렌더링이라고 하고 후자는 리테인드(Retained) 렌더링이라고 한다. 이미디어트 렌더링은 리테인드 렌더링과 비교하면 보다 원초적으로 동작하는데 그만큼 앱이 렌더링에 더 많은 것을 관여할 수도 있다. 이미디어트 렌더링의 경우 앱이 드로잉 커맨드를 호출할 때마다 실제로 드로잉 대상 버퍼에 드로잉 작업이 수행되기 때문에 드로잉 호출에 보다 신중해야 한다. 앱은 불필요한 드로잉을 피해야 하며, 윈도우 시스템에 의해 무효(Invalid) 영역이 발생했을 때 화면을 새로 갱신하기 위한 드로잉 컨텍스트 관리 작업 등이 필요하다.


    그림 7: 이미디어트 렌더링

    /*
     * 이 예제에서는 이미지를 교체하고 버튼의 위치를 이동한다.
     * 버튼과 이미지의 인스턴스는 이전에 이미 생성, 초기화 되었다고 가정하자. 
    */
    update()
    {
        ...
    
        //새 버튼 이미지를 불러온다.
        img.open(NEW_IMG);
    
        //이미지를 화면에 다시 그리기 위해 무효 영역을 설정한다.
        invalidateArea(img.geometry());
    
        //버튼의 변화를 위해 이전 영역을 무효 영역으로 설정한다.
        invalidateArea(btn.geometry());
    
        //버튼의 위치가 (200, 200)에서 (300, 300)으로 이동하였다고 가정하자.
        btn.position(300, 300);
    
        //버튼의 새로운 위치를 기준으로 화면을 다시 그려야 한다.
        invalidateArea(btn.geometry());
    
        //무효 영역을 모두 지정한 이후, 화면을 갱신하기 위한 렌더링을 요청한다.
        render();
    
        //이 시점에서 화면에서 이미지와 버튼 위치가 바뀐 것을 확인할 수 있다.
        ...
    }
    
    코드 2: 이미디어트 렌더링 사용 예

    전통적인 그래픽스 시스템인 그놈(Gnome)의 GTK+, 마이크로소프트의 GDI, GDI+가 이미디어트 렌더링 엔진을 제공하며 3차원 그래픽스 인터페이스인 OpenGL과 Direct3D 역시 기본적으로 이미디어트 렌더링을 제공한다. 모바일 플랫폼인 안드로이드 역시 이미디어트 렌더링을 수행한다. 이미디어트 렌더링의 경우 호출자가 비교적 렌더링에 적극적으로 관여할 수 있기 때문에 할 수만 있다면 보다 복잡하고 최적화된 렌더링도 구현이 가능하다. 하지만 그만큼 구현이 복잡해지는 단점도 존재한다. 특히 게임과 같이 특수 효과 및 최적화가 중요 요소인 경우 자유도가 높은 이미디어트 모드의 렌더링이 보다 용이할 수도 있다.

    반면, 리테인드 렌더링의 경우 엔진 스스로 많은 작업을 알아서 처리해 준다. 차폐된 렌더링 객체를 걸러내고 클리핑(Clipping)을 통해 드로잉 영역을 최소화하면서 적절한 시점에 렌더링을 수행한다. UI 요소의 컨텍스트를 내부적으로 보유하고 있기 때문에 윈도우 시스템에 의해 무효 영역이 발생하더라도 엔진 스스로 해당 영역을 다시 그리는 작업을 수행할 수 있다. 이러한 이유로 앱 개발자는 드로잉과 관련된 작업보다 앱의 순수 로직에 더 집중하여 개발할 수 있는 장점을 제공한다. 동시에 같은 이유로, 드로잉과 관련된 전반적인 컨텍스트 관리는 물론 UI 요소들의 정보를 엔진이 내부적으로 유지하고 있어야 하기 때문에 이미디어트 모드에 비해 엔진 구현이 보다 복잡하고 어려운 편이다. 이는 곧, 엔진 내부적으로 드로잉과 관련된 모든 정보가 은닉되어 있어서 앱 개발자는 드로잉과 관련된 작업에 직접적인 관여를 하기가 어렵다. 하지만 최근 기본 리테인드 모드의 그래픽스 시스템을 살펴보면 리테인드 렌더링 뿐만 아니라 이미디어트 렌더링 기능도 추가로 제공한다. 리테인드 모드 그래픽스 시스템에서의 이미디어트 렌더링 기능은 필요에 따라서 이미지 캡처 및 디버깅 등의 목적으로 활용될 수 있다. 마이크로소프트의 WPF와 그보다 최신인 UWP 그리고 애플의 IOS는 리테인드모드를 제공한다. 타이젠에 탑재된 Enlightenment Foundation Libraries(EFL) 역시 마찬가지이다.


    그림 8: 리테인드 렌더링

    /*
     * 이 예제에서는 이미지를 교체하고 버튼의 위치를 이동한다.
     * 버튼과 이미지의 인스턴스는 이전에 이미 생성, 초기화 되었다고 가정하자. 
    */
    update()
    {
        ...
        
        //새 버튼 이미지를 불러온다.
        img.open(NEW_IMG);
    
        //버튼의 위치가 (200, 200)에서 (300, 300)으로 이동하였다고 가정하자.
        btn.position(300, 300);
    
        /* 이미디어트 렌더링에 비해 코드가 매우 간소하다. 렌더링을 직접 요청하지도 않는다.
           하지만, 이 시점에서 화면에서 이미지와 버튼 위치가 바뀌었을까?... 
           앱 개발자 입장에서는 알 수가 없다. */
        ...
    }
    
    코드 3: 리테인드 렌더링 사용 예


    3.캔버스 엔진

    캔버스는 렌더링의 상위 개념이다. 우리는 캔버스 엔진 학습에 있어서 이미디어트보다 더 고급 개념인 리테인드 방식의 캔버스 엔진에 집중할 것이다. 리테인드 방식의 캔버스는 렌더링 대상인 UI 객체를 다루면서 필요시 렌더링을 수행한다. 일반적으로 하나의 출력 영역을 갖는 앱은 하나의 캔버스를 가지며 캔버스는 출력 영역에 그려질 화면을 생성한다. 1장에서 살펴보았듯이, 전통적인 시스템의 UI 앱은 윈도우를 통해 출력 영역을 확보하며 윈도우에 자신이 출력할 캔버스의 출력 버퍼를 매핑한다.


    그림 9: 캔버스 출력 버퍼 윈도우 매핑 도식화

    캔버스 엔진을 구현하기에 앞서, 우선 UI앱의 윈도우가 생성될 경우 윈도우와 매핑될 캔버스를 생성하고 초기화하는 구현부를 구축해보자. 현재 캔버스는 블랙박스인 채로 이해해도 무방하다.

    /* * UIWindow는 앱의 출력 영역을 결정하는 객체이다. * UIWindow 내부적으로 캔버스를 생성하고 초기화한다. * 생성한 캔버스는 UI엔진과 연동한다. * UIWindow의 동작은 윈도우 관리자의 관리를 받기 때문에 일반 UIObject와는 다른 * 방식으로 동작을 수행해야 한다. */ UIWindow { /* * 생성할 윈도우 타입을 정의한다. 윈도우 관리자는 윈도우 타입에 따라 관리 정책을 * 다르게 적용한다. 일부 선택 옵션은 앱의 권한에 따라 허용이 불가할 수 있다. * * Basic: 일반 UI앱을 위한 윈도우 * Desktop: 윈도우 관리자가 데스크탑 화면을 출력하기 위한 윈도우 * Popup: 임시적으로 컨텍스트 전환을 위한 윈도우. 다른 윈도우보다 우선순위를 높다. * Notification: 사용자에게 어떤 정보를 알리기 위한 윈도우 * ... * 필요에 따라 그 외 다른 타입의 윈도우를 정의/설계한다. */ UIWindowType = {TypeBasic, TypeDesktop, TypePopup, TypeNotification, ... }; UIWindowType type; //윈도우 타입 UICanvas canvas; //윈도우에 매핑된 캔버스 객체 /* 윈도우 시스템에서 제공하는 네이티브 윈도우 객체. 윈도우 관리자와 데이터를 주고받는 등의 통신을 수행하기 위한 포트(Port) 역할을 수행하며 윈도우 상태를 요청하거나 전달받는다. */ NativeWindow window; /* * 생성자. * 앱 개발자는 윈도우를 생성할 때 윈도우의 타입을 결정할 수 있다. * 윈도우 타입의 기본 값 설정은 BASIC이다. */ constructor(type = UIWindow.TypeBasic) { //type의 유효성 검사 ... //네이티브 윈도우의 타입을 결정한다. NativeWindowType windowType; switch(type) { UIWindow.TypeBasic: UIWindow.TypeDialog: UIWindow.TypeDock: UIWindow.TypeView: UIWindow.TypeDesktop: windowType = NativeWindow.TypeTopLevel; break; UIWindow.TypeMenu: UIWindow.TypeNotification: UIWindow.TypePopup: UIWindow.TypeTooltip: windowType = NativeWindow.TypeMenu; break; UIWindow.TypeDnd: windowType = NativeWindow.TypeDnd; break; default: windowType = NativeWindow.TypeDefault; } //네이티브 윈도우를 생성한다. 네이티브 윈도우는 내부적으로 캔버스를 생성한다. self.window = new NativeWindow(windowType); /* 윈도우 관리자에 의해 네이티브 윈도우의 상태가 변경될 수 있으므로 이 경우 이벤트를 등록해 UIWindow의 상태도 동시에 변경해야 한다. */ self.window.addEventCb(NativeWindow.EventResize, //EventResize 이벤트 발생 시 아래 코드가 수행된다. lambda(NativeWindow window) { ... } ); self.window.addEventCb(NativeWindow.EventShow, //EventShow 이벤트 발생 시 아래 코드가 수행된다. lambda(NativeWindow window) { ... } ); self.window.addEventCb(NativeWindow.EventHide, //EventHide 이벤트 발생 시 아래 코드가 수행된다. lambda(NativeWindow window) { ... } ); //그 외에 더 많은 이벤트가 존재한다. ... //캔버스를 생성한다. self.canvas = UICanvas(); //캔버스 엔진을 초기화한다. 매핑할 윈도우의 정보를 전달한다. self.canvas.setupEngine(self.window, ...); //캔버스를 엔진과 공유한다. UIEngine.canvas(self.canvas); } /* * 윈도우의 크기를 변경하면 캔버스의 크기도 변경한다. * 캔버스는 출력 버퍼의 크기를 최신의 크기로 재조정한다. */ size(w, h) { //캔버스의 크기를 변경한다. self.canvas.size(w, h); //윈도우 시스템에게 윈도우 버퍼 크기 변경 사실을 알려야만 한다. self.window.requestSize(w, h); ... } /* * 윈도우를 화면에 보인다. */ show() { //윈도우 시스템에게 윈도우가 화면에 나타나도록 요청한다. self.window.requestShow(); ... } /* * 윈도우를 화면에서 감춘다. */ hide() { //윈도우 시스템에게 윈도우가 화면에서 사라지도록 요청한다. self.window.requestHide(); ... } ... }

    코드 4: 캔버스를 생성하는 윈도우

    코드 4를 보면 UIWindow 내부적으로 NativeWindow가 존재한다. NativeWindow는 윈도우 시스템 인터페이스를 구현하며 서버 역할을 수행하는 윈도우 관리자/컴퍼지터와 통신을 수행하는 포트(Port) 역할을 담당한다고 가정한다. NativeWindow는 UI앱과 윈도우 관리자/컴퍼지터간의 통신 규약을 준수하며 동작 신호를 주고 받을 수 있다. 실제로 리눅스 시스템에서는 윈도우 시스템으로서 X Window와 Wayland을 대표적으로 활용한다.


    그림 10: NativeWindow와 윈도우 관리자 사이의 메시지 통신

     X Window와 Wayland


    X Window는 1984 MIT대학에서 고안한 윈도우 시스템으로 현재 버전 11까지 개발되었으며 긴 역사만큼 많은 기능들을 소화한다. 그에 반에 Wayland는 2012년 릴리즈한 윈도우 시스템으로인 만큼 최신 트렌드 기능 중심으로 X Window보다 경량화된 윈도우 시스템이다. 특히 불편하고 복잡한 인터페이스를 개선하고 X Window 시스템에서 사용되지 않은 불필요한 요소를 제거하여 릴리즈 시 여러 시스템 개발자들로 하여금 많은 관심을 모았다. Wayland는 클라이언트와 컴퍼지터간 캔버스 버퍼를 직접 공유하고 IPC의 보안 취약 요소를 제거하여 보다 안정적이고 효율적이다. 최근 몇 년간 많은 시스템이 X Window시스템으로부터 Wayland 시스템으로 전환하였으며 GNOME, KDE, EFL 등의 리눅스 UI 시스템 역시 Wayland를 지원한다.


    코드 4의 92번째 라인을 보면, UIWindow는 화면 출력을 위해 UICanvas 인스턴스를 하나 생성하여 UIEngine에 전달해 준다. UIEngine은 전달받은 UICanvas를 이용하여 적절한 시점에 렌더링을 요청한다.

    /* * UIEngine은 UI 엔진을 구동하는 클래스. UI 앱의 메인루프를 구동하며 시스템, 사용자 * 이벤트를 처리하고 캔버스를 통해 렌더링이 발생할 수 있게 한다. 하나의 프로세스(앱)은 * 반드시 하나의 UIEngine을 구동한다. 그렇기 때문에 UIEngine은 싱글턴(singleton) 또는 * 정적 객체로 구현하는 것이 가능하다. */ UIEngine { bool stop = true; //엔진 동작 여부 UICanvas canvas; //캔버스 객체 ... /* * 렌더링을 수행할 UICanvas를 지정한다. */ canvas(canvas) { /* 기존에 이미 캔버스가 설정되어 있는 경우 별다른 처리가 필요하다. 하나의 엔진이 반드시 하나의 캔버스가 보유해야 할까? 사실 여러 개의 윈도우를 보유한 앱이 존재할 수도 있다... */ if (self.canvas != null) { ... } self.canvas = canvas; //멤버변수로 캔버스 객체를 전달한다. } /* * 엔진 초기화 작업을 수행한다. */ init() { /* 비정상 호출. 이미 엔진이 가동 중이다... 발생해선 안된다. 에러 메시지 등의 적절한 예외처리를 수행한다. */ if (self.stop == false) { System.printError(...); return false; } self.stop = false; self.canvas = null; ... } /* * 엔진을 구동한다. 앱의 메인루프에 해당하는 무한루프가 발생하며 일렬의 작업을 * 지속 수행한다. 그림 1.14 참고 */ run() { repeat {

    //stop()이 호출되면 메인루프도 종료

    if (self.stop == true) break;

    //이벤트 대기 ... //이벤트 처리 ... //캔버스 업데이트 self.canvas.update(); //캔버스 렌더링 수행 self.canvas.render(); } } /* * 엔진 가동을 중지한다. */ stop() { self.stop = true; ... } /* * 엔진 리소스를 정리하는 작업을 수행한다. */ term() { //음? 아직 엔진이 가동 중이다... 정상적인 호출일까? if (self.stop == false) { self.stop(); } self.canvas = null; ... } }

    코드 5: 엔진의 캔버스 렌더링 수행 코드

    캔버스는 기본적으로 윈도우에 매핑할 출력 버퍼를 생성하고 초기화하는 작업을 수행한다. UI앱의 캔버스 버퍼는 컴퍼지터와 공유되므로 단순히 프로세스에 종속된 메모리 이상으로, 공유 메모리의 특성을 가져야 한다. 다음 코드는 UICanvas에서 버퍼를 초기화하는 과정이다.

    /* * UICanvas은 UI 객체의 라이프사이클은 물론 동작을 통제한다. 동시에 렌더링할 * 대상의 버퍼를 설정하고 씬그래프(Scene-Graph)를 통해 활동 중인 객체를 렌더링 * 한다. 캔버스에 입력된 사용자 입력을 좌표값 및 포커스를 통해 올바른 UI 객체로 * 전달한다. 하나의 UICanvas 인스턴스는 UIWindow 인스턴스와 1:1로 매핑된다. */ UICanvas { /* 캔버스 엔진 정보... */ NativeDisplay displayInfo = null; //디스플레이 정보 NativeWindow window = null; //네이티브 윈도우 NativeSurface surface = null; //윈도우 서피스 (네이티브 윈도우에 종속) NativeBuffer buffer = null; //캔버스 버퍼 (서피스에 종속) RenderContext ctx = null; //렌더링 컨텍스트 Size size = {1, 1}; //캔버스의 크기 var rotation; //화면 회전 각도 var depth; //화면 깊이 정보 bool alpha; //알파 채널? /* 이하 생략... */ ... /* * 캔버스 화면 출력 */ flush() { /* 그림을 완성했다고 컴퍼지터에게 신호를 보낸다. 신호를 받은 컴퍼지터는 해당 윈도우의 버퍼를 이미지로서 컴퍼지팅할 수 있다. */ self.window.commit(NativeWindow.CommitAsync); } public: /* * 캔버스 엔진을 설정한다. 전달받은 디스플레이 정보는 출력 형식 정보를 제공하며 * 이를 토대로 캔버스 버퍼를 생성한다..

    * window: NativeWindow */ setupEngine(window, ...) { self.displayInfo = window.displayInfo(); self.window = window; self.surface = window.createSurface(...); /* 새로운 크기로 버퍼를 할당한다. 전달받은 displayInfo는 출력장치 정보를 제공하며 이를 토대로 캔버스 버퍼를 생성한다고 가정하자. */ self.buffer = NativeBuffer(self.surface, self.width, self.height, RGBA32, IO_WRITE + IO_READ ...); /* NativeBuffer는 공유하는 기능을 제공한다. 인터페이스에 맞춰 여러 설정 작업을 수행한다. */ self.buffer.shareInfo(IPC_PRIVATE | IPC_CREAT | 0600, ...); // 아래 코드는 현재 중요하지 않다... self.ctx = self.surface.context(...); self.rotation = window.rotation(); self.depth = self.surface.depth(); self.alpha = self.surface.alpha(); ... } /* * 캔버스 크기 설정. 주어진 크기로 버퍼를 할당한다. */ size(width, height) { self.size.w = width; self.size.h = height; /* 새로운 크기로 버퍼를 재할당한다. 캔버스 버퍼는 더이상 유효하지 않으므로 이 후 반드시 드로잉을 다시 수행해야 할 것이다. */ self.buffer.realloc(width, height); ... } /* * 캔버스 업데이트. 렌더링을 수행하기 전에 캔버스에 존재하는 UI 객체를 대상으로 * 어떠한 사전 준비를 수행한다. */ update() { //TODO: 캔버스에 존재하는 UI객체를 업데이트한다. } /* * 캔버스 렌더링 수행. 캔버스 버퍼에 UI를 그리는 작업을 수행한다. */ render() { //TODO: 캔버스 버퍼에 UI를 그리는 작업을 수행한다. self.flush(); //그림 완료 신호를 보낸다. } }

    코드 6: UICanvas 초기화 코드

    코드 6은 캔버스 엔진이 UI객체를 그릴 버퍼를 할당하고 이와 관련된 부수적인 초기화 작업을 수행한다. 이 과정은 setupEngine()에 구현되어 있으며 전달받은 NativeWindow를 통해 필요한 정보를 요청할 수 있다. NativeWindow는 윈도우 관리자/컴퍼지터 사이의 통신 프로토콜 역할을 수행하면서도 UICanvas의 버퍼를 위한 NativeSurface를 제공한다. NativeSurface는 실제로 윈도우 관리자/컴퍼지터와 공유되는 자원으로서 실제 버퍼는 NativeBuffer를 통해 접근이 가능하다. 본 예제에서는 NativeBuffer라는 리소스를 정의하였지만, 실제로 이같은 공유 자원은 각 그래픽스 시스템에서 제공하는 리소스 타입으로 대처할 수 있다. 일반적으로 임베디드 시스템에서는 openGL ES를 활용하여 드로잉 작업을 수행하며 eglSurface를 통해 드로잉 대상을 지정한다. eglSurface가 가리키는 실제 버퍼 메모리는 다른 프로세스와 공유하여 불필요한 메모리 복사 과정을 줄인다. 이는 앞서 살펴본 NativeSurface/NativeBuffer와 개념적으로 동일하다.

    UICanvas.flush()가 발생하면, UI앱(클라이언트)는 윈도우 윈도우 관리자/컴퍼지터로 그림을 완성했다는 메세지를 보낸다. 윈도우 관리자/컴퍼지터 공유받은 버퍼를 기반으로 추가적으로 윈도우 효과를 적용하거나 다른 윈도우 화면과 함께 최종적으로 디스플레이에 출력을 한다. 앞서 살펴본 코드에서 캔버스와 컴퍼지터 사이의 버퍼의 공유 구조를 도식화 하면 다음과 같다.


    그림 11: UI앱과 컴퍼지터 사이의 캔버스 버퍼 공유 구조

    사실상 앱 개발자는 캔버스의 생성/소멸, 렌더링 여부를 확인하기가 어렵다. 불필요한 코어 기능 노출은 사용자로 하여금 치명적인 오류를 유발할 기회를 제공하므로 이와 관련된 일련의 작업은 윈도우 및 엔진 클래스로 감추었다. 이러한 구현 컨셉은 리테인드 렌더링 특성에 부합하기도 하다. 엔진 및 윈도우는 프레임워크 기본 구조를 바탕으로도 다르게 설계할 수 있다. 대표적으로 안드로이드 시스템의 경우에는 윈도우 관리지와 더불어 서피스플링거(SurfaceFligner)가 컴퍼지터 역할을 수행하며 각 UI 앱의 화면을 조합하여 최종적으로 화면에 출력한다.

    출력 버퍼 설정을 완료했다면, 캔버스는 실제로 그림을 그리기 위한 도구 준비를 마친 셈이다. 하지만 캔버스가 그려야 할 대상은 무엇일까? 캔버스가 렌더링을 수행하기 위해서는 렌더링할 정보가 필요하다. 그 정보는 캔버스에 거주하는 UI 객체에 저장되어 있다. 캔버스는 생성된 UI 객체를 대상으로 렌더링을 수행할 수 있으며 실제로 앱에서 생성한 UI 컨트롤 객체 역시 캔버스가 내부적으로 관리하며 적절한 시점에 이들을 이용해 렌더링을 수행한다.

    기본적으로 UICanvas는 생성된 객체를 리스트로 관리하며 이들의 상태를 추적하며 렌더링을 할지 말지를 결정할 수 있다. 캔버스는 수시로 화면 갱신(보통 초당 60번)을 해야 하므로 객체를 관리하는 방식 역시 가급적 효율적이어야 한다. 특히나 UI앱의 특성 상, 화면을 구성하는 UI가 수시로 변경될 가능성이 크기 때문에, UI 객체 역시 생성/삭제가 빈번히 발생한다는 점을 염두해야 할 것이다. 보통 캔버스에 거주할 객체의 개수는 런타임 시 결정되므로, 객체를 리스트 형태로 구성하는 것도 무난하다.


    그림 12: UI 객체를 대상으로 렌더링을 수행하는 캔버스

    UICanvas { ... List<UIObject> objs; //생성된 오브젝트 목록 /* * 캔버스에 새로운 오브젝트를 추가한다. */ addObj(obj) { /* 오브젝트가 캔버스 외부에서 삭제되는 것을 방지하기 위해 참조되고 있다는 사실을 기록한다. */ obj.ref(); self.objs.append(obj); } /* * 캔버스에서 기존 오브젝트를 제거한다. */ removeObj(obj) { self.objs.remove(obj); obj.unref(); //더 이상 참조되지 않는다. } /* * 소멸자 */ destructor() { ... //소멸되기 전 역시 오브젝트 참조를 해제한다. foreach(self.objs, obj) obj.unref(); ... } public: /* * 캔버스 업데이트. 렌더링을 수행하기 전에 캔버스에 존재하는 UI 객체를 대상으로 * 어떠한 사전 준비를 수행한다. */ update() { //캔버스에 존재하는 UI객체를 업데이트한다. foreach(self.objs, obj) obj.update(); } /* * 캔버스 렌더링 수행. 캔버스 버퍼에 UI를 그리는 작업을 수행한다. */ render() { /* 캔버스 버퍼에 UI를 그리는 작업을 수행한다. 이 로직이라면, 생성된 모든 오브젝트를 그리게 된다... */ foreach(self.objs, obj) obj.render(); self.flush(); //그림 완료 신호를 보낸다. } }

    코드 7: UIObject를 다루는 UICanvas 코드

    UICanvas의 update()와 render()를 보면, 생성된 모든 객체에 대해 처리하는 것을 볼 수 있다. 실제로 UI앱의 구현에 따라, 캔버스에 추가된 객체의 개수는 상당히 많을 수 있다. 생성된 객체가 실제로 화면에 모두 보인다고 가정하기 어렵기 때문에 앞선 구현 로직은 다소 비효율적으로 보인다. 하지만, 렌더링 로직은 추후에 개선하도록 하고 일단은 여기서는 UICanvas와 UIObject가 어떻게 연동되고 이들 간의 렌더링이 어떻게 호출되는지 이해하는 것만으로도 충분하다.


    4. 오브젝트 모델

    캔버스 버퍼가 화면에 UI를 출력하기 위한 도화지라면, 2.3절의 구현을 통해 UICanvas는 화면에 무언가를 그릴 조건을 갖춘 셈이다. 실제로 UIEngine은 UICanvas.render()를 호출하여 그리는 작업을 수행할 것이다. 하지만 화면에 존재하는 수많은 UI 객체를 효율적으로 그리기 위해서는 데이터를 체계화 할 필요가 있다.

    오브젝트 모델은 UI 객체의 데이터를 구조화 한다. HTML의 도큐먼트 오브젝트 모델 트리(Document Object Model Tree)처럼 오브젝트를 부모-자식 관계로 연결하고 오브젝트 사이의 관계 및 특성을 타입별로 구분할 수 있다면 오브젝트를 탐색하고 업데이트하며 렌더링을 보다 안정적이고 빠르게 처리할 수 있다.


    그림 13: HTML 도큐먼트 오브젝트 모델 트리 (W3C)


    UICanvas가 UI 객체를 엔진 내부적으로 동일하게 처리할 수 있도록 뼈대에 해당하는 기반 클래스를 제공한다면, 추후 다양한 타입의 UI 컨트롤을 쉽게 확장해 나갈 수 있을 것이다. 이는 UICanvas의 수정없이 UI 컨트롤을 확장할 수 있기 때문에 설계 관점에서 고려할만한 사항이다 . 그뿐만 아니라, 다양한 UI 객체의 공통 특성 및 동작을 재사용할 수 있으며, 앱 개발자 역시 다양한 UI 객체를 동일한 방식으로 구현하여 앱의 UI를 구현할 수 있다. 실제로 많은 UI 시스템에서는 UI 컨트롤를 상속구조로 확장/구현한다. IOS의 경우 NSObject라는 기저 클래스를 구현하고 이를 상속하여 다양한 UI 컨트롤을 확장한다.



    그림 14: IOS Cocoa 클래스 상속 구조 (일부)

    본 시스템에서의 UIObject는 UI 컨트롤의 기본 클래스로서 UI 객체가 수행해야할 기본 동작의 인터페이스를 제공한다. UIObject를 구현함으로써 다양한 타입의 UI 객체를 확장하여 정의할 수 있다. 1장의 예제로서 잠깐 활용했던 UISearchbar 및 UIButton 역시 UIObject의 자식 클래스로 확장 가능하다.



    그림 15: UIObject 클래스 상속 예

    그림 15는 UIObject와 하위 UI컨트롤 사이의 관계의 복잡도를 최대한 단순화시킨 예이지만, 앞으로 살펴볼 UIObject가 어떤 위치에 있는지를 명확하게 보여준다.

    다음 그림은 UICanvas에서 동작하는 UIObject의 클래스 다이어그램이다. 주요 속성과 동작만 정의해 보자.


    그림 16: UIObject 클래스 다이어그램

  • type: 오브젝트 타입 이름. UIButton의 경우 type은 “UIButton” 을 보유한다.
  • refCnt: 현재 객체에 접근하고 있는 참조 횟수.
  • geom: 위치, 크기 지오메트리 정보
  • layer: 레이어 위치. 레이어의 값이 클수록 상단에 표시된다.
  • canvas: 오브젝트가 종속된 UICanvas의 인스턴스
  • parent: 부모 UIObject
  • children: 자식 UIObject 리스트
  • visible: 가시 상태 여부 (화면에 보이는가?)
  • changed: 내부 상태 변화가 발생했는지 여부
  • deleted: UICanvas로부터 삭제 요청을 받은 경우 true이며 이 경우, 객체는 더 이상 유효하지 않다. deleted가 true인 상황에서 객체에 접근이 발생할 경우, 에러 메시지를 출력할 수 있다. C 언어와 같은 저수준 프로그래밍 언어처럼 안전한 메모리 접근 메커니즘을 제공하지 않는 경우에는 태그 식별자를 이용하여 메모리의 유효성을 추가로 검증할 수 있다.

  •  레이어(layer)


    쉽게 포스트잇 용지를 생각하면 이해하기 쉽다. 어떤 용지가 최상단에 위치하고 있는가? 화면 상에 겹겹이 쌓인 UI 객체의 경우, 레이어의 값을 통해 스택 순서를 결정한다. 레이어의 값을 상향 또는 하향 조율하여 객체의 위치를 변경할 수 있다.



     태그(tag) 식별자


    프로그래밍 언어가 런타임으로 동작하는 비교적 안전한 메모리 관리 메커니즘을 제공하더라도 UICanvas 스스로 UIObject의 메모리를 꼼꼼하게 통제하고 싶다면 tag는 유효하다. tag는 오브젝트의 특정 필드에 유니크한 값을 기록하고 이후 오브젝트의 메모리에 접근할 때 마다 이 필드의 값을 확인하여 메모리가 유효한지 검증한다. 메모리가 유효하지 않다면 엔진 레벨에서 오브젝트 접근을 방지할 수 있다. UI 시스템 독자적인 메모리 공간(Memory Pool)을 사용하는 경우에는 메모리 공간이 보장되기 때문에 이러한 방지가 가능하다. 하지만 태그 식별자를 사용하더라도 엔진 독자적인 메모리 공간을 사용하지 않는다면 간헐적으로 앱의 메모리 사용 위반에 문제가 발생할 수도 있다. 특히 가상 메모리 시스템에서는 이미 해제된 사용자 영역의 메모리일지라도 커널 레벨에서의 해당 주소의 페이지가 보존되고 있다면 해당 메모리 접근 시 세그멘테이션 폴트(Segmentation Fault)가 발생하지 않는다. 이 경우, 태그 식별자를 통해 메모리 접근 오류 메시지를 출력할 수 있다. 반면, 페이지가 삭제되어 더이상 존재하지 않는 메모리 주소인 경우에는 커널은 세그먼테이션 폴트를 발생시킨다. 이 경우 프로세스가 바로 중단되기 때문에 프로세스가 크래시(crash)로부터 더 이상 안전하지 않다. 이러한 동작 여부는 커널의 메모리 관리 유닛(MMU)에 달려있으며 이로 인해 발생하는 일관적이지 않는 동작은 오히려 문제를 감추거나 앱 개발자를 혼란에 빠뜨릴 수도 있다.



    여기서는 지면상 단순한 클래스를 구성했다는 점을 알아두자. 보다 확장이 용이하고 유지보수가 쉬운 실질적인 UI 엔진을 개발한다면, UIObject를 구성하는 기능을 특성별 엔티티(Entity)로 구성하고 인터페이스를 여러 집합으로 분리하는 것이 고려되어야 한다.그뿐만 아니라 UIObject 자체는 실체가 존재하지 않기 때문에 이를 추상 클래스로 정의하여 UIObject 객체 자체를 생성하는 것도 방지해야 한다.

    다음은 UIObject 구현부이다.

    /* * UIObject는 모든 UI 컨트롤의 기저(base) 클래스에 해당하며 UI 객체의 기본 동작 및 * 속성을 구현한다. UICanvas에 종속되며 여기서 보여주는 예시는 모델을 매우 간소화하여 * 핵심만 보여주고자 함을 이해하자. */ abstract UIObject { //friends 지정으로 UICanvas는 UIObject의 내부 기능에 접근 가능하다. friends UICanvas; String type; Var refCnt = 0; Geometry geom = {0, 0, 0, 0} Var layer = 0; UICanvas canvas; UIObject parent; List<UIObject> children; Bool visible = false; Bool changed = true; Bool deleted = false; /* * 생성자 * canvas: UIObject가 종속된 캔버스 * type: 객체의 타입 이름 * parent: 생성할 UIObject의 부모 객체. 지정하지 않으면 최상위 객체이다. */ constructor(canvas, type, parent = null) { if (canvas.ready() == false) { //캔버스가 준비되어 있지 않다... 예외 처리를 수행한다. } self.canvas = canvas; self.type = type; //캔버스에 오브젝트를 추가한다. canvas.addObj(self); self.parent(parent); //부모를 지정한다. } /* * 레퍼런스 카운팅 메커니즘을 흉내낸다. 자바처럼 가비지 컬렉터 위에 레퍼런스 * 카운팅이 내장되어 있는 경우 따로 구현할 필요가 없다. ref()는 컴파일러의 * 해석으로 객체를 생성하거나, obj = obj2; 와 같이 객체 레퍼런스 복사가 발생할 때 * 호출된다고 가정한다. */ ref() { //이미 삭제를 요청받은 인스턴스이다. 더 이상의 레퍼런스는 허용하지 않는다. if (self.deleted == true) return; ++refCnt; } /* * 레퍼런스 카운팅 메커니즘을 흉내낸다. 자바처럼 가비지 컬렉터 위에 레퍼런스 * 카운팅이 내장되어 있는 경우 따로 구현할 필요가 없다. unref()는 컴파일러의 * 해석으로 obj = null; 처럼 객체를 제거하거나 스택 영역이 종료될 때 호출된다고 * 가정한다. */ unref() { --refCnt; //더 이상 레퍼런스가 존재하지 않는다. dispose()로 제거하자. if (refCnt == 0) dispose(); } /* * 부모를 재지정한다. * parent: 지정할 부모 객체. null인 경우 현재 객체는 독립한다. */ parent(UIObject parent) { if (self.deleted == true) return; //부모와 이 객체의 캔버스가 다르다? 허용하지 않는다! if (parent && parent.canvas != self.canvas) { System.printError(“...”); return; } //기존 부모로부터 현재 객체를 제거한다. if (self.parent) self.parent.removeChild(self); //새로운 부모가 null일 수도 있다!! if (parent) parent.addChild(self); self.parent = parent; self.changed = true; } /* * 자식 객체를 추가한다. * child: 추가할 자식 객체 */ addChild(UIObject child) { if (self.deleted == true) return; //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. if (self.children.exists(child) == true) return; self.children.add(child); child.parent(self); self.changed = true; } /* * 자식 객체를 제거한다. * child: 제거할 자식 객체 */ removeChild(UIObject child) { if (self.deleted == true) return; //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. self.children.remove(child); child.parent(null); self.changed = true; } /* * 이 객체를 캔버스에 그리는 작업을 수행한다. */ render() { if (self.deleted == true) return; //TODO: 어떻게?... } /* * 이 객체의 상태를 새로 갱신하다. */ update() { if (self.deleted == true) return; //변경된 사항이 없다. if (self.changed == false) return; //TODO: 어떻게?... } public: /* * 화면(캔버스)에 보인다. */ show() { if (self.deleted == true) return; self.visible = true; self.changed = true; } /* * 화면(캔버스)에서 감춘다. */ hide() { if (self.deleted == true) return; self.visible = false; self.changed = true; } /* * 객체의 위치를 지정한다. * x: x 좌표값 * y: y 좌표값 */ move(x, y) { if (self.deleted == true) return; self.geom.x = x; self.geom.y = y; self.changed = true; } /* * 객체의 크기를 지정한다. * w: 가로 크기값 * h: 세로 크기값 */ size(w, h) { if (self.deleted == true) return; self.geom.w = w; self.geom.h = h; self.changed = true; } /* * 레이어의 값을 변경한다. * layer: 새 레이어 값 */ restack(layer) { if (self.deleted == true) return; self.layer = layer; self.changed = true; } /* * UI 객체가 더 이상 필요없는 경우 호출하여 캔버스로부터 제거한다. */ dispose() { if (self.deleted == true) return; self.deleted = true; --refCnt; //부모가 있는 경우, 부모로부터 연결을 끊는다. if (self.parent) self.parent.removeChild(self); self.children.clear(); //자식이 있는 경우 자식도 제거한다. //캔버스에서 오브젝트를 제거한다. self.canvas.removeObj(self); /* 더 이상 사용하지 않는 인스턴스이므로 Memory Management Unit에게 메모리 반환을 요청한다. 예시로 보여줄 뿐, 자바의 가비지 컬렉터처럼 메모리 관리 시스템이 따로 있는 경우 이러한 구현은 필요없다. */ if (refCnt <= 0) System.MMU.retrieve(self); } /* * 부모 객체를 반환 */ const parent() { if (self.deleted == true) return; return self.parent; } /* * 자식 목록을 반환 */ const children() { if (self.deleted == true) return; return self.children; } /* * 객체의 지오메트리 값을 반환한다. */ const geometry() { if (self.deleted == true) return; return self.geom; } }

    코드 8: UIObject 클래스

    2절에서 설명했던 이미지, 텍스트, 벡터 출력 역시 역시 UIObject를 확장하여 구현하며 실제로 UIObject는 모든 UI 객체의 기반이 되는 동작을 수행하므로 반드시 눈여겨 보도록 하자.

    하나의 UIObject 객체는 다수의 자식을 보유할 수 있는데, 실제로 하나의 UI 컨트롤은 이미지, 텍스트 등 여러 요소가 모여서 하나의 컨트롤을 구성한다. 그림 1.3의 검색 상자의 경우에도 검색 상자를 표현하는 배경 이미지, 가이드 텍스트 그리고 아이콘 등 다수의 UIObject가 모여서 하나의 컨트롤을 구성할 수 있다.


    그림 17: UISearchBar 계층 구성도

    UIObject는 다수의 자식을 보유함으로써 트리(tree)를 구축한다. UIObject가 내부적으로 parent와 children를 구현하고 있는 이유이다. 이를 위해 parent(), addChild(), removeChild()를 구현한다. 부모는 자식을 소유함으로써 자식 객체의 위치 및 크기는 물론, 자식 고유의 동작도 제어하고 관리할 수 있다. 만일 자식에 해당하는 UI 객체의 인스턴스를 UI앱이 직접 접근할 수 있다면, 부모 객체와 UI앱 둘 모두 자식 객체에 접근이 가능하므로 동작 충돌에도 염두해야 한다. 예를 들면, UI앱이 UISearchbar의 아이콘을 멋대로 변경하거나 삭제해 버린다면 UISearchbar 본연의 기능을 제대로 보여주지 못할 수도 있다. 프레임워크 개발 관점에서는 이러한 예외 가능성을 염두하여 UI 컨트롤의 인터페이스를 디자인하거나 내부 동작을 구현시 다양한 가능성을 염두하여 오류가 최대한 발생하지 않도록 주의해야 한다.

    기본적으로 UIObject는 dispose()를 통해 캔버스로부터 제거되며 레퍼런스 카운트가 0이 될때야 비로소 메모리로부터 삭제된다. 이는 안전한 메모리(safe memory) 메커니즘을 모방하고 프로세스의 크래시(crash)를 방지하기 위한 자체적인 방편이다. 만약 dispose()가 요청되었음에도 불고하고 레퍼런스 카운트가 0보다 크다면 deleted만 태깅한 후, 레퍼런스(refCnt)가 0이 될 때까지 삭제를 보류한다. 이 후 어디선가 해당 인스턴스의 기능에 접근한다면 그 어떠한 동작도 수행하지 않도록 방어한다. 이를 위해 다음과 같은 코드를 UIObject의 메서드에 추가한 것을 확인할 수 있다.

    if (self.deleted == true) return;

    코드 9: 지연 삭제를 위한 방어 코드

    사실 이미 dispose()가 요청된 인스턴스에 어떤 기능 요청이 발생한다면 이것은 프로그래밍 로직의 오류에 해당한다. 개발자에게 이러한 사실을 알려준다면 프로그램 개발에 큰 도움이 될 것이다. 기본적으로 오류 메시지를 출력하거나, 보다 강건한 프로그램을 작성해야 한다면 abort()를 발생시킬 수도 있다.

    if (self.deleted == true) { //개발 단계에서만 동작한다. #if DEVEL_MODE System.printError(“This object is invalid! please debug your program! …”); abort(); #endif return; }

    코드 10: 지연 삭제를 위한 보다 용이한 방어 코드

    이러한 코드를 매 메서드마다 추가하기보다는 시스템의 로깅 또는 디버깅 시스템을 구축한 후 일괄 적용하는 것이 더 바람직하다.

    if (self.deleted == true) { /* SystemLog는 요청한 메시지를 파일 내지 콘솔에 출력하거나, 심지어 네트워크를 통한 메시지를 전달할 수 있는 기능을 구현한다. 로그 레벨로(1, 2, 3 ...) abort() 여부를 결정할 수 있으며 해당 프로세스와 관련된 부가 정보 및 콜스택 정보도 같이 출력하여 개발자의 디버깅 작업에 도움을 줄 수 있다. 제품 릴리즈시에는 내부 동작을 비활성화 시킬 수 있는 옵션도 제공 가능하다. */ SystemLog.printError(SystemLog.LOG_LEVEL1, “This object is invalid! please debug your program! …”); return; }

    코드 11: 개발자를 위한 로그 시스템 활용

    UIObject 자체는 화면 출력을 위한 비주얼 정보를 보유하고 있지 않으므로 update()와 render()는 별다른 구현이 존재하지 않는다. 대신 폴리모피즘 특성을 활용, update()와 render()를 오버라이드하거나 자식의 update()와 render()를 통해 비주얼 정보를 보여줄 수 있다.


    5. 씬그래프

    씬그래프(Scene-Graph) 또는 장면 그래프라고도 하며 일반적으로 하나의 가상 공간에서 여러 객체를 순차적으로 렌더링할 때 응용할 수 있는 하나의 자료 구조이다. 씬그래프는 각 객체(노드)를 통해 객체의 지역 공간 내 장면을 구축하고 이러한 객체들의 조합하여 최종 스크린을 생성하기 위한 하나의 메커니즘으로서 활용된다. 씬그래프의 트리 구조는 부모-자식 특성상 연관이 있는 노드끼리 연결되어 있기 때문에 탐색에도 효율적이다. 예를 들면, 어떤 이벤트가 발생했을 때도 이벤트를 전달할 대상을 찾기 위해서는 하나의 노드는 자식 노드만을 대상으로 이벤트를 전달하기만 하면 되며 형제(sibling) 간의 검색은 고려하지 않아도 된다. 이는 자식 노드가 부모 노드의 위치 및 크기 등의 영향 범위 내에 위치하고 있기 때문인데 이와 관련된 자세한 사항은 이후에 살펴보면서 이해하기로 한다.

    UIObject 하나의 객체가 자신만의 뷰(View) 또는 룩(Look)을 구성한다면, UICanvas도 씬그래프 기법을 활용하여 이러한 객체들을 조합, 최종 장면을 구축할 수 있다. 이해를 돕기 위해, 1장에서 보았던 크롬 브라우저의 구글 페이지를 다시 살펴보자.


    그림 18: 크롬 브라우저 구글 페이지

    그림 18의 구글 페이지 화면을 씬그래프로 구성한다면 다음과 같다.


    그림 19: 크롬 브라우저 구글 페이지 씬그래프

    그림 19의 씬그래프 예제는 편의를 위해 눈에 보이지 않는 여러 계층을 생략하거나 하나의 요소로 합쳐서 표현하였기 때문에 실제 구글 페이지의 내부 구성과 다를 수 있지만 일반 UI 앱을 구현하는데 있어서 씬그래프를 활용한 화면 구성과 개념적으로는 다르지 않다. UIWebView는 보유하고 있는 자식들을 순회하면서 각 자식 객체의 비주얼 결과물 혹은 뷰를 조합하며 최종적으로 그림 18과 같은 크롬 브라우저 화면을 표현할 수 있다.

    이러한 개념을 구현하기 위해 UIObject는 내부적으로 부모-자식 관계를 구축하고 update(), render()를 오버라이딩 가능하도록 인터페이스를 설계함으로써 씬그래프(Scene-Graph)를 구현한다.

    객체지향 관점에서 씬그래프를 위한 별도의 인터페이스 또는 클래스를 UIObject로부터 분리하여 정의하는 것이 관리 및 확장 측면에서 더 효율적이기 때문에 실제로 UIObject 모델을 다음과 같이 조정한다.


    그림 20: UIObject로부터 씬그래프 분리

    UISGNode의 멤버를 UIObject에게 개방함으로써 UIObject는 parent 및 children에 직접 접근이 가능하도록 하고 최소한의 메서드만 추가했다. 사실, 이들의 접근 제한자를 private로 지정하였다면 이들 멤버에 접근하기 위한 보다 많은 메서드가 요구되었을 것이다. 너무 많은 예제 코드의 분량은 맥락을 이해하는데 걸림돌이 될 것 같아서 접근 제한을 낮추고 구현도 생략하였다. 하지만, 본질적으로 독립적이면서 안정적인, 재사용이 가능한 UISGNode를 설계하기 위해서는 디자인 원칙에 기반하여 접근 제한자를 지정해야 한다.

    본 예제에서는 UISGNode를 UIObject의 상속(is-a) 관계로 설계하였지만 사실 소유(have) 관계로 구축하더라도 나쁘지 않아 보인다. 사실 객체지향에서 상속은 폴리모피즘(Polymorphism)의 특성을 그대로 활용할 수 있는 반면, 클래스를 기능 단위별로 잘 분리하지 않는다면 확장한 클래스의 기능이 불필요하게 방만해질 수 있기 때문에 주의해야 한다. OOP 개념적으로 믹싱(mixing)과 같은 실용적인 확장 메커니즘도 존재하지만, 완벽한 디자인 설계를 하자면 이야기가 너무 방대해지므로, 여기서는 설계자의 심사숙고한 디자인 철학에 맡기도록 하고 대신 엔진에 가까운 핵심만 짚고 넘어가도록 한다.

    /* * UISGNode: UI Scene-Graph Node * 씬그래프 트리를 구축한다. * 부모와 자식들 간의 접근을 통해 트리를 순회할 수 있다. * UISGNode에 연결된 객체 타입은 템플릿 형식으로 지정하여 실제 객체와 UISGNode간의 * 상호 의존성을 제거한다. */ template <class T> UISGNode { T parent = null; List<UISGNode> children; /* * 노드의 상태를 초기화 한다. */ reset() { //부모가 있는 경우, 부모로부터 연결을 끊는다. if (self.parent) self.parent.removeChild(self); //자식이 있는 경우 자식도 제거한다. self.children.clear(); } public: /* * 부모를 재지정한다. * parent: 지정할 부모 객체. null인 경우 현재 객체는 독립한다. */ parent(T parent) { //기존 부모로부터 현재 객체를 제거한다. if (self.parent) self.parent.removeChild(self); //주의: 새로운 부모가 null일 수도 있다!! if (parent) parent.addChild(self); self.parent = parent; } /* * 자식 객체를 추가한다. * child: 추가할 자식 객체 */ addChild(T child) { //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. if (self.children.exists(child) == true) return; self.children.add(child); child.parent(self); } /* * 자식 객체를 제거한다. * child: 제거할 자식 객체 */ removeChild(T child) { //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. self.children.remove(child); child.parent(null); } }

    코드 12: UISGNode(UI Scene-Graph Node) 구현부

    UISGNode 클래스를 정의하였으므로 이제 UIObject는 씬그래프 트리와 관련된 기능을 UISGNode에게 위임하기만 하면 된다.

    /* 신그래프 노드를 확장한다. UISGNode를 통해 부모와 자식에 접근 가능하며 코드 7에서 UIObject가 구현하고 있던 트리 기능을 UISGNode의 기능으로 대체한다. */ UIObject extends UISGNode<UIObject> { String type; Var refCnt = 0; Geometry geom = {0, 0, 0, 0} Var layer = 0; UICanvas canvas; Bool visible = false; Bool changed = true; Bool deleted = false; ... }

    코드 13: UIObject로부터 씬그래프 기능 분리(멤버 선언부)

    본래 UIObject에서 구현하던 parent, children 멤버는 UISGNode로 대처하였기 때문에 UIObject에서 직접 보유하던 parent, children은 더 이상 필요가 없다. 반면 이들을 위한 일부 메서드는 다음과 같이 수정한다.

    abstract UIObject extends UISGNode<UIObject> { ... /* * 생성자 * canvas: UIObject가 종속된 캔버스 * type: 객체의 타입 이름 * parent: 생성할 UIObject의 부모 객체. 지정하지 않으면 최상위 객체이다. */ constructor(canvas, type, parent = null) { if (canvas.ready() == false) { //캔버스가 준비되어 있지 않다... 예외 처리를 수행한다. } self.canvas = canvas; self.type = type; //주의: 이 코드는 더이상 필요없다. 대신 parent() 코드를 보자. //canvas.addObj(self); self.parent(parent); //부모를 지정한다. } /* * 부모를 재지정한다. * parent: 지정할 UIObject 부모 객체. null인 경우 현재 객체는 독립한다. */ parent(parent) override { //부모와 이 객체의 캔버스가 다르다? 허용하지 않는다! if (parent && parent.canvas != self.canvas) { System.printError(“...”); return; } super(parent); /* 주의: 기본적으로 부모가 자식을 관리하지만, 부모가 없다면 캔버스에서 오브젝트를 관리한다. 부모가 없는 경우에만 캔버스에 추가하자. */ if (!parent) self.canvas.addObj(self); self.changed = true; } /* * 자식 객체를 추가한다. * child: 추가할 UIObject 자식 객체 */ addChild(child) override { super(child); self.changed = true; } /* * 자식 객체를 제거한다. * child: 제거할 UIObject 자식 객체 */ removeChild(child) override { super(child); self.changed = true; } /* * UI 객체가 더 이상 필요없는 경우 호출하여 캔버스로부터 제거한다. */ dispose() { if (self.deleted == true) return; self.deleted = true; --refCnt; //노드 상태를 초기화한다. self.reset(); //부모가 없다면, 캔버스에서 오브젝트를 제거한다. if (!self.parent) self.canvas.removeObj(self); /* 더 이상 사용하지 않는 인스턴스이므로 Memory Management Unit에게 메모리 반환을 요청한다. 예시로 보여줄 뿐, 자바의 가비지 컬렉터처럼 메모리 관리 시스템이 따로 있는 경우 이러한 구현은 필요없다. */ if (refCnt <= 0) System.MMU.retrieve(self); } /* * 부모 객체를 반환 */ const parent() { if (self.deleted == true) return; return self.parent; } /* * 자식 목록을 반환 */ const children() { if (self.deleted == true) return; return self.children; } ... }

    코드 14: UIObject로부터 씬그래프 기능 분리(메서드 구현부)

    실제로 씬그래프 기반으로 렌더링이 올바르게 수행되기 위해 우리는 UIObject의 update()와 render() 코드를 다음과 같이 확장한다. 구현 핵심은 부모가 자식들을 순회하면서 렌더링을 재귀적으로 호출할 수 있도록 알고리즘을 구현하는 것이다.

    /* * 이 객체를 캔버스에 그리는 작업을 수행한다. */ UIObject.render() { if (self.deleted == true) { SystemLog.printError(...); return; } //TODO: 해당 객체의 렌더링을 수행한다... 어떻게? //이어서, 자식들이 렌더링을 수행할 수 있도록 render()를 호출해 준다. foreach(self.children, child) { //기본적으로 visible 상태가 아니면 렌더링을 할 필요가 없다. if (child.visible) child.render(); } } /* * 이 객체의 상태를 새로 갱신하다. */ UIObject.update() { if (self.deleted == true) { SystemLog.printError(...); return; } //우선, 자식들의 상태를 업데이트 한다. foreach(self.children, child) child.update(); //변경된 사항이 없다. if (self.changed == false) return; //TODO: 렌더링 위한 준비작업 또는 객체 특성에 따른 무언가를 수행한다. }

    코드 15: UIObject의 render()와 update() 구현

    UIObject 클래스 자체는 어떠한 비주얼 결과물을 만들어내는 클래스가 아니기 때문에 아직은 render() 및 update()가 실제로 무엇을 처리하는 지 고민하지 말자. 다만, UIObject는 씬그래프를 통해 자식들을 재귀적 순회하면서 render() 및 update()를 호출한다.

    씬그래프의 구조는 그림 12의 캔버스의 오브젝트 리스트에도 변화를 가져온다. 이제는 모든 생성 객체가 캔버스의 리스트에 선형적으로 연결된 구조가 아닌, 최상단 부모만 캔버스의 오브젝트 리스트에 추가되며 자식들은 각 부모가 관리하는 구조로 바뀐다. 만약, 부모가 렌더링 대상이 아니라면 그 자식은 검토할 필요도 없는 문제이다. 이는 캔버스가 업데이트 및 렌더링을 위해 모든 객체를 스캔하는 부담을 줄일 수 있으므로 성능 차원에서도 큰 도움이 된다.


    그림 21: 씬그래프 기반의 데이터 연결 구조

    이제 UI 객체의 render()가 어떤 이미지 결과물을 만들어 낸다고 가정했을 때, 어떤 UI 객체의 render()를 호출할 경우 다음 그림처럼 트리를 순회하며 이미지를 완성할 것이다.


    그림 22: 씬그래프 기반 렌더링 수행

    그림 22의 각 화살표의 번호는 렌더링 순서를 가리킨다. 씬그래프 기반에서 오브젝트는 자식 트리를 전위순회(Pre-order Traversal)하면서 렌더링을 요청한다. 각 자식은 렌더러를 통해 드로잉을 수행하며 생성된 드로잉 결과물은 부모 객체의 렌더링 일부가 된다. 최종적으로 UICanvas가 직접 접근하는 오브젝트, 즉 최상위 부모는 완성된 하나의 렌더링 결과물을 생성할 수 있다.

    씬그래프의 렌더링 순서를 이해하면, 어떤 객체가 어떤 객체의 위에 그려지는 지 이해할 수 있다. 캔버스에 종속된 최상위 부모의 렌더링 순서는 캔버스의 UIObject List에 추가된 순서에 영향을 받으며 자식 객체들의 렌더링 순서는 부모의 children 리스트에 추가된 순서에 영향을 받는다. 최상위 부모의 객체 드로잉의 순서를 변경하고 싶다면, 레이어의 순서를 조절해야 한다. 앞서 UIObject 속성 중 layer 를 추가한 이유이기도 하다. 기본적으로 obj.layer(); 를 통해 레이어 순서를 지정할 수도 있지만, 특정 객체를 대상으로 위, 또는 아래를 지정할 수도 있을 것이다. 이를 위해 above(), below(), mostTop(), mostBottom()와 같은 유용한 메서드를 추가로 제공할 수 있다. 주의할 점은, 레이어의 영향 범위를 자신이 존재하는 공간으로 제한해야야 한다. 캔버스에 직접 추가된 객체는 UIObject List를 대상으로 레이어 순서를 경합하고 부모 객체의 children으로 추가된 객체는 해당 children을 대상으로 레이어 순서를 경합한다. 새로운 오브젝트를 추가할 때마다 레이어의 순서를 참고하여 각 리스트의 연결 순서를 지정할 수 있다. 이미 추가된 오브젝트의 레이어의 순서가 변경될 경우에는 리스트의 연결 순서를 중간에 바꿔야 한다. 레이어의 순서가 특별히 지정되지 않는 경우(기본 값인 경우)에는 리스트의 맨 끝에 추가함으로써 해당 공간에서의 최상단에 위치하게 할 수 있다.

    obj.above(obj2); //obj를 obj2 바로 위로 이동한다. obj.below(obj2); //obj를 obj2 바로 밑으로 이동한다. obj.top(); //obj의 레이어를 한칸 상승시킨다. obj.bottom(); //obj의 레이어를 한칸 하강시킨다. obj.topMost(); //obj를 가장 최상단으로 이동시킨다. obj.bottomMost(); //obj를 가장 최하단으로 이동시킨다.

    코드 16: UIObject의 레이어 변경(렌더링 순서 변경)


    그림 23: 레이어 순서 조절 결과

    코드 16의 실제 메서드 구현와 더불어, 레이어와 관련된 기능 구현은 생략한다. 개념적으로 이들은 연결 리스트의 노드 이동에 불과하며 구현 역시 크게 어렵지 않다.


    6. 정리하기

    이번 장에서 우리는 UI 렌더링의 개념과 캔버스 엔진을 살펴보는 시간을 가졌다. 캔버스는 UI 앱이 화면에 UI를 출력할 수 있는 기능을 제공하였다. 기본적으로 윈도우에 매핑될 출력 버퍼를 생성하고 생성된 버퍼가 출력 시스템과 연동될 수 있는 기본 설정 작업을 수행했다. 캔버스 엔진은 동작 컨셉에 따라 리테인드 혹은 이미디어트 렌더링 방식을 구현하며 이미디어트 방식은 사용자가 원하는 시점에, 원하는 영역에 UI를 그릴 수 있도록 인터페이스를 제공하였다. 반면, 리테인드 방식은 렌더링과 관련된 모든 기능을 감추고 백그라운드에서 렌더링을 알아서 수행하는 컨셉을 제공하였다. 리테인드 방식은 이미디어트 대비 렌더링과 관련된 섬세한 작업이 불가능하지만 앱 개발자가 렌더링에 깊은 이해가 없어도 쉽고 빠르게 앱의 UI를 개발할 수 있는 장점을 제공함을 알 수 있었다. 이러한 동작 컨셉을 위해 캔버스 엔진은 기본적으로 캔버스 상에 동작하는 UI 오브젝트 모델을 설계하며 이러한 모델을 기반으로 다양한 컨트롤을 확장할 수 있는 인터페이스를 구현하였다. 그뿐만 아니라, 캔버스 엔진은 캔버스에 추가된 UI 객체를 효율적으로 다루기 위해 씬그래프 데이터 구조를 통해 오브젝트 트리를 구성하였다. 캔버스 엔진은 씬그래프 트리를 주기적으로 탐색하면서 UI 객체의 상태를 업데이트하고 렌더링을 수행할 수 있음을 알 수 있었다. 마지막으로 UI 객체의 렌더링 순서를 조작하기 위해 레이어의 개념을 살펴보았다.



    Anti-Aliasing

    Aliasing is the generation of a false (alias) frequency along with the correct one when doing frequency sampling. For instance, in graphics world, it produces a jagged edge or stair-step effect for images(src: whatis.techtarget.com). Sometimes this aliasing phenomenon is bad for rendering quality. It disturbs people do satisfied the visuals from the real-time rendering.

    In computer science, some have developed a sort of Anti-Aliasing(AA) techniques such as SSAA(Super Sampling Anti-Aliasing), MSAA(Multi-Sampling Anti-Aliasing), FXAA(Fast Approximate Anti Aliasing), TXAA, RSAA, TAA, QAA, MLAA, etc to get rid of aliasing, to get cleaner and nicer realistic visual images. Thereby, AA have been many used in computer games for years.

    But In this article, I'm not going to take cover those famous AA techniques but share you another practical case that I designed for a certain UI system.

    No AA(left), AA(right)

    Before get jumped in, let's review the basic graphics concept for a drawing model briefly. The traditional polygon is a shape filled with color. One polygon has multiple points(vertices) and lines which are connecting to each other. This polygon could be used for a shape(boundary) of the graphical components in the UI. Next figure helps you understand what I'm talking about.

    Mapping texture onto polygon

    While the rasterization, filling pixels into the polygon boundary, it may encounter the aliasing problem due to the screen display limitation. Normally, a display is consisted with lighting dots which are possibly 1:1 mapping to each pixel. As far as the color-tone contrast is high, we could see the aliasing issue more easily.

    Pixels mapping to display

    We could generate and add intermediate color pixels on the edges, it will reduce the stair-step effect smoothly by reducing high color contrast.

    Adding intermediate pixels


    Polygon Edge Anti-Aliasing (Hermetic AA Method)

    As some of AA techniques such as FXAA, MSAA, AA algorithm requires a step for finding edges for applying AA partially. Here polygon edge AA mechanism is same as them. It applies AA processing only to edge area. This speeds up AA, it's very cheap and fast method by avoiding unnecessary processing, of course, we need to know edge information as prerequisite.

    In this article, we don't take cover the prerequisite. Also, we don't take cover mathematics for polygon and texture mapping techniques. More than them, I'd like to more focus on AA rendering step.

    So, let's say, we satisfied those prerequisite having information for edges and the pixel colors which were filled with a polygon. Here we could construct a span which is somewhat simplified data for convenient. A span contains edge and pixel information for a horizontal line.

    //A span structure for a horizontal line 
    struct Span {
        int x, y;    //Line start point 
        int length;    //Line length 
        Pixel* pixels;    //pixel data. Size is line length. 
    };
    

    This span indicates pixel data which should be drawn onto a canvas(buffer). For instance, if span x is 3, span y is 3 and length is 6, this will be a horizontal line from (3, 3) to (9, 3) on a buffer. Of course, the pixels of the line come from the pixels in the span.


    Likewise, we could construct a series of spans for a polygon.


    Come to think of it, we don't need y field in Span because y position of spans are always incremental by 1 pixel. Consequently, we just need this.

    //A span structure for a horizontal line
    struct Span {
        Pixel* pixels;    //Pixel data. Size is line length. 
        int x;    //Line start point x 
        int length;    //Line length 
    };
    
    //A polygon image structure for drawing
    struct PolygonImage {
        Span *spans;    //Span data. Size is span length. 
        int y;    //Span start point y 
        int length;    //Span length 
    };
    


    Now, we are ready to draw a polygon, it's time to consider how to generate intermediate pixels. The overall rule is very simple.

    A. Divide a polygon by 2 sections, left and right.
    B. Find edge lines.
    C. Decide anti-aliasing coverage for an edge line.
    D. Fine-tune for better quality.
    E. Compute alpha channel.

    Then let's go through it step by step.

    A. Divide a polygon by 2 sections, left and right

    Basically, this AA method scans outlines vertically. To do this, Scan vertical edges for two directions, left and right sides in the order. Even though left and right sides of a polygon are different, we could apply same AA algorithm for them because they are basically homogeneous. For instance, if there is a polygon (see next figure), we could scan the left and right outline in order.


    It does not matter whatever the polygon look like. We could apply this scanning always. See next figures.



    Since we know pixel position and length for a line, we could scan the left and right edges easily.


    //eidx (edge index) 0: left edge, 1: right edge
    void calc_aa_coverage(PolygonImage *polygon, int eidx)
    {
        //Scan edge vertically
        for  (int yidx = 0; yidx < polygon->length; yidx++)
        {
            //Decide left or right
            int xidx = 0;
            if (eidx == 1) xidx = polygon->spans[yidx].length - 1;
    //x, y: position of current edge int x = polygon->spans[yy].x; if (eidx == 1) x += polygon->spans[yidx].length; int y = polygon->y + yidx; //Access the pixel on the edge? Pixel *p = &polygon->spans[yidx].pixels[xidx]; } } //Somewhere calls this function to apply AA... on rendering? void apply_aa(PolygonImage *polygon) { //One function enough to scan 2 edges. //Scan left edge calc_aa_coverage(polygon, 0); //Scan right edge calc_aa_coverage(polygon, 1); }

    B. Find edge lines

    In this step, we read each pixel's position and classify edge lines. For your understanding, see next figures.



    As you can see above figures, Spotlights on left side edge. The point of 'finding an edge line' is how pixel continues. If the continual pixel direction is changed, we should classify a new line. For doing this, we define 7 directions.


    However, perfect vertical and horizontal lines (above 1, 4, 7 directions) actually don't require AA processing, they don't need to generate any intermediate color pixels. So here we don't care 1, 4, 7 cases. Only matter is to 2, 3, 5, 6 directions. Let's see actual scenario along with this direction classification.


    For implementing, define necessary variables first.


    And code.

    //Define edge direction
    #define DirOutHor 0x0011    //Horizontal Outside
    #define DirOutVer 0x0001    //Vertical Outside
    #define DirInHor 0x0010    //Horizontal Inside
    #define DirInVer 0x0000    //Vertical Inside
    #define DirNone 0x1000    //Non specific direction
    
    //eidx (edge index) 0: left edge, 1: right edge
    void calc_aa_coverage(PolygonImage *polygon, int eidx)
    {
        Point p_edge = {-1, -1};    //previous edge point
        Point edge_diff = {0, 0};    //temporary use for point's distance (between previous and current)
        int tx[2] = {0, 0};    //This is just for computation convenience.
        int ptx[2];    //Back up previous tx here.
        int prev_dir = DirNone;    //previous line direction
        int cur_dir = DirNone;    //current line direction
    
        //Scan edge vertically
        for  (int yidx = 0; yidx < polygon->length; yidx++)
        {
            //x, y: position of current edge
            int x = polygon->spans[yidx].x;
            if (eidx == 1) x += polygon->spans[yidx].length;
            int y = polygon->y + yidx;
    
            //Ready tx. Since left and right edge' faces are inverted, previous and current x should be inverted as well. 
            if (eidx == 0)
            {
                tx[0] = p_edge.x;
                tx[1] = x;
            }
            else
            {
                tx[0] = x;
                tx[1] = p_edge.x;
            }
    
            //Compute distance between previous and current edge
            edge_diff.x = (tx[0] - tx[1]);
            edge_diff.y = (yidx - p_edge.y);
    
            //Evaluate Edge direction
            if (edge_diff.x > 0)
            {
                if (edge_diff.y == 1) cur_dir = DirOutHor;
                else cur_dir = DirOutVer;
            }
            else if (edge_diff.x < 0)
            {
                if (edge_diff.y == 1) cur_dir = DirInHor;
                else cur_dir = DirInVer;
            }
            else cur_dir = DirNone;
    
            switch (cur_dir)
            {
                case DirOutHor:
                {
                    //TODO:
                    PUSH_EDGE_POINT();
                }
                break;
                case DirOutVer:
                {
                     //TODO:
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInHor:
                {
                     //TODO:
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInVer:
                {
                    //TODO:
                    PUSH_EDGE_POINT();
                }
                break;
            }
            if (cur_dir != DirNone) prev_dir = cur_dir;
        }   
    }
    

    As you can see the above code, we declare 'tx' variable for computation convenient. Since this AA algorithm is used for left, right both directions, we need to handle x direction in the same way. However, direction is totally inverted, the size increase is inverted as well. See next figure for your understanding.


    In case of left, current x - previous x is -3. On the other hand in case of right, it's value is 3. The result is solely inverted. To fix this, tx came out. We could get both results same -3. In the meanwhile, PUSH_EDGE_POINT() is a macro to update p_edge and ptx values.

    #define PUSH_EDGE_POINT() \
    { \
        p_edge.x = x; \
        p_edge.y = yidx; \
        ptx[0] = tx[0]; \
        pty[1] = ty[1]; \
    }
    

    C. Decide anti-aliasing coverage for an edge line.

    In the previous step, we examined edge turning points, obtained all edge lines, Horizontal Outside(DirOutHor), Vertical Outside(DirOutVer), Horizontal Inside(DirInHor), Vertical Inside(DirInHor). Each edge lines start from previous point(p_edge) to current point(x, yidx). Using these point's information, we can examine length of the line, exactly pixel count. If we define AA spectrum from 0 to 255 for a line, we could calculate each pixels AA coverage for a line. See next figures.


    In the meanwhile, vertical line is not too different.


    Keep in mind, we should examine vertical inside and horizontal inside as well. The only different is incremental order of opacity is reversed.


    Trivial but obviously, we can skip for opacity 255 case for avoiding meaningless computation.

    Before see implementation bodies, check additional fields for this.

    //A span structure for a horizontal line
    struct Span {
        Pixel* pixels;    //Pixel data. Size is line length. 
        int x;    //Line start point x 
        int length;    //Line length 
        int aa_length[2]:    //Opacity(less than 255) pixels count. [0]: left edge, [1]: right edge
        int aa_coverage[2];    //Coverage unit. [0]: left edge, [1]: right edge
    };
    

    Now, update AA length and coverage for each case.

    //eidx (edge index) 0: left edge, 1: right edge
    void calc_aa_coverage(PolygonImage *polygon, int eidx)
    {
            ...
    
            switch (cur_dir)
            {
                case DirOutHor:
                {
                    calc_horiz_coverage(polygon, eidx, yidx, tx[0], tx[1]);
                    PUSH_EDGE_POINT();
                }
                break;
                case DirOutVer:
                {
                     calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, true);
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInHor:
                {
                     //Here inner case is one step faster than outer, so pass y - 1 than y. 
                     calc_horiz_coverage(polygon, eidx, (yidx - 1), tx[0], tx[1]);
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInVer:
                {
                    calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, false);
                    PUSH_EDGE_POINT();
                }
                break;
            }
        ...
    }
    

    So far, two functions were introduced - calc_horiz_coverage() and calc_vert_coverage(). Let's see them.

    //y: current edge y position, rewind: vertical length, reverse: decides the direction whether opacity increase or decrease on y axis
    void calc_vert_coverage(PolygonImage *polygon, int eidx, int y, int rewind, bool reverse)
    {  
        if (eidx == 1) reverse = !reverse;
        int coverage = (255 / (rewind + 1));
        int tmp;
    
        for (int ry = 1; ry < (rewind + 1); ry++)
        {
            tmp = y - ry;
            if (tmp < 0) return;    //just in case.
            polygon->spans[tmp].aa_length[eidx] = 1;    //vertical lines AA pixel is only one.
            if (reverse) polygon->spans[tmp].aa_coverage[eidx] = (255 - (coverage * ry));
            else polygon->spans[tmp].aa_coverage[eidx] = (coverage * ry);
        }
    }
    
    //y: current edge y position, x: current edge x position, y: previous edge x position
    void calc_horiz_coverage(PolygonImage *polygon, int eidx, int y, int x, int x2)
    {
        //Tip: edge point pixels could be targeted AA twice in horizontal and vertical ways. In this case, we apply horizontal first. See next figure for your understand.
        if (polygon->spans[y].aa_length[eidx] < abs(x - x2))
        {
            polygon->spans[y].aa_length[eidx] = abs(x - x2);
            polygon->spans[y].aa_coverage[eidx] = (255 / (polygon->spans[y].aa_length[eidx] + 1));
        }
    }
    

    Here is a figure about Tip comment in calc_horiz_coverage() to understand you.


    D. Fine-tune for better quality.

    Actually, we implemented most parts of AA. However, it still remains a few cases to deal with yet.

    a. 1 pixel stair-step diagonal lines

    Even though above basic algorithm naturally take deal 1 pixel stair-step diagonal lines, we need to take deal it in a special way. Let me show you the reason.


    In the above figure, it's a bit ideal case if we can expect continuous 1 pixel stair-step lines. If it does, actually we don't need to take care of it anymore, our algorithm will take deal AA coverage like the illustration in the figure. It looks no problems in the result. However, what if the diagonal lines look like this?


    Our algorithm generates AA pixels looking like this.


    This is still better than Non-AA case but it doesn't very good because the stair-step pattern is irregular. Next picture is a one of the cases. Please look at the diagonal edge seriously.


    Now the problem looks more clear. If so, how we can improve it? One solution is shaving.


    Shaving edge works quite well. It removes jiggling points by transparenting stair-step pixels gradually. Next figure shows you an actual result adopting this method.


    And code.

    void calc_aa_coverage(PolygonImage *polygon, int eidx)
    {
        ...
    
        //Scan edge vertically
        for  (int yidx = 0; yidx < polygon->length; yidx++)
        {
            ...
    
            //Evaluate Edge direction
            if (edge_diff.x > 0)
            {
                if (edge_diff.y == 1) cur_dir = DirOutHor;
                else cur_dir = DirOutVer;
            }
            else if (edge_diff.x < 0)
            {
                if (edge_diff.y == 1) cur_dir = DirInHor;
                else cur_dir = DirInVer;
            }
            else cur_dir = DirNone;
    
            //1 pixel stair-step diagonal increase
            if (cur_dir == prev_dir)
            {
                if ((abs(edge_diff.x) == 1) && (edge_diff.y == 1))
                {
                    //Don't do anything, just keep tracking next point...
                    ++diagonal;
                    PUSH_EDGE_POINT();
                    continue;
                }
            }
    
            switch (cur_dir)
            {
                case DirOutHor:
                {
                    calc_horiz_coverage(polygon, eidx, yidx, tx[0], tx[1]);
                    if (diagonal > 0)
                    {
                        calc_irregular_coverage(polygon, eidx, yidx, diagonal, 0, true);
                        diagonal = 0;
                    }
                    PUSH_EDGE_POINT();
                }
                break;
                case DirOutVer:
                {
                     calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, true);
                     if (diagonal > 0)
                     {
                         calc_irregular_coverage(polygon, eidx, yidx, diagonal, edge_diff.y, false);
                         diagonal = 0;
                     }
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInHor:
                {
                     //Here inner case is one step faster than outer, so pass y - 1 than y. 
                     calc_horiz_coverage(polygon, eidx, (yidx - 1), tx[0], tx[1]);
                     if (diagonal > 0)
                     {
                         calc_irregular_coverage(polygon, eidx, yidx, diagonal, 0, false);
                         diagonal = 0;
                     }
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInVer:
                {
                    calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, false);
                    if (diagonal > 0)
                    {
                        calc_irregular_coverage(polygon, eidx, yidx, diagonal, edge_diff.y, true);
                        diagonal = 0;
                    }
                    PUSH_EDGE_POINT();
                }
                break;
            }
        ...
    }
    
    //y: current edge y position, diagonal: vertical length to rewinding, edge_dist: distance to the previous edge y position, reverse: decides the direction whether opacity increase or decrease on y axis
    void calc_irregular_coverage(PolygonImage *polygon, int eidx, int y, int diagonal, int edge_dist, bool reverse)
    {
       if (eidx == 1) reverse = !reverse;
       int coverage = (255 / (diagonal + 1));
       int tmp;
       for (int ry = 0; ry < (diagonal + 1); ry++)
         {
            tmp = y - ry - edge_dist;
            if (tmp < 0) return;    //just in case.
            polygon->spans[tmp].aa_length[eidx] = 1;    //vertical lines AA pixel is only one.
            if (reverse) polygon->spans[tmp].aa_coverage[eidx] = 255 - (coverage * ry);
            else polygon->spans[tmp].aa_coverage[eidx] = (coverage * ry);
         }
    }
    

    Code is not too complex. Firstly, it just counts pixels number for 1 pixel stair-step case. After that, it deals with AA for four directional lines. calc_irregular_coverage() is almost same with calc_vert_coverage(). It just rewinds pixels vertically in order to compute AA coverage for target spans. The only different of calc_irregular_coverage() is, it passes edge_dist argument to jump to a start point to rewind. The reason is calc_irregular_coverage() will be triggered one step after of the end of the 1 pixel stair-step diagonal.

    b. The turning point

    This fine-tune is not serious but this turning point indicates that when the incremental direction is sharply changed. The incremental directions are only under this scenario. DirOutVer <-> DirOutHor, DirOutHor -> DirInHor, DirOutHor -> DirInVer. I decided those cases experimentally for better quality. See next figure.


    void calc_aa_coverage(PolygonImage *polygon, int eidx)
    {
        ...
    
            switch (cur_dir)
            {
                case DirOutHor:
                {
                    calc_horiz_coverage(polygon, eidx, yidx, tx[0], tx[1]);
                    if (diagonal > 0)
                    {
                        calc_irregular_coverage(polygon, eidx, yidx, diagonal, 0, true);
                        diagonal = 0;
                    }
                    /* Increment direction is changed: Outside Vertical -> Outside Horizontal */
                    if (prev_dir == DirOutVer) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                    PUSH_EDGE_POINT();
                }
                break;
                case DirOutVer:
                {
                     calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, true);
                     if (diagonal > 0)
                     {
                         calc_irregular_coverage(polygon, eidx, yidx, diagonal, edge_diff.y, false);
                         diagonal = 0;
                     }
                     /* Increment direction is changed: Outside Horizontal -> Outside Vertical */
                     if (prev_dir == DirOutHor) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInHor:
                {
                     //Here inner case is one step faster than outer, so pass y - 1 than y. 
                     calc_horiz_coverage(polygon, eidx, (yidx - 1), tx[0], tx[1]);
                     if (diagonal > 0)
                     {
                         calc_irregular_coverage(polygon, eidx, yidx, diagonal, 0, false);
                         diagonal = 0;
                     }
                     /* Increment direction is changed: Outside Horizontal -> Inside Horizontal */
                     if (prev_dir == DirOutHor) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInVer:
                {
                    calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, false);
                    if (diagonal > 0)
                    {
                        calc_irregular_coverage(polygon, eidx, yidx, diagonal, edge_diff.y, true);
                        diagonal = 0;
                    }
                    /* Increment direction is changed: Outside Horizontal -> Inside Vertical */
                    if (prev_dir == DirOutHor) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                    PUSH_EDGE_POINT();
                }
                break;
            }
        ...
    }
    


    c. Leftovers

    This is the last part we are going to look at. Since this AA algorithm looks for and examines two points(current point, previous point) which are linking together, it rewinds the spans along the y-axis and apply coverage. If the scanning reaches to the end all of sudden, it misses AA chance for the last edge. So we need to take care the leftovers additionally. Next code is full main source code including the leftovers.

    void calc_aa_coverage(PolygonImage *polygon, int eidx)
    {
        Point p_edge = {-1, -1};    //previous edge point
        Point edge_diff = {0, 0};    //temporary use for point's distance (between previous and current)
        int tx[2] = {0, 0};    //This is just for computation convenience.
        int ptx[2];    //Back up previous tx here.
        int prev_dir = DirNone;    //previous line direction
        int cur_dir = DirNone;    //current line direction
    
        //Scan edge vertically
        for  (int yidx = 0; yidx < polygon->length; yidx++)
        {
            //x, y: position of current edge
            int x = polygon->spans[yidx].x;
            if (eidx == 1) x += polygon->spans[yidx].length;
            int y = polygon->y + yidx;
    
            //Ready tx. Since left and right edge' faces are inverted, previous and current x should be inverted as well. 
            if (eidx == 0)
            {
                tx[0] = p_edge.x;
                tx[1] = x;
            }
            else
            {
                tx[0] = x;
                tx[1] = p_edge.x;
            }
    
            //Compute distance between previous and current edge
            edge_diff.x = (tx[0] - tx[1]);
            edge_diff.y = (yidx - p_edge.y);
    
            //Evaluate Edge direction
            if (edge_diff.x > 0)
            {
                if (edge_diff.y == 1) cur_dir = DirOutHor;
                else cur_dir = DirOutVer;
            }
            else if (edge_diff.x < 0)
            {
                if (edge_diff.y == 1) cur_dir = DirInHor;
                else cur_dir = DirInVer;
            }
            else cur_dir = DirNone;
    
            //Evaluate Edge direction
            if (edge_diff.x > 0)
            {
                if (edge_diff.y == 1) cur_dir = DirOutHor;
                else cur_dir = DirOutVer;
            }
            else if (edge_diff.x < 0)
            {
                if (edge_diff.y == 1) cur_dir = DirInHor;
                else cur_dir = DirInVer;
            }
            else cur_dir = DirNone;
    
            //1 pixel stair-step diagonal increase
            if (cur_dir == prev_dir)
            {
                if ((abs(edge_diff.x) == 1) && (edge_diff.y == 1))
                {
                    //Don't do anything, just keep tracking next point...
                    ++diagonal;
                    PUSH_EDGE_POINT();
                    continue;
                }
            }
          
            switch (cur_dir)
            {
                case DirOutHor:
                {
                    calc_horiz_coverage(polygon, eidx, yidx, tx[0], tx[1]);
                    if (diagonal > 0)
                    {
                        calc_irregular_coverage(polygon, eidx, yidx, diagonal, 0, true);
                        diagonal = 0;
                    }
                    /* Increment direction is changed: Outside Vertical -> Outside Horizontal */
                    if (prev_dir == DirOutVer) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                    PUSH_EDGE_POINT();
                }
                break;
                case DirOutVer:
                {
                     calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, true);
                     if (diagonal > 0)
                     {
                         calc_irregular_coverage(polygon, eidx, yidx, diagonal, edge_diff.y, false);
                         diagonal = 0;
                     }
                     /* Increment direction is changed: Outside Horizontal -> Outside Vertical */
                     if (prev_dir == DirOutHor) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInHor:
                {
                     //Here inner case is one step faster than outer, so pass y - 1 than y. 
                     calc_horiz_coverage(polygon, eidx, (yidx - 1), tx[0], tx[1]);
                     if (diagonal > 0)
                     {
                         calc_irregular_coverage(polygon, eidx, yidx, diagonal, 0, false);
                         diagonal = 0;
                     }
                     /* Increment direction is changed: Outside Horizontal -> Inside Horizontal */
                     if (prev_dir == DirOutHor) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                     PUSH_EDGE_POINT();
                }
                break;
                case DirInVer:
                {
                    calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, false);
                    if (diagonal > 0)
                    {
                        calc_irregular_coverage(polygon, eidx, yidx, diagonal, edge_diff.y, true);
                        diagonal = 0;
                    }
                    /* Increment direction is changed: Outside Horizontal -> Inside Vertical */
                    if (prev_dir == DirOutHor) calc_horiz_coverage(polygon, eidx, p_edge.y, ptx[0], ptx[1]);
                    PUSH_EDGE_POINT();
                }
                break;
            }
            if (cur_dir != DirNone) prev_dir = cur_dir;
        }
        
        //leftovers...?
        if ((edge_diff.y == 1) && (edge_diff.x != 0))
        {
            //Finished during horizontal increment.
            calc_horiz_coverage(polygon, eidx, yidx, tx[0], tx[1]);
        }
        else
        {
            //Finished during vertical increment
            calc_vert_coverage(polygon, eidx, yidx, edge_diff.y, (prev_dir & 0x00000001));
        }
    }
    

    E. Compute alpha channel.

    So far, we implemented the core algorithm of AA. Next code is just for a code snippet that returns Alpha channel value using coverage that we computed before.

    void _aa_coverage_apply(Span *span)
    {
        for (int i = 0; i < span->length; i++)
        {
            //Left Edge Anti Anliasing
            if (span->aa_length[0] <= i)
            {
                Pixel val = MUL256(span->pixels[i], (i + 1) * span->aa_coverage[0]);
            }
    
            //Right Edge Anti Aliasing
            if (i  >= span->length - span->aa_length[1])
            {
                Pixel val = MUL256(span->pixels[i], (span->aa_length[1] - (i - (span->length - span->aa_length[1]))) * span->aa_coverage[1]);
            }
        }
    }
    

    MUL256() is a trivial, we suppose that it just multiply a pixel with alpha value.

    It's done! Next video demonstrates the this Polygon Edge Anti-Aliasing rendering. Please see with 1080p full screen.


    Tool: Corel Painter, Wacom INTUOS ART