카테고리 없음

Metal by Example | Metal 시작하기, Part 1: 화면 준비하기

61315 2021. 5. 4. 20:08

Metal by Example | Metal 시작하기, Part 1: 화면 준비하기

25 AUG 2014 | 기초, 렌더링

이번 포스트에서는 Metal 환경에서 화면을 준비하기위한 기초를 배울 것입니다. 별 일 아닌 작업이지만 그럼에도 Metal 프레임워크가 제공하는 몇 가지 개념에 대해 짚고 넘어가야 합니다. 앞으로 작성할 Metal 시작하기 시리즈의 처음 포스트 몇 개는 이번 포스트에서 다룬 주제를 토대로 작성될 것입니다. 그런 다음 3D 렌더링 기초와 이미지 프로세싱에 대해 다룰 것입니다.

이번 포스트에서 사용한 예제 프로젝트는 다음 링크에서 다운로드 할 수 있습니다.

이 포스트를 작성하는 시점에서, 시뮬레이터 빌드에서는 Metal 코드를 실행할 수 없다는 점을 알아두세요. 적어도 A7 프로세서가 장착된 기기(아이폰 5S 또는 레티나 아이패드 미니 이후 기종)와 iOS 8 이상이 설치된 기기가 필요합니다. 또한 최신 버전의 Xcode 룰 준비하세요. iOS 8 을 타겟으로 컴파일 하기위해서는 적어도 Xcode 6 이 필요합니다. Metal 프레임워크는 지금도 활발히 작성되고있습니다. 예제 프로젝트를 컴파일 하기위해서 Xcode 6의 버전이 beta 6 이후의 것인지 확인하세요.


새 프로젝트 작성하기

새로운 프로젝트를 만들어봅시다. iOS 탭의 App 프로젝트를 선택하는 것이 좋겠습니다. 기본으로 뷰컨트롤러 하나를 제공하는 동시에 스토리보드를 통해 바로 출력할 수 있는 뷰 하나를 제공해주니까요.

figure-1

링킹 대상으로 MetalCore Animation을 지정해야합니다. “Build Phases ▸ Link Binary with Libraries” 순서로 진입해 등장하는 다이알로그에서 Metal.frameworkQuartzCore.framework 을 추가해주세요. Quartz CoreCore Animation의 또 다른 이름입니다. 같은 용어라는 사실 알고 넘어갑시다.

인터페이스와 UIKit

알고계시겠지만 iOS 상의 UIViewCore Animation에 상당 부분 의존합니다. 각종 컨텐츠가 겉으로 보기엔 그냥 UIView 인스턴스에 담겨있는 것 같아 보이지만, 실제로 화면에 그려질 또는 출력될 내용은 UIView 인스턴스의 layer 프로퍼티 속에 담겨있는 셈이죠. 이런 경우 우리는 이 뷰가 CALayer(Core Animation Layer) 인스턴스에 의해 backed 된다고 말합니다.

UIViewsuperclass로 하는 커스텀 UIView를 만든 경우, 우리는 이 커스텀 뷰가 컨텐츠를 담을 backing layer로 사용하기 위해 생성할 layer 인스턴스의 타입을 명시적으로 지정할 수 있습니다. layerClass 메서드를 override하면 됩니다.

새로운 커스텀 뷰를 만들어볼까요? “File ▸ New ▸ File…” 순서로 이동해 새로운 Swift 클래스를 생성해보겠습니다. 이름은 MetalView.swift가 좋겠습니다.

figure-2

figure-3

헤더를 추가하는 일이 남았습니다. 헤더가 없으면 Xcode는 Metal이 Metal.framework의 Metal을 말하는 것인지 알지 못하기 때문입니다. 커스텀 뷰의 헤더 파일(MetalView.h)에 다음 두 개의 import를 추가하는 것을 잊지 마세요.

#import <Metal/Metal.h>
#import <QuartzCore/CAMetalLayer.h>

