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


한편, UI 렌더링 엔진에서는 이미지 프로세싱을 전담하기 위해 이미지 렌더러(Image-Renderer)를 구성할 수 있다. 이는 이미지 소스(파일)로부터 데이터를 읽어와 비트맵 데이터를 생성하고 필요에 따라 스케일링(Scaling), 회전, 색상 변환 그리고 블러(Blur)와 같은 이미지 필터(filter) 기능을 수행한다. 따라서 UI 렌더링 엔진에서 이미지 프로세싱은 3장에서 살펴본 벡터 그래픽스와 더불어 UI 이미지 리소스를 화면에 출력하는 핵심 기능에 해당한다. 이번 장에서는 캔버스 엔진에서 이미지를 출력하기 위한 주요 기능 및 동작 과정을 이해하고 이미지 프로세싱을 수행하는 이미지 렌더러의 특징을 살펴보도록 하자.



1. 학습 목표

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

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


  • 2. 이미지 포맷

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

    기본적으로 32비트 크기의 색상에서 화소의 각 채널은 8비트 데이터를 구성함으로 하나의 화소는 32비트 즉, 4바이트 데이터 공간을 요구한다. 따라서 1080p 해상도의 시스템에서 화면 전체를 채우는 한 장의 이미지를 출력하기 위해서는 1920 x 1080 x 4 = 8,294,400바이트(약 8MB) 크기의 메모리 공간이 필요하고 4K 해상도 시스템에서는 약 35MB 크기의 데이터 공간이 필요하다. 만약 UI 앱에서 한 장의 이미지를 위해 35MB의 메모리 공간을 요구한다면 일부 임베디드 시스템에서는 적은 비용이 아닐 것이다. 이미지 해상도가 높을수록 디스크 저장 장치에서 주메모리로 데이터를 읽어오는 작업 비용이 커지며 네트워크 등을 통한 이미지 데이터 전송 시에도 그만큼 처리해야 할 작업량은 많아진다.

    이러한 작업 비용을 줄이기 위한 해결책으로 이미지 압축을 이용할 수 있다. 이미지 압축을 이용하면 이미지 데이터의 크기를 크게 감축할 수 있다. 대표적으로 JPEG과 PNG는 일반 사용자 환경에서 매우 많이 활용되는 이미지 포맷이다.


    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은 16,777,215 색상과 256 그레이 색상 두 가지 표현이 가능하며 jpg, jpeg, jpe 등의 확장명을 갖는다. JPEG을 인코딩/디코딩하는 대표 오픈소스 라이브러리로는 libjpeg이 존재한다.


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

  • 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, PNG, GIF의 특성을 모두 갖춘 셈이다. Webp는 손실과 무손실 압축 모두 지원하는데 공식 사이트에 의하면 동일 압축률을 적용했을 때 손실 압축인 JPEG보다 30% 파일 크기가 작고 무손실 압축인 PNG보다 20~30% 파일 크기가 작다고 한다. 크롬, 오페라, 파이어폭스, 인터넷 브라우저, 사파리 등 주요 브라우저에서는 모두 지원하지만, 아직 일부 이미지 관련 툴에서 지원하지 않는 점도 존재한다. 현재는 JPEG, PNG에 이어 대표 포맷으로 자리 잡고 있다. 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: 수평 샘플링 기준 단위 (보통 4개)
  • a: J 샘플 중 첫 번째 행의 크로마(Cb, Cr) 샘플링 수
  • b: J 샘플 중 첫 번째 행과 두 번째 행의 크로마 샘플이 변경된 수

  • 이를테면, 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)에 이미지 데이터를 불러오도록 요청한다. 파일명을 전달받은 이미지 로더는 확장명을 확인하여 해당 이미지가 어떤 포맷인지 판단한 후 사용할 후보 이미지 로더(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 타입 목록을 참고할 수도 있다. 이미지 로더는 전달받은 파일 확장명으로부터 준비된 로더를 결정해야 하며 이는 미리 준비한 MIME 타입 테이블로부터 매핑을 통해 후보 이미지 로더를 결정할 수 있다.

    MIME (Multipurpose Internet Mail Extensions)


    MIME은 원래 SMTP 프로토콜 상에서 이메일을 보낼 때 참조된 콘텐츠의 파일 포맷을 명시하기 위해 설계되었으며 현재는 MIME을 미디어(Media) 타입으로 부르기도 한다. 이러한 타입 목록은 다음 링크(www.freeformatter.com/mime-types-list.html)를 통해 확인할 수 있다


    MIME 메세지 예:
    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”


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

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


    첨언하자면, 시스템의 물리적 스레드의 개수는 한정적이므로 그림 5의 태스크 스케줄러를 이미지 로더에 종속하는 것보단 UI 엔진 전체에 걸쳐 다양한 스레드 작업 요청을 관장하는 구조로 구성하는 편이 병렬화 측면에서 바람직하다. 그림 5는 현재의 기능 이해를 도우려고 이러한 구조를 단순화하였다.

    이미지를 불러오는 작업을 별도의 스레드를 통해 수행한다면 이 작업은 렌더링 엔진과는 비동기 상태로 수행된다. 그렇기 때문에 동기화를 추가로 수행하지 않는다면 이미지 데이터는 어쩌면 몇 프레임이 지난 후에 완성될 수도 있다. 이점을 활용하여 지연 로딩(Lazy Loading)을 제공할 수 있는데 이미지가 즉시 출력될 필요가 없다면 지연 로딩을 활용하는 편이 앱 동작을 더욱더 매끄럽게 할 수 있기 때문이다. 앱은 이미지를 늦게 출력하거나 미리 준비된 다른 이미지를 먼저 보여준 후 나중에 완성된 이미지로 바꿔서 출력(프로그레시브 로딩)할 수도 있다. 이러한 방식을 이용하면 이미지를 불러오는 작업 부하로 인해 메인 루프의 동작이 지체되는 현상을 피할 수 있다.

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

    코드 2: 이미지 지연 로딩


    3. PNG 로더

    특정 포맷의 이미지 로더를 지원하기 위해서는 해당 이미지 포맷의 기능적 특성은 물론 데이터 구조와 압축 알고리즘 등을 이해해야 한다. 이러한 사항을 파악하기 위해서는 이미지 포맷의 명세서(https://www.w3.org/TR/PNG/)를 확인할 수 있다.

    이미지 로더의 주 기능은 이미지 파일로부터 정보를 가공한 후 원본 이미지 비트맵 정보를 반환하는 것이다. 여기서 핵심은 압축된 이미지 정보를 디코딩하는 작업인데 이 작업은 이미지 로더에서 직접 구현할 수도 있지만 이미 만들어진 라이브러리를 이용하는 것도 좋은 방안이다. 앞절에서도  살펴보았듯이 대부분의 이미지 포맷에는 해당 이미지 데이터를 인코딩하고 디코딩할 수 있는 무료 라이브러리가 존재한다. 하지만 우리는 학습을 목적으로 하므로  대표적인 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로 초기화 되어있어야 한다. */ 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 값 반환.

    crc() 함수의 최종 반환 값은 1의 보수에 해당한다. */ unsigned long crc(unsigned char *buf, int len) { return update_crc(0xffffffffL, buf, len) ^ 0xffffffffL; }

    코드 3: 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는 하나의 필터 방법을 표준으로 사용하며 이 필터 방법 내에는 다수의 필터 타입이 존재한다. 전 단계에서 준비한 각 스캔라인에 대해서 필터 타입을 개별적으로 선택 적용하기 때문에 각 스캔라인에 필터 타입 정보가 추가된다. 더욱 자세한 내용은 4.3.3절을 확인하자.

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

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

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

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


    3.2 IHDR

    IHDR(Image Header)은 이미지 헤더로서 이미지의 속성 정보를 보유한다. 헤더의 데이터 크기는 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

    코드 4: IHDR 데이터 구성 예


    3.3 IDAT

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

    그림 13: PNG 데이터 인코딩 절차

    IDAT에 적용하는 기본 필터 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

    코드 5: Paeth Predictor 의사코드

    필터를 적용하면 적용한 필터 타입이 무엇인지 알아야 하므로 이미지 데이터의 첫 번째 열 1바이트에 이를 기록한다.

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


    데이터 압축은 기본적으로 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 파일 데이터의 끝부분임을 명시한다. 실제 데이터가 존재하지 않음으로 길이는 0이고 타입 값은 73 69 68 68이다.


    4. 이미지 스케일링

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

    그림 15: 이미지 렌더러 기능 구성도

    그림 15에서는 이미지 렌더러가 수행하는 대표적 기능을 구성한다. 이미지 렌더러는 이러한 기능을 파이프라인(Pipeline)을 그리며 정해진 절차대로 수행하거나 로직의 순서를 변경해가며 실제 필요한 기능만 최적으로 수행할 수 있다. 이러한 동작은 캔버스 엔진이 제공하는 기능과  렌더러의 최적화 방침에 따른다. 기본적으로 Converter, Scaler, Filter, Blender 기능은 PNG와 같은 이미지 데이터를 대상으로 수행되지만, 벡터 및 텍스트 등 다른 렌더링 요소로부터 생성된 이미지를 가공하기 위해서도 사용될 수 있다. 그뿐만 아니라 여러 드로잉 요소를 합성하여 최종 화면을 가공할 때도 사용할 수 있다. 따라서 렌더링 엔진에서는 이미지 렌더러를 범용적인 형태로 구성할 필요가 있다. (그림 36 참고)

    이미지 프로세싱의 핵심 기능 중 하나는 이미지 스케일링이며 이는 이미지 크기를 조정한다. 기본적으로 UI는 여러 해상도 기기를 지원하기 위해 이미지나 UI 컨트롤의 크기를 동적으로 변경할 수 있어야 한다. 또는 사용자 취향에 따라 이들의 크기를 변경하거나 줌인(Zoom in), 줌아웃(Zoom out) 같은 효과를 통해서도 콘텐츠의 크기가 변경될 수 있다. 이미지 스케일링은 이러한 기능을 충족하기 위해 렌더링 엔진에서 제공해야 할 필수 기능에 해당한다. 이미지 렌더러는 이미지 스케일링을 수행하여 원본 이미지로부터 이미지의 크기를 키우거나 축소하는 작업을 수행하고 최종적으로 사용자가 원하는 크기로 이미지를 출력할 수 있도록 한다.

    그림 16: 이미지 스케일링


    4.1 포인트 샘플링
    • 최단 입점 보간
    이미지 스케일링 작업의 가장 단순한 방법은 스케일이 변한 지점에 가장 근접한 원본 이미지의 픽셀을 찾는 것이다. 최단 입점 보간(Nearest Neighbor Interpolation)으로도 알려진 포인트 샘플링은 이미지 스케일링의 기반에 해당하며 이 방식은 슈퍼샘플링(Super Sampling)처럼 인접한 픽셀 간 색상 보간을 수행하지 않음으로 스케일링 후 결과에서 도트가 상대적으로 더 뚜렷하게 드러난다. 그렇기 때문에 일반적인 콘텐츠 이미지에는 포인트 샘플링을 많이 사용하지 않는 편이다. 하지만 픽셀 아트처럼 도트의 비주얼 특성을 유지하거나 체스판처럼 수직, 수평선으로만 구성된 이미지일 경우 최단 입점 보간을 이용한 스케일링이 더 적합할 수 있다.

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

    /* * Point Sampling(Nearest Neighbor) 스케일링 구현 * @p src: NativeBuffer * @p dst: NativeBuffer */ UIImageRenderer.nearestNeighborScale(src, dst) : //가로, 세로 스케일 요소를 구한다. xScale = src.width() / dst.width(); yScale = src.height() / dst.height(); //버퍼 메모리 접근 Pixel srcBitmap[] = src.map(); Pixel dstBitmap[] = dst.map(); /* 포인트 샘플링을 수행한다. 스케일된 픽셀 위치로부터 원본 이미지 픽셀 위치를 찾는다. 접근하는 위치가 메모리 버퍼 영역을 벗어나는 검사는 생략한다. */ for (y = 0; y < dst.height(); ++y) for (x = 0; x < dst.width(); ++x) sx = xScale * x; sy = yScale * y; destBitmap[y * dst.scanlineSize() + x] = srcBitmap[sy * src.scanlineSize() + sx];

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



    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을 구하고 S3, S4을 보간하여 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) 스케일링 구현 * @p src: 원본 이미지 데이터 * @p dst: 스케일을 적용한 결과 이미지 데이터 */ UIImageRenderer.bilinearScale(src, dst) : //가로, 세로 스케일 요소를 구한다. xScale = src.width() / dst.width(); yScale = src.height() / dst.height(); //버퍼 메모리 접근 Pixel srcBitmap[] = src.map(); Pixel destBitmap[] = dst.map(); /* Bilinear Interpolation 샘플링을 수행한다. 스케일된 픽셀 위치로부터 원본 이미지

    픽셀을 찾은 후, 인접한 세 개의 픽셀과 색상을 혼합한다. */ for (y = 0; y < dst.height(); ++y) for (x = 0; x < dst.width(); ++x) dx = xScale * x; dy = yScale * y; sx1 = round(dx); sy1 = round(dy); sx2 = sx + 1; sy2 = sy + 1; //Index Overflow에 주의 if (sx2 == src.width() sx2 = sx1; if (sy2 == src.height() sy2 = sy1; //소수점 이하 값 xFraction = dx - sx1; yFraction = dy - sy1; s1 = srcBitmap[sy1 * src.scanlineSize() + sx1]; s2 = srcBitmap[sy1 * src.scanlineSize() + sx2]; s3 = srcBitmap[sy2 * src.scanlineSize() + sx1]; s4 = srcBitmap[sy2 * src.scanlineSize() + 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 * ((s2 >> 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); t = ((sx4 - dx) / (sx4 - sx3)); t2 = ((dx - sx3) / (sx4 - sx3));

    //D2 = ((xS4 - xD2) / (xS4 - xS3)) * S3 + ((xD2 - xS3) / (xS4 - xS3)) * S4 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[y * dst.scanlineSize() + x] = d;

    코드 7: Bilinear Interpolation 구현 코드

    슈퍼샘플링에는 이중선형 보간 외에 삼선형(Trilinear) 및 이방성(Anisotropic) 보간 방식도 존재한다. 이들 보간법은 이중선형 대비 더 좋은 이미지 품질을 제공하지만, 더 많은 리소스와 계산량을 요구한다. 일반적인 UI 시스템에서는 이중선형 보간식으로도 만족할만한 수준의 이미지 품질을 제공한다.


    5. 텍스처 매핑

    텍스처 매핑(Texture Mapping)은 이미지를 기하 형태로 출력한다. 원래는 단어 뜻처럼 재질을 씌우는 개념으로 볼 수 있는데 전통적인 3D 그래픽스에서는 텍스처 매핑 기술을 이용해 폴리곤(Polygon)에 이미지를 매핑함으로써 메시(Mesh)나 도형에 재질감을 부여한다. UI 엔진에서 텍스처 매핑은 출력하고자 하는 이미지가 직사각형이 아닌 기하 형태로 출력할 수 있게 도와주므로 특수 효과 등의 목적으로 활용할 수 있다. 특히 UI 엔진에서 텍스처 매핑은 이미지를 회전하거나 쉬어(Sheer) 변환 등을 수행할 때 유용하다. 가령 UI가 z축을 중심으로 회전을 수행한다면 해당 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.open(“texture.png”);

    코드 8: 텍스처 매핑 좌표 지정

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

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

    코드 9: 텍스처 매핑 대상 지정 예

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


    5.1 회전


    회전처럼 범용적인 기능은 더욱더 쉬운 인터페이스를 제공함으로써 앱 개발자가 UI 효과를 쉽고 빠르게 구현할 수 있도록 도와줄 수 있다. 이를 위해 사용자는 정점 좌표를 지정하는 대신 회전 각도를 지정할 수 있을 것이다. 코드 10의 경우 버튼을 회전하지만 엔진 내부적으로는 버튼을 소스로 텍스처 매핑을 수행하는 것과 동일하다.

    /* rotate()는 x, y, z축 회전 각도를 파라미터로 정의한다.

    예제의 경우 버튼을 z축 중심으로 45도 회전한다. */ button.rotate(0, 0, 45);

    코드 10: UI 컨트롤 회전 구현 예

    사실 단순 회전 효과를 구현하기 위해서는 회전한 UI 객체의 경계선 위치만 결정하면 된다. 이 경우 오일러(Euler) 각에 대한 회전 행렬을 구현하는 것으로도 충분하다. 이를 위해 x, y, z 각 축에 대한 회전 행렬을 결합하여 최종 변환 행렬을 구축할 수 있다. 이미지를 구성하는 각 픽셀은 이미지 중심을 원점으로 3D 벡터를 구하여 이를 행렬과 곱해주면 회전된 위치의 픽셀을 구할 수 있다. 회전을 구현하는 로직은 3.4.5절에서도 언급하므로 여기서는 생략한다.

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

    위의 구현 방식은 이미지를 구성하는 모든 픽셀을 대상으로 변환을 수행한다. 구현 방식은 직관적이지만 회전 외의 변환을 수행해야 한다면 이 방식은 효율적이지 않은 단점이 존재한다.


    5.2 원근법 구현

    원근법(Perspective)을 구현하기 위해서는 z 좌푯값을 어떻게 다뤄야 할지 고려해야 한다. 전통적인 3D 시스템을 모방하자면 월드 공간 내에 여러 UI 객체가 공존할 수 있다. 이들은 사용자가 설정한 전역적인 시야 절두체(Frustum) 정보를 공유하고 이를 기반으로 투영 변환을 수행한다. 다른 방안으로는 UI 객체마다 독립적인 투영식을 수행한다. 이 방법에서는 객체 자신의 영역을 독립적인 뷰포트(Viewport)로 정의할 수 있고 이 뷰포트 영역 내에 독립적인 투영 공간을 구축하고 이를 토대로 z 축에 대한 투영 변환을 수행한다. 이 방식은 OpenGL, Direct3D 환경의 전통적인 모델-뷰-투영 변환과 달리 객체마다 전역적인 뷰 변환(카메라 변환)이 존재하지 않는 UI 시스템의 특성에 기인한다.

    그림 24객체 간 독립적인 지역 공간을 보유한 경우

    결과적으로 이 방식은 객체 단위로 독립적인 지역 공간을 갖는 것으로 간주할 수 있으며 객체마다 다른 원근법을 허용한다. 만일 3D 공간 내에서 한 객체가 다른 객체와 z축에 따른 렌더링 우선 순서를 염두에 두어야 한다면 첫 번째 방식이 적절하고 그렇지 않다면 후자의 방식을 선택할 수 있다. 데스크톱이나 모바일 환경처럼 보편적인 UI 환경은 대체로 2D 공간을 기반으로 하지만 게임 또는 MR(Mixed Reality)처럼 가상 현실 또는 실세계의 사물에 접목된 UI나 일부 특수 목적의 시스템에서는 첫 번째 방식인 3D 공간의 UI가 적합할 수 있다. 여기서 첫 번째 방식은 객체를 투영 변환한 후 다른 객체와 화면 우선순위를 결정하기 위해 깊이 정보 비교(depth-test)를 수행해야 하는 부담이 발생한다. 이러한 작업 부담을 줄이기 위해서는 여러 컬링 기법을 접목할 수 있는데 기본적으로 바운딩 박스(Bounding Box)를 이용하여 계산 부담을 줄이는 것이 가능하다. 바운딩 박스는 객체 간 겹치는 영역이 존재하는지를 개략적으로 판별할 수 있어서 불필요한 정교한 비교를 피할 수 있게 한다.

     

    그림 25depth-test 따른 출력 결과 (좌: depth-test on, 우: depth-test off)

    후자의 방안에서 원근 투영을 구현하기 위해서는 최소한 투영할 공간의 영역과 거리(깊이) 값이 필요하다. 투영 공간은 UI 객체가 놓인 2D 평면에 해당하고 거릿값은 z 축 상에서 가상의 출력 가능 범위 중 한 위치에 해당한다. 거리의 끝에 해당하는 꼭짓점은 디자인 관점에 소실점(Vanishing Point)으로 간주할 수 있는데 소실점은 객체가 점으로 수렴하는 지점에 해당한다. 즉 객체의 z 값이 이 거리에 도달한다면 점 이하의 크기로 축소된다고 볼 수 있다.

    그림 26: 소실점(Vanishing Point)


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

    코드 11: Perspective 설정 예

    투영된 정점의 최종 위치는 원래 객체의 정점 위치로부터 소실점까지의 거리를 토대로 판단할 수 있다. 다시 말해서 초점 위치로부터 각 정점을 향하는 방향 벡터를 구하고 각 정점의 z 값을 distance로 나눈 값을 벡터 길이로써 활용한다.

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



    5.3 매핑 알고리즘

    텍스처 매핑 구현을 이해하기 위해 선형 보간부터 살펴보자. 구현 방법은 폴리곤을 채우는 픽셀을 행 단위로 구분하고 각각의 행 정보를  Span(coord[2]와 uv[2] 데이터로 구성)으로 정의한다. 다음으로 각 Span을 텍스처의 원본 이미지 공간으로 역변환하는데 핵심 계산은 Span을 채우는 픽셀의 위치를 원본 이미지로부터 찾는 것이다. 사실 Span 개념은 3.7절 RLE 최적화 편에서 살펴본 바 있다. 

    여기서 우리는 Span의 시작과 끝점 위치를 알고 있음으로 이들로부터 두 지점에 매핑될 텍스처 좌표를 계산할 수 있다. 이해를 돕기 위해 다음 그림은 임의 변환을 수행한 폴리곤으로부터 Span 데이터를 구축하고 그중 한 Span의 시작점과 끝점에 위치하는 텍스처 좌표를 구하는 방법을 보여준다.

    그림 28: Span 기반 텍스처 매핑 도식화

    요약하자면, 투영 변환까지 수행한 정점은 z 축을 생략하므로 x, y 위치 정보만 갖는다. 이 투영된 정점을 외곽선으로 연결하면 엔진이 화면에 출력해야 할 최종적인 도형의 윤곽을 구할 수 있다. 다음으로 도형 내부를 채워야 할 색상 정보가 필요하므로 도형의 Span 데이터를 구축한다. 이때 각 Span은 해당 행의 시작점과 끝점 위치 그리고 시작점이 가리키는 텍스처 좌표와 끝점이 가리키는 텍스처 좌표 정보를 기록한다. 이들 정보를 구하기 위해서는 Span의 수평선이 교차하는 폴리곤의 외곽 지점(coord) 정보가 필요하다.

    Span의 시작점과 끝점 그리고 폴리곤 외곽선이 만나는 교차점(span[8].coord[0]과 span[8].coord[1])을 구하는 핵심을 설명하자면 폴리곤의 한 모서리에 해당하는 벡터(coord[3] - coord[0])로부터 방향 벡터를 구하고 이를 원점으로, 계산하고자 하는 Span의 y축 거리를(span[8].coord[0] - coord[0])를 추가로 구한다. 그리고 방향 벡터로부터 y축 거리만큼 x 좌푯값을 증감시키면 교차점을 구할 수 있다. 이는 다른 교차점에도 동일하게 적용해야 하므로 폴리곤이 구성하는 다른 모서리에 대해서도 확인한다. 이 과정에 정점의 순서를 정렬하고 때에 따라 폴리곤을 분할한다면 보다 효율적인 계산이 가능하다.

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

    교차점을 모두 구한 후에는 교차점에 해당하는 텍스처 좌표(uv)를 구한다. 폴리곤을 구성하는 정점은 정점에 매핑할 텍스처 좌표 정보를 추가로 가지므로 Span의 시작과 끝점을 구한 것과 동일하게 정점으로부터 방향과 거리 정보를 응용하면 uv값을 계산할 수 있다. 쉽게, 그림 29의 coord를 uv로 대처하면 된다. 참고로 코드 8의 UIQuad는 네 정점의 위치(coord) 뿐만 아니라 텍스처 좌표(uv)도 보유하고 있음을 확인할 수 있다. 

    Span의 uv 좌표까지 구했다면 Span을 채우는 색상 정보를 구해야 한다. 이를 위해 Span의 시작과 끝점을 이용하여 텍스처 공간의 벡터(vTex)와 그 길이를 구한다. 그리고서 Span 길이와 vTex 길이의 비율로 offset을 결정한다. 이 offset은 텍스처 공간에서 vTex의 픽셀 이동 거리로 활용할 수 있다. 달리 말하면, Span의 픽셀을 채우는 작업을 수행하기 위해서는 Span 길이만큼 반복문을 수행하면서 픽셀값을 결정하는데 이때 픽셀값은 텍스처 공간에서 vTex가 가리키는 방향으로부터 인덱스(반복문 횟수) x offset만큼 이동한 위치의 픽셀값에 해당한다.

    그림 30: Span에 매핑할 픽셀값 찾기


    코드 12는 그림 30의 핵심을 구현한다.

    /* Span[8]의 px[5](여섯 번째 픽셀)를 구해보자.

    먼저 px[5]에 대응하는 vTex의 픽셀 위치를 찾기 위해 비율을 계산한다. */ pxPos = 6 / (spans[8].coord[1] - spans[8].coord[0]);

    /* Span[8]에 매핑할 텍스처 공간의 벡터를 구한다. */ vTex = spans[8].uv[1] - spans[8].uv[0];

    /* vTex의 방향 벡터를 구한다. */ vTexNorm = vTex.normalize();

    /* 마지막으로 vTex에서 pxPos에 위치하는 텍스처 좌표를 찾는다. */ texCoord = vTexNorm * pxPos;

    코드 12: Span에 매핑할 텍스처 픽셀 결정부

    코드 12에서  최종적으로 구한 texCoord는 텍스처 이미지로부터 픽셀 위칫값에 해당한다. 달리 말하면, 이미지 데이터를 가리키는 주소(시작점)로부터 x, y만큼 떨어진 위치의 픽셀 데이터이며 이는 곧 px[5]에 기록해야 할 색상 값에 해당한다. 

    실제로 투영된 폴리곤은 원본 이미지 해상도와 다르기 때문에 텍스처 매핑 결과물은 이미지 스케일링이 반영되었다고 해도 무방하다. 사실, 벡터 연산을 통해 도출한 texCoord 값은 정확히 정숫값으로 도출되지 않음으로 texCoord를 정수로 취급한다면 포인트 샘플링과 동일한 수준의 텍스처 품질을 가질 수 있다. 따라서 텍스처 품질을 개선하기 위해서는 앞에서 살펴본 이중 선형 보간을 텍스처 매핑에서도 동일하게 적용해야 한다. texCoord 값을 실수형으로 취급하고 소수점 이하 값은 샘플링 보간에 이용할 가중치 값으로 활용하면 된다.


    5.4 앤티에일리어싱

    Span 기반 매핑 알고리즘을 이용하여 폴리곤을 출력했다면 도형 외곽선의 품질을 고려해야 한다. 외곽선 품질은 앤티에일리어싱(Anti-Aliasing, 줄여서 AA) 기법을 통해 개선할 수 있으며 환경에 따라 여러 앤티에일리어싱 기법의 하나를 선택할 수 있다.

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

  • 대표적인 슈퍼샘플링 AA는 방법은 단순하지만, 퀄리티 개선 효과는 탁월하다. 원리는 우리가 출력하고자 하는 이미지보다 N 배 큰 해상도의 이미지를 생성한 후 원래 해상도로 이미지를 축소하는 것이다. 이때 4.2절에서 기술한 보간법으로 다운 샘플링을 수행하여 도형 외곽라인의 계단 현상을 자연스럽게 완화할 수 있다. N은 샘플링 개수를 의미하며 네 개의 샘플링이면 품질을 충분히 개선할 수 있다. 구현 방법은 단순하지만, N 배 해상도의 이미지를 생성해야 하므로 메모리 및 프로세싱의 추가 부담이 존재한다.


    슈퍼샘플링 AA는 화면 영역 전체를 대상으로 샘플링 작업을 수행하므로 외곽선의 계단 현상뿐만 아니라 다운 스케일링 시 발생하는 색상의 손실을 해소하고 텍스처 도트가 두드러지게 보이는 현상도 개선할 수 있다. 슈퍼샘플링 AA는 샘플링을 적용하지 않은 전체 화면을 대상으로 적용할 때 보다 효율적이다.


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

  • 슈퍼 샘플링 AA의 부하를 회피하는 방편으로 멀티샘플링 AA를 고려할 수 있다. 이미지 전 영역을 대상으로 수행하는 슈퍼샘플링 AA와 달리 멀티샘플링 AA는 도형의 외곽선만 샘플링을 수행한다. 이 경우 샘플링 수행 범위를 대폭 축소할 수 있음으로 프로세싱 부담도 그만큼 줄어든다. 다만 멀티샘플링 특성상 도형의 외곽선을 인지할 수 있는 전제 조건이 필요하다. OpenGL 및 Direct3D와 같은 전통 그래픽스 출력 시스템에서는 폴리곤 단위로 래스터 작업을 수행하므로 렌더링 파이프라인 과정에서 도형의 지오메트리 정보를 취급하는 것이 가능하다.


    멀티샘플링 AA의 구현 시 한 픽셀의 색상을 결정하기 위해 샘플링 필터를 적용할 수 있다. 여기서 샘플링 필터는 샘플링할 픽셀의 개수 및 위치 정보를 구현한다. 네 점의 샘플링 필터라면 회전한 그리드 형태로 구성할 수 있으며 샘플링한 네 점 위치의 픽셀 값을 보간하여 최종 픽셀 색상을 결정할 수 있다. 샘플링 필터의 잘 분산된 샘플링 위치 정보는 하나의 픽셀을 지나는 도형의 외곽선으로부터 균형 있는 색상 보간이 가능하도록 도와준다. 


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

  • 스팬 엣지 앤티에일리어싱 (SEAA Span Edge AA)

  • 커스텀 패더링(Custom Feathering)의 일환으로 텍스처 매핑을 위해 Span 데이터를 구성한 것에 기반한 AA 기법이다. 핵심은 Span 각 행의 시작과 끝이 도형의 외곽선에 위치하므로 이 양 끝점을 대상으로 AA 작업을 수행한다. 이는 멀티샘플링 AA와 동일하게 도형의 외곽선만 AA를 적용한다고 볼 수 있다. 3장에서 살펴본 것처럼 벡터 도형 래스터 작업 시 도형을 Span 데이터로 구성한 점을 고려하면 이 AA 기법은 충분히 효과적이다. 게다가 Span 단위로 AA를 적용하면 필요한 도형만 선택적으로 AA를 적용 시 용이하다.

    SEAA에서 Span 데이터는 출력할 픽셀의 위치와 색상을 미리 결정하고 있음으로 Span 시작과 끝점에 적용할 샘플 정보만 추가로 필요하다. 필자가 고안한 SEAA는 Span 목록으로부터 외곽선에 해당하는 시작과 끝점이 어떤 패턴으로 나열되어 있는지 추적하고 이를 토대로 외곽선의 픽셀에 투명도(Opacity)를 적용한다. 

    알고리즘을 더욱 자세히 언급하자면 먼저 도형을 최상단 꼭짓점을 기준으로 좌, 우 영역으로 분할한다. 다음으로 좌측 외곽선을 도형의 최하단 꼭짓점까지 따라가며 진행 방향을 살핀다. 이때 진행 방향이 변경될 때마다 외곽선을 구분하고 각 외곽선의 길이를 계산한다. 외곽선의 길이는 투명도 범위(Coverage)에 해당하므로 이 값을 픽셀당 투명도 증감 단위로 활용할 수 있다. 따라서 투명도를 점진적으로 Coverage 값으로 증감시키면서 이를 해당 외곽선을 구성하는 픽셀의 알파 채널 값으로 적용한다.

    외곽선 방향은 크게 일곱 방향으로 구분할 수 있으며 각 방향에 맞는 투명도 결정 식을 적용한다. 좌측 외곽선을 수행하면 동일한 방식으로 우측 외곽선도 동일하게 알고리즘을 수행한다.

    그림 32: SEAA 외곽선 진행 방향

    그림 33: SEAA 외곽선 방향 패턴

    그림 34: SEAA 외곽선 길이를 토대로 투명도 결정

    SEAA에 대한 보다 자세한 내용은 다음 페이지(hermet.pe.kr/122)를 참고한다.


    6. 이미지 합성

    렌더링 엔진에서는 생성한 개별 UI 이미지를 합성하는 작업을 수행해야 한다. 이를 이미지 합성 내지  컴퍼지션(Composition) 또는 블렌딩(Blending)이라고도 하는데 이는 두 장 이상의 이미지를 같은 영역에 출력하기 위한 작업의 일환이다. 이미지 합성의 핵심은 합성하는 두 이미지의 픽셀값과 합성식에 있다. 간단한 예로 알파 블랜딩에서 배경 이미지 위에 반투명한(Opacity=50%) 한 이미지를 출력한다면 두 이미지의 색상 농도를 반으로 감소한 후 이를 합산하여 그린다.

    그림 35: 알파 블랜딩 계산

    기본적으로 이미지 합성을 지원하기 위해 렌더링 엔진에서는 알파 채널을 가진 이미지 데이터 포맷을 지원해야 한다. 여기서 이미지를 구성하는 각 픽셀의 알파 값은 투명도를 결정한다. 32비트 시스템에서 RGBA의 각 채널은 8비트의 메모리 공간을 가지며 이는 0~255 사이의 값을 허용한다. 따라서 이미지 각 픽셀의 투명도는 0~255 사이에서 결정할 수 있으며 0인 경우 해당 픽셀은 완전한 투명(Transparent), 255인 경우 완전한 불투명(Opaque)으로 간주한다.

    앞서 살펴보았던 벡터 기반의 도형이나 이미지 로더를 통해 불러온 이미지 데이터 그리고 텍스처 매핑을 통해 가공된 폴리곤 기반 이미지의 경우에도 렌더링 엔진 내에서는 모두 동일 데이터 포맷(RGBA)으로 통일할 수 있다. 그렇게 함으로써 데이터 출처나 원본 포맷이 서로 다를지라도 이미지 프로세싱 과정에서 이미지 합성을 효과적으로 수행할 수 있다.

    그림 36: 렌더링 엔진 이미지 합성 단계 수행


    6.1 알파 블랜딩

    알파 블랜딩에 대해서 조금 더 자세히 알아보자. 알파 블랜딩은 이미지 합성의 가장 대표적인 방법으로 서로 다른 두 이미지의 색상을 혼합할 때 유용하다. 핵심은 두 색상의 합성을 결정하는 비율에 있는데 이 비율을 알파 값(투명도)으로 결정한다. 따라서 RGB 색상 공간에서 알파 채널을 추가한 RGBA는 일반적으로 사용되는 픽셀 데이터 구성에 해당한다.

    본래 디지털 이미지에서 알파값 개념은 1970년 후반 Alvy Ray Smith에 의해 처음 소개되었다. 알파 채널이 도입된 이후 1984년 Tomas Porter와 Tom Duff에 의해 12개의 알파 블랜딩 방식을 언급한 논문이 발표되었고 이 후 알파 블랜딩은 보다 다양한 방법으로 확장되었다. 이러한 방법은 사실상 두 픽셀의 합성 수식과 연관이 있으며 OpenGL 명세서에서 제시한 블랜딩 수식만 하더라도 약 20여개에 도달한다. 정리하자면 알파 블랜딩은 블랜딩을 수행할 두 이미지 즉, 소스(Source)와 대상(Destination) 그리고 이들을 합성할 수식이 필요하다. 여기서 소스는 추가하고자 하는 이미지에 해당하고 대상은 소스가 그려질 대상 이미지를 가리킨다. 따라서 대상 이미지에 소스 이미지를 추가한다면 두 이미지는 합성될 수 있다. 수채화를 그릴 때 캔버스에 이미지를 추가로 덧칠하는 것을 상상해보면 이해하기 쉽다.

    위 개념을 바탕으로 렌더링 엔진은 소스와 대상을 알파 블랜딩 식을 구현한 함수로 전달해 최종 색상을 도출한다.

    그림 37: 알파 블렌딩 함수

    /* * 알파 블렌딩 구현 * 블렌딩 식: (DstRGB * (1 - SrcA)) + (SrcRGB * SrcA) * @p src: Pixel * @p dst: 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로 가정한다. 수식에서 소스 이미지의 투명도가 20%에 해당한다면 대상 이미지의 투명도는 80%를 적용하며 Out = Src * 0.2 + Dst * 0.8로 이해할 수 있다. 실제 채널당 색상 값은 0~255구간을 가지므로 0~1은 0~255로 매핑되어야 한다. 


    코드 13 comp()는 렌더링 엔진에서 도형 또는 이미지를 그릴 때 호출할 수 있다. 다음 코드는 3.5.1절의 사각형 그리는 로직에 위 comp()를 적용한 주요 코드를 보여준다.


    /* * 사각형 그리는 작업 수행 * @p buffer: NativeBuffer * @p rect: Geometry * @p clipper: Geometry * @p fill: UIFill * @p comp: UICompositor */ UIVectorRenderer.drawRect(buffer, rect, clipper, fill, comp, ...):

    ... //src는 사각형 색상, dst는 사각형이 그려질 대상에 해당 src = fill.color(); for (y = 0; y < rect.h; ++y) for (x = 0; x < rect.w; ++x) dst = bitmap[y * scanlineSize + x]; /* comp()는 src와 dst에 알파블랜딩을 적용한 값을 반환한다. */ bitmap[y * scanlineSize + x] = comp(src, dst);

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

    다음은 UI 객체에 블렌딩 옵션을 적용하기 위한 API 호출 예시이다.

    //대상 객체 UICircle dst; dst.position(200, 100); dst.radius(100); dst.fill(UIRGBA(233, 30, 99, 255)); //소스 객체 UIRect src; src.geometry(100, 200, 200, 200); src.fill(UIRGBA(33, 150, 243, 127));

    //알파 블렌딩 지정 src.compMethod(UIComposition.AlphaBlend);

    코드 15: 블랜딩 옵션 호출

    코드 15는 사용자 관점에서 블랜딩 옵션을 적용하는 방안을 보여준다. 3장에서 fill()이 UIFill 객체를 인자로 전달받지만 여기서 fill()은 편의상 단색을 직접 전달받는다. 또한 본 예시에서는 src와 dst를 직접 명시하지 않지만 2장에서 다룬 레이어 개념을 통해 사각형이 원 위에 위치하게 되며 두 객체 간 블렌딩 대상이 자연스럽게 지정된다.

    알파블랜딩 외로 다음 그림은 가능한 다른 블랜딩 식을 보여준다.

    그림 38블랜딩 종류 및 수식 (Android PorterDuff Mode)

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

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

    /* Compositor 인터페이스. 입력과 출력을 정의 */ Interface UICompositor:

    /*

    * @p src: Pixel

    * @p dst: Pixel

    */ Pixel comp(src, dst); /* Compositor 인터페이스를 기반으로 AddBlend 구현 */ UIAddBlend implements UICompositor: comp(src, dst): ... return out; /* Compositor 인터페이스를 기반으로 AlphaBlend 구현 */ UIAlphaBlend implements UICompositor: comp(src, dst): ... return out; /* 그 외 브랜딩 옵션 구현 */

    ...

    코드 16 다형성을 통한 블랜딩 옵션 확장

    /* UI 객체에 AlphaBlend 지정 */ UIRect src;

    /* compMethod()에 익명(Anonymous)의 UIAlphaBlend 객체를 전달 */ src.compMethod(UIAlphaBlend); ...

    코드 17 블랜딩 옵션 호출

    /* UIObject는 블랜딩 옵션으로 UCompositor 객체를 전달받는다.

    * @p comp: UICompositor

    */ UIObject.compMethod(comp): self.comp = comp; ... /* 이후 객체가 render()를 수행할 시 comp를 래스터라이저로 전달한다. 이 comp는 래스터 단계에서 호출된다. 이후 코드는 14와 동일하다. */ UIRect.render(...): ... UIVectorRenderer.drawRect(..., self.comp, ...);

    코드 18블랜딩 함수 호출 수행 과정


    6.2 마스킹

    마스킹(Masking)은 이미지 합성 범주에 속하는 하위 개념으로서 이미지를 오려내는 기능을 수행한다. 

    그림 39: 마스킹 함수 수행

    마스킹은 입력 데이터로서 소스(Source)와 마스킹(Masking) 두 개의 이미지를 요구하며 마스킹 이미지의 픽셀 데이터는 소스 이미지 픽셀 데이터와 함께 마스킹 함수로 전달되어 합성된다. 일반적으로 마스킹 이미지가 지닌 데이터를 알파 값으로 간주하는데 달리 말하면 마스킹 이미지의 픽셀 데이터는 알파 값으로서 소스 이미지에 적용할 수 있다. 이때 두 데이터 간 동일 위치에 있는 픽셀끼리 마스킹 연산을 수행하고 그 결과(Output)를 저장한다.

    그림 40마스킹 적용 예 (Tizen)

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

    마스킹 함수 또한 블렌딩 함수처럼 수식 정의에 따라 동작 결과가 달라질 수 있다. 만약 마스킹 이미지의 픽셀 데이터가 4바이트 크기라면 마스킹 함수는 이미지를 오려내는 기능 이상의 동작을 수행할 수 있다. 이 경우 마스킹 이미지는 알파 값뿐만 아니라 RGB 색상 정보도 다룰 수 있으며 마스킹뿐만 아니라 앞서 배운 알파 블랜딩 기능도 동시에 수행할 수 있다. 물론 알파 값만 갖는 마스킹 대비 데이터 크기가 네 배로 커지기 때문에 마스킹과 알파 블랜딩의 기능을 적재적소로 구분하여 수행하는 것도 최적화 측면에서 도움이 된다. 

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

    디자인 관점과 달리 마스킹 동작은 비용이 다소 비싼 작업에 해당한다. 원형 아이콘을 예로 들자면 하나의 아이콘을 위해 마스킹 이미지를 별도로 준비해야 한다. 만약 마스킹 이미지가 따로 구비되어 있지 않다면 렌더링 과정을 거쳐 마스킹 이미지를 동적으로 생성해야 한다. 이후 준비된 마스크 이미지는 소스 이미지와 마스킹 합성 작업을 거치고 최종 아이콘을 생성할 수 있다. 최악의 경우 마스킹이 필요 없는 아이콘 대비 두 배 이상의 메모리 사용과 데이터 처리 비용이 든다. 만약 디자인 단계에서 미리 마스킹 작업을 처리할 수 있다면 보다 최적화된 UI 앱 개발에 도움이 된다.


    6.3 필터

    필터(Filter)는 이미지 후처리(Post-Processing) 기법의 하나로 이미지에 효과를 적용한다. 대표적인 사용 사례로 카메라 필터 기능이 있다. 대표적인 필터 효과는 회색조(Grayscale), 선명하게(Sharpen), 흐리게(Blur), 잔광(Glow), 그림자(Shadow)가 있으며 필터 조합에 따라 이미지 출력 결과물도 달라진다. 일반적으로 필터 효과는 UI 컨트롤 내지 응용 단계에서 앱 개발자가 직접 결정할 수 있다. 일반적으로 많이 사용되는 필터 효과는 UI 프레임워크의 내장(Built-in) 기능으로 제공할 수 있지만, 필터 효과는 앱 디자인에 의존하는 경향이 있음으로 사용자가 요구하는 필터 효과를 정확하게 제공하기 어렵다는 점에 있다. 따라서 렌더링 엔진은 필터를 적용할 수 있는 렌더링 시퀀스를 구축해야 하고 프레임워크에서는 내장 필터뿐만 아니라 사용자가 직접 추가 및 커스텀 할 수 있는 필터 인터페이스를 제공해야 한다. 

    그림 42: 이미지 필터 효과


    일반적인 필터 방안은 출력 준비가 끝난 비트맵을 입력값으로 받아 필터 함수를 통해 비트맵을 변조한 후 출력될 수 있도록 한다. 이때 필터 함수는 내장 함수 또는 사용자 커스텀 함수일 수 있으며 요청으로  복수의 필터가 연속으로 수행될 수도 있다. 더 나은 성능을 위해서는 필터 입력으로 사용할 비트맵을 생성함과 동시에 필터를 적용하고 렌더링 단계를 최소화할 수 있다.


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

    /* UserCustomFilter는 UIFilter 인터페이스를 구현한다.*/ UserCustomFilter implements UIFilter:

    /*

    * 필터 수행 함수

    * @p in: Pixel

    * @p coord: Point

    */ func(in, coord, ...): Pixel out; /* 여기서 필터 동작을 수행하는 로직을 작성한다. in으로 받은 픽셀 데이터를 변조하여 out에 기록한다. 구체적인 로직은 애니메이션 & 이펙트에서 다룬다. */ ... return out;

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

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

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

    /* UIImage는 이미지를 출력하는 UIObject의 파생 타입이다.

    * @p buffer: nativeBuffer */ UIImage.render(buffer, ...): ... /* 필터를 적용한 중간 결과물을 저장할 버퍼 생성 */ tmp = NativeBuffer(...); /* 출력할 이미지 데이터를 대상 버퍼(buffer)가 아닌 임시 버퍼(tmp)에 그린다. */ UIVectorRenderer.drawImage(temp, ...); /* 객체에 지정된 필터 목록(self.filters())을 참조하여 필터 프로세싱을 수행한다. UIFilterProcess.proc()은 filters()에 지정된 필터 목록을 순차적으로 수행해 주는 역할을 한다고 가정하며

    입력 데이터 tmp으로부터 결과물을 buffer에 기록한다. */ UIFilterProcessor.proc(tmp, self.filters(), ..., buffer); ...

    코드 21: 필터 수행 엔진 구현부

    코드 21의 핵심은 객체를 렌더링하는 시점에 필터를 적용하는 부분(17줄)에 있다. 코드 이해를 돕기 위해 tmp이라는 임시 버퍼를 이용하고 있지만 drawImage() 내에서 필터 기능을 바로 수행하는 렌더링 시퀀스를 고려할 수도 있다. self.filter()는 addFilter()로 전달받은 필터 객체 목록이다. 이 목록을 UIFilterProcessor에게 전달함으로써 필터 함수(UIFilter.func())가 순차적으로 호출될 수 있도록 한다.

    만약 렌더링 엔진이 그래픽스 하드웨어 가속 기반으로 동작한다면 필터 수행 단계를 하드웨어 가속 동작 방식에 맞게 구성해야 한다. OpenGL과 Direct3D처럼 셰이더(Shader) 기술을 제공하는 그래픽스 환경에서는 필터 함수를 셰이더 코드로 구현할 수 있다. 이를 위해 필터 구현 코드를 셰이더 바이너리로 미리 변환하고 런타임 단계에서 이를 드라이버 메모리에 적재한 후 GPU 동작 설계에 맞춰 필터 셰이더 프로그램을 수행한다. 다만 커스텀 필터의 경우 사용자가 직접 HLSL(High-Level Shader Language)이나 GLSL(GL Shader Language)를 이용하여 셰이더 코드를 작성하고 엔진에 추가해야 하는데 이 과정은 쉽지 않을 수 있다. 따라서 UI 엔진에서는 커스텀 필터 셰이더를 적용할 수 있는 편의 인터페이스를 고려해야 하며 셰이더의 렌더링 컨텍스트 내에서 부가 정보를 사용자에게 전달할 수 있도록 인터페이스를 규격화해야 한다. 사용자 관점에서는 다소 셰이더의 진입 장벽이 있을 수 있으나 결과적으로 필터 동작을 GPU 단계에서 수행할 수 있으므로 CPU 가용성을 높일 수 있는 이점이 있다. 참고로 최신의(Direct3D 11 또는 OpenGL 4 이후) 3D 그래픽스에서는 컴퓨트 셰이더(Compute Shader) 기능을 제공함으로써 렌더링 파이프라인과 독립된 계산 목적의 셰이더를 구축할 수 있다.

    그림 44: 셰이더 기반 필터 수행

  • 필터 스크립트

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


    그림 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라면 사용한 캐시 메모리는 5MB에 해당한다. 반면 동일 시나리오일지라도 이미지 평균 크기가 4MB인 경우에는 사용한 캐시 메모리는 200MB가 될 수 있다. 따라서 캐시 메모리 크기의 일관성을 위해서는 캐시 할 비트맵 개수가 아닌 캐시 할 비트맵의 총 크기를 지정하는 것이 안정적이다.

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

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

    이제 캐시 공간이 제한되어 있기 때문에 캐시 적중률(Cache Hit Ratio)이 중요하다. 캐시 적중률이 높은, 즉 재사용이 많은 데이터일수록 캐시 효과는 좋다. 하지만 재사용을 하지 않거나 거의 하지 않는 데이터는 캐시 공간만 차지할 뿐이다. 이미지 사용 여부는 런타임에 결정되므로 이미지 캐시 관리자는 어떤 이미지가 캐시 적중률이 높은지 사전에 판단하기 어렵다. 따라서 이미지 캐시 관리자는 런타임 중 캐시 적중률 히스토리를 기록하고 필요하면 적중률이 낮은 데이터를 우선순위로 폐기 처분할 수 있다.

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

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

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

    우리가 구현하고자 하는 이미지 캐시는 단순히 캐시 데이터의 개수가 아닌 데이터 크기를 기준으로 하므로 데이터를 새로 추가할 때 새 데이터의 크기를 기존 캐시 데이터의 총합(cacheSize)에 더하고 그 결괏값이 최대 캐시 값(maxCacheSize)을 초과하는지 판단한다. 만약 최대 캐시 값을 초과한다면 초과한 크기를 확보할 때까지 캐시 리스트의 0번째 항목부터 데이터를 제거해 나간다. 캐시 공간을 확보하면 새 데이터는 리스트의 끝에 추가한다. 만약 캐싱하고자 하는 데이터가 캐시 리스트에 이미 존재할 경우 캐시 리스트에서 해당 데이터를 리스트의 끝으로 이동함으로써 해당 데이터 접근 시점을 최신으로 유지할 수 있게 한다. (그림 47의 네 번째 데이터 A 추가 시점)

    렌더링 엔진이 다루고자 하는 이미지를 캐싱하기 위해서는 이미지에 식별자를 부여해야 한다. 그렇게 함으로써 이미지 캐시 관리자는 식별자를 통해 캐시 리스트로부터 특정 캐시 데이터에 접근할 수 있다. 즉, 이미지 캐시 관리자는 이미지 데이터와 함께 이미지 고유 ID를 전달하여 캐싱을 시도하고 캐싱된 이미지 데이터를 반환받을 때는 고유 ID를 이용한다. 이러한 캐시 접근 방식을 구현하기 위해서는 해시(Hash) 자료 구조가 적합하다. 이미지 데이터에 고유 ID를 지정하기 위해서는 이미지 출처명(파일명)과 이미지 해상도를 조합할 수 있으며 폰트 글리프의 경우에는 폰트명, 폰트 스타일, 글리프 캐릭터명 등을 활용할 수 있다. UI 객체 메모리 주소 자체를 활용하는 것도 고려할 만하다.

    /* * UIImage.render() 전 수행되는 이미지 준비 단계 */ UIImage.prepare(...) ... //Cache ID 생성. 이미지 경로와 출력할 이미지 가로, 세로 크기 조합 key = self.path() + self.width().toString() + "x" + self.height().toString(); //기존에 캐싱된 데이터인지 확인 dstBuffer = ImageCacheMgr.get(key); /* 캐싱되어 있지 않으므로 이미지 데이터를 새로 만들어서 추가한다. 만약 dstBuffer가 존재한다면 데이터를 새로 만들지 않고 재활용한다. */ if (!dstBuffer) dstBuffer = NativeBuffer(UICanvas.getSurface(), self.width(), self.height(), RGBA32, IO_WRITE + IO_READ); //srcBuffer로부터 dstBuffer에 이미지 스케일링 수행 UIImageRenderer.bilinearScale(self.srcBuffer, dstBuffer); //재사용을 위해 dstBuffer를 캐싱 ImageCacheMgr.add(key, dstBuffer); ... /* * 새 캐시 데이터 추가 * @p key: String * @p data: NativeBuffer */ UIImageCacheMgr.add(key, data) size = data.size(); //새로 캐싱할 이미지 크기 //캐시 크기 초과 if (size > self.maxCacheSize) return false; self.cacheSize += size; //누적 캐시 크기 갱신 overSize = self.cacheSize - self.maxCacheSize; //초과한 캐시 크기 //overSize만큼 캐시 메모리 확보 while(overSize > 0) /* 캐시 목록에서 첫 번째 (가장 오래된) 데이터를 가져와서 삭제한다. Pair는 키와 데이터 쌍을 담는 자료구조이다. */ Pair it = self.cacheList.get(0); NativeBuffer tmp = it.data(); overSize -= tmp.size(); self.cacheSize -= tmp.size(); self.cacheList.remove(0); //캐시 공간이 확보되었으므로 cacheHash에 새 데이터 추가 Pair it(key, data); self.cacheList.append(it); /* * 기존 캐시 데이터 반환 * @p key: String */ UIImageCacheMgr.get(key) Pair it; 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); return it.data();

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


    8. 정리하기


    이미지 프로세싱은 UI 렌더링 엔진의 핵심 기능으로서 이미지 가공을 수행한다. 이번 장에서 우리는 렌더링 엔진이 이미지를 출력하기 위해 파일로부터 이미지 데이터를 불러오는 과정을 확인하였다. JPEG, PNG, WEBP, GIF 등 포맷별 특징을 살펴보았고 이들의 데이터 구조 및 인코딩 방식이 다르기 때문에 렌더링 엔진이 전달받은 이미지 데이터를 특수 알고리즘을 이용하여 디코딩하고 이를 출력 가능한 형태로 변형함을 확인할 수 있었다.


    추가로 렌더링 엔진은 이미지 파일 뿐만 아니라 벡터 래스터를 통해 완성된 도형 내지 폰트 글리프 역시 비트맵 형태로서 이미지 데이터를 가공한다는 사실을 알 수 있었다. 그리고 이미지 프로세싱을 통해  이미지 데이터의 해상도를 변경하거나 색상 합성, 필요에 따라 이미지를 후처리하여 필터 효과 등을 적용하는 방법도 살펴보았다.


    나아가 3차원 변환 효과를 위해 원근법 구현 및 텍스처 매핑 기술을 확인해 보았고 이미지 프로세싱 과정에서 발생하는 이미지 품질 손실을 보완하기 위해 앤티에일리어싱 및 샘플링 보간 등 이미지 품질 개선 기법도 함께 살펴보았다.


    마지막으로 이미지 캐싱 관리자를 통해 이미지 데이터를 재사용하는 방법과 벡터 프로세싱 기술을 이용하여 이미지 프로세싱의 작업 부담을 해소할 방법을 살펴보았다.