Metal by Example | Metal 시작하기, Part 2: 삼각형 그리기
26 AUG 2014 | 기초
, 렌더링
지난 포스트에서 우리는 위대한 첫 발을 내딛었습니다. 디바이스와 텍스처, 커맨드 버퍼와 커맨드 큐와 같은 메탈 프레임워크의 핵심 요소에 대해 알아보았죠. 지난 포스트의 양이 적었다고 말할 수는 없지만, 그것들을 충분히 설명했다고 하기엔 턱없이 부족합니다. 이번 포스트에서는 바로 이전에 배운 주제에 대한 깊이 있는 설명을 포함해, 메탈 프레임워크를 이용해 지오메트리(=Geometry)를 렌더 하는 방법에 대해 설명할 것입니다. 풀어 설명하자면, 메탈 프레임워크의 Rendering Pipeline
이 무엇인지 알아보고, Function
과 Library
에 대해 소개하고, 우리의 첫 드로우 콜을 호출해볼 것입니다.
이 포스트의 목표는 다음과 같습니다. 메탈 프레임워크를 이용해 실제로 무언가를 그리기 위해서-이 경우에는 지오메트리-어떤 작업을 수행해야 하는지 보여드리는 것입니다. 2 차원 공간에 삼각형을 그리는 일이죠. 언제 3 차원으로 넘어가냐구요? 그것이 궁금한 당신을 위해 다음 포스트에는 수학 공부가 준비되어있습니다. 3 차원 공간에 도형을 그리고, 애니메이션을 포함한 3D 오브젝트를 렌더링 하기 위해서는 약간의 수학을 먼저 알아야 하기 때문이죠. 각오하세요🤗
예제 코드는 다음 링크에서 다운로드 할 수 있습니다.
Setup
이번 포스트를 위해 MetalView
클래스의 생성자를 리팩토링 했습니다. 재구성한 생성자 안에서 순차적으로 메서드를 호출해 렌더링 준비를 위한 작업을 수행할 것입니다.
required init?(coder: NSCoder) {
super.init(coder: coder)
buildDevice()
buildVertexBuffers()
buildPipeline()
}
이전 포스트 MetalView
의 생성자에서 사용한 코드를 buildDevice()
메서드로 추출해 그대로 옮긴 것입니다.
var device: MTLDevice?
var metalLayer: CAMetalLayer?
func buildDevice() {
device: MTLDevice? = MTLCreateSystemDefaultDevice()
metalLayer: CAMetalLayer? = layer as? CAMetalLayer
if let device = device, let metalLayer = metalLayer {
metalLayer.device = device
metalLayer.pixelFormat = MTLPixelFormat.bgra8Unorm
metalLayer.contentsScale = UIScreen.main.scale
}
}
나머지 두 개 메소드에 대해서는 아래에서 설명하겠습니다.
버퍼를 이용해 데이터 저장하기
메탈 프레임워크는 MTLBuffer
라는 프로토콜을 제공합니다. 특별한 타입 없이(=raw 바이트 데이터) 고정된 길이를 가진 버퍼를 표현하는 프로토콜입니다. 이 프로토콜이 제공하는 인터페이스는 NSData
와 많이 닮았습니다. 다른 점이 있다면, 메탈 버퍼는 특정한 메탈 디바이스를 위해 사용된다는 점이죠. 드로우 콜을 수행하는 도중에 사용할 데이터를 위한 데이터 인터페이스인 것입니다.
두개의 어레이를 생성해 우리가 그리고자하는 지오메트리를 정의할 것입니다. 이번 포스트에서는 삼각형을 그리고싶어요. 더 정확히는 빨강, 초록, 파랑. 각자 색상을 가진 세 개의 버텍스(=꼭지점)를 가진 지오메트리를 그릴거예요. 꼭지점에 색깔이 있다는 말이 무슨 뜻일까요? 나중에 설명하겠습니다.
버텍스가 세 개이니까 이 지오메트리를 삼각형이라고 불러도 되겠죠? 그런 다음 이 지오메트리를 정의하는 데이터를 저장하는 버퍼를 만들어야합니다. 여기서 만든 메탈 버퍼를 MetalView
클래스에 프로퍼티로 만들어 저장하겠습니다. 하나는 positionBuffer
, 다른 하나는 colorBuffer
라는 이름으로 프로퍼티를 생성했습니다.
var positionBuffer: MTLBuffer?
var colorBuffer: MTLBuffer?
func buildVertexBuffers() {
// +1
// * - Upper middle
// / \
// -1 / 0 \ +1
// / \
// *-------* - Lower right
// | -1
// Lower left
let positions: [Float] = [
0.0, 0.5, 0, 1, // Upper middle
-0.5, -0.5, 0, 1, // Lower left
0.5, -0.5, 0, 1 // Lower right
]
let colors: [Float] = [
1, 0, 0, 1, // Red color, Opaque
0, 1, 0, 1, // Green color, Opaque
0, 0, 1, 1 // Blue color, Opaque
]
positionBuffer = device?.makeBuffer(
bytes: positions,
length: MemoryLayout<Float>.size * positions.count,
options: [] // MTLResourceOptionCPUCacheModeDefault
)
colorBuffer = device?.makeBuffer(
bytes: colors,
length: MemoryLayout<Float>.size * colors.count,
options: [] // MTLResourceOptionCPUCacheModeDefault
)
}
눈치채셨나요? 2 차원 환경에서 지오메트리를 렌더하겠다고 이야기했는데 버텍스 좌표 하나 당 Float
형 자료를 네 개나 가지고 있네요. 2 차원이면 X 좌표, Y 좌표 두 개면 충분할텐데. 왜 그럴까요?
컴퓨터 그래픽스에서 물체를 표현하는 유용한 방법 중 하나는 4 차원 homogeneous coordinates를 이용해 물체를 정의하는 것입니다. 애플이 만든 컴퓨터 그래픽스 API인 메탈 프레임워크도 다르지 않습니다. 이 특별한 좌표계에서는 하나의 점이 4 개의 좌표를 가지고 있습니다. x
, y
, z
, 그리고 w
입니다. 당장은 w
좌표를 1
로 고정해두면 됩니다.
이 w 좌표가 존재해야하는 이유, 이번 예제에서 그 값이 1
이어야 하는 이유는 지오메트리 프로젝션(=projective geometry)과 관련이 있습니다. 약간의 수학(🤗)을 곁들여 다음 포스트에서 설명할 것입니다. 물체의 위치, 크기, 방향 같은 속성을 하나의 4x4 매트릭스로 정의하는 transform matrix에 대해 알고나면 이 방법이 얼마나 편리하고 유용한지 이해할 수 있게 됩니다.
셰이더에서 코드를 작성할 때의 편의를 위해, 우리 삼각형 지오메트리를 구성하는 버텍스 세 개의 좌표를 NDC(Normalized Device Coordinates) 상의 좌표로 대우할거예요. 이 NDC 공간에서 X 축은 왼쪽에서 오른쪽으로 -1
~ +1
, Y 축은 아래에서 위쪽으로 -1
~ +1
로 표현됩니다. 쉽게 떠올릴 수 있는 데카르트 좌표계와 방향이 유사하네요. NDC 공간에서 점을 표현하면 무엇이 편한지 역시 나중에 설명합니다.
두 번째 어레이 colors: [Float]
를 살펴볼까요? 하나의 색이 빨강, 초록, 파랑, 알파 컴포넌트로 표현되었네요. 이제는 익숙한 방법입니다.
이렇게 해서 우리의 삼각형 지오메트리를 정의하는 데이터가 메탈 버퍼MTLBuffer
에 담겼습니다. 이 버퍼가 앞으로 어떻게 사용되는지, 이 버퍼를 처리하게 될 Vertex Function
과 Fragment Function
이 무엇인지 계속해 알아보겠습니다.
Function과 Library
Functions(a.k.a 셰이더)
Shader[IPA: /ˈʃeɪdɚ/]는 셰이더라고 쓰고 읽어야 한다고 생각한다. 쉐이더[쇠이더, 수에이더, 소에이더]가 아니다. 셰이더[시애이더, 시에이더]이다. 쇤네야 뭐야. 셰이크도 마찬가지다. 쉐이크가 아니다. 쇠이크가 아닌 이유와 같다. 쇤네 쇠이크. 쉑쉑버거도 마찬가지다!!. 쇡쇡버거가 아니다. swag-swag 버거도 아니다. 셱셱버거인 것이다!!! 쇡쇡버거에서 쇠이크를 먹는게 아니다. 셱셱버거에서 셰이크를 시켜먹어야 한다!!!!!
메탈 프레임워크를 포함한 현대 그래픽 라이브러리는 다수는 "Programmable pipeline", "프로그래밍이 가능한 파이프라인" 을 제공합니다. 다시말해 GPU가 수행할 수많은 작업은 하나 이상의 작은 프로그램에 의해 정의된다는 뜻입니다. 버텍스나 픽셀(=프래그먼트)를 처리하는데 사용할 프로그램 말이죠. 이 프로그램은 보통 "셰이더"라고 불립니다. 엉뚱한 이름입니다. 왜냐하면 셰이더는 shader라는 단어의 의미 그대로 픽셀의 명암이나 색을 정하는 것보다 보다 훨씬 더 많은 일을 해낼 수 있기때문입니다. 실제로도 메탈 프레임워크가 제공하는 클래스나 프로토콜 중에 셰이더라는 단어를 포함한 것은 한 개도 없습니다. 그렇지만 통상적으로 버텍스 함수와 프래그먼트 함수를 셰이더라고 부릅니다.
메탈 셰이더 코드를 우리 예제프로젝트에서 사용하기위해 메탈 소스 파일을 만들어야 합니다. 이 소스 파일은 버텍스와 프래그먼트(=픽셀)를 처리할 셰이더 코드를 담고있습니다.
Shaders.metal
파일의 내용은 다음과 같습니다:
using namespace metal;
struct ColoredVertex
{
float4 position [[position]];
float4 color;
};
vertex ColoredVertex vertex_main(constant float4 *position [[buffer(0)]],
constant float4 *color [[buffer(1)]],
uint vid [[vertex_id]])
{
ColoredVertex vert;
vert.position = position[vid];
vert.color = color[vid];
return vert;
}
fragment float4 fragment_main(ColoredVertex vert [[stage_in]])
{
return vert.color;
}
vertex_main
함수는 우리가 가진 버텍스가 하나 처리될 때마다 실행되고, fragment_main
함수는 픽셀 하나가 그려질 때 마다 실행될 것입니다. 각 함수의 앞에는 특별한 한정자(=qualifier)가 붙어있습니다. 당장 위 코드블록에 vertex
와 fragment
가 보이시나요? 이 한정자들이 각각의 함수가 어떤 역할을 하는지 컴파일러에 알리는 역할을 합니다. 컴퓨트 셰이더를 위한 kernel
이라는 함수 한정자도 있으니 알아두세요.
ColoredVertex
라는 이름을 가진 구조체를 하나 정의했습니다. 이 구조체는 버텍스 함수vertex_main
의 결과를 담는데 쓰일 것입니다. 버텍스 함수의 입력 파라미터는 이전에 우리가 생성한 버텍스 버퍼positionBuffer: MTLBuffer
의 구조와 맞물려있습니다.
position
파라미터에 [[buffer(0)]]
라는 이름을 가진 속성 한정자가 붙은 것을 보세요. 이 말은 즉 나중에 드로우 콜을 수행하기위해 필요한 데이터 중 포지션 데이터를 참조할 때, 앞으로 우리가 제공할 버퍼 중 0번 인덱스를 가진 버퍼를 참조하라는 뜻입니다. 같은 원리로 color
파라미터에 붙은 [[buffer(1)]]
한정자가 무슨 의미인지 이해할 수 있습니다.
vid
파라미터는 이전에 만든 메탈 버퍼positionBuffer
, colorBuffer
상의 인덱스를 가리킵니다. 어떤 인덱스냐 하면, 현재 버텍스 함수가 몇 번째 버텍스를 대상으로 실행중인가에 대한 정보를 제공하는 인덱스입니다. vertex_id
를 줄여 vid
인 것이죠. 우리는 삼각형을 그리기위해 세 개의 버텍스를 만들었으므로 vertex_main
함수는 세 번 실행될 것이고, 함수가 실행될 때마다 vid
는 0
부터 2
까지 바뀔 것입니다.
vertex_main
함수는 파라미터를 통해 입력받은 position
과 color
데이터를 ColoredVertex
구조체에 담아 반환합니다. 이 구조체는 그저 택배 상자일 뿐입니다. 결론적으로는 프래그먼트 함수를 위해 데이터를 포장하는 것이죠.
만약 우리가 3 차원 공간에서 렌더링을 진행하는 중이었다면 vertex_main
함수는 지금보다 복잡했을 겁니다. 3 차원 상의 점을 2 차원 상의 점으로 프로젝션 한다거나, 버텍스 라이팅을 한다거나. 아무튼 약간의 산수와 수식이 더 필요했겠죠. 그렇지만 이번 포스트에서는 2 차원 공간에서 무언가를 그리기때문에 vertex_main
함수의 바디는 보시는대로 네 줄이 전부입니다.
프래그먼트 함수인 fragment_main
은 ColoredVertex
구조체를 입력받습니다. 이 파라미터vert
에는 [[stage_in]]
이라는 속성 한정자가 붙어있습니다. 이 한정자는 약간 그런 뜻입니다, 드로우콜을 수행하는 도중에 바뀌지않고 계속 참조할 수 있는 값을 가지는 그런거 말고, 프래그먼트 마다, 다시말해 픽셀 하나를 처리할 때마다 받게 될 데이터임을 뜻하는 것입니다.
설명이 아직 안되는 것 같습니다. 더 설명해볼게요. 프래그먼트 함수가 받는 저 vert
라는 ColoredVertex
타입의 파라미터가, 버텍스 함수가 반환할 세 개의 ColoredVertex
인스턴스들과 1:1로 매치되지 않는다는겁니다. 메탈 버퍼에 버텍스를 세 개 입력했으니 버텍스 함수는 세 번 실행되고, ColoredVertex
인스턴스를 세 번 반환하겠죠? 그렇지만 우리의 프래그먼트 함수는 도화지로 사용할 프레임 버퍼의 픽셀 갯수만큼 실행될 수 있습니다. 가로 1280
픽셀, 세로 720
픽셀인 도화지를 준비했다면 많게는 1280 * 720
번 만큼 실행될 수도 있는겁니다. 921,600
. 구십 이만 천 육백번 실행된다고? 여전히 무슨말인지 모르겠습니다.
// Vertices marked with asterisks(*).
//
// +1
// *
// / \
// -1 / 0 \ +1
// / \
// *-------*
// -1
//
대충 이런 느낌입니다.
*
갯수만큼 버텍스 함수가 실행될 것이고(위), 프래그먼트(=픽셀) 함수는 몇 번인지는 모르지만 세 번 보다는 많이 실행되겠죠?(아래). 이해가 갈듯 말듯 합니다.
// Fragments marked with asterisks(*).
//
// +1
// *********
// ***/*\***
// -1**/*0*\**+1
// */*****\*
// *-------*
// ***-1****
//
실제로 우리의 삼각형 지오메트리를 그리기위해 버텍스 함수는 세 번 호출되겠지만, 프래그먼트 함수는 화면 크기 또는 프레임 버퍼의 가로/세로 크기에 따라서 수 천번 이상 호출될 수도 있습니다.
그럼 저 수 천번 호출될지도 모를 프래그먼트 함수가 수 많은 컬러드버텍스 인스턴스를 어디서 받아올지 궁금하지 않으신가요? 버텍스 함수는 세 번만 실행될 테니까요.
드러나지 않은 절차 중 하나에 실마리가 있습니다. 래스터라이제이션, Rasterization을 거치기 때문입니다. 버텍스 함수가 반환한 세 개의 ColoredVertex
인스턴스를 재료로 래스터라이제이션 작업을 수행하는거죠. 수 천개의 프래그먼트(=픽셀)을 그리기위해 프래그먼트 함수가 수 천번 실행될텐데, 한 번 실행될 때마다, 래스터라이제이션에 기반한 ColoredVertex
인스턴스를 생성하고, 프래그먼트 함수에 vert
파리미터로 제공하는 것입니다. 우리가 그릴 삼각형이 화면의 어느 부분을 차지하는지, 다시말해 삼각형을 그리기위해 어떤 픽셀 영역을 무슨 색으로 칠해야하는지 정하는 것입니다. 래스터라이제이션에 관한 설명이었습니다.
이 래스터라이제이션 스테이지는 버텍스 함수가 실행된 다음
/프래그먼트 함수가 실행되기 전
에 수행됩니다. 버텍스 함수가 반환한 각 버텍스의 값, 이 경우에는 위치 데이터와 색상 데이터가 인터폴레이션을 거쳐 버텍스 사이를 채우고, 이 채워진 값을 프래그먼트 셰이더에 전달하는 것입니다. 인터폴레이션이 뭔지 대충 설명하면, 두 값 사이를 걸으며, 얼마나 걸었을때 값이 어떻게 변하는가 살펴보는 것입니다. 0
과 10
사이를 중간쯤 걸었으면 값이 5
라는 거죠. 위치도 서서히. 색상도 서서히. 이 방법으로 ColoredVertex
를 만들어 채웠기때문에 세 꼭지점 사이 색상 역시 서서히 변할 것임을 알 수 있습니다.
Library
많은 경우 그래픽 라이브러리에 셰이더를 제공하기위해선 각각의 셰이더를 개별적으로 컴파일해 준비해야합니다. 그 다음 컴파일된 셰이더들을 재료로 링킹 작업을 수행하고 결국엔 프로그램을 만들어야하죠. 메탈 프레임워크는 라이브러리라는 이름으로 이 번거로운 작업에 대한 앱스트랙션을 제공합니다. 추상화라고는 하지만, 라이브러리란 결국 메탈 셰이더 언어로 작성된 함수들을 논리적인 개념으로 묶어놓은 것에 불과합니다.
셰이더 프로그램의 재료가 될 셰이더들을 우리는 이전 섹션에서 확인했습니다. Shaders.metal
파일에 버텍스 함수와 프래그먼트 함수를 입력한 것을 떠올려보세요. *.metal
파일은 나머지 프로젝트와 함께 컴파일되고, 컴파일된 셰이더는 앱 번들에 탑재됩니다. 그리고 런타임 상에서 메탈 프레임워크는 단순히 이름만으로 원하는 함수에 접근하게 해줍니다. 다음 코드 블럭에서 확인해볼까요?
func buildPipeline() {
...
let library: MTLLibrary? = device?.makeDefaultLibrary()
let vertexFunc: MTLFunction? = library?.makeFunction(name: "vertex_main")
let fragmentFunc: MTLFunction? = library?.makeFunction(name: "fragment_main")
...
}
이렇게 해서 컴파일된 버텍스 함수와 프래그먼트 함수에 대한 참조를 확보했습니다. 이제 렌더링 파이프라인에 이 함수들을 셰이더로 사용할 것임을 알려주기만 하면 됩니다. 나머지는 메탈 프레임워크가 알아서 합니다. 어떻게 알려주는지는 아래에서 설명하겠습니다. 위의 코드 블록은 buildPipeline
메서드의 일부입니다.
Render Pipeline
그래픽에 관련된 하드웨어는 일종의 스테이트 머신이라고 생각하면 편합니다. 개발자는 몇 가지 스테이트 또는 옵션을 지정하고 이 스테이트들이 나중에 수행할 드로우콜에 영향을 끼치는 식입니다. 정할 수 있는 옵션들에는 뭐 대충 이런 것들이 있을겁니다. 어느 버퍼에서 무슨 데이터를 가져와야하는지, 렌더링 결과물을 depth 버퍼에 쓸지 말지 같은 것들이겠죠. OpenGL을 포함한 많은 그래픽 라이브러리가 제공하는 API 중 대부분은 이런 스테이트를 설정하는데 쓰입니다.
메탈 프레임워크는 이 부분에서 약간 특별합니다. 일종의 하드웨어 스테이트 머신으로 생각할 수 있는 우리의 그래픽 카드에 대한 앱스트랙션으로 메탈 프레임워크는 다른 그래픽 라이브러리와는 조금 다른 방법을 택했습니다. 컨텍스트가 아닌 그래프를 이용하는 것이죠. 모라고하는지 하나도 못알아들으시겠지만 지금은 그냥 넘어가도 됩니다. OpenGL이 드로우콜을 수행하기위해 글로벌한 어떤 context 오브젝트를 중심으로 API를 이용하는 것과 다르게, 메탈 프레임워크는 스테이트를 정의하는 오브젝트들을 만들고 그것들을 조립해 드로우콜을 정의하는가보다 생각하시면 됩니다.
각종 오브젝트를 연결해 렌더링의 시작과 끝을 일종의 그래프로 표현하는겁니다. 자료구조에서 말하는 그래프와 문맥이 같습니다. 한쪽에선 버텍스 데이터를 받아서 다른 한 쪽에서는 래스터라이즈 된 이미지를 출력하는 오브젝트 그래프를 만드는 거예요. 렌더링을 위한 가상의 파이프라인을 만드는거죠.
이 그래프 시스템이 어떻게 작동하는지 모르셨겠지만 우리는 이미 확인한 바 있습니다. MetalView
의 메탈 레이어metalLayer: CAMetalLayer
에 렌더링에 사용할 메탈 디바이스device: MTLDevice
를 지정했던 일을 떠올려보세요. 우리도 모르게 렌더링 파이프라인 그래프 상의 두 노드 사이에 링크를 만들었던겁니다.
우리의 파이프라인 그래프 속에서 중요한 역할을 하는 메탈 오브젝트를 몇가지 더 알아볼 것입니다. 우리가 원하는 렌더 커맨드를 수행하기 위해서 GPU 스테이트를 정의하는데 사용할 오브젝트들이죠. Render Pipeline Descriptor
와 Render Pipeline State
입니다.
Render Pipeline Descriptors
설명자? 정의자? 그렇게 해석할 이유가 없습니다. 어차피 한국어로 쓰인 자료는 없습니다. descriptor, 디스크립터로 받아들이는게 좋다고 생각합니다.
MTLRenderPipelineDescriptor
란 하나의 파이프라인에 대한 설정을 담고있는 오브젝트입니다. 예를 하나 들까요? 이 오브젝트를 이용해 우리가 원하는 지오메트리를 그리기위한 드로우콜을 수행할 때 어떤 버텍스 셰이더와 프래그먼트 셰이더를 사용할 지 정할 수 있습니다. Descriptor
오브젝트를 생성하는 방법입니다:
func buildPipeline() {
...
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunc
pipelineDescriptor.fragmentFunction = fragmentFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = metalLayer.pixelFormat
...
}
colorAttachments
가 무엇인지는 나중에 작성할 포스트에서 다룰 것입니다. 당장은 0
번 인덱스를 가진 첫번째 colorAttachments
가 일반적으로 프레임 버퍼로 사용된다 정도만 알고 넘어가도 됩니다.
프레임 버퍼란 렌더링 결과를 저장하는 2 차원 MTLTexture
라고 이전에 설명했습니다. 이 프레임 버퍼가 결국 화면에 출력되는 것이죠. CAMetalDrawable
을 통해 접근할 수 있다는 사실 역시 기억나실거예요. metalLayer: CAMetalLayer
가 소유한 MTLTexture
를 프레임 버퍼로 사용할 것이기때문에, 첫번째 colorAttachments
와 메탈 레이어가 같은 픽셀 포맷을 사용하도록 만든 것입니다.
Render Pipeline State
Render Pipeline State
는 MTLRenderPipelineState
프로토콜을 준수하는 오브젝트입니다. 위에서 만든 파이프라인 디스크립터를 이용해 파이프라인 스테이트를 생성하고 pipeline
프로퍼티로 저장했습니다.
var pipeline: MTLRenderPipelineState?
func buildPipeline() {
...
pipeline = try device?.makeRenderPipelineState(descriptor: pipelineDescriptor)
...
}
파이프라인 스테이트는 입력받은 셰이더 함수를 대상으로 컴파일과 링킹을 수행해 셰이더 프로그램을 만들어 보관하는 오브젝트입니다. OOP에서의 캡슐화와 맥락이 같습니다. 여기서 말하는 셰이더 프로그램은 바로 위에서 파이프라인 디스크립터를 통해 참조한 버텍스 셰이더vertexFunc
와 프래그먼트 셰이더fragmentFunc
를 소스로 두고 빌드한 하나의 프로그램을 말합니다.
단순한 자료 몇 개를 저장하는 것이 아닌, 무려 컴파일된 프로그램을 보관하는 오브젝트인 만큼, 파이프라인 스테이트 오브젝트를 위한 적절한 대우가 필요해보입니다. 매 프레임을 렌더할 때 마다 초 당 60
번 이상 프로그램을 빌드하는 프로그램을 작성하는 것은 낭비(=costly)니까요. 특별한 이유가 없다면 처음 한 번 읽어서 메모리에 탑재시키면 충분합니다. 일반적으로 하나의 셰이더 프로그램*을 위해서, 하나의 *파이프라인 스테이트 오브젝트가 있으면 됩니다. 이것이 파이프라인 스테이트 오브젝트를 MetalView
에 프로퍼티로 만들어 저장한 이유입니다.
여기서 생성한 파이프라인 스테이트 오브젝트를 이용해 커맨드 버퍼를 설정할 것입니다. 우리의 삼각형 지오메트리를 처리하는데 이전에 프로그램으로 만들어 저장한 우리의 셰이더를 이용해야하니까요.
여기까지 buildPipeline()
메서드의 내용을 설명했습니다.
Encoding Render Commands
지난 포스트에서는 렌더 커맨드 인코더를 만들기만 했지 실제로 인코딩을 거칠 렌더 커맨드를 제공하지는 않았습니다. 초기화 색상 옵션clearColor
만 이용했으니까요. 이번 포스트에서는 삼각형 지오메트리를 그려야합니다. 삼각형 지오메트리를 그리기 위한 드로우콜을 구성하는 하나 이상의 렌더 커맨드를 작성해 렌더 커맨드 버퍼에 넣어 저장할 것입니다.
메탈 레이어 속 drawable
을 참조하고 렌더 패스 디스크립터를 작성하는 법을 복습해볼까요? 이번 예제 프로젝트 속 redraw()
메서드의 시작입니다.
let drawable: CAMetalDrawable = metalLayer?.nextDrawable()
let frameBufferTexture: MTLTexture = drawable.texture
let renderPass = MTLRenderPassDescriptor()
renderPass.colorAttachments[0].texture = frameBufferTexture
renderPass.colorAttachments[0].clearColor = MTLClearColor(
red: 0.5,
green: 0.5,
blue: 0.5,
alpha: 1.0
)
renderPass.colorAttachments[0].storeAction = MTLStoreAction.store
renderPass.colorAttachments[0].loadAction = MTLLoadAction.clear
지난 포스트와 같은 방법으로 renderPass
오브젝트를 생성했습니다. 하나 다른 점이 있다면 부담스러운 빨간색이 아닌, 점잖은 회색으로 초기화 색상을 지정한 것입니다.
이제 커맨드 인코더에게 하나 이상의 렌더 커맨드로 구성된 우리의 드로우콜을 제공할 차례입니다:
let commandBuffer: MTLCommandBuffer? = commandQueue?.makeCommandBuffer()
let commandEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPass)
commandEncoder?.setRenderPipelineState(pipeline!)
commandEncoder?.setVertexBuffer(positionBuffer, offset: 0, index: 0)
commandEncoder?.setVertexBuffer(colorBuffer, offset: 0, index: 1)
commandEncoder?.drawPrimitives(
type: MTLPrimitiveType.triangle,
vertexStart: 0,
vertexCount: 3,
instanceCount: 1
)
commandEncoder?.endEncoding()
이전에 만든 메탈 버퍼 positionBuffer: MTLBuffer
,colorBuffer: MTLBuffer
로부터 셰이터 프로그램 속 버텍스 함수에 전달할 파라미터로 사용할 데이터를 참조하기위해 setVertexBuffer(_:offset:index:)
메서드를 사용했습니다. 버텍스 함수의 position
파라미터에 [[buffer(0)]]
속성 한정자가 붙어있던 모습을 떠올려보세요. 우리의 *드로우콜**을 구성하는 *렌더 커맨드 중 하나인 첫번째 setVertexBuffer
메서드의 index
파라미터로 0
을 제공한 이유입니다. 같은 이유로 두번째 setVertexBuffer
메서드의 index
파라미터에 1
을 제공해야합니다. 버텍스 함수의 color
파라미터에 [[buffer(1)]]
속성 한정자가 붙어있었으니까요.
drawPrimitives(type:vertexStart:vertexCount:instanceCount:)
를 호출해 트라이앵글을 그리는 렌더 커맨드를 커맨드 인코더에 전달합니다. 여기서 말하는 트라이앵글은 어떤 관념적인 도형으로서의 삼각형이 아닙니다. 하나 이상의 트라이앵글이 모여 폴리곤, 또는 메쉬를 형성한다고 말할 때 그 단위 유닛으로서의 삼각형입니다.
아무튼 그래서 type
파라미터로 MTLPrimitiveType.triangle
을 전달했습니다. 점 세 개를 이용해 삼각형을 그리는 렌더 커맨드를 커맨드 인코더에 전달한 것입니다. vertexStart
파라미터에 0
을 제공한 이유는 우리 메탈 버퍼의 0
번 인덱스부터 버텍스 데이터가 시작되기 때문입니다. vertexCount
파라미터에 3
을 제공한 이유는 삼각형을 그리기위해 세 개의 버텍스를 준비했기때문이죠. instanceCount
파라미터의 존재 이유는 나중의 포스트에서 다룰 것입니다. 당장은 1
을 전달합니다.
이렇게 해서, 지난 포스트와 마찬가지로 drawable
을 화면에 출력할 준비를 마치고 커맨드 버퍼를 *커맨드 큐에 제출합니다. 커맨드 인코더에 의해 *low-level 명령으로 변환된 렌더 커맨드 묶음을 담은 커맨드 버퍼는 이제 커맨드 큐에 적재되었습니다. 우리가 작성한 파이프라인 그래프에 따라 GPU는 곧 우리가 의도한 드로우콜을 수행할 것입니다.
commandBuffer?.present(drawable)
commandBuffer?.commit()
여기까지 작성해서 우리의 redraw()
메서드를 완성합니다.
동기화 할땐 CADisplayLink
이제 redraw()
메서드는 준비되었습니다. 남은 일은 어떻게 이 redraw()
메서드를 반복해 호출할지 정하는 것입니다. 우리의 오랜 친구 NSTimer
를 사용할 수 있지만, 그보다 멋진 방법이 있습니다. Core Animation 프레임워크가 제공하는 CADisplayLink
를 이용하는 것입니다.
CADisplayLink
는 기기의 디스플레이 루프와 맞물려 동작하도록 특별히 고안된 타이머 오브젝트입니다. 디스플레이 타이밍에 관련해서 더 나은 정확도와 안정성을 제공하죠. 디스플레이 링크 이벤트가 발생할때마다 우리의 redraw()
메서드를 호출해 화면을 업데이트 할 것입니다.
MetalView
의 didMoveToSuperview
메서드를 오버라이드해 디스플레이 링크를 설정하겠습니다:
override func didMoveToSuperview() {
super.didMoveToSuperview()
if (superview != nil) {
displayLink = CADisplayLink.init(target: self, selector: #selector(displayLinkDidFire(displayLink:)))
displayLink?.add(to: RunLoop.main, forMode: RunLoop.Mode.common)
} else {
displayLink?.invalidate()
displayLink = nil
}
}
이렇게 해서 메인 런 루프*를 따라 *디스플레이 링크 이벤트가 발생하게 설정합니다. 우리의 MetalView
가 뷰 하이라키에 존재하지 않게 되는 경우엔 디스플레이 링크를 처분합니다.
일반적으로 1
초에 60
번. 디스플레이 링크 이벤트가 발생할때마다 타겟 메서드로 지정한 displayLinkDidFire
를 호출합니다. 결국 redraw()
를 호출하는겁니다.
@objc func displayLinkDidFire(displayLink: CADisplayLink) {
redraw()
}
Build and Run
이번 포스트에서 작성한 예제 프로젝트는 여기서 확인할 수 있습니다. 프로젝트를 빌드하고 실행해보세요. 오색찬란한 삼각형이 등장할 것입니다.
마치며
이번 포스트에서는 메탈 프레임워크가 어떻게 작동하는지에 대해 조금 더 자세히 알아보았습니다. 화면에 무언가를 실제로 그리는 작업도 했죠. 다음 포스트에서는 지금보다 더 깊이 들어가 수학🤗과 Metal Shading Language
에 대해 공부할 것입니다. 3 차원 세계를 다루어야하니까요. 다음 이시간에 만나요!
이 포스트는 Warren Moore 선생님의 Metal by Example 시리즈를 의역해 옮긴 것입니다.