무슨 이유에서인지 CAMetalLayerMetal 프레임워크에 속하지 않고 Core Animation 프레임워크에 속해있습니다. QuartzCore는 즉 Core Animation과 같다고 말씀 드렸죠? CAMetalLayerUIKitMetal 을 연결하는 접착제라고 생각하세요. 아래에서 보게 될 멋진 기능을 CAMetalLayer 가 제공합니다.

UIView의 자식 클래스인 우리의 커스텀 뷰에, 부모 클래스인 UIView 클래스의 layerClass 메서드를 구현해볼까요? 우리는 지금 일반적인 컨텐츠가 아닌 Metal 컨텐츠를 출력하려고 합니다. 그렇기때문에 우리 커스텀 뷰의 컨텐츠를 담을 backing layer의 타입을, UIView가 기본으로 제공하는 CALayer가 아닌 CAMetalLayer로 지정해야합니다. 다음과 같이 구현하면 됩니다.

import Foundation
import UIKit

class MetalView : UIView {

    override class var layerClass: AnyClass { CAMetalLayer.self }

}

이제 스토리보드로 이동해 기본으로 제공되는 뷰의 클래스를 우리가 방금 만든 MetalView로 설정합니다. 이렇게 하면 어플리케이션 런타임이 시작되고 스토리보드가 호출되는 순간, 기본으로 제공되는 UIView 클래스가 아닌 MetalView 클래스의 인스턴스가 생성되겠죠? 여기까지가 backing layer로 Metal 프레임워크를 사용하는 뷰를 생성하는 과정이었습니다.

figure-4figure-5

편의를 위해서 CAMetalLayer 타입인 프로퍼티를 하나 생성한 다음 init 메서드를 통해 viewlayer 프로퍼티 역할을 하게 만드는 것도 좋은 방법입니다. 이렇게 하면 CAMetalLayer만이 제공하는 특별한 메서드에 접근하기 위해 반복해서 viewlayer 프로퍼티를 CAMetalLayer로 캐스팅할 필요가 없어질 테니까요.

이 상태로 프로젝트를 실행해보요. 물론 흰색 빈 화면 말고는 아무것도 나타나지 않습니다. 실제로 무언가를 그리고 출력하기위해서는 MTLDevice를 포함한 각종 오브젝트에 대해 알아야합니다. 먼저 Metal 프레임워크에서 사용하는 protocols에 대해 알아볼까요?

Protocols

Metal API를 관통하는 하나의 주제가 있다면 그것은 바로 프로토콜입니다. concrete한 클래스를 사용하는 것이 아닌, 인터페이스와 닮은 듯 다른, 프로토콜를 이용해 Metal 프레임워크의 기능을 제공하는 것이죠. 실제로 많은 Metal API들이 특정한 클래스가 아닌 특정한 프로토콜을 준수하는 오브젝트를 반환합니다. 이 방법은 개발자에게 몇 가지 편의를 제공합니다. 특정한 기능을 구현하는 클래스가 도대체 무엇인지 찾아볼 필요 없이 그저 프로토콜이 요구하는 규칙을 따라가기만 하면 되기때문이죠.

특정한 프로토콜을 준수하는 오브젝트가 뭐지? 그냥 이겁니다.

let device: MTLDevice

이제 device에 접근하고 이 device를 사용하는 방법을 알아보겠습니다.

Devices

device란 GPU를 추상화 한 오브젝트라고 생각하면 좋습니다. 실제로 GPU가 이용할 커맨드 큐(Command Queues), 렌더 상태(Render States), 라이브러리(Libraries) 오브젝트를 생성하는 메서드를 제공합니다. 하나 하나 알아보겠습니다.

메탈 프레임워크는 MTLDevice 형 포인터 변수(id<MTLDevice>)를 생성하고 반환하는 C언어 함수인 MTLCreateSystemDefaultDevice 를 제공합니다. 이 함수에는 어떠한 파라미터도 필요하지 않습니다. 무슨 파라미터를 어디에 넣어서 무언가를 달성하려면 적어도 사용하려는 device: MTLDevice가 무슨 프로퍼티를 가지고있는지 알고있어야 하지만, 우리는 아직 어떤 GPU에 접근할 지 모르는 상태이기 때문이죠. 실제로 이 함수는 nullable MTLDevice를 반환합니다.

