이미지 프로세싱이란, 넓은 의미에서 화소 데이터를 처리하는 과정을 의미한다. 이미지 프로세싱은 벡터 래스터라이징을 통해 생성된 장면 또는 컨텐츠 이미지를 추가로 가공하거나, JPEG, PNG 형식의 미리 가공된 이미지 자원으로부터 이미지 화소 정보를 불러와 화면에 출력하는 과정을 포함한다. 또한, 영상 처리 분야에서의 영상 가공, 이를 테면 특정 이미지를 추출하고 변환하여 다른 이미지와 합성하는 작업 역시 이미지 프로세싱의 범주에 포함된다. 정리하면, 이미지 프로세싱은 이미지 리소스의 출처가 무엇이든 간에 이미지 데이터를 인코딩(encoding), 디코딩(decoding) 하여 이미지 형태를 변환하거나 후처리(post-processing)을 통해 어떤 효과를 적용하는 작업으로 볼 수 있다.

렌더링 엔진에서의 이미지 프로세싱은 이미지 출력을 전담하는, 이미지 렌더러(Image-Renderer) 독립 모듈로 구성할 수 있는데 이는 이미지 파일로부터 데이터를 읽어와 비트맵 데이터를 생성하고 필요에 따라 스케일링(Scaling), 변환(Transform), 색상 공간(Color-space) 변환 그리고 블러(Blur)와 같은 이미지 필터(filter) 기능을 제공할 수 있다.

이처럼, UI 그래픽스 시스템에서 이미지 프로세싱은 3장에서 살펴본 벡터 그래픽스 이상으로 중요한 기능을 담당하며 방대한 이미지 리소스를 화면에 출력하기 위한 주요 동작에 해당한다. 이번 장에서는 UI 엔진에서 이미지를 출력하기 위한 기본 지식을 학습하고 이미지 렌더러의 구조와 이미지 프로세싱의 주요 동작 구현을 살펴보도록 한다.