우리의 layer: CAMetalLayer는 어느 device: MTLDevice가 렌더링에 사용될 것인지 먼저 알아야 합니다. 그 다음 layer: CAMetalLayer의 픽셀 포맷을 지정해야합니다. 색상 컴포넌트들의 크기와 순서를 알아야 그리는 쪽과 출력하는 쪽이 같은 의도로 동작하겠죠? 여기서 크기와 순서란, 하나의 픽셀을 데이터로 표현했을 때 자료의 크기와, R, G, B, A 채널의 순서를 말합니다.

알파 채널이 없는 컬러 비트맵을 떠올려볼까요? 텔레비전을 아주 가까이 들여다봤을때 등장하는 픽셀 하나를 생각해보세요. 픽셀 하나에 빨강, 초록, 파랑 서브픽셀이 사이좋게 들어가있습니다. 그리고 각각의 서브픽셀 밝기를 조정하면서 다양한 색을 표현합니다.

하나의 서브픽셀의 밝기를 256단계로 조정할 수 있다면요? 하나의 서브픽셀에 관한 정보를 8-bit로 표현할 수 있습니다. 2의 여덟제곱은 256이니까요. 이러한 서브픽셀이 색상 별로 세 개 있습니다. 24-bit네요.

제가 들여다 본 이 텔레비전의 서브픽셀은 왼쪽부터 빨강, 초록, 파랑 순서로 나열되어있습니다. 순서는 R-G-B 구요. 텔레비전으로 비유한 우리의 텍스처는 세개의 색상 채널을 가지고 있으며, 채널 별로 8-bit 데이터를 담고있으며, 색상 채널은 빨강, 초록, 파랑 순서로 구성되어있다고 말할 수 있습니다.

우리 예제에서는 MTLPixelFormatBGRA8Unorm이 좋겠습니다. 일반적으로 사용되는 픽셀 포맷 중 하나입니다. 이 픽셀 포맷에서 하나의 픽셀은 파랑, 초록, 빨강 그리고 알파 컴포넌트 순서로 구성됩니다. 그리고 하나의 색상 컴포넌트는 8-bit unsigned integer 자료로 표현됩니다. 8-bit를 사용하는 채널이 네 개 있으니 하나의 픽셀은 32-bit로 표현되겠네요.

우리가 만들 이 device: MTLDevice 오브젝트를 MetalView 클래스에 프로퍼티로 추가하면 좋겠습니다. 앞으로 우리 클래스 여기저기에서 필요할테니까요. 저는 init 메서드 내부에서 device: MTLDevice를 확보하고 metalLayer: CAMetalLayer의 픽셀 포맷을 지정했습니다.

import Foundation
import UIKit

class MetalView : UIView {

    var device: MTLDevice?
    var metalLayer: CAMetalLayer?

    required init?(coder: NSCoder) {
        super.init(coder: coder)

        device = MTLCreateSystemDefaultDevice()
        metalLayer = layer as? CAMetalLayer

        if let device = device, let metalLayer = metalLayer {
            metalLayer.device = device
            metalLayer.pixelFormat = MTLPixelFormat.bgra8Unorm
        }
    }
}

Redraw()

앞으로 작성할 포스트에서 등장할 각종 그리기 명령(Drawing Commands)은 이 redraw 메서드 속에서 호출됩니다. 당장 이번 포스트에서는 화면을 반복해서 그릴 필요가 없습니다. 단색으로 화면을 한 번 채우기만 하면 됩니다. redraw 메서드 역시 한 번만 호출하면 되죠.

didMoveToWindow 메서드를 override 해 그 안에서 redraw 메서드를 호출할게요. 이렇게 하면 만들어진 MetalView 인스턴스가 UIWindow 인스턴스에 장착되는 순간 한 번 실행될테니까요.

override func didMoveToWindow() {
    redraw()
}

이번 포스트에서는 사실상 이 redraw 메서드가 전부입니다. 화면을 지우고 단색으로 초기화하는 작업은 이 redraw 메서드가 합니다. 아래에서 작성할 코드 모두 redraw 메서드 속에 들어간다고 생각하세요.

Textures와 Drawables

메탈 프레임워크에서 텍스처는 이미지를 담는 컨테이너입니다. 그냥 단일 이미지 하나가 텍스처라고 생각하기 쉽지만, 사실 텍스처 오브젝트는 약간의 추상화(abstraction)를 거친 결과물입니다.

텍스처 오브젝트 하나에, 하나 이상의 이미지를 배열로서 담을 수도 있어요. 이 경우에 하나의 이미지는 slice라고 불립니다. 각각의 이미지가 각기 다른 가로/세로 크기와 픽셀 포맷을 가질 수도 있구요. 텍스처 오브젝트는 1 차원일 수도 있고, 2 차원일 수도 있으며, 심지어 3 차원일 수도 있습니다. 어레이와 비슷하네요.

저런 제멋대로 뒤죽박죽인 텍스처는 나중에 걱정해도 됩니다. 당장은 2D 텍스처 하나를 프레임 버퍼로 사용할 거니까요. 이 2D 텍스처의 해상도는 우리의 앱이 구동되는 기기에 장착된 디스플레이의 실제 해상도와 같습니다. 말이 어렵지만 프레임 버퍼로 사용되는 이 2D 텍스처에 접근하는 쉬운 방법이 있습니다. Core Animation 프레임워크의 CAMetalDrawable 프로토콜이죠.

  • 프레임 버퍼 : 실제로 픽셀 데이터가 담길 메모리 공간 어딘가를 말합니다

drawable: CAMetalDrawble. 낮선 이름이지만 그저 texture: MTLTexture 오브젝트의 래퍼 클래스에 지나지 않습니다. 화면을 그릴 때 마다 MetalView에 장착된 layer: CAMetalLayer로부터 drawable: CAMetalDrawable 오브젝트를 얻어옵니다. 이 drawable: CAMetalDrawable 오브젝트에서 프레임 버퍼로 열일 할 바로 그 2D 텍스처를 추출해낼 수 있죠. 많은 코드가 필요하지 않습니다.

let drawable: CAMetalDrawable = metalLayer.nextDrawable()
let texture: MTLTexture = drawable.texture

여기서 참조한 drawable: CAMetalDrawable 오브젝트는 후에, "우리 프레임 버퍼에 렌더링 하는 거 끝났어. 이제 화면에 출력해도 좋아!"라고 Core Animation 프레임워크에 알릴 때 다시 사용됩니다.

물론 실제로 프레임 버퍼를 지우고 단색으로 초기화 하는데는 몇 가지 작업이 더 필요합니다. 일단 프레임 하나를 완성(=render)하기 위해서 어떤 준비를 마쳐야하는가에 대한 정보를 담은 렌더 패스 디스크립터(Render Pass Descriptor)를 하나 준비해야하죠.

Render Passes

렌더 패스 디스크립터(Render Pass Descriptor)는 그리고자하는 이미지를 어떻게 취급해야하는가에 대한 옵션 몇 가지를 메탈 프레임워크에 전달합니다.

일단 기존에 texture: MTLTexture에 남아있을지도 모를 픽셀 데이터를 초기화(=clear)할지 보존할지 정해야 합니다. loadAction를 설정해 초기화 여부를 선택할 수 있습니다.

storeAction은 렌더링한 결과물이 텍스처에 입혀지는가의 여부를 정합니다. 우리는 단색으로 화면을 가득 채워야하기 때문에 그려진 픽셀 데이터를 실제로 화면에 출력해야합니다. 그렇게 하려면 그리기 작업의 결과를 texture: MTLTexture에 실제로 저장해야하죠. storeAction 옵션은 MTLStoreActionStore로 하겠습니다.