1. 이번 장 목표

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

  • 이미지 포맷의 종류와 특징에 대해 살펴본다.
  • UI 엔진에서 이미지 로더의 구조 및 구현 방안에 대해 살펴본다.
  • PNG 포맷 구조와 압축 방식을 이해하고 인코딩, 디코딩 절차를 살펴본다.
  • 이미지 스케일링을 위한 방안을 살펴본다.
  • 3차원 효과를 위한 3D 변환 및 원근법, 텍스처 매핑 기술에 대해 이해한다.
  • 앤티엘리어싱을 통한 이미지 퀄리티 개선 방법에 대해 살펴본다.
  • 알파 블랜딩, 마스킹, 필터 등 이미지 합성 기술에 대해 이해한다.
  • 이미지 캐싱과 벡터 프로세싱을 통한 최적화 기법에 대해 알아본다.


  • 2. 이미지 포맷

    UI 앱에서 하나의 이미지를 화면에 출력하기 위해 어떤 과정을 거쳐야 할까? UI 앱에서는 UI 프레임워크에서 제공하는 이미지 출력 기능을 이용하면 쉽고 빠르게 구현이 가능하지만, 실제로 이미지를 출력하는 기능을 구현하는 UI 엔진은 다소 복잡한 절차를 구현하여 최종적으로 화면에 이미지를 출력할 수 있다. 하나의 이미지는 연속된 픽셀 데이터로 구성되는데, 여기서 하나의 픽셀은 RGBA(Red, Green, Blue, Alpha), 네 개의 채널로 구성되며 이 네 개의 채널은 0 ~ 255 범위의 값을 가짐으로써 각 색상의 강도(density)를 표현한다. 채널당 256가지의 색상을 가질 수 있는 네 개의 채널을 조합하면, 최종적으로 16,581,375가지의 색상 표현이 가능하다. 이 정도 색상 수이면 자연을 표현하기 위한 충분한 스펙트럼의 색상 수이다. 참고로, 32비트의 색상 표현은 보편적으로 사용된 그래픽스 시스템의 방법이며 여전히 유효한 기술이지만 시스템의 발전으로, HDR(High Dynamic Range) 렌더링과 같은 고급 렌더링 기술을 사용하면 채널당 16비트 이상의 데이터를 활용할 수도 있다. HDR 기법은 이미지 생성 시, 더 넓은 색상 스펙트럼을 표현할 수 있기 때문에 음영에 따른 미묘한 색상 차이도 보다 자세히 표현할 수 있으며 이를 통해, 사실적인 색상 표현이 가능하다. HDR를 지원하기 위해 RGBE 또는 Radiance HDR와 같은 이미지 포맷이 존재한다

    기본적으로 32비트 시스템에서 픽셀의 각 채널은 8비트 메모리로 구성되므로 하나의 픽셀은 32비트 즉, 4바이트의 메모리로 구성된다. 따라서, 4k 해상도의 시스템에서 화면 전체를 이루는 한 장의 이미지를 출력하기 위해서는 4096 x 2160 x 4 = 35,389,440 바이트(약 35mb) 크기의 메모리 공간이 필요하며 1080p 해상도의 시스템에서는 1920 x 1080 x 4 = 8,294,400 바이트(약 8mb) 크기의 메모리 공간을 요구한다. 만약 다수의 이미지를 이용하는 앱에서 한 장의 이미지를 위해 35mb의 메모리 공간을 요구한다면 이는 결코 적은 비용이 아닐 것이다. 앱에서 필요로 하는 디스크 저장 장치 공간은 매우 커질 수 밖에 없다.

    고해상도의 이미지의 저장 메모리를 줄이기 위한 방안으로 이미지 압축을 이용할 수 있다. 이미지 압축을 이용하면 이미지의 원본 비트맵 대비 메모리 크기를 크게 감축시킬 수 있다. 대표적으로, JPEG과 PNG는 일반 컴퓨터 사용 환경에서 보편적으로 사용되는 이미지 포맷(format)인데, 이미지 압축은 이미지 용량을 줄임으로써 단순히 저장 장치의 사용량 뿐만 아니라, 하드웨어 메모리 대역폭 및 네트워크 데이터 전송의 부하로부터 도움을 준다.


    2.1 이미지 포맷 종류

    이미지를 활용하기에 앞서 JPEG, PNG와 같이 현존하는 이미지 포맷을 통해 이미지 데이터를 출력한다면 효율성을 보다 향상시킬 수가 있다. 그렇다면, 이미지 포맷으로 어떤 것들이 있을까? 다음 목록은 UI 엔진에서 활용할 수 있는 대표적인 이미지 포맷의 종류와 특징을 설명한다.

  • JPEG: Joint Photographic Expert Group의 약자로 ISO, ITU-T에서 표준을 제정하였다. 실제로 JPEG으로 산출된 결과물은 JFIF(Jpeg File Interchange Format)의 이름을 가지며 우리가 인터넷에서 전송받은 jpeg 파일 형식은 엄밀히 JFIF에 해당한다. JPEG은 픽셀 손실이 존재함에도 불구하고 비교적 뛰어난 압축 품질을 보여주기 때문에 인터넷에서 이미지를 출력하기 위해 널리 쓰인다. JPEG은 R, G, B 색상을 Y Cb, Cr 색상 공간(Color Space)로 변환하고 샘플링(Sampling)을 통해 압축 효과를 얻는데, 이 과정에서 Cb, Cr의 성분 일부를 생략한다. 즉, 연속된 픽셀 중에 인접한 픽셀 정보 일부를 버리게 된다. 압축률을 높일 수록 손실된 픽셀이 많아지므로 이미지 품질은 저하되지만 JPEG의 압축 특성상, 비교적 사람의 눈에는 둔감한 부분인 고주파의 명도 변화 부분에서 압축을 보다 많이 수행하기 때문에 시각적 측면에서 이미지 품질 저하를 줄일 수 있다. JPEG은 16777215 색상과 256 그레이 색상 두 가지로 표현이 가능하며 jpg, jpeg, jpe 등의 확장명을 갖는다. JPEG을 인코딩/디코딩하는 대표 오픈소스 라이브러리로는 libjpeg이 존재한다.


  • 그림 1: JPEG 압축률에 따른 파일 크기 비교 (kathleenhalme)

  • PNG: Portable Network Graphics의 약자로 데이터, GIF 대처하기 위해 1996년에 처음 등장하였다. GIF가 애니메이션을 위한 이미지 프레임 데이터와 0과 1의 투명 정보를 갖는 반면, PNG는 단일 이미지와 0 ~ 255 사이의 투명 정보를 갖는다. 무손실과 투명 정보를 포함하는 장점을 가지고 있으며 다른 무손실 포맷인 BMP, TGA 등에 비해 압축 효율이 높은 편이다. 불투명 이미지의 대표주자가 JPEG이라면, 투명 이미지로는 PNG가 가장 대표적이다. 투명은 기존 RGB 채널에 8비트 알파 채널을 하나 더 갖춤으로서 표현 가능하며 32비트 데이터 구조를 갖춘다. PNG는 기본적으로 Zip 압축 방식(Deflate 알고리즘)을 적용하고 있으며 픽셀의 패턴이 반복되거나 단순할 경우 매우 높은 압축률을 보여준다. PNG를 활용할 수 있는 대표 오픈소스 라이브러리로는 libpng가 있다.

  • GIF: Graphics Interchange Format의 약자로 웹페이지 배너 등 JPEG, PNG에 이어 인터넷 상에서 많이 쓰이는 이미지 포맷 중 하나이다. GIF는 하나의 파일에 여러 장의 장면을 내포할 수 있기 때문에 짧은 애니메이션을 표현할 때 적합하지만 최대 256 색상만 지원되는 제약사항이 존재하여 고품질의 이미지로서는 적합하지 않다. GIF는 무손실 압축 방식인 LZW(Lempel-Ziv-Welch) 알고리즘을 이용한다. GIF를 이용할 수 있는 대표 오픈소스 라이브러리로 giflib이 있다.


  • 그림 2: GIF는 하나의 파일에 다수의 이미지 정보를 보유한다. (Calvin and Hobbes)

  • WebP: 이미지 포맷 중 나이가 가장 어리다. 2010년 구글에서 처음 소개했으며 가장 최근에 나온만큼 성능과 기능 역시 우수한 편이다. VP8 비디오 코덱 기반으로 영상을 압축하며 알파 채널과 애니메이션 데이터를 보유한다. Webp는 손실과 무손실 압축 둘 다 지원하는데 공식 사이트에 의하면 똑같은 압축률을 적용했을 때 손실 압축인 JPEG보다 30% 파일 크기가 작고 비손실 압축인 PNG보다 20 ~ 30% 파일 크기가 작다고 한다. 전반적으로 보면, JPEG, PNG, GIF의 특성을 모두 갖춘 셈이다. 가장 최근에 나왔지만 크롬, 오페라, 파이어폭스, 인터넷 브라우저, 사파리 등 주요 브라우저에서는 모두 지원하지만 일부 이미지 관련 툴에서 지원하지 않는 점도 존재한다. WebP 프로젝트로 libwebp가 있으며 깃허브에서 검색 가능하다.


  • 그림 3: WebP 압축률 비교 (Google Developer)

  • EXIF: Exchangeable Image File Format의 약자로, 기존 이미지 파일에 이미지의 부가 정보를 저장하기 위해 나온 메타데이터 포맷이다. 1998년 JEIDA(일본 전자산업 진흥협회)에서 개발하였고 카메라 및 스캐너 등에서 널리 사용하고 있다. EXIF는 이미지를 제작한 날짜, 시간, 위치, 디바이스 모델명과 저작권 등의 정보를 기록하며 JPEG, TIFF 등 일부 이미지 포맷에만 Exif 정보를 추가할 수 있다. PNG 경우는 1.2 버전에서 Exif를 공식적으로 지원하기 시작했으며(2017년) GIF는 현재 지원되지 않는다. Exif는 사실상 연관된 이미지 포맷의 부가 정보로서 기입되기 때문에 UI 엔진에서 별도로 처리할 부분은 없지만 일부 이미지는 exif 확장명을 사용하므로 이를 인식할 수 있도록 해야 한다. 오픈소스로 libexif 프로젝트가 있다.

  • TIFF: Tag Image File Format의 약자로 약 32년의 오랜 기간동안 스캔 및 인쇄 분야에서 사용된 포맷이다. 현재는 24비트 RGB, CMYK, YCbCr 색상 공간을 지원하며 미압축, RLE, LZW, JPEG 등 여러 압축 방식 모두 지원하나, 태생 자체가 스캔과 인쇄를 목적으로 하였기 때문에 원본 그대로의 최고의 품질을 보장할 때 사용하기 적합하다. 하지만 파일 크기는 매우 큰 편에 속하고 여러 웹 브라우저 지원하지 않는다. 오픈소스로 libtiff 프로젝트가 있다.

  • BMP: MS에서 개발하여 과거 MS 윈도우즈에서 주로 사용되던 포맷으로 기본적으로 데이터를 압축하지 않는 특징을 가지고 있다. 따라서 다른 압축 포맷에 비해 이미지 파일 크기가 큰 편이며 대신 인코딩/디코딩 절차를 거치지 않아 이미지를 불러오는 성능은 더 좋다. 하지만, 최신 기기의 프로세싱 성능은 과거에 비해 월등히 좋아졌기 때문에 사실상 이러한 성능 이점도 크게 부각되지 않아서 이제는 원본 이미지 데이터를 그대로 보관하기 위한 용도 외엔 많이 활용되지 않는 편이다. BMP는 기본적으로 JPEG과 같은 24비트 색상을 지원하며 선택사항으로 RLE 압축 기능도 지원 가능하다.

  • JPEG2000: Joint Photographic Expert Group에서 기존 JPEG 보다 품질과 압축률을 더 개선한 포맷이며 비손실 압축도 지원하나 JPEG 대비 인코딩/디코딩 과정이 느리고 메모리 사용량도 상대적으로 높아서 JPEG2000을 선보였던 2000년도에는 산업에서 외면을 받았다. 시스템 성능이 높아진 지금은 이러한 단점을 보완하지만, JPEG에 비해 하드웨어 및 소프트웨어 호환성이 낮기 때문에 사용빈도가 여전히 낮은편이다. JPEG2000을 활성화하기 위해서 OpenJPEG 오픈소스 커뮤니티가 생겼으며 확장명는 jp2, j2k이다.

  • TGA: 트루비전사의 IBM 최초 그래픽스 카드인 Truevision Graphics Adaptor를 위한 파일 포맷으로 1에서 23비트 색상과 알파 채널을 추가로 지원 가능하다. 특허 방해가 없고 무손실 압축 방식인 RLE을 통해 이미지를 압축하여 사용하기 쉬운 장점이 있으나 RLE 특성상 압축 성능은 단순한 이미지에만 효과적이다. 주로 3차원 그래픽스에서 텍스처 리소스로 사용된다. 활용가능한 라이브러리로 libtga가 있다.

  • 이 외에도 더 많은 이미지 포맷이 존재하지만 업계에서 주로 사용하는 포맷은 위 목록에 나열되어 있다. 이 중에서도 PNG는 UI 리소스로서 가장 많이 활용되는 포맷이고 JPEG은 앱의 컨텐츠로 많이 활용되므로 UI 엔진에서 이 두 포맷을 지원하는 것은 필수적이다. 보다 최적화된 UI 엔진을 설계하는데 있어서 필요에 따라 자체적인 이미지 포맷을 새로 설계하거나 기존 포맷을 변형/확장할 수 도 있지만 이 경우 범용성이 떨어지기 때문에 주의를 해야한다. 자체적인 새로운 포맷을 추가하였다면 JPG, PNG와 같은 기존 포맷으로부터 새로운 포맷으로 변형할 수 있는 툴을 함께 제공하여 앱 개발자가 새로운 포맷의 이미지를 쉽게 생성할 수 있도록 해야한다. 포토샵, 일러스트레이터, 에프터 이펙트 등의 대표 이미지 제작 툴에서 새로운 포맷의 파일을 바로 추출할 수 있는 추출기(exporter)를 제공하는 것도 방법이다. 새로 만든 포맷을 범용적인 포맷이 될 수 있도록 오픈소스로 프로젝트로 공개하는 것도 좋은 방침이다. 하지만, 기존 포맷 대비 분명한 장점이 있지 않는 한, 새로운 포맷을 지원하는 일은 신중해야 한다.

     YCbCr 색상 공간


    주로 예전의 디지털 TV를 위해 사용된 색상 공간 중 하나이며, mpeg 모델에서 사용되는 모델이기도 하다. YUV로 불리기도 한다. 휘도, 즉 밝기 성분 Y와 색상 성분 Cb, Cr을 통해 최종 색상을 표현한다. 사람의 눈이 색상보다는 밝기의 차이에 보다 민감하다는 점을 감안해서 휘도에 보다 큰 스펙트럼의 수치를 저장하고 색상은 RGB 모델보다 적은 Cb, Cr 두 요소로만 기입한다.

    CbCr 색상 공간 (위키피디아)

    추가로 설명하자면, 영상 시스템에서 YUV는 크로마 서브샘플링(Chroma Subsampling) 기법을 이용하여 명도를 제외한 색차 정보만 줄여서 영상을 인코딩한다. 샘플링 정보는 J:a:b와 같이 표기한다.

  • J: 수평 샘플링 기준 단위
  • a: 첫 번째 열의 크로마(Cb, Cr) 샘플링 수
  • b: 첫 번째, 두 번째 열의 크로마 샘플링 수

  • 이를 테면, 4:2:2는 4x2 영역의 픽셀에 대해서 각 루마(Y)를 적용하되, 첫 번째 열 네 픽셀에 크로마 두 개(a=2), 두 번째 열 4 픽셀에 대해서 크로마 두 개(b=2)를 적용한다. 4:2:0라면, 4x2 영역의 픽셀에 대해서 각 루마(Y)를 적용하되, 첫 번째 열 네 픽셀에 크로마 두 개(a=2), 두 번째 열 네 픽셀에 크로마 0 개(b=0)를 적용한다. 4:4:4의 경우는 샘플링 효과가 없다.

    크로마 서브샘플링 (위키피디아)

    다음 코드는 YCbCr과 RGB 색상 모델간의 변환식을 구현한다.

    //YCbCr from RGB
    Y = 16 + 1/256 * (65.738 * R + 129.057 * G + 25.064 * B);
    Cb = 128 + 1/256 * (-37.945 * R - 74.494 * G + 112.439 * B);
    Cr = 128 + 1/256 * (112.439 - 94.154 * G - 18.258 * B);

    //RGB from YCbCr
    R = Min(255, Y + 1.40200 * (Cr - 128));
    G = Min(255, Y - 0.34414 * (Cb - 128) - 0.71414 * (Cr - 128));
    B = Min(255, Y + 1.77200 * (Cb - 128));


    2.2 이미지 로더

    이미지를 활용하기에 앞서 UI 엔진에서 활용할 수 있는 대표적인 이미지 포맷을 살펴보았다. 이제 UI 엔진에서는 이미지 파일로부터 데이터를 불러서 이미지 비트맵을 구축해야 한다. 우리는 앞서 살펴본 다양한 포맷의 파일로부터 데이터를 읽은 후, 디코딩 작업을 거쳐 이미지 비트맵을 구축할 수 있다. 문제는 각 포맷마다 데이터 구성 및 압축 방식이 다르기 때문에 이들 포맷의 세부사항을 이해하고 그에 따른 디코딩 작업을 수행해야 한다. 이러한 로딩 절차는 포맷에 따라 개별적인 루틴으로 수행되도록 포맷별로 이미지 로더를 분리하는 것이 구현은 물론, 유지와 관리 측면에서 용이하다.


    그림 4: UI 엔진에서 이미지를 불러오는 작업 절차

    그림 4은 요청받은 이미지 파일을 불러오는 엔진 내부 구조를 도식화한다. UIImage는 이미지를 출력하는 UI 객체를 구현하며 앱으로 하여금 이미지를 화면에 배치하고 출력할 수 있는 인터페이스를 제공한다. UIImage는 불러올 파일명을 전달받고 이미지 로더(Image Loader)에게 이미지 데이터를 불러오도록 요청한다. 파일명을 전달받은 이미지 로더는 확장명을 확인하여 해당 이미지가 어떤 포맷인지 판단한 후, 사용할 보조 이미지 로더(그림 4에서 png loader)를 결정할 수 있다.

    이미지 로더는 지원하는 이미지 포맷의 수만큼 보조 이미지 로더를 내부적으로 구축한다. 이러한 보조 이미지 로더는 정적 또는 동적 모듈로서 통합이 가능하며 이는 UI 시스템의 설계 방침에 따른다. 핵심은 개별적인 보조 이미지 로더 구현부를 하나의 로더로 통합하지 않고 분리하여 확장이 쉽도록 프레임워크를 구성한다는 점이다. 범용적인 이미지 포맷은 기술 발전 흐름에 영향을 받기 때문에 이러한 방식은 이미지 포맷을 새로 지원하거나 기존에 구현된 로더를 폐기할 때 보다 용이하다.

    이미지 로더는 JPG, PNG, GIF 등의 보조 이미지 로더로부터 어떤 로더를 사용할지 결정해야 한다. MS 윈도우 시스템과 같이 확장명을 권장하는 시스템에서는 문제가 없지만, 리눅스 시스템만 하더라도 확장명은 필수가 아니다. 심지어 시스템과 상관없이 사용자에 의해 이미지 파일의 확장명은 명시되지 않을 수도 있다. 이 경우, 이미지 로더는 파일 불러오기를 실패할 수도 있지만 예외처리를 통해 보조 이미지 로더를 결정한다면 호환성을 한결 더 개선시킬 수 있다. 이를 위해서는, 준비된 보조 이미지 로더를 순회하며 파일 불러오기를 순차적으로 시도해보는 방식을 취한다.

    //준비된 서브 이미지 로더 해시 Hash imageLoaders; UIImageLoader.FindImageLoader(file): /* 파일 확장명을 참고하여 서브 이미지 로더를 결정한다. 여기서 extension은 png값을 갖는다고 가정한다. */ String extension = getExtension(file); if (extension == “png”) return imageLoaders.get(“png”); else if (extension == “jpg”) return imageLoaders.get(“jpg”); else if (...) ... /* 파일 확장명으로부터 서브 이미지 로더를 결정하지 못한 경우, 준비된 로더로부터 하나씩 불러오기를 시도해 본다. */ foreach(imageLoaders, loader) if (loader.open(file) == success) return loader;

    코드 1: 서브 이미지 로더 결정부

    다양한 종류의 파일 포맷으로부터 어떤 로더를 결정할지 좀 더 표준화된 명세가 필요하다면 MIME(Multipurpose Internet Mail Extensions) 타입 목록을 참고할 수도 있다. MIME은 원래 SMTP 프로토콜 상에서 이메일을 보낼 때 참조된 콘텐츠의 파일 포맷을 명시하기 위해 설계되었으며 현재는 MIME은 미디어(Media) 타입으로 부르기도 한다.

    From: Hermet Park <hermetpark@gmail.com> MIME-Version: 1.0 This is a multipart message in MIME format. --XXXBoundary String Content-Type: image/png; Content-Disposition: attachment; filename=”car.png”

    코드 2: MIME 메시지 예

    이미지 로더는 전달받은 파일 확장명으로부터 준비된 로더를 결정해야 하며 이는 사전에 준비한 MIME 타입 테이블로부터 매핑을 통해 보조 이미지 로더를 결정할 수 있다. 이러한 타입 목록은 다음 링크(www.freeformatter.com/mime-types-list.html)를 통해 확인 가능하다.

    이미지를 불러오는 절차를 스레드화하여 이미지 로딩의 부하를 줄일 수 있다. 대체로 이미지 파일은 특수 알고리즘을 통해 압축된 데이터를 기록하고 있기 때문에 이미지를 불러오기 위해서는 압축 알고리즘을 역수행하는 디코딩 절차를 거쳐야 한다. 파일 시스템에 접근하여 디스크로부터 데이터 읽기 접근(Read-Access) 하는 일 자체도 부담이 될 수도 있지만, 이미지 데이터의 압축을 해제하는 디코딩 절차 역시 앱 프로세스에 부담이 될 수 있다. 특히 이미지 해상도가 크면 클수록 이러한 부담은 더욱 크게 증가한다. 따라서, 이미지 로더에서는 스레드를 활용한 이미지 불러오기 절차를 수행하도록 인프라를 구축한다면 성능 향상에 도움이 된다.


    그림 5: 스레드 풀 (Thread-Pool) 기반 이미지 로더 구성

    첨언하자면, 그림 5의 태스크 스케줄러는 이미지 로더에 종속하는 것보단 UI 엔진 전체에 걸쳐 다양한 스레드 작업 요청을 관장하는 별도의 기능 구조로 구성하는 편이 스레드 활용 측면에서 더욱 바람직하다. 그림 5는 현재의 기능 이해를 돕기위해 이러한 구조를 단순화하였다.

    이미지를 불러오는 작업을 별도의 스레드를 통해 수행한다면, 이 작업은 근본적으로 렌더링 엔진과는 비동기 상태로 수행된다. 그렇기 때문에 이미지 데이터는 어쩌면 몇 프레임이 지난 후에 완성될 수도 있다. 이미지를 출력하길 기대하는 앱 개발자는 이점을 활용하여 이미지를 비동기적으로 불러올지 말지를 결정할 수 있다. 어떤 콘텐츠 이미지가 그 즉시 출력될 필요가 없다면, 비동기적으로 이미지를 불러오는 기능을 활용하는 편이 앱 동작을 더욱 매끄럽게 할 수 있기 때문이다. 이미지를 뒤늦게 출력하거나 미리 준비된 다른 이미지를 먼저 보여준 후 나중에 완성된 이미지로 바꿔서 출력할 수도 있다. 이러한 트릭을 이용하면 이미지를 불러오는 작업 부하로 인해 메인 루프의 동작이 지체되는 현상을 피할 수 있다.

    UIImage img; //앱은 Async 기능을 활성화하여 이미지를 불러오도록 요청한다. img.path(“car.png”, async=true); //비동기적으로 이미지를 불러온다면, 이미지 불러오는 작업이 끝났다는 신호도 필요하다. img.addEventCb(UIImage.EventLoadDone, ...);

    코드 3: 이미지 비동기 불러오기 요청


    3. PNG 로더

    특정 포맷의 로더를 지원하기 위해서는 해당 이미지 포맷의 기능적 특성은 물론, 파일 구조와 해당 포맷에서 사용하는 압축 알고리즘을 이해해야 한다. 포맷의 특성과 파일 구조를 정확히 이해해야만 해당 포맷의 로더를 구현할 수 있으며 이러한 사항은 포맷의 명세서(https://www.w3.org/TR/PNG/)를 통해 파악이 가능하다.

    이미지 로더를 구현하면 파일로부터 데이터를 가공 후 원본 이미지인 비트맵을 얻을 수 있다. 이러한 기능 동작은 이미지 로더에서 직접 구현할 수도 있지만, 사실상 이미 만들어진 라이브러리를 이용하는 편이 개발 비용 및 유지보수 측면에서 더 효율적이다. 실제로 앞절에서도 살펴보았듯이, 대부분의 이미지 포맷에는 해당 이미지 데이터를 인코딩하고 디코딩할 수 있는 무료 라이브러리가 존재한다. 하지만, 우리는 학습을 목적으로 하기 때문에 대표적으로 PNG 포맷 구조를 살펴봄으로써 보조 이미지 로더의 동작 방식과 그 과정을 이해해 보도록 한다. 한 개 포맷의 로더를 이해하면 다른 포맷의 구현 및 동작 방식을 유추하고 이해하는데 도움이 될 것이다.

    어떠한 포맷이든 간에, 파일로 부터 이미지 데이터를 읽기 위해서는 먼저 그 포맷의 파일 구조를 파악해야 한다. PNG 파일 구조를 살펴보면 다음과 같다.


    그림 6: PNG 파일 구조

    PNG 파일은 파일 시그니처와 다수의 데이터 조각(Chunk)으로 구성되어 있다. 파일 시그니처는 파일 시작으로부터 8 바이트 영역을 차지한다. 이 곳에는 137 80 78 71 13 10 26 10 값을 기록하는데 만약 파일을 오픈한 후, 처음 8 바이트의 값이 위와 동일하다면 해당 파일을 PNG 포맷으로 간주할 수 있다. 앞서 코드 1의 18번째 라인처럼 파일의 확장명이 png가 아니라면, 파일을 오픈하여 위 시그니처를 통해 해당 파일이 png인지 아닌지를 판단한다.

    signature: 137 80 78 71 13 10 26 10

    PNG 파일 시그니처

    시그니처가 약속된 값이라면 이후에는 이미지의 실제 데이터를 기록하고 있는 데이터 조각을 해석한다. 유효한 이미지라면 데이터 조각은 최소 세 개 이상으로 구성되는데, 하나의 데이터 조각은 데이터의 길이(Length), 타입(Chunk Type), 데이터(Chunk Data), CRC(Cyclic Redundancy Check)로 구성된다. 데이터 조각을 읽을 때는 처음 4바이트로부터 읽어야 할 데이터 길이를 참조한다. 만약 길이가 0이라면, 해당 조각은 길이, 타입 그리고 CRC 필드만 갖출 뿐이다. 따라서 하나의 데이터 조각은 최소 12 바이트를 차지하며 해당 조각에 담긴 데이터 길이만큼 실제 데이터가(Chunk Data) 추가로 존재한다.

    CRC(순환 중복 검사)는 본래 네트워크 등을 통해 전송받은 데이터에 오류가 있는지 검증하기 위한 용도로 사용된다. 여기서도 마찬가지로, PNG의 데이터 조각에 기록되어 있는 CRC의 값이 계산 값과 다르다면 해당 조각은 훼손되었다고 판단할 수 있다. 다음 C 코드는 PNG 명세서에서 제시하는 CRC 계산 로직을 구현한다.

    /* 8비트 요소로 구성한 CRC 테이블 */ unsigned long crc_table[256]; /* CRC 테이블 초기화 여부 기록 */ int crc_table_computed = 0; /* 빠른 CRC를 위한 테이블 구축 */ void make_crc_table(void) { unsigned long c; int n, k; for (n = 0; n < 256; n++) { c = (unsigned long) n; for (k = 0; k < 8; k++) { if (c & 1) c = 0xedb88320L ^ (c >> 1); else c = c >> 1; } crc_table[n] = c; } crc_table_computed = 1; } /* CRC 값 계산 수행. 첫 번째 인자 crc의 필드는 모두 1로 초기화 되어있어야 하며, crc() 루틴의 최종 반환 값은 1의 보수에 해당한다.*/ unsigned long update_crc(unsigned long crc, unsigned char *buf, int len) { unsigned long c = crc; int n; if (!crc_table_computed) make_crc_table(); for (n = 0; n < len; n++) { c = crc_table[(c ^ buf[n]) & 0xff] ^ (c >> 8); } return c; } /* buf[0 ~ len -1]에 대한 CRC 값 반환 */ unsigned long crc(unsigned char *buf, int len) { return update_crc(0xffffffffL, buf, len) ^ 0xffffffffL; }

    코드 4: Sample Cyclic Redundancy 구현 코드

    데이터 조각 유형은 총 18개로 다양하지만, 실제로는 다음 네 유형이 핵심이다.

  • IHDR: 이미지 헤더. PNG 데이터 조각 중 첫 번째에 해당된다.
  • PLTE: 인덱싱 방식 색상을 표현할 때 사용. 팔레트 테이블 정보를 가진다.
  • IDAT: 이미지 데이터
  • IEND: 데이터 조각 중 마지막 조각임을 가리킨다.

  • 이 네 개의 유형 중 IDAT 조각만이 유일하게 다수가 존재할 수 있으며 그 외는 반드시 하나만 허용된다. 앞서 나열한 네 개의 유형 외에 나머지 14개는 모두 부수적인 용도이다. 유효한 PNG 파일이라면, 반드시 IHDR 조각으로 시작해서 다수의 IDAT 조각을 거쳐 IEND 조각으로 끝난다.

    각 유형에 대한 설명은 추후에 언급하고 우선 PNG 인코딩 절차부터 살펴보자.


    3.1 PNG 인코딩

    PNG는 여러 절차를 걸쳐 인코딩을 수행한다. 다음은 인코딩의 절차를 순차적으로 설명한다.

    a. 패스 추출(Pass Extraction): 인터레이스드(Interlaced) 기능을 위해 수행되며 아담(Adam) 7 알고리즘을 이용하여 일곱 단계의 축소된 이미지를 생성한다. 참고로 인터레이스드 기능은 이미지를 점진적으로 보여주기 위한 기능이며, 디코딩을 완료한 부분 이미지를 우선적으로 보여줌으로써 저 사양의 시스템에서도 사용자에게 즉각적인 이미지 출력 반응을 보여주는 효과를 제공한다.


    그림 7: PNG 패스 추출

    PNG에서 인터레이스드 기능은 선택 사항이며 일반적으로 이 기능이 많이 활용되지 않는 편이다. 만약 인터레이스드 기능을 활용하지 않는다면, 패스 추출 단계 역시 수행할 필요가 없다.


    그림 8: 아담 7의 각 단계별 이미지 완성 상태 (위키피디아)

    b. 스캔라인 직렬화(Scanline Serialization): 패스 추출 단계를 거친 후 준비된 각 부분 이미지 조각에 대해서 이미지 행 단위(Scanline)로 데이터를 분리한다. 만약 패스 추출을 수행하지 않는다면, 원본 이미지의 행 단위로 데이터를 분리한다.


    그림 9: PNG 스캔라인 직렬화

    c. 필터링(Filtering): 보다 효율적인 압축을 위해 사전에 필터링을 수행한다. PNG는 하나의 필터 방법을 표준으로 사용하며 이 필터 방법 내에는 다수의 필터 타입이 존재한다. 전 단계에서 준비된 각 스캔라인에 대해서 필터 타입을 개별적으로 선택 적용하기 때문에 각 스캔라인에 필터 타입의 정보가 새로 추가된다. 보다 자세한 내용은 3.3 필터를 참고하자.


    그림 10: 필터를 적용한 PNG 스캔라인 데이터

    d. 압축(Compression): 필터링을 적용한 스캔라인 데이터를 압축한다. 압축은 정의된 방식 중 하나를 지정할 수 있으나 일반적으로 PNG 표준 압축 방식인 DEFLATE(압축 해제: INFLATE)를 적용한다. 압축은 연속된 스캔라인 데이터를 입력으로 받고, 무손실인 zlib 포맷으로 하나의 압축 데이터스트림을 결과물로 생성한다. 보다 자세한 내용은 4.3.3 압축을 참고하자.

    e. 조각화(Chunking): 압축된 데이터 스트림을 다수의 IDAT 조각으로 분리한다. 각 데이터 조각은 PNG 파일의 시그니처 영역 뒤에 일렬로 연결되어 저장되므로 각 IDAT의 조각의 데이터를 순서대로 연결하면 하나의 zlib 데이터가 완성된다. 염두해야할 사항은 IDAT 조각이 순서대로 연결되어 있을지라도 각 데이터 조각간의 경계는 임의의 바이트에 해당한다는 점이다. 달리 말하면, 스캔라인 또는 픽셀을 기준으로 데이터를 조각화하지 않기 때문에 하나의 데이터 조각이라도 손실된다면 데이터를 복원할 수가 없다.


    그림 11: PNG 데이터 압축 및 조각화 과정


    3.2 IHDR

    IHDR(Image Header)은 이미지 헤더로서 PNG 이미지의 속성 정보를 갖춘다. 해당 조각에 포함된 데이터는 총 13바이트이며 이 데이터는 이미지 크기, 색상 깊이, 색상 타입, 압축과 필터링 방식 등의 정보를 포함한다.


    그림 12: IHDR Chunk 구조

  • Width & Height: 이미지의 가로, 세로 크기
  • Bit Depth: 색상 깊이. 색상을 구성하는 각 채널이 몇 비트로 구성되는지를 결정. 색상 타입에 따라 지정할 수 있는 비트의 값은 다르며 32비트 RGBA의 경우, 8의 값을 갖는다.
  • Color Type: 색상 타입을 결정. 트루칼라인 RGB 색상은 2, 여기서 알파 속성을 추가로 갖는 RGBA의 경우 값은 6이다.

  •  이미지 타입

     색상 타입

    (Color Type)

     가능한 색상 깊이

    (Bit Depth)

     설명

     그레이스케일 (Grayscale)


     0

     1, 2, 4, 8, 16

     그레이스케일(흑백) 색상

     트루칼라 (Truecolor)


     2

     8, 16

     RGB 색상

     인덱스칼라 (Indexed-color)


     3

     1, 2, 4, 8

     인덱싱 방식. PLTE 데이터가 반드시 있어야 한다.

     그레이스케일 + 알파

     4

     8, 16

     투명 속성을 지닌 그레이스케일

     트루칼라 + 알파

     6

     8, 16

     RGBA 색상

    표 1: Color Type과 Bit Depth 조합

  • Compression: 압축 방식을 지정하며 표준은 DEFLATE 방식(값: 0) 한 가지만 존재한다.
  • Filter: 필터링 방식. PNG 국제 표준 방식은 현재 0번 하나이다.
  • Interlace: 인터레이스 방식. 미사용: 0, 사용: 1.

  • Length: 13, Chunk type: IHDR, #16진수 값은 73 72 68 82 Chunk data: { Width: 800, Height: 600, Bit depth: 8, Color type: 6, Compression method: 0, Filter method: 0, Interlace method: 0 }, CRC

    코드 5: IHDR 데이터 구성 예


    3.3 IDAT

    IDAT(Image Data)는 실제 이미지 데이터를 보유한다. IDAT의 이미지 데이터는 필터링과 압축을 거친 결과물이며 데이터는 직렬화(Serialization)을 수행하여 연속된 바이트 집합을 이룬다. 하나의 IDAT 데이터 조각의 최대 크기는 65,536바이트이므로 가공된 데이터의 크기가 이보다 더 클 경우, IDAT 여러 데이터 조각에 걸쳐 하나의 스트림(Stream)을 구축한다. IDAT의 타입 값은 73 68 65 84 이다.


    그림 13: PNG 데이터 인코딩 과정

    IDAT에 적용하는 필터를 살펴보면, PNG의 기본 필터 방식 0번에는 필터 타입이 총 다섯 개가 있다. 필터 타입은 기본적으로 휴리스틱(Heuristic) 방식을 통해 결정하며 다섯 개의 필터 함수를 모두 수행하여 결과를 비교한 후, 가장 최적의 타입을 선택한다. 각 함수는 이미지 픽셀의 각 행(스캔라인)마다 개별적으로 적용하는 것이 압축률에서 가장 효과적이다. 필터는 깊이 정보 및 색상 타입과 관계없이, 이미지 픽셀 단위가 아닌 바이트 단위로 계산을 수행하는 점을 염두한다.

    타입
    이름
     필터 함수  필터 역함수
    0
    None
     Filt(x) = Orig(x)  Recon(x) = Filt(x)
    1
    Sub
     Filt(x) = Orig(x) - Orig(a)  Recon(x) = Filt(x) + Recon(a)
    2
    Up
     Filt(x) = Orig(x) - Orig(b)  Recon(x) = Filt(x) + Recon(c)
    3
    Average
     Filt(x) = Orig(x) - floor((Orig(a) + Orig(b)) / 2)  Recon(x) = Filt(x) + floor(((Recon(a)+Recon(b)) / 2)
    4
    Paeth
     Filt(x) = Orig(x) - PaethPredictor(Orig(a), Orig(b), Orig(c))  Recon(x) = Filt(x) + PaethPredictor(Recon(a), Recon(b), Recon(c))
    표 2: PNG 필터 타입

    필터에서 입력값으로 주어지는 x, a, b, c의 정의는 다음과 같다.

  • x: 필터를 수행할 바이트
  • a: x가 속한 픽셀의 바로 이전 픽셀 중, x와 동일한 위치의 바이트
  • b: x 이전 스캔라인 상에서 동일한 위치의 바이트
  • c: x 이전 스캔라인 상에서 a와 동일한 위치의 바이트

  • 이해를 돕기 위해 트루칼라(24비트) 이미지에서 픽셀(0F2865)의 x를 기준으로 a, b, c를 도식화하면 다음 그림과 같다.


    그림 14: PNG 필터 입력 바이트

    RGBA 색상의 경우, 알파 채널은 별도로 분리하여 RGB와 동일한 방식으로 데이터를 가공한다.경험적으로 트루칼라 및 그레이스케일과 같은 색상에서는 다섯 개의 필터 타입 모두 대체로 효과적이다. 만약 딱 하나의 고정 필터만 사용해야할 경우에는 Paeth 필터가 최선의 선택이다.

    p = a + b - c pa = abs(p - a) pb = abs(p - b) pc = abs(p - c) if pa <= pb and pa <= then Pr = a else if pb <= pc then Pr = b else Pr = c return Pr

    코드 6: Paeth Predictor 의사코드

    필터를 적용하면 적용한 필터 타입이 무엇인지를 기록해야 하므로, 이미지 데이터의 첫 번째 열 1 바이트에는 필터 타입의 정보를 새로 추가한다.

    04 0F32AE 962AE2 429D72 098FD3 0AEF21 058271 028E11 083123 ... (Paeth) 02 0F32AE 962AE2 429D72 098FD3 0AEF21 058271 028E11 083123 ... (Up)


    PNG의 데이터 압축은 기본적으로 DEFLATE 알고리즘(해제는 INFLATE)을 이용한다. DEFLATE는 많이 알려진 zip, gzip에서 사용되는 압축 포맷이자 알고리즘이다. 이는 기본적으로 LZ77 알고리즘을 통해 데이터를 압축한 후, 중복되는 데이터를 허프만 부호화 알고리즘을 통해 한 번 더 압축하는데 타 압축 방식에 비해 압축률이 좋고 속도도 빠른편에 속해서 압축 방식의 표준으로 인정받아 왔다.

     LZ77 압축 알고리즘


    LZ1으로도 불기도하며 비손실 압축 알고리즘으로서 1977년 아브람 렘펠의 의해 처음 발표되었다. LZW, LZSS, LZMA 등 다양한 변형 알고리즘이 존재하며 LZ77은 DEFLATE 뿐만 아니라 GIF, ZIP의 압축에 활용되고 있다. LZ77 압축의 핵심 원리는 매우 단순하다. 주어진 입력값 중 반복되는 데이터가 존재할 시 데이터의 오프셋과 그 길이만을 입력하여 데이터의 양을 줄이는 방식이다. 가령, ‘ABCDEFGHIJKABCDEFG1234’ 라는 입력 데이터가 있을 경우 입력값 후반의 ABCDEFG는 중복 데이터에 해당하므로 이를 ‘ABCDEFGHIJK{11,7}1234’ 와 같이 변경하여 데이터의 크기를 줄인다.



    실제로 인코딩 결과물은 오프셋과 길이뿐만 아니라 바로 다음에 올 데이터(코드)까지 같이 기입하는데 위 예제의 경우 인코딩 결과물은 {11, 7, c(1)} 에 해당한다.

    중복되는지 판단하기 위한 데이터 검색 영역을 일정 크기 단위로 분할할 수도 있다. LZ77에서는 이를 슬라이딩 윈도우(Sliding Window) 또는 히스토리 테이블(History Table)라고도 한다. 이러한 개념을 도입하면 입력 데이터를 슬라이딩 윈도우 영역 내에서만 중복되는 지 판단할 수 있다. 슬라이딩 윈도우 범위 내에서 이미 인코딩을 끝낸 영역은 검색 버퍼(Search Buffer)로 지정하고 아직 인코딩을 끝내지 않은 영역을 Look-Ahead Buffer 라고 한다. 슬라이딩 윈도우의 크기는 압축 속도와 압축률에 영향을 미치기 때문에 상황에 맞게 그 값을 조정할 수도 있다. LZ77에서 중복 영역의 데이터 길이는 최대 256바이트로 제한하며 이를 인코딩 시 최소 3바이트까지 줄일 수 있다.


     허프만 부호화 (Huffman Coding)


    1951년 허프만 박사에 의해 고안된 알고리즘이며 무손실 압축 기법으로서 데이터의 사용 빈도수에 따라 데이터에 다른 길이의 부호를 사용하는 알고리즘이다. 이 알고리즘의 핵심은 가장 많이 사용되는 데이터일수록 더 적은 수의 비트를 활용한다는 점에 있다. 이는 이 알고리즘에서 압축 효과를 높이는 가장 중요한 핵심 원리이다. 가령, “abcd_abb_abbbc_ccddd” 라는 입력 데이터가 주어졌을 때, 사용 빈도수를 비교해보면 b는 6, a는 3이라는 점을 알 수 있는데 이를 허프만 알고리즘을 적용한다면, b는 2비트로서 11, a는 3비트로서 100 이라는 값으로 치환(부호화)할 수 있다. 그리고 원래의 데이터와 치환된 값을 허프만 테이블에 따로 기입하여 인코딩된 비트값이 원래의 어떤 값으로 치환될지를 확인한다. 치환될 값을 결정하는 방식은 입력 데이터의 출현 빈도수에 의거, 이진 트리를 구성하는 과정에 의존한다. 즉, 입력 데이터를 트리로 구축하고 특정 데이터에 접근하기 위해 트리를 탐색하는 절차(좌: 0, 우: 1)가 바로 치환될 값을 결정한다. 100의 값을 가지는 a 입력 데이터는 실제로 트리의 루트 노드로부터 우(1)->좌(0)->좌(0) 의 방향으로 깊이 탐색하는 과정과 동일하다. 이러한 절차에 의해 치환값은 중복되거나 다른 치환값의 부분값이 되지 않으므로 결과값을 해석할 때 문제가 되지 않는다. 허프만 부호화의 알고리즘을 정리하면 다음과 같은 절차를 수행한다. 1. 모든 입력 데이터를 출현 빈도수로 정렬하여 목록을 구축 2. 하나의 데이터가 남을 때까지 아래 단계 반복 a. 목록에서 빈도 수가 가장 낮은 두 개의 데이터를 선택하고 목록에서는 제거 b. 선택한 두 데이터로부터 하나의 새로운 (접두부호) 데이터를 생성하고 선택한 두 개의 데이터를 새 데이터의 이진 트리 자식으로서 좌, 우에 추가. 이 때 새로운 데이터의 출현 빈도수는 두 자식 데이터의 출현 빈도수의 합으로 결정 c. b에서 생성된 새로운 데이터를 출현 빈도수로 정렬하여 목록에 추가 위 과정을 그림으로 도식화하면 다음과 같다.



    3.4 IEND

    IEND(Image End)는 마지막 데이터 조각으로서 PNG 파일 데이터의 끝부분임을 명시한다. 실제 데이터가 존재하지 않으므로 length 값은 0이며 type 값은 73 69 68 68에 해당한다.


    4. 이미지 프로세싱

    이미지 로더로부터 생성한 이미지 데이터는 이미지 렌더러를 통해 출력이 가능한 포맷으로 변환되는 것은 물론 추가적인 이미지 프로세싱 과정을 거친다. 예를 들면, 외부에서 요청한 크기에 맞게 원본 이미지의 크기를 조정하는 작업을 수행하거나 이미지 회전, 색상 반전, 다른 이미지와 합성 등의 작업을 수행할 수 있다. 이미지 로더가 다양한 이미지 소스로부터 실제 가용한 이미지 데이터를 생성하는 과정을 수행한다면, 이미지 렌더러는 원본 이미지의 비트맵 데이터를 토대로 렌더링 엔진에서 가용한 형태로 어떠한 후처리(Post-Processing) 과정을 수행한다고 이해하면 된다.


    그림 15: 이미지 렌더러 후처리 기능 구성

    그림 15에서는 이미지 렌더러가 수행하는 대표적인 기능 네 개를 표현한다. 이러한 기능은 파이프라이닝 방식으로 정해진 절차에 의해 순차적으로 수행되도록 구성할 수도 있지만, 요구되는 기능의 상태에 따라 절차를 변경하여 최적의 기능이 수행되도록 로직을 구축할 수도 있다. 그리고 이러한 설계 사항은 캔버스 엔진이 제공하는 기능의 범위와 렌더러의 최적화 방침에 영향을 받을 수 있다. 이미지 렌더러에서 수행하는 Converter, Scaler, Filter, Blender와 같은 기능은 png와 같은 이미지 파일 데이터 뿐만 아니라 벡터 및 텍스트와 같은 다른 입력 데이터로부터 이미지를 가공하기 위해서도 사용 가능하다. 생성한 다양한 UI 요소들을 합성하여 최종 화면을 가공하기 위해서도 이미지 렌더링의 기능은 사용될 수 있다. 따라서 이미지 렌더러를 보다 범용적인 프로세싱 모듈로 구축하는 것도 가능하다. (그림 35 참고)


    4.1 포인트 샘플링

    이미지 처리의 핵심 기능은 이미지 크기를 조정하는 작업이다. 특히 UI 프레임워크에서는 가변 해상도 기기를 지원하기 위해 이미지나 UI 컨트롤의 크기를 동적으로 변경할 수 있어야 하고, 사용자는 줌인(Zoom in), 줌아웃(Zoom out)을 통해 기기에서 출력되는 컨텐츠의 크기를 변경할 수도 있다.

    그래픽스에서는 이미지 크기를 변경하는 작업을 이미지 스케일링(Scaling)라고도 부르는데, 이미지 렌더러는 이미지 스케일링을 수행하여 원본 이미지로부터 이미지의 크기를 키우거나 축소하는 작업을 수행하고 최종적으로 사용자가 원하는 크기로 이미지를 출력할 수 있도록 기능을 제공한다.


    그림 16: 이미지 스케일링

    이미지 스케일링을 수행하는 가장 기본 방법은 스케일이 변한 지점에 가장 근접한 원본 이미지의 픽셀을 찾는 작업을 수행하는 것이다. 최단 입점 보간(Nearest Neighbor Interpolation)으로 더 잘 알려진 포인트 샘플링은 스케일링의 기본 방식에 해당한다. 이 방식은 슈퍼샘플링(Super Sampling)처럼 인접한 픽셀간 색상 보간이 발생하지 않으므로 스케일링 결과에서 도트가 상대적으로 더 뚜렷하다. 그렇기 때문에 일반적으로 포인트 샘플링은 많이 사용되지 않는 편이다. 하지만 픽셀 아트처럼 도트의 비주얼 특성을 유지해야 하거나, 체스판처럼 경계선이 뚜렷해야 하는 이미지라면 최단 입점 보간을 이용한 스케일링이 오히려 더 적합할 수도 있다.


    그림 17: 포인트 샘플링이 적합한 경우

    /* * Point Sampling(Nearest Neighbor)을 이용한 이미지 스케일링 구현 * srcBuffer: 원본 이미지 버퍼 * destBuffer: 스케일을 적용한 이미지 버퍼 */ UIImageRenderer.nearestNeighborScale(srcBuffer, destBuffer) : //가로 세로 스케일 요소를 구한다. xScale = srcBuffer.width() / destBuffer.width(); yScale = srcBuffer.height() / destBuffer.height(); //버퍼 메모리 접근 Pixel srcBitmap[] = srcBuffer.map(); Pixel destBitmap[] = destBuffer.map(); /* 포인트 샘플링을 수행한다. 스케일된 픽셀 위치 값으로부터 원본 이미지의 픽셀 위치를 구한다. 정수 연산의 경우 소수점은 버린다. */ for (int i = 0; i < destBuffer.height(); i++) for (int j = 0; j < destBuffer.width(); j++) sx = xScale * j; sy = yScale * i; destBitmap[i * destBuffer.lineLength() + j] = srcBitmap[sy * srcBuffer.lineLength() + sx];

    코드 7: 포인트 샘플링 구현 코드



    4.2 슈퍼샘플링

    포인트 샘플링에 비해 슈퍼샘플링은 보다 고급 스케일링 기법에 해당한다. 하나의 픽셀을 선택하여 스케일된 공간에 배치하는 포인트 샘플링에 비해 슈퍼샘플링은 인접한 복수의 픽셀을 선택 후, 이들을 하나로 합성하여 스케일된 공간에 배치한다. 이 방식은 이미지 스케일링 시, 색상의 손실이나 도트가 두드러지게 보이는 현상을 줄여주는 효과를 가져온다. 일반적으로 슈퍼샘플링은 포인트 샘플링 대비 시각 인지 상 보다 부드럽고 깨끗한 질감을 만들어내므로 보다 많이 이용되는 샘플링 방식에 해당한다.


    그림 18: 샘플링 비교: 슈퍼샘플링(좌), 포인트 샘플링(우)

    슈퍼샘플링의 효과는 이미지 내에 존재하는 경계선에도 똑같이 적용된다. 특히 이미지 내 경계선에 도트가 두드러지는 앨리어싱 현상(일명 계단 현상)은 사람의 시각적 인지 상 이미지 품질이 좋아 보이지 않으므로 오직 앤티에일리어싱을 목적으로 슈퍼샘플링을 이용하는 것도 가능하다. 이는 출력하고자 하는 해상도보다 더 큰 해상도의 이미지를 준비한 후, 슈퍼샘플링을 통해 원래 출력하고자 했던 크기로 이미지 해상도를 축소시키는 과정을 수행한다. 이러한 앤티에일리어싱 기법은 슈퍼샘플링 앤티에일리어싱(SSAA) 또는 Full Scene Anti-Aliasing(FSAA)이라고 이라고도 부르며 그냥 다운 샘플링(Down Sampling)이라고도 한다.


    그림 19: 앤티에일리어싱 적용 결과 비교

    슈퍼샘플링에서도 여러 방식들이 존재하지만, 대표적으로 이중선형 보간(Bilinear Interpolation) 기법은 계산량 대비 충분한 품질의 이미지를 가공한다. 이는 현재 픽셀에 간섭을 미치는 인접한 세 방향(우측, 하단, 우측 하단)의 픽셀 색상을 서로 보간한다. 실제로 모니터에 주사되는 픽셀은 정수라는 점에서 이러한 보간법은 유효하다. 이미지 크기를 스케일링할 시 샘플링할 픽셀 위치의 소수점 이하 값은 보간식에서 사용할 가중치 인자로 사용할 수 있다.

    예를 들어, [200 x 200] 해상도의 이미지(이하 S)를 [300 x 300] 해상도 이미지(이하 D)로 크기를 확대할 경우를 생각해 보자. D:(17, 17)에 위치한 픽셀 색상은 어떻게 결정할까? S와 D간 스케일 팩터는 1.5에 해당한다. 이 스케일 팩터을 이용하면 D:(17, 17) 위치는 S:(11.3, 11.3)에 해당한다. 여기서 실수 이하의 값 (0.3, 0.3)은 S:(11, 11)와 S:(12, 12) 두 픽셀 사이의 가중치 인수로 적당하다.


    그림 20: Bilinear Interpolation

    그림 20에서 우리가 구하고자 하는 D는 총 네 개의 픽셀 - S1, S2, S3, S4의 혼합 결과이다. S1, S2을 보간하여 D1을 구하고 S1, S2을 보간하여 D2를 구한다. 다시 D1과 D2를 보간하면 최종적인 D를 구할 수 있다.

        D1 = ((xS2 - xD1) / (xS2 - xS1)) * S1 + ((xD1 - xS1) / (xS2 - xS1)) * S2
        D2 = ((xS4 - xD2) / (xS4 - xS3)) * S3 + ((xD2 - xS3) / (xS4 - xS3)) * S4
        D = ((yD2 - yD) / (yD2 - yD1)) * D1 + ((yD - yD1) / (yD2 - yD1)) * D2

    D를 구하는 수식은 개입한 픽셀의 R, G, B, A 채널에 개별적으로 적용하여 계산한다면 우리가 원하는 최종 픽셀 D의 값을 구할 수 있다. 이러한 작업은 [300 x 300]을 이루는 모든 픽셀에 대해 반복적으로 수행한다.

    /* * Super Sampling(Bilinear Interpolation Filter)을 이용한 이미지 스케일링 구현 * srcBuffer: 원본 이미지 버퍼 * destBuffer: 스케일을 적용한 이미지 버퍼 */ UIImageRenderer.bilinearScale(srcBuffer, destBuffer) : //가로 세로 스케일 요소를 구한다. xScale = srcBuffer.width() / destBuffer.width(); yScale = srcBuffer.height() / destBuffer.height(); //버퍼 메모리 접근 Pixel srcBitmap[] = srcBuffer.map(); Pixel destBitmap[] = destBuffer.map(); /* Bilinear Interpolation 샘플링을 수행한다. 스케일된 픽셀 위치 값으로부터 원본 이미지의 픽셀을 구한 후, 인접한 세 개의 픽셀과 색상을 혼합한다. */ for (int i = 0; i < destBuffer.height(); i++) for (int j = 0; j < destBuffer.width(); j++) dx = xScale * j; dy = yScale * i; sx1 = UIMath.round(dx); sy1 = UIMath.round(dy); sx2 = sx + 1; sy2 = sy + 1; //Index Overflow에 주의 if (sx2 == srcBuffer.width() sx2 = sx1; if (sy2 == srcBuffer.height() sy2 = sy1; //소수점 이하 값 xFraction = dx - sx1; yFraction = dy - sy1; s1 = srcBitmap[sy1 * srcBuffer.lineLength() + sx1]; s2 = srcBitmap[sy1 * srcBuffer.lineLength() + sx2]; s3 = srcBitmap[sy2 * srcBuffer.lineLength() + sx1]; s4 = srcBitmap[sy2 * srcBuffer.lineLength() + sx2]; //D1 = ((xS2 - xD1) / (xS2 - xS1)) * S1 + ((xD1 - xS1) / (xS2 - xS1)) * S2 t = ((sx2 - dx) / (sx2 - sx)); t2 = ((dx - sx1) / (sx2 - sx)); alpha = t * ((s1 >> 24) & 0xff) + t2 * ((s1 >> 24) & 0xff); red = t * ((s1 >> 16) & 0xff) + t2 * ((s2 >> 16) & 0xff); green = t * ((s1 >> 8) & 0xff) + t2 * ((s2 >> 8) & 0xff); blue = t * (s1 & 0xff) + t2 * (s2 & 0xff); d1 = (alpha << 24) | (red << 16) | (green << 8) | (blue); //D2 = ((xS4 - xD2) / (xS4 - xS3)) * S3 + ((xD2 - xS3) / (xS4 - xS3)) * S4 t = ((sx4 - dx) / (sx4 - sx3)); t2 = ((dx - sx3) / (sx4 - sx3)); alpha = t * ((s3 >> 24) & 0xff) + t2 * ((s4 >> 24) & 0xff); red = t * ((s3 >> 16) & 0xff) + t2 * ((s4 >> 16) & 0xff); green = t * ((s3 >> 8) & 0xff) + t2 * ((s4 >> 8) & 0xff); blue = t * (s3 & 0xff) + t2 * (s4 & 0xff); d2 = (alpha << 24) | (red << 16) | (green << 8) | (blue); //D = ((yD2 - yD) / (yD2 - yD1)) * D1 + ((yD - yD1) / (yD2 - yD1)) * D2 t = ((sy2 - dy) / (sy2 - sy1)); t2 = ((dy - sy1) / (sy2 - sy1)); alpha = t * ((d1 >> 24) & 0xff) + t2 * ((d2 >> 24) & 0xff); red = t * ((d1 >> 16) & 0xff) + t2 * ((d2 >> 16) & 0xff); green = t * ((d1 >> 8) & 0xff) + t2 * ((d2 >> 8) & 0xff); blue = t * (d1 & 0xff) + t2 * (d2 & 0xff); d = (alpha << 24) | (red << 16) | (green << 8) | (blue); destBitmap[i * destBuffer.lineLength() + j] = d;

    코드 8: Bilinear Interpolation 구현 코드

    슈퍼샘플링에는 이중선형 보간 외에 삼선형(Trilinear) 및 이방성(Anisotropic) 보간 방식도 존재한다. 이들의 보간 기법은 이중선형 대비 더 좋은 이미지 품질을 생성하지만, 더 많은 리소스와 계산을 요구하는 단점이 존재한다. 일반 사용자 중심의 UI 시스템에서는 이중선형 보간식으로도 충분히 만족할만한 이미지 품질을 보여줄 수 있다.


    5. 텍스처 매핑

    텍스처 매핑(Texture Mapping)은 이미지를 다각형의 기하 형태로 변형하여 출력하기 위해 사용된다. 원래는 원어 뜻 그대로 재질을 씌운다는 개념으로 볼 수 있는데 전통적인 3D 그래픽스에서는 폴리곤(Polygon)에 이미지를 덭붙임으로써 물체나 도형의 표면에 재질감을 부여할 수 있는 기술로 통용된다. 무엇보다도, 텍스처 매핑은 출력하고자 하는 이미지의 형태가 직사각형이 아닌 기하학의 형태로 출력할 수 있게 도와주기 때문에 특수 효과 등의 목적으로도 활용될 수 있다.

    UI 엔진에서 텍스처 매핑은 이미지가 회전을 수행하거나 쉬어(Sheer) 변환 등을 수행할 때 유용하다. 특히 이미지 회전은 UI 시스템에서 유용한 기능 중 하나이며 어떤 컨텐츠가 원근 투영(Perspective Projection)의 y축 회전을 수행한다고 가정한다면, 이미지는 직사각형에서 사다리꼴 형태로 변환될 수 있다. 이 경우 변형된 이미지 모형을 텍스처 매핑을 통해 원하는 형태로 출력할 수 있다. 따라서 UI 엔진에서는 텍스처 매핑 기능을 제공함으로써 보다 활용도가 높은 프레임워크를 구축하도록 도와준다.


    그림 21: 폴리곤 텍스처 매핑

    텍스처 매핑의 핵심은 폴리곤과 폴리곤의 각 꼭지점에 매핑될 텍스처 좌표를 지정하는 것에 기인한다. OpenGL 과 Direct3D와 같은 대표적인 3D 출력 시스템에서는 텍스처 매핑 기능을 위해 폴리곤의 각 꼭지점에 매칭될 텍스처 좌표 UV 값을 입력받는다. 이 후 렌더링 엔진에서는 각 꼭지점에 입력된 텍스처 좌표를 참조하여 폴리곤에 채워질 이미지의 픽셀을 계산한다. 텍스처 좌표값 UV는 맵핑할 이미지 공간을 정규화한 값으로서 0 ~ 1 사이의 값을 받는다. 이 값은 이미지의 크기에 상관없이 항상 일정하다.

    /* Quadron은 사각형 도형을 출력하기 위한 인터페이스이다. Quadron의 정점은 시계 방향(Clock-Wise)으로 구성된다고 가정한다. */ UIQuadron quad; //Quadron의 네 정점 좌표 {x, y, z}를 지정한다. 인덱스는 정점의 순서를 가리킨다. quad.coord[0] = {200, 0, 0}; quad.coord[1] = {400, 50, 0}; quad.coord[2] = {400, 100, 0}; quad.coord[3] = {200, 150, 0}; //Quadron의 텍스처 좌표를 지정한다. 인덱스는 정점의 순서를 가리킨다. quad.uv[0] = {0, 0}; //이미지 좌측 상단 quad.uv[1] = {1, 0}; //이미지 우측 상단 quad.uv[2] = {1, 1}; //이미지 우측 하단 quad.uv[3] = {0, 1}; //이미지 좌측 하단 //매핑할 텍스처 리소스 quad.path(“texture.png”);

    코드 9: 폴리곤 텍스처 좌표 지정 예

    폴리곤에 매핑할 텍스처 소스는 반드시 이미지 리소스일 필요는 없다. 텍스처의 소스는 어떠한 UI 객체가 될 수도 있다.

    //코드 9와 동일하게 Quadron의 정점과 텍스처 좌표를 설정한다. UIQuadron quad; ... //매핑할 텍스처 리소스로 버튼 컨트롤을 준비한다. UIButton button; ... //path()보다는 source()가 더 유연한 인터페이스로 보인다. quad.source(button);

    코드 10: 폴리곤 텍스처 소스 지정 예


    그림 22: 코드 10 매핑 도식화

    한편, 회전 효과처럼 범용적으로 쓰이는 기능이라면 보다 쉬운 인터페이스를 제공함으로써 UI 효과를 빠르고 쉽게 구현할 수 있도록 할 수 있다. 이를 위해 각 꼭지점의 좌표를 지정하는 것보다 회전 각도를 바로 지정할 수도 있을 것이다. 코드 11의 경우 버튼 자체에 회전 효과를 적용하지만, 엔진 내부적으로는 버튼을 대상으로 텍스처 매핑을 수행하는 것과 차이가 없다.

    /* rotate()의 파라미터는 x, y, z 축에 대한 회전 각도를 정의한다. 이 경우 버튼은 z축으로 45도 회전을 수행한다. */ button.rotate(0, 0, 45);

    코드 10: UI 컨트롤에 회전을 적용하는 예

    사실, 회전 효과를 구현하기 위해서 버튼의 외양을 구성하는 각 픽셀에 대해 회전된 위치만 결정하면 된다. 이같은 단순 회전의 효과의 경우에는 회전 행렬과 오일러(Euler)의 회전 방식을 이용하는 것도 가능하다. x, y, z 각 축에 대한 회전 행렬을 결합한 후, 최종 변환 행렬을 하나 구축한다. 이미지를 구성하는 각 픽셀은 이미지 중심을 원점으로 2D 벡터값을 구하여 이를 행렬과 곱해주면 회전된 위치의 픽셀을 구할 수 있다. 픽셀은 사실상 XY 평면상에 위치하므로 Z값은 기적벡터로서 1의 값을 지정해 준다. 회전 수식을 구현하는 기본 로직은 벡터 래스터라이저의 4.4 부채꼴에서도 언급하므로 여기서는 생략한다.


    그림 23: x, y, z 회전 행렬

    위의 기본 구현 방식은, 이미지를 구성하는 모든 픽셀을 대상으로 변환을 수행한다. 다만, 이미지 해상도가 크다면 시스템 환경에 따라 연산이 부담이 될 수 있으며 회전 외의 다른 변환이 필요한 경우라면, 이 방식은 그다지 효율적이지 못하다.


    5.1 원근법 구현

    원근법을 적용하기 위해 z 좌표값을 어떻게 계산해야 할지 결정해야 한다. 첫 번째 방법은, 전통적인 3D 시스템처럼 월드 공간 내에 여러 UI 객체가 공존할 수 있다. 이들은 사용자가 지정한 하나의 시야 절두체(Frustum) 정보를 공유하며 이를 토대로 투영 변환을 수행한다. 다른 방법으로는 UI 객체 개별적인 투영식을 수행한다. 이들은 객체 자신의 가로 세로 영역을 고유의 뷰포트(Viewport)로 보유할 수 있으며 개별적으로 적용된 투영 공간 정보를 토대로 z 축에 대한 투영 변환을 수행한다. 이 방식은 사실상 각 객체마다 독립적인 월드 공간을 갖는 것으로 볼 수도 있다. 그렇기 때문에 객체마다 원근 표현이 제각기 다를 수 있다. 만일, 한 객체가 3D 공간 내에서 다른 객체들과 거리에 따른 렌더링 우위 순위를 비교해야 한다면 첫 번째 방식이 적절하고 그렇지 않다면 후자의 방식을 선택할 수 있다. 데스크탑이나 모바일 환경의 보편적인 UI 환경은 대체로 2D 공간으로 한정하지만 게임 또는 MR(Mixed Reality) 처럼 가상 현실 또는 실세계에 접목된 UI나 일부 특수 목적의 시스템에는 첫 번째 방식인 3D 공간의 UI가 필요할 수도 있다. 대신 첫 번째 방식은 각 객체를 투영 변환한 후, 다른 객체들의 픽셀과 깊이 정보 비교(depth-test)를 일일히 수행해야 하는 부담이 발생한다. 이러한 부담을 줄이기 위한 여러 컬링 기법들을 접목시킬 수 있는데 기본적으로 바운딩 박스(Bounding Box)를 이용하여 부담을 줄이는 것도 가능하다. 바운딩 박스는 객체들 사이에 서로 겹쳐 보이는 지 여부를 개략적으로 판별할 수 있기 때문에 불필요한 정교 테스트를 피할 수도 있다.


    그림 24: depth-test 수행에 따른 출력 결과 비교 (좌: depth-test on, 우: depth-test off)

    기본적으로 원근 투영을 구현하기 위해서는 최소한 투영할 공간의 가로 세로 크기와 거리 값이 필요하다. 투영 공간은 오브젝트가 최종적으로 매핑될 2D 평면에 해당하고 거리 값은 z 축선 상에서 시각적 출력이 가능한 범위에 해당한다. 거리의 끝에 해당하는 꼭지점은 디자인 관점에 소실점(Vanishing Point)으로 볼 수도 있는데, 소실점은 오브젝트가 점으로 수렴하는 지점에 해당한다. 즉, 오브젝트의 z값이 이 거리에 도달한다면 점 이하의 크기로 축소된다고 볼 수 있다.


    그림 25: 소실점(Vanishing Point)


    그림 26: 객체마다 다른 투영 변환 수행

    이러한 개별 투영 변환은 다이렉트나 오픈지엘 환경의 전통적인 시야 절두체 방식과 조금은 다를 수 있다. 대신, 이 방식은 오브젝트마다 독립적인 프로젝션 공간을 갖고 뷰 변환(카메라 변환)이 존재하지 않는다는 점에 기인한다.

    /* 투영 정보를 설정한다. 초점의 중심 위치와 소실점까지 거리를 지정 */ obj.perspect(focalX, focalY, distance);

    코드 11: Perspective 설정 예시

    투영된 최종적인 xy 좌표값은 원래 객체의 정점 위치로부터 소실점까지의 거리를 토대로 계산을 수행할 수 있다. 다시 말해서, xy 평면의 초점의 위치로부터 각 정점을 향하는 방향 벡터를 구한 다음, 각 정점의 z값을 distance로 나눈 값의 역을 벡터의 길이로 활용한다.


    그림 27: 정점의 투영 변환 수행식


    5.2 매핑 알고리즘

    텍스처 매핑 기술의 개념을 이해하기 위해 전통적인 선형 보간 방식부터 이해하는 것이 좋다. 구현 방법은 폴리곤을 구성하는 픽셀을 라인 단위로 그룹 짓고 (이를 Span 이라고 정의한다.) 각 Span 라인을 텍스처 소스의 원본 이미지 공간으로 역변환하여 이미지로부터 스팬 라인에 위치하는 픽셀의 위치를 찾는다. Span의 기본 컨셉은 사실 3장의 RLE 최적화 편에서 살펴본 바가 있다.

    문제는 각 Span을 통해 래스터라이징을 수행할 시, 텍스처로부터 각 픽셀에 위치하는 데이터를 구하는 것이다. Span은 시작과 끝점의 위치를 알고 있으므로, 이를 통해 두 지점에 매핑될 텍스처의 좌표를 구해야 한다. 우선 이해를 돕기 위해 임의로 변환을 수행한 폴리곤의 정점으로부터 각 Span의 시작점과 끝점에 위치하는 텍스처 좌표를 구하는 방법부터 살펴보자.


    그림 28: Span 데이터 텍스처 매핑 도식화

    투영 변환을 수행한 각 정점은 xy좌표값을 가지고 있으며 이들을 서로 연결하면 픽셀단위에서 우리가 출력해야할 최종적인 도형의 모습을 구할 수가 있다. 하지만, 추가로 도형 내부를 채워야할 픽셀의 색상 정보가 필요하므로 도형은 다시 픽셀 단위에서 행 단위로 Span 데이터를 구축하는데, 이 때 각 Span은 해당 라인의 시작점과 끝점의 위치 그리고 시작점이 가리키는 텍스처의 좌표와 끝점이 가리키는 텍스처 좌표 정보를 기록한다. 이들의 정보를 구하기 위해서는 Span의 수평선과 교차하는 폴리곤의 외곽선을 찾는 것은 물론 정확한 교차점(coord)을 구해야 한다.

    교차점 계산은 폴리곤의 한 모서리에 해당하는 벡터(coord[3] - coord[0])로부터 방향 벡터를 구하고, 교차점에 해당하는 벡터(span[8].coord[0] - coord[0])의 놈(Norm), 즉 길이를 추가로 구한 후 이를 방향 벡터에 곱함으로써 가능하다. 이는 다른 교차점에도 동일하게 적용한다.


    그림 29: Span의 시작과 끝점의 위치 계산

    교차점을 모두 구한 이후에는 교차점에 해당하는 텍스처 좌표(uv)를 구해야 한다. 폴리곤을 구성하는 각 정점은 정점에 매핑할 텍스처 좌표 정보를 가지고 있으므로, Span의 시작과 끝점을 구한 것과 마찬가지로 정점으로부터 방향과 거리를 통한 계산을 수행한다. 이를 위해서, 그림 29의 coord 정보 대신 uv로 대처하면 된다. 코드 9에서 UIQuad는 네 정점의 위치 좌표(coord) 뿐만 아니라 텍스처 좌표(uv)도 보유하고 있음을 기억할 것이다.

    보다 중요한 문제는 Span을 채우는 픽셀 정보를 구하는 일이다. 이를 위해서는 모서리의 두 텍스처 좌표로부터 방향 벡터와 두 점 사이의 길이를 구한 후, 해당 벡터(vTex) 선상에 위치한 텍스처 좌표의 픽셀 데이터를 비율을 통해 계산한 offset만큼 이동하면서 인덱싱을 수행한다.


    그림 30: Span에 매핑할 텍스처 픽셀 찾기

    그림 30의 핵심 구현부는 다음과 같다.

    /* Span[8]에서 현재 찾고자 하는 픽셀(px[5])의 위치 비율을 계산한다. px[5]는 정확히 6번째 픽셀이며 실제로는 for() 문으로 통해 모든 픽셀을 반복적으로 구한다. */ Var pxPos = 6 / (spans[8].coord[1] - spans[8].coord[0]); /* Span[8]에 매핑될 텍스처 공간의 벡터를 구한다. */ Vector2 vTex = spans[8].uv[1] - spans[8].uv[0]; /* vTex의 방향 벡터를 구한다. */ Vector2 vTexNorm = vTex.normalize(); /* vTex의 길이를 구한다. */ Var vTexLength = vTex.length(); /* 마지막으로, vTex에서 pxPos에 위치하는 텍스처 좌표를 찾는다. */ Point texCoord = vTexNorm * (vTexLength * pxPos);

    코드 12: Span에 매핑할 텍스처 픽셀 계산 핵심 로직

    텍스처 매핑에서 최종적으로 구한 texCoord는 텍스처 데이터부터 인덱싱을 수행할 x, y 위치에 해당한다. 달리 말하면, 텍스처 이미지 버퍼를 가리키는 주소(시작점)로부터 x, y 만큼 떨어진 위치의 픽셀 데이터이며 이는 곧 px[5]에 기록해야 할 데이터에 해당한다.

    실제로 투영된 폴리곤은 원본 이미지의 크기와 다르기 때문에 텍스처 매핑에는 이미지 스케일링 작업이 반영되었다고 해도 무방하다. 여기서 벡터 연산을 통해 계산한 texCoord 값은 정확히 정수 단위로 도출되지 않으므로, texCoord를 정수로 처리한다면 포인트 샘플링과 동일한 수준의 텍스처 품질을 가질 수 있다. 품질을 개선하기 위해서는 앞에서 살펴본 슈퍼샘플링을 텍스처 매핑에도 동일하게 적용해야 하며 texCoord의 x, y값을 실수형으로 구하고 이들의 소수점 이하 값은 슈퍼샘플링 보간에 이용할 가중치 인자로서 활용해야 한다.


    5.3 앤티에일리어싱

    앞서 Span 기반의 매핑 알고리즘을 이용하여 폴리곤을 출력하고 나면, 도형의 외곽 라인의 품질을 고려해야 한다. 외곽라인의 품질 개선은 앤티에일리어싱(Anti-Aliasing, 이하 줄여서 AA라고 하자.) 적용 여부로서 해결 가능하며 기존의 여러 앤티에일리어싱 기법 중 하나를 선택하여 적용할 수 있다.

  • 슈퍼샘플링 앤티에일리어싱(Super Sampling AA)

  • 대표적으로 슈퍼샘플링 AA는 단순하지만 효과적인 기법이다. 원리는 우리가 출력하고자 하는 이미지보다 N배 큰 해상도의 이미지를 생성한 후, 원래 해상도로 이미지 크기를 축소하는 것이다. 이 때 4.2에서 언급한 보간 방식으로 다운 샘플링을 수행하면서 도형 외곽라인의 계단 현상을 자연스럽게 완화시킬 수 있다. 여기서 N은 샘플링 개수를 의미하는데 네 개의 샘플링이면 품질을 충분히 개선시킬 수 있다. 구현 방법은 단순하지만 N 배 해상도의 이미지를 생성해야 하며 메모리 및 프로세싱 등 기타 추가 부담이 존재한다. 슈퍼샘플링 AA는 화면 영역 전체를 대상으로 샘플링 작업을 수행하기 때문에 경계의 계단 현상 뿐만 아니라, 이미지 스케일링 시 발생하는 색상의 손실이나 도트가 두드러지게 보이는 현상을 줄여주는 효과를 모두 가질 수가 있다. 실제로 이 기법은 iOS에서 UI를 출력할 때 사용된다.

  • 멀티샘플링 앤티에일리어싱 (Multi Sampling AA)

  • 한편, 멀티샘플링 AA는 슈퍼샘플링의 부담을 줄이기 위해 고안되었다. 핵심은 이미지 전체 영역을 대상으로 수행하는 슈퍼샘플링 AA와 달리, 출력할 도형의 모서리만 대상으로 샘플링을 수행한다. 이 경우 샘플링 수행 영역을 대폭 줄일 수 있기 때문에 프로세싱 부담을 많이 줄일 수 있다. 다만 멀티샘플링 특성 상, 도형의 모서리를 판단할 수 있다는 전제 조건이 필요하다. OpenGL 및 다이렉트 3D와 같은 전통적인 그래픽스 출력 시스템에서는 폴리곤 단위로 래스터라이징을 수행하기 때문에 도형의 지오메트리 정보를 구하는 것이 가능하다.

    멀티샘플링의 또다른 특징은 한 픽셀의 색상을 결정하기 위해 샘플링 필터를 적용한다는 점이다. 샘플링 필터는 구현에 따라 다르지만, 네 개의 샘플링의 경우 대게 회전한 그리드 형태로 구성할 수 있다. 필터의 분산된 샘플 정보는 하나의 픽셀을 지나는 도형의 모서리로부터 보다 정교한 색상 결정이 가능하도록 도와준다. 네 개의 샘플링 평균값은 하나의 픽셀 색상을 결정하는 데 이용할 수 있다.


    그림 31: 멀티샘플링 수행 영역과 네 개의 샘플링 필터

  • 스팬 경계 앤티에일리어싱 (Span based Edge AA)

  • 앞서 우리가 텍스처 매핑에서 도형을 출력하기 위해 Span 데이터를 구성한 것에 기반한 AA 기법이다. 사실상 구체적으로 이름이 정형화되어 있는 기법은 아니지만 핵심은, Span의 각 행의 시작과 끝이 도형의 모서리에 해당하기 때문에 이 양쪽 모서리만 대상으로 AA 작업을 수행하는 것이다. 이는 멀티샘플링의 일부 개념을 Span 데이터 형식에 적용한 것으로도 볼 수 있다. 3장에서 배운 벡터 도형을 출력하는 경우에도 도형의 출력 정보를 Span 데이터로 구성한 점을 고려하면 이 AA 기법은 충분히 효과적이다. 게다가, Span 단위로 AA를 적용하면 필요한 도형만 선택적으로 AA를 적용할 수가 있다.

    다만, SEAA에서 Span 데이터는 출력할 픽셀의 위치와 색상이 미리 결정되어 있기 때문에 모서리 픽셀에 적용할 샘플 정보가 추가로 필요하다는 점이 풀어야할 문제에 해당한다. 하나의 방법으로서 필자가 고안한 SEAA는 모서리를 구성하는 연속된 픽셀이 어떤 패턴으로 나열되어 있는지를 판단하고 이를 토대로 투명도(Alpha)를 점진적으로 적용한다. 구체적으로, 도형을 최상단 꼭지점을 기준으로 좌, 우 영역으로 분할한다. 좌측의 모서리에 대해서 모서리의 방향을 살핀다. 방향 서로 다른 모서리에 대해 모서리의 길이를 각각 계산하고 길이를 토대로 적용할 투명도 Coverage를 계산한다. 투명도 Coverage는 각 모서리의 픽셀에 적용하여 최종적으로 픽셀의 투명도를 결정할 수 있다.

    모서리의 방향은 크게 일곱 방향으로 구분할 수 있으며 각 방향에 맞게 투명도 식을 적용한다. 이를 우측 모서리에도 동일하게 적용한다.


    그림 32: SEAA 모서리 진행 방향 결정


    그림 33: SEAA 모서리 진행 방향 패턴


    그림 34: SEAA 모서리 진행 방향과 길이 기반으로 투명도 계산

    SEAA에 대한 보다 자세한 내용은 다음 링크(hermet.pe.kr/122)에서 참고한다.


    6. 이미지 합성

    렌더링 엔진에서는 생성한 여러 UI 요소의 이미지를 최종적으로 합성하는 작업을 수행한다. 이를 이미지 컴퍼지션(Composition) 또는 블렌딩(Blending)라고도 하는데, 이는 두 장 이상의 이미지를 같은 영역에 출력하기 위한 작업에 해당한다. 이미지 합성의 핵심은 블렌딩을 수행하는 두 이미지의 픽셀값과 합성 수식에 있다. 쉬운 예로, 배경 이미지에 반투명한(Opacity=50%)한 이미지를 합성한다면, 두 이미지의 색상 농도는 각각 절반으로 감소된 후 합산되어 최종적으로 화면의 같은 위치에 그려질 수 있다.


    그림 35: 이미지 합성 예

    일반적으로 이미지 합성을 지원하기 위해 UI 엔진에서는 이미지 프로세싱 메커니즘으로서 RGBA 네 개의 채널을 가진 데이터 포맷을 기반으로 한다. 채널의 구성 순서는 다를 수 있으나 핵심은 알파 채널이 존재함에 있다. 한 장의 이미지를 구성하는 각 픽셀은 알파값을 통해 투명도를 결정한다. 32비트 시스템에서 RGBA의 각 채널은 8비트의 메모리 공간을 가지며 이는 0 ~ 255 사이의 값을 허용한다. 따라서, 이미지 각 픽셀의 투명도는 0 ~ 255 사이에서 결정하며 0인 경우 해당 픽셀은 완전한 투명(Transparent)에 해당하고 255인 경우 완전한 불투명(Opaque)에 해당한다.

    앞서 살펴보았던 벡터 기반의 도형이나 이미지 로더를 통해 불러온 이미지 파일 데이터 그리고 3D 변환과 텍스처 매핑을 통해 가공된 폴리곤 이미지의 경우에도 렌더링 엔진 내에서는 모두 RGBA 형태의 동일 데이터 포맷으로 다룰 수 있다. 이를 통해, 데이터 출처나 원본 포맷이 서로 다를지라도 엔진의 동일 로직으로 이미지 합성을 유용하게 수행할 수 있도록 엔진 기반을 마련한다.


    그림 36: 이미지 프로세싱 합성 단계 도식화


    6.1 알파 블랜딩

    본격적으로 알파 블랜딩(이하 블랜딩)에 대해서 살펴보자. 알파 블랜딩은 색상을 섞는 메커니즘으로서 서로 다른 두 이미지의 색상을 합성할 때 이용된다. 알파 블랜딩의 핵심은 두 색상의 합성을 결정하는 비율에 있는데 이 비율을 알파값(채널)으로 결정할 수 있다. 이미지에서 알파 채널은 1970년 후반 Alvy Ray Smith에 의해 처음 소개되었다. 일반적으로 사용되는 RGB 색상 공간에서 알파 채널이 추가되면 RGBA로서 데이터를 구성하게 된다. 여기서 알파값은 개별 픽셀에 적용할 수 있다.

    알파 채널 개념이 도입된 이후, 1984년 Tomas Porter와 Tom Duff에 의해 12개의 블랜딩 방식을 언급한 논문이 발표되었고 이 후 블랜딩은 보다 다양한 방안으로 확장되었다. 사실상, 블랜딩의 핵심은 두 픽셀의 합성 수식에 있다. 오픈지엘 명세서에서 제시한 블랜딩 수식만 하더라도 약 20개에 도달한다. 핵심은 블랜딩을 수행할 두 이미지 즉, 소스(Source)와 대상(Destination) 그리고 이들을 합성할 수식에 있다. 여기서 소스는 새롭게 그리고자 하는 이미지에 해당하고 대상은 소스가 그려질 대상 버퍼를 가리킨다. 만약 버퍼에 다른 이미지가 기록되어 있다면 이 역시 하나의 이미지로서 소스와 대상은 합성될 수 있다. 수채화를 그릴 때 캔버스에 이미지를 추가적으로 덧칠하는 것을 생각해보면 이해하기 쉽다. 물론, 대상을 새롭게 그리고자 하는 다른 이미지로 간주할 수도 있다.

    위 개념을 바탕으로 이미지 프로세서는 소스와 대상을 입력값으로서 합성 수식을 구현하는 함수에 전달해 최종 색상을 도출한다.


    그림 37: 블렌딩 함수 수행

    /* * 알파 블렌딩 구현부 * 블렌딩 수식: (DstRGB * (1 - SrcA)) + (SrcRGB * SrcA) * src: Source Pixel * dst: Destination Pixel */ blend(src, dst): Pixel out; out.r = dst.r * ((255 - src.a) / 255) + (src.r * (src.a/255)); out.g = dst.g * ((255 - src.a) / 255) + (src.g * (src.a/255)); out.b = dst.b * ((255 - src.b) / 255) + (src.b * (src.a/255)); out.a = (255 - src.a) + src.a; return out;

    코드 13: 알파 블랜딩 함수 구현


    코드 13은 두 색상을 합성하는 알파 블랜딩 함수를 구현한다. 소스(src)와 대상(dst) 색상을 입력받고 지정된 수식에 의거 색상을 합성한다. 여기서 사용된 블렌딩 수식은 (Src Color / Src Alpha) + (Dst Color * (1 - Src Alpha))에 해당한다. 이 때, 각 채널의 색상 범위는 0 ~ 1로 기정한다. 실제 색상은 0 ~ 255 구간을 가지므로 1은 255로 매핑된다. 만약 소스 이미지의 투명도가 20%에 해당한다면, 대상의 투명도는 80%를 적용하며 Out = Src * 0.2 + Dst * 0.8 가 된다. 최종 알파값은 255로서 완전 불투명한 색상이다.

    위 블랜딩 함수는 도형 또는 이미지를 그릴 때 호출한다. 다음은 벡터 래스터라이저 4.1의 사각형 그리는 로직에 블랜딩을 적용한 코드를 보여준다.

    /* * 사각형을 그리는 메서드 * buffer: NativeBuffer * rect: 드로잉할 사각 영역 (타입: Geometry) * clipper: 클립 영역 (타입: Geometry) * fill: 채우기 색상 (타입: UIFill) * blender: 블랜더 함수 */ UIVectorRenderer.drawRect(buffer, rect, clipper, fill, blender, ...): Pixel bitmap[] = buffer.map(); //버퍼 메모리 접근 //bitmap의 가로 한 줄의 크기를 구한다. => buffer width * sizeof(Pixel) lineLength = buffer.lineLength(); //사각형이 그려질 영역 재계산. 버퍼 영역과 사각영역 수행. Geometry clipped = clipRects(rect, Geometry(0, 0, buffer.width(), buffer.height())); //오브젝트 영역에 대해서도 수행한다. 이후 rect 대신 clipped를 이용한다. Geometry clipped = clipRects(clipped, clipper); //bitmap에 그리기 위한 시작 위치를 찾아간다. offset = (clipped.y * scanLineSize) + clipped.x; /* 여기가 사각형을 그리는 실제 로직! src는 사각형이 칠해야 할 색상, dst에는 사실 무엇이 그려져 있는지 알지 못한다. */ UIRGBA src = fill.color(); for (y = 0; y < clipped.h; ++y) for (x = 0; x < clipped.w; ++x) UIRGBA dst = bitmap[offset + (y * lineLength) + x]; //src와 dst에 알파블랜딩을 적용하여 최종 버퍼에 다시 기록한다. bitmap[offset + (y * lineLength) + x] = blender(src, dst);

    코드 14: 블랜딩을 적용한 도형 드로잉

    다음은 UI 객체를 대상으로 블렌딩 옵션을 적용하기 위해 호출하는 API 예시이다.

    //대상 객체 UICircle dst; dst.position(200, 100); dst.radius(100); dst.fill(UIRGBA(233, 30, 99, 255)); dst.show(); //소스 객체 UIRect src; src.geometry(100, 200, 200, 200); src.fill(UIRGBA(33, 150, 243, 127)); //알파 블렌딩을 적용한다. src.blendOp(UIBlenderOp.Alpha); src.show();

    코드 15: 블랜딩 옵션 호출

    코드 15는 사용자 관점에서 블랜딩 옵션을 적용하는 예시이다. 3장에서 fill()이 UIFill 객체를 전달받은 것을 기억한다면, 여기서 fill()은 단색을 바로 받을 수 있도록 편의 기능을 제공한다고 이해하자.

    다음 그림은 그외 다른 블랜딩 옵션을 보여준다.


    그림 38: 블랜딩 옵션 (Android PorterDuff Mode)

    이 외에도 블랜딩 옵션은 다양하다. 하지만 블랜딩 옵션은 엄밀히 규정된 것은 아니기 때문에 필요하다면 새 수식을 통해 새로운 블랜딩 옵션을 정의할 수도 있다. 실제로 렌더링 엔진마다 블랜딩 옵션은 조금씩 차이가 있다. 안드로이드 플랫폼에서는 PorterDuff 모드로서 여러 블랜딩 옵션을 제공한다. 자세한 사항은 다음 링크(http://developer.android.com/reference/android/graphics/PorterDuff.Mode.html#SRC)를 참고하자. 오픈지엘의 블랜딩 옵션에 대한 명세서는 다음 링크(https://www.khronos.org/registry/OpenGL-Refpages/gl4/html/glBlendFunc.xhtml)에서 참조할 수 있다.

    참고로, 수행해야 할 블랜딩 함수는 지정한 블랜딩 옵션에 맞게 교체될 수 있다. 게다가, 추후에는 새로운 옵션이 추가될 수도 있기 때문에 블랜딩 함수는 조건문을 통한 분기보다는 다형성(Polymorphism)을 이용하는 것도 고려할만 하다. 절차지향 언어라면 함수 테이블을 이용할 수 있다.

    //Blender 인터페이스. 입력과 출력 정의 interface UIBlender: Pixel blend(Pixel src, Pixel dst); //Blender 인터페이스를 토대로 AddBlender 구현 UIAddBlend implements UIBlender: blend(src, dst): ... return out; //Blender 인터페이스를 토대로 AlphaBlender 구현 UIAlphaBlend implements UIBlender: blend(src, dst): ... return out; //그 외 브랜딩 기능 구현...

    코드 16: 다형성을 이용한 블랜딩 기능 구현

    //사용자는 소스 객체에 AlphaBlend를 지정한다. UIRect src; src.blendOp(UIAlphaBlend()); ...

    코드 17: 다형성을 이용한 블랜딩 옵션 지정

    /* UIObject는 블랜딩 옵션으로 UIBlender를 전달받는다. 전달받은 blender는 객체가 직접 보관한다. */ UIObject.blendOp(UIBlender blender): self.blender = blender; ... /* 이후 객체가 render() 요청을 받으면 객체의 blender를 래스터라이저로 전달한다. 이 blender는 래스터라이징시 호출된다. 참고로 UIRect는 UIObject의 자식 클래스이다. */ UIRect.render(...): ... UIVectorRenderer.drawRect(..., self.blender);

    코드 18: 블랜딩 옵션 연동 과정


    6.2 마스킹

    마스킹(Masking)은 알파 블랜딩의 한 범주에 속하는 개념으로서 두 이미지의 합성을 통해 이미지를 오려내는 기능을 수행한다.


    그림 39: 마스킹 함수 수행

    마스킹은 입력 데이터로서 소스(Source)와 마스킹(Masking) 두 개의 이미지를 필요로 한다. 일반적으로 마스킹 이미지가 지니고 있는 데이터는 알파값으로 간주한다. 즉, 마스킹 이미지의 픽셀 데이터는 오직 알파값으로서만 활용된다. 마스킹의 알파값은 동일한 곳에 위치한 소스의 픽셀과 함께 마스킹 함수에 입력값으로 전달되어 합성된다. 마스킹 함수가 수행된 후 그 결과(Output)는 마스킹 이미지의 알파값이 합성된 소스 이미지이며, 마스킹의 투명 영역이 소스에도 그대로 적용된다.


    그림 40: 아이콘 마스킹 적용 예 (Tizen)

    마스킹 함수는 소스 이미지의 픽셀을 가려내기 위해서 0과 1의 신호가 필요하다(여기서 값이 0이면 보이지 않는 영역, 1이면 보이는 영역). 즉, 마스킹 이미지는 픽셀당 최소 1비트 데이터를 요구하며 각 비트는 0과 1을 통해 해당 위치의 소스의 픽셀이 화면에 그려질 대상인지 아닌지를 판단할 수 있게 한다. 하지만, 이 같은 1비트 마스킹 이미지는 오려진 소스의 테두리 영역에 앨리어싱 현상을 남기므로 마스킹 테두리 영역이 일반 알파채널의 데이터 크기인 1바이트를 활용하는 편이 품질 측면에서 더 유리하다. 이 경우, 마스킹의 알파값은 0 ~ 255 사이의 값을 가질 수 있으므로 마스킹 테두리 부분에 안티 앨리어싱이 적용된 결과물을 만들어낼 수 있다.

    마스킹 함수 역시 수식의 정의 및 구현부에 따라 동작의 결과가 달라질 수 있다. 만약, 마스킹 이미지가 4바이트라면, 마스킹 함수는 단순히 이미지를 오려내는 기능 이상의 것을 수행할 수 있다. 이제는 마스킹 이미지 자체에 알파값 뿐만 아니라 RGB 색상정보도 다룰 수 있으므로 이 경우 마스킹은 물론, 앞서 배운 알파 블랜딩 기능을 동시에 수행하는 것도 가능하다. 물론 이경우에는 단순 오려내기를 위한 마스킹보다 데이터의 크기는 네 배로 커지기 때문에, 마스킹과 알파 블랜딩의 기능을 적재적소로 구분하여 정의하는 것도 최적화 측면에서 도움이 된다.


    그림 41: 마스킹 + 블랜딩 수행

    마스킹은 출력 결과물 대비 비용이 다소 비싼 작업에 해당한다. 아이콘을 예를 들자면, 먼저 하나의 아이콘 출력 위해서 마스킹 이미지를 추가로 준비해야 한다. (필요하다면 마스크 이미지를 생성하기 위해 렌더링을 수행해야 한다.) 준비된 마스크 이미지는 소스와 마스킹 합성 작업을 통해 최종 아이콘을 생성해 낸다. 이는 실질적으로 마스킹이 없는 아이콘 대비 두 배 이상의 메모리 사용 및 렌더링 비용이 든다. 만약 디자인(리소스 준비) 단계에서 마스킹을 선처리 할 수 있다면 그렇게 하는 것이 보다 최적화된 앱 개발에 도움이 된다.


    6.3 필터

    필터(Filter)는 이미지 후처리(Post-Processing) 기법 중 하나로서, 카메라 필터 기능처럼 이미지에 특수 효과를 적용하기 위한 방안으로 사용된다. 필터 효과는 대표적으로 회색조(Grayscale), 선명하게(Sharpen), 흐리게(Blur), 잔광(Glow), 그림자(Shadow) 등이 있으며 어떠한 필터를 적용하냐에 따라 이미지 출력 결과물이 달라진다. 일반적으로 필터 효과는 위젯의 테마 또는 응용 단계에서 사용자가 결정할 수 있다. 일반적으로 사용되는 필터 효과는 엔진에서 내장(Built-in) 옵션으로 제공할 수 있다. 문제는, 필터는 사용자 시나리오에 의존적이기 때문에 프레임워크 레벨에서 필터 효과를 모두 제공하여 사용자 요구사항을 만족시키기는 어렵다는 점이다. 렌더링 엔진에서는 필터를 적용할 수 있는 렌더링 시퀀스를 고려해야하고 프레임워크에서는 사용자가 필터 효과를 적용하는 것은 물론, 커스텀할 수 있는 인터페이스를 제공해야 한다.

    그림 42: 이미지 필터 종류 및 효과


    개념적으로 필터는 출력 준비가 끝난 비트맵을 입력값으로 받아 필터 함수를 추가로 거쳐 비트맵을 변조한 후 출력될 수 있도록 한다. 이 때 필터 함수는 내장 함수 또는 사용자 커스텀 함수일 수 있으며 요청에 의해 복수의 필터가 연속적으로 수행될 수도 있다.


    그림 43: 이미지 필터 수행 과정

    /* UserCustomFilter는 UIFilter 인터페이스를 구현한다.*/ UserCustomFilter implements UIFilter: func(Pixel in, Point coord, ...): Pixel out; /* 여기서 필터 동작을 수행하는 로직을 작성한다. 실제 구현 예시는 추후에 다룬다. */ ... return out;

    코드 19: 사용자 정의 필터 구현

    UIImage content; /* 특정 UI객체에 사용자 정의 필터를 추가는 물론 기본 필터를 추가할 수도 있다. */ content.addFilter(UserCustomFilter()); content.addFilter(UIBlurFilter());

    코드 20: 필터 기능 사용 예시

    /* UIImage는 이미지를 출력하는 캔버스 객체이다. 여기서는 이미지 객체를 출력하는 기능을 수행한다. */ UIImage.render(buffer, ...): ... /* 필터를 적용한 중간 결과물을 저장할 버퍼를 생성한다. */ NativeBuffer temp = NativeBuffer(...); /* 출력할 원본 데이터를 대상 버퍼(buffer)가 아닌 temp에 그린다. */ UIVectorRenderer.drawImage(temp, ...); /* 객체에 지정된 필터 옵션(self.filters())을 참조하여 필터 프로세싱을 수행한다. 원본 이미지 temp로부터 출력 결과물을 buffer에 그린다. UIFilterProcessor.proc() 은 filters()에 지정된 필터 함수들을 순차적으로 호출해 주는 역할을 한다고 가정하자. */ UIFilterProcessor.proc(buffer, temp, self.filters(), ...); ...

    코드 21: 필터를 수행하는 엔진 코드

    코드 21의 핵심은 객체를 렌더링하는 시점에 필터를 적용하는 부분(16줄)에 있다. 좀 더 코드 분석의 이해를 돕기 위해 temp라는 중간 버퍼를 이용하고 있지만, 보다 최적화된 동작을 수행하려면 drawImage() 내에서 바로 필터 기능을 수행할 수 있도록 렌더링 시퀀스를 구축할 수도 있다. self.filter()는 사용자가 호출한 addFilter()로부터 구성된 수행해야할 필터 객체 목록이다. 이 정보를 UIFilterProcessor에게 전달함으로써 필터 함수들이 순차적으로 호출될 수 있도록 한다.

    만약, 렌더링 엔진이 그래픽스 하드웨어 가속 기반으로 동작한다면 필터의 수행 단계는 하드웨어 가속 동작 방식과 맞물려 구성되어야 한다. 오픈지엘과 다이렉트 3D처럼 셰이더(Shader) 기술을 제공하는 그래픽스 환경에서는 필터 함수를 셰이더 프로그램으로서 구성할 수 있다. 이는 필터 기능이 호출되기 전에 미리 필터 함수를 셰이더 바이너리 프로그램으로 변환하고 이를 드라이버 메모리에 적재한 후, 렌더링 파이프라인에 의해 셰이더 프로그램이 수행될 때 필터 셰이더도 같이 동작할 수 있도록 구성해야 한다. 문제는 렌더링 엔진의 내부 구성은 사용자로 하여금 블랙박스와 같으므로 렌더링 컨텍스트(Context)를 모르는 사용자가 직접 HLSL(High-Level Shader Language)이나 GLSL(GL Shader Language)를 이용하여 필터 셰이더를 작성, 이를 엔진에 추가하는 것은 쉽지 않다. UI 엔진에서는 사용자가 원하는 필터 셰이더를 적용할 수 있는 추가 인터페이스를 고려해야 하며 셰이더의 규격 범위 내에서 부가 정보를 전달할 수 있도록 인터페이스 내에서 규격화해야 한다. 필터 작업을 GPU 레벨에서 처리할 수 있으므로 CPU 가용성을 보다 높일 수 있는 이점이 있지만 셰이더 프로그래밍 범주로 사용 방법이 기능이 제약적일 수 있다.


    그림 44: 셰이더 기반 필터 동작 도식화

    위 개념과 유사하게, 안드로이드 플랫폼에서는 렌더스크립트(RenderScript)를 제공한다. 렌더스크립트는 필터 기능 이상으로 보다 범용적인 계산을 위해 설계된 메커니즘이다. 렌더스크립트는 CPU는 물론 GPGPU(General Purpose GPU) 프로세싱 능력을 활용하며 헤테로지니어스(Heterogenous) 컴퓨팅을 통한 고성능 프로세싱을 목표로 한다. 사용자가 작성한 스크립트 코드는 컴파일 시 비트코드로 변환되고 런타임 시점에 가용한 프로세싱 유닛으로 해당 프로그램이 전달되어 수행된다. 이러한 작업 스케줄링은 렌더스크립트 런타임 엔진이 담당하므로 확장성은 물론, 사용자가 병렬처리의 개념에 익숙하지 않아도 자동화를 알아서 수행해주는 장점을 제공한다. 그뿐만 아니라, 셰이더보다 더 유연한 구조를 갖추는 GPGPU 특성상, 필터 함수를 필요시 수행할 수 있는 유연성도 갖출 수 있다.


    그림 45: 헤테로지니어스 기반 필터 스크립트 동작 도식화

    참고로, 안드로이드는 렌더스크립트의 개념을 확장하여 필터스크립트를 제공하며 필터 기능에 보다 최적화된 인터페이스와 기능으로 사용자가 필터 함수를 구현할 수 있도록 도와준다.

    #pragma version(1) #pragma rs java_package_name(com.example.rssample) #pragma rs_fp_relaxed /* 필터 함수. 23번 줄의 forEach_invert()에 의해 호출 */ uchar4 __attribute__((kernel)) invert(uchar4 in, uint32_t x, uint32_t y) { uchar4 out = in; out.r = 255 - in.r; out.g = 255 - in.g; out.b = 255 - in.b; return out; } //렌더스크립트 초기화 mRs = RenderScript.create(this); mInAllocation = Allocation.createFromBitmap(mRS, bm, Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT); moutAllocation = Allocation.createFromBitmap(mRS, bm, Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT); mScript = new ScriptC_mono(mRS, getResource(), R.raw.mono); //렌더스크립트 실행 mScript.forEach_invert(mInAllocation, mOutAllocation);

    코드 22: 안드로이드 필터스크립트 예제 코드


    7. 최적화 방안

    7.1 이미지 캐싱

    UI 렌더링 엔진에서의 이미지 프로세싱은 시나리오상 기능이 많이 호출되는 부분 중 하나이다. 특히 벡터 래스터라이징을 통해 생성한 이미지나 이미지 스케일링을 통해 가공된 다른 해상도의 이미지는 프레임이 새로 갱신되면서 재사용될 가능성이 높으므로 캐싱(Caching) 최적화를 검토해 볼 수 있다. 사실상 일반 캐싱에서 크게 벗어나지 않지만, 여기서는 이미지만 전담한 캐싱 기능을 수행하므로 이미지 캐싱이라고 부르자. 렌더링 엔진에서는 내부적으로 이미지 캐시 관리자(Image Cache Manager)를 구현하여 비트맵 데이터를 통합 관리하면서 엔진 내부 정책에 따라 비트맵 데이터를 재사용하거나 폐기할 수가 있다.


    그림 46: 이미지 캐시 관리자 운용 도식화

    이미지 캐시 관리자가 캐싱할 수 있는 대상은 크게 다음과 같다.

  • 소스에서 불러온 원본 데이터 이미지
  • 크기가 변경된 이미지
  • 폰트 글리프 이미지
  • 벡터 래스터를 통해 생성한 도형 이미지
  • 마스킹 적용 이미지
  • 필터 적용 이미지

  • 경우에 따라 재사용성이 있는 이미지라고 추가로 있다면 캐시 대상에 추가하면 된다.

    캐싱 메커니즘을 적용할 때 고려해야할 문제는 캐시 적중률과 캐시 허용 크기이다. 어떠한 조건 없이 이미지 캐시를 허용한다면 프로세스의 메모리 사용량이 몇 배 이상 커질 수 있는 문제가 발생하며 최악의 경우 프로세스 메모리에 과부하가 쉽게 발생할 수도 있다. 따라서, 이미지 캐시 관리자는 캐시를 허용할 비트맵의 개수 또는 전체 캐시 메모리 공간을 정해야 한다. 여기서 비트맵의 크기에 상관없이 캐시의 제약을 개수로 정한다면 사용한 캐시의 최대 크기는 매우 큰 차이를 보일 수도 있다. 예를 들어, 캐시 가능한 비트맵의 개수를 50이라고 가정해 보자. 시나리오상 캐싱을 요구한 이미지의 크기가 평균 100KB이고 50번의 요청이 왔다면 사용된 캐시 메모리는 5MB에 해당된다. 반면, 이미지의 평균 크기가 4MB인 경우에는 200MB가 될 수도 있다. 사용하는 캐시 메모리 크기의 일관성을 보장하기 위해서는 캐시의 개수가 아닌 최대 크기를 지정하는 것이 보다 안정적이다.

    //해당 프로세스에서 사용할 이미지 캐시 최대 크기 지정 (예시: 50MB) UICanvas.cacheSize(50000000);

    코드 23: 프로그램에서 이미지 캐시 크기 지정 예

    이제 캐시할 수 있는 공간이 한정되어 있기 때문에 캐시 적중률(Cache Hit Ratio)은 보다 중요하다. 재사용성이 높은 이미지 데이터일 수록 캐시 효과를 만족할 수 있으며 반대로 재사용이 안되거나 거의 되지 않는다면 단순히 캐시 공간만 차지할 수도 있다. 이미지 캐시 관리자는 어떠한 이미지가 재사용성이 높은지 미리 판단할 수 없기 때문에 실제로 재사용되는 캐시 데이터의 패턴을 기반으로 캐시 메커니즘을 운용하는 것이 바람직하다.

    캐시 교체 방법 중, 대표적으로 LRU(Least Recently Used) 알고리즘은 OS의 메모리 관리를 위한 페이징 기법에서도 이용되어 왔지만 여기의 이미지 캐싱 방식과도 잘 부합한다. LRU 알고리즘을 이용하면 가장 오랫동안 사용되지 않은 캐시 데이터를 가장 먼저 폐기할 수 있게 한다. 즉, 이미지 캐시 공간이 가득 찬 상황에서 새로운 비트맵 데이터가 캐시 대상으로 주어지면, 기존에 캐싱된 비트맵 중에서 가장 오랫동안 사용되지 않은 비트맵 데이터를 폐기하고 새로운 비트맵을 캐싱할 수 있도록 공간을 확보하는 것이다.


    그림 47: LRU 기반의 이미지 캐싱 동작

    그림 46은 LRU의 개념을 이해하기 쉽게 도식화한다. 다섯 개의 데이터를 저장할 수 있는 공간이 있을 때 A, B, C, A, D, D, E, B 의 데이터를 순차적으로 캐싱한다. 그림 46은 이 때의 캐시 공간을 보여준다.

    우리가 구현하고자 하는 이미지 캐시는 단순히 캐시 데이터의 갯수가 아닌 사이즈를 기준으로 하므로, 데이터를 추가할 때 새로운 데이터의 크기를 기존 캐시된 데이터의 총합(cacheSize)에 더하여 그 결과값이 최대 캐시값(maxCacheSize) 를 초과하는지 판단한다. 만약 최대 캐시값을 초과한다면, 리스트에 추가된 데이터 중 가장 첫 번째부터 초과한 크기를 확보할 때까지 데이터를 제거해 나간다. 데이터 확보가 끝나면, 새로운 데이터는 리스트의 끝에 추가한다. 만약 캐시하고자 하는 데이터가 캐시 리스트에 이미 존재하거나 또는 기존의 캐시 데이터 반환을 요청할 경우, 캐시 리스트에서 해당 데이터를 찾은 후 리스트에서 데이터를 제거하고 곧바로 리스트의 끝에 데이터를 다시 추가함으로써 해당 데이터 접근 시점을 최신으로 유지할 수 있게 한다. (그림 46의 네 번째 데이터 A 추가 시점)

    우리가 다루고자 하는 이미지를 캐시하기 위해서는 이미지에 고유 이름을 부여할 필요가 있다. 이미지 캐시 관리자는 이름을 통해 캐시 리스트로부터 특정 캐시 데이터에 접근한다. 즉, 이미지 프로세서는 이미지 캐시 관리자에서 이미지 비트맵과 해당 이미지 비트맵의 고유 명칭을 전달함으로써 캐싱을 시도하고 추후에 데이터를 반환받을 때 이미지 이름을 이용한다. 이러한 캐시 접근 방식을 구현하기 위해서는 해시(Hash) 자료 구조를 이용하는 것이 바람직하다. 이미지 캐시에 고유 이름을 지정하기 위해서는 이미지 소스(파일명), 이미지 해상도를 조합할 수 있으며 폰트 글리프의 경우에는 폰트명, 폰트스타일, 글리프 캐릭터명을 활용할 수 있다. 또는 UI 객체의 핸들 주소 자체를 문자열로 치환하여 이용하는 것도 고려할만 하다. 치환한 문자열은 해시키로서 활용한다.

    /* * UIImage.render() 이전의 이미지를 준비하는 단계 */ UIImage.prepare(...) ... //Cache key를 생성. 이미지 경로와 출력할 이미지 가로, 세로 크기를 조합 String key = self.path() + self.width().toString() + self.height().toString(); //기존에 이미 캐싱되어 있는지 확인 Pixel data = ImageCacheMgr.request(key); /* 캐싱되어 있지 않으므로, 이미지 데이터를 새로 만들어서 추가한다. 만약 data가 존재한다면, 데이터를 새로 만들지 말고 그대로 이용하자. */ if (data == null) NativeBuffer srcBuffer = self.srcBuffer; NativeBuffer dstBuffer(UICanvas.getSurface(), self.width(), self.height(), RGBA32, IO_WRITE + IO_READ); //srcBuffer로부터 dstBuffer에 이미지 스케일링 수행 UIImageRenderer.bilinearScale(srcBuffer, dstBuffer); //재사용을 위해 dstBuffer를 캐싱 ImageCacheMgr.add(key, dstBuffer); ... /* * UIImageCacheMgr에 새로운 캐시 데이터 추가 * key: 캐시 데이터 접근 키 * data: 캐시 데이터 */ UIImageCacheMgr.add(String key, NativeBuffer data) size = data.size(); //새로 추가하고자 하는 이미지의 크기 //캐시할 수 없는 크기 if (size > 0 && self.maxCacheSize == 0) return false; self.cacheSize += size; //누적 캐시 메모리 갱신 overflow = self.cacheSize - self.maxCacheSize; //초과한 캐시 용량 //overflow 크기만큼 캐시 메모리 확보한다. while(overflow > 0) /* 캐시 목록에서 첫 번째 (가장 오래된) 데이터를 가져와서 삭제한다. Pair는 키와 데이터 쌍을 담는 자료구조이다. */ Pair it = self.cacheList.get(0); NativeBuffer temp = it.data(); overflow -= temp.size(); self.cacheSize -= temp.size(); self.cacheList.remove(0); //공간이 다 확보되었으므로 cacheHash에 새로운 데이터를 추가한다. Pair it(key, data); self.cacheList.append(it); /* * UIImageCacheMgr에 기존 캐시 데이터 반환 * key: 반환할 데이터 키 */ UIImageCacheMgr.get(String key) Pair it; Bool found = false; //캐시 목록에서 현재 키와 일치하는 데이터를 찾는다. for (i = 0; i < self.cacheList.length; i++) it = self.cacheList.get(i); //데이터를 찾은 경우, 캐시 목록에서 제거 if (it.key() == key) self.cacheList.remove(i); found = true; break; if (!found) return; //예외 상황? //데이터를 리스트의 최상단으로 옮긴 후, 캐시 데이터를 반환한다. self.cacheList.append(it); NativeBuffer data = it.data(); return data;

    코드 24: LRU 기반의 이미지 캐싱 메커니즘 구현


    7.2 벡터 프로세싱

    블랜딩이나 마스킹처럼 동일한 연산이 주어진 상황에서 동일한 입력과 출력 형태를 갖는 작업을 반복적으로 수행한다면 SIMD(Single Instruction Multiple Data)를 적용하기 적합하다. SIMD는 병렬 처리의 한 종류로서 벡터 프로세싱이라고도 하며, 동일한 연산을 복수 데이터에 적용하는 기술이다. SIMD는 한 번의 명령어를 복수의 입력 데이터에 적용하기 때문에 CPU 명령 사이클을 줄일 수 있다(그림 47). 사실, 벡터 래스터라이징의 드로잉 작업에서도 SIMD를 고려해볼 수 있는데, 드로잉을 위한 동일 수식과 동일 형태의 입력값이 반복적으로 주어지는 상황이 존재하기 때문이다. 벡터 프로세싱은 이미지 스케일링, 블랜딩, 회전 및 텍스처 매핑 등의 작업을 수행 시 적용가능한 부분이 다분한 편이다.


    그림 48: 스칼라 vs SIMD 연산 비교

    SIMD는 아키텍처에 따라 지원하는 기술이 다르다. Arm의 Cortex 프로세서 기반의 아키텍처에서는 NEON 이라는 기술을, Intel과 AMD의 x86, x64 기반의 프로세서에서는 SSE(1 - 4) SIMD 기술을 제공한다. 그 외 각 CPU 아키텍처마다 MMX, Altivec 등 다른 이름의 SIMD 기술을 추가로 제공하며 CPU 세대에 따라 그 기술은 꾸준히 진보하고 있다.

    SIMD는 CPU 구조에 따라 제공되는 기술과 명칭이 다르지만, 결국 사용자 관점에서는 벡터 프로세싱 명령어의 집합에 해당하므로 SIMD의 개념은 일맥상통한다. 따라서 각 기술에 해당하는 환경과 SIMD 명령어셋을 새로 숙지한다면 새로운 SIMD 기술을 바로 적용할 수 있다. SIMD는 기본적으로 어셈블리 수준의 언어를 제공하고 사용자 편의를 위한 고급 프로그래밍 언어나 컴파일러 내재 명령어(Intrinsics)를 추가로 제공하기도 한다.

    /* Neon intrinsic 사용을 위한 헤더 선언. * gcc 컴파일러 -mfpu=neon 옵션 지정하는 것도 잊지 말 것. */ #include “arm_neon.h” void add3(uint8x16_t *data) { //8비트 크기의 16개 벡터 원소에 3의 값을 저장 uint8x16_t three = vmovq_n_u8(3); //data 벡터 각 원소에 3을 더함 *data = vaddq_u8(*data, three); } void print(uint8x16_t *data) { static uint8_t p[16]; //data 벡터의 16개 원소를 p 배열에 저장 vst1q_u8(p, data); for (int i = 0; i < 16; i++) printf(“%02d “, p[i]); printf(“\n”); } void main() { //임의의 입력 데이터 const uint8_t input[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; //입력 데이터를 8비트 원소 * 16개 벡터 레지스터에 복사 uint8x16_t data = vld1q_u8(input); //data의 각 원소에 3을 더함 add3(&data); //data 값 출력 print(&data); }

    코드 25: ARM NEON Intrinsic 예제 코드

    코드 25는 실제로 벡터 프로세싱을 어떻게 구현할 수 있는지 이해하기 위해 제시한 매우 단순한 예제 코드이다. 코드로부터 짐작할 수 있겠지만, 이미지 처리 과정에서는 NEON 가속화를 위해 입력값으로 연속된 픽셀 데이터 집합을 전달할 수 있다. 만약 128비트 벡터화를 지원하는 칩셋이라면, 32비트 크기의 픽셀을 동시에 네 개씩 처리할 수 있다. 128x128 해상도의 이미지라고 가정한다면, 128번의 이미지 연산을 32번으로 축소할 수 있는 셈이다. 실제 벡터 프로세싱을 수행하기 위해서는 수행할 연산을 결정하고, 입력과 출력 값을 벡터 레지스터로 전달하기 위해 데이터를 잘 분산하는 것이 고려되어야 한다.


    8. 정리하기

    이미지 프로세싱은 UI 엔진의 핵심 기능으로서 이미지를 가공하는 작업을 수행한다. 이번 장에서 우리는 UI 엔진이 파일로부터 이미지 데이터를 불러오며 이러한 이미지 데이터는 JPEG, PNG, WEBP, GIF 등 다양한 형식으로 존재한다는 사실을 배웠다. 그뿐만 아니라, 이미지 데이터는 저장 용량 및 메모리 대역폭으로부터 부담을 줄이기 위해 특수 알고리즘으로 인코딩되어 있으며 UI 엔진은 전달받은 이미지 데이터를 디코딩하여 출력가능한 형태로 변형하는 절차를 살펴보았다.

    추가로, 렌더링 엔진은 다양한 포맷으로부터 이미지 데이터를 일괄처리하기 위해 내부적으로 비트맵 형식으로 데이터를 변환하며, 벡터 래스터를 통해 완성된 도형이나 폰트 글리프 역시 동일한 형태로서 데이터를 가공한다는 사실을 짐작할 수 있었다. 이미지 프로세서는 이러한 이미지 데이터의 해상도를 변경하거나 색상 합성, 필요에 따라 이미지를 후처리하여 특수한 효과를 적용한 이미지로 재가공하기도 한다.

    나아가, 이미지 프로세서는 3차원 변환을 위해 원근법을 구현하고 텍스처 매핑 기술을 구현한다. 이미지 프로세싱 과정에서 발생하는 이미지 손실 등을 보완하기 위해 앤티에일리어싱 및 샘플링 보간 등을 통해 이미지 퀄리티 저하를 방지하는 기술에 대해서도 살펴보았다.

    마지막으로, 이미지 캐싱 관리자를 통해 재사용될 이미지를 캐싱하는 방법과 벡터 프로세싱을 통해 이미지 프로세싱의 연산 속도 증가시킬 수 있는 방안에 대해서도 살펴보았다.