그리기 명령을 통해 렌더한 결과물을 텍스처에 적용할 수도 있고 적용하지 않을 수도 있습니다. 애써 렌더한 결과물을 버리는 일이 있냐고 궁금해 하실 수 있습니다. 그런 경우는 나중의 포스트에서 다룰 것입니다.

알아두세요. 이번 프로젝트에서는 명시적인 그리기 작업을 한 개도 사용하지 않습니다. 초기화(=clear)만 할 뿐입니다. 초기화 하는데 색상을 지정할 수 있기 때문에 초기화 옵션을 통해 마치 그리기 작업을 한 번 수행한 효과를 얻는 것이죠.

어떤 색상으로 화면을 지우고 초기화할지 정하는 일은 MTLRenderPassDescriptor의 역할 중 하나입니다. 아래 예제에서 우리는 투명도가 없는 빨간색을 선택합니다. (red = 1, green = 0, blue = 0, alpha = 1)

let drawable: CAMetalDrawable = metalLayer.nextDrawable()
let texture: MTLTexture = drawable.texture

let passDescriptor: MTLRenderPassDescriptor = MTLRenderPassDescriptor()
passDescriptor.colorAttachments[0].texture = texture
passDescriptor.colorAttachments[0].loadAction = MTLLoadAction.clear
passDescriptor.colorAttachments[0].storeAction = MTLStoreAction.store
passDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)

여기서 만든 passDescriptor: MTLRenderPassDescriptor를 토대로 MTLCommandEncoder 오브젝트를 생성할 것입니다. MTLCommandEncoder는 사람이 생각하는 방법으로 표현한 그리기 명령(Render Command)을 GPU가 이해할 수 있는 코드로 변환합니다.

Queues와 Buffers 그리고 Encoders

커맨드 큐(Command Queue)는 커맨드 버퍼(Command Buffer) 를 관리하는 오브젝트입니다. MTLCommandBuffer는 각종 그리기 명령을 담고있습니다. 이전에 생성한 device: MTLDevice 오브젝트를 통해 MTLCommandQueue를 생성할 수 있습니다. 자료구조로 공부해 알고있는 그 큐와 같은 개념입니다. 큐에우에로 읽어도 아무 상관 없습니다.

일반적으로 MTLCommandQueue 오브젝트는 상대적으로 긴 수명을 가지고 있습니다. 단색으로 화면을 한 번 채우기만 하면 되는 이번 예제에서는 아니지만, 일반적으로 MTLCommandQueue는 하나 이상의 프레임을 그리는데 반복해서 사용되는 일이 잦기때문에 상대적으로 긴 수명 주기를 가집니다.

let commandQueue: MTLCommandQueue? = device.makeCommandQueue()

MTLCommandBuffer는 일련의 그리기 명령들을 묶어 단위 작업으로 표현합니다. 한번에 묶어 실행할 그리기 명령의 그룹인 셈입니다. MTLCommandBufferMTLCommandQueue는 일반적으로 1:1로 매칭됩니다.

let commandBuffer: MTLCommandBuffer? = commandQueue?.makeCommandBuffer()

MTLCommandEncoder란 우리가 원하는 그리기 명령을 메탈 프레임워크가 이해할 수 있는 언어로 바꾸어 전달하는 프로토콜입니다. 셰이더의 무슨 값을 어떻게 지정하고, 무슨 폴리곤을 어디에 그리라는 우리 사람이 이해할 수 있는 명령(high-level)GPU가 이해할 수 있는(low-level) 명령으로 번역하는 겁니다. 비디오 카드 메모리(VRAM)나 GPU의 ALU, 레지스터를 직접 조작하는 그런 명령들이요.

MTLCommandEncoder에 의해 low-level 명령으로 변환된 작업들은 해당 MTLCommandEncoder에 매칭된 MTLCommandBuffer에 저장됩니다. 원하는 이미지를 그리기 위해 드로우 콜(=Draw Call)을 모두 입력한 다음엔, MTLCommandEncoder에게 endEncoding 메시지를 보내 인코딩 작업을 마쳐도 좋다는 의사를 전달합니다.

MTLRenderCommandEncoder 프로토콜은 MTLCommandEncoder 프로토콜의 여러 하위 프로토콜 중 하나입니다. 우리는 이번 예제에서 실제 그리기 명령을 수행해야하기때문에 MTLRenderCommandEncoder프로토콜을 이용합니다.

let commandEncoder: MTLRenderCommandEncoder? = commandBuffer?.makeRenderCommandEncoder(descriptor: passDescriptor)
commandEncoder?.endEncoding()

마지막으로, MTLCommandBuffer에 모든 그리기 명령이 입력되었다면, 이 MTLCommandBuffer가 소유한 drawable: CAMetalDrawable이 화면에 출력되어도 좋다고 전달해야합니다. present 메서드에 drawable: CAMetalDrawble 오브젝트를 담아서 말이죠. drawable: CAMetalDrawable이란 MTLTexture의 래퍼 클래스라고 위에서 설명했습니다.

그런 다음엔 MTLCommandBuffercommit 메서드를 호출해 이 MTLCommandBuffer에 필요한 그리기 명령이 모두 입력되었으며 GPU에 의해 실행되기 위해 MTLCommandQueue에 적재될 준비를 마쳤음을 알립니다. 바로 이 commit 메서드를 호출하는 작업이 우리가 아까 초기화 색상으로 정한 빨간색으로 프레임 버퍼를 채울 것입니다.

commandBuffer?.present(drawable)
commandBuffer?.commit()

Build and Run

import Foundation
import UIKit

class MetalView : UIView {

    var device: MTLDevice?
    var metalLayer: CAMetalLayer?

    override class var layerClass: AnyClass { CAMetalLayer.self }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)

        device = MTLCreateSystemDefaultDevice()
        metalLayer = layer as? CAMetalLayer

        if let device = device, let metalLayer = metalLayer {
            metalLayer.device = device
            metalLayer.pixelFormat = MTLPixelFormat.bgra8Unorm
        }
    }

    override func didMoveToWindow() {
        redraw()
    }

    func redraw() {
        guard let drawable: CAMetalDrawable = metalLayer?.nextDrawable() else {
            return
        }

        let texture: MTLTexture = drawable.texture

        let passDescriptor: MTLRenderPassDescriptor = MTLRenderPassDescriptor()
        passDescriptor.colorAttachments[0].texture = texture
        passDescriptor.colorAttachments[0].loadAction = MTLLoadAction.clear
        passDescriptor.colorAttachments[0].storeAction = MTLStoreAction.store
        passDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)

        let commandQueue: MTLCommandQueue? = device?.makeCommandQueue()
        let commandBuffer: MTLCommandBuffer? = commandQueue?.makeCommandBuffer()
        let commandEncoder: MTLRenderCommandEncoder? = commandBuffer?.makeRenderCommandEncoder(descriptor: passDescriptor)
        commandEncoder?.endEncoding()

        commandBuffer?.present(drawable)
        commandBuffer?.commit()
    }
}

예제에 사용된 코드는 여기서 확인할 수 있습니다. iOS 장치를 대상으로 프로젝트를 컴파일하고 실행해보세요. 빨간색으로 가득 찬 화면을 만날 수 있을 것입니다. 고생하셨습니다! 이 화면이 바로 이번 포스트의 결과물입니다.

result


마치며

이 포스트에서 우리는 앞으로 다룰 3D 렌더링과 같은 흥미로운 기술을 이해하기 위한 기초를 다졌습니다. Metal을 다루면서 만나게 될 많은 오브젝트 중 몇가지에 대해 이해하는데 도움이 되었으면 좋겠습니다. 다음 포스트에서는 삼각형 그리기와 같은 기초적인 렌더링에 대해 설명할 것입니다. 앞으로 다루었으면 하는 주제가 있으시다면 주저없이 의견을 남겨주세요. 감사합니다.


이 포스트는 Warren Moore 선생님의 Metal by Example 시리즈를 의역한 것입니다.