Metal을 시작해보자

oto·2023년 4월 8일
0

예전에 수박 겉핥기식으로 사진 필터앱을 만들었던 적이 있다. 프로젝트 기간이 2주여서 시간이 너무 부족했다. 그리고 Metal, CoreImage, Vision과 같은 프레임워크를 사용했었지만, 말그대로 겉핥기만 했던터라 좀더 공부해보고 싶은 마음이었다. 그래서 오늘은 Metal에 대한 자료를 정리해봤다.

고레벨 수준의 프레임워크들이 많아서 Metal 자체를 학습하는게 비효율적이라고 생각이 들겠지만, 해당 글에서는 Metal을 배워야하는 두 가지 좋은 이유를 들었다.
1. 하드웨어를 최대한 활용한다. 메탈은 매우 낮은 수준에 있기때문에 하드웨어를 최대한 활용하고 작동 방식을 완전히 제어할 수 있다.
2. 훌륭한 학습 경험이다. 메탈을 배우는 것은 3D 그래픽, 자신만의 그래픽 엔진 작성, 고급 게임 프레임워크 작동 방식에 대해 많은 것을 배울 수 있다.

그래서 Metal의 한 튜토리얼 내용을 요약 및 정리해봤다.


Metal vs. SpriteKit, SceneKit or Unity

시작하기 전에, Metal이 SpriteKit, SceneKit 이나 Unity 같은 고레벨 프레임워크와 무엇이 다른지 비교를 해보자.

먼저, 고레벨 프레임워크의 경우 Metal이나 OpenGL ES와 같은 저수준 3D 그래픽 API 위에 고안되어 일반적으로 필요한 많은 상용 코드들을 제공한다. 게임을 만든다던지, 이미지처리를 할 때, 더 쉽고 빠르게 만들 수 있도록 도와준다.

반대로, Metal은 OpenGL ES와 유사한 저수준 그래픽 API이지만 오버헤드가 낮아 성능이 향상된다. Sprite나 3D 모델을 화면에 렌더링하는 거의 모든 작업을 수행하려면 이를 수행하기 위한 모든 코드를 작성해야하지만, 완전한 권한과 제어권을 갖는다.

Metal vs. OpenGL ES

OpenGL ES는 크로스 플랫폼에 맞게 설계되었다. 즉, C++ OpenGL ES 코드를 작성했다면 대부분의 경우 약간의 수정만 하면 Android와 같은 다른 플랫폼에서 실행할 수 있다.

애플은 OpenGL ES의 크로스 플랫폼 지원은 좋았지만, Apple이 제품을 설계하는 방식에 있어 근본적인 것을 놓치고 있었다. 애플은 운영 체제, 하드웨어, 소프트웨어를 완전한 패키지로 통합하는 기능을 강조하는데, 이러한 기능이 OpenGL ES에서는 부족한 것으로 판단한 것이다.

그래서 애플은 깨끗한 룸(clean-room) 접근 방식을 택하여 목표인 매우 낮은 오버헤드 및 성능을 지원하면서 최신 기능을 지원하기 위해 애플 하드웨어를 위해 그래픽 API를 특별히 디자인하는 것이 어떨지 살펴보았다.

그 결과는 Metal이다. Metal은 OpenGL ES와 비교하여 앱에 대한 최대 10배의 드로우 콜을 제공할 수 있다. 이로 인해 놀라운 효과가 나타날 수 있다. 이를 예로 들면 2014 WWDC 기조 연설에서 소개된 Zen Garden 예제가 있습니다.

이제 Metal 코드를 살펴보자!

Getting started

Xcode의 iOS 게임 템플릿은 Metal 옵션과 함께 제공되지만 여기서는 선택하지 않습니다. 거의 처음부터 Metal 앱을 구성할 것이기 때문에 프로세스의 모든 단계를 이해할 수 있습니다.

여기서는 거의 처음부터 Metal 프로세스의 모든 단계를 살펴보자.

이 튜토리얼의 상단 또는 하단에 있는 자료 다운로드 버튼을 사용하여 이 튜토리얼에 필요한 파일을 다운로드하십시오. 파일이 있으면 HelloMetal_starter 폴더에서 HelloMetal.xcodeproj를 엽니다. 단일 ViewController가 있는 빈 프로젝트가 표시됩니다.

메탈 렌더링을 시작하기위해 만들어야할 7가지 요구되는 단계가 있다

  1. MTLDevice
  2. CAMetalLayer
  3. Vertex Buffer
  4. Vertex Shader
  5. Fragment Shader
  6. Render Pipeline
  7. Command Queue
    한번에 하나씩 살펴보자

1) Creating an MTLDevice

먼저 MTLDevice에 대한 참조를 가져와야 한다.

MTLDevice를 GPU에 대한 직접 연결로 생각해라. 이 MTLDevice를 사용하여 필요한 다른 모든 Metal 개체(예: command queue, buffer 및 texture)를 생성한다.

ViewController.swift를 열고, 파일 맨 위에 이 프레임워크 임포트를 추가한다.

Import Metal

이건 파일 내부에서 MTLDevice와 같은 Metal 클래스들을 사용할수 있는 Metal framework를 임포트한다.

다음은, ViewController에 이 property를 추가한다.

var device: MTLDevice!

이 property를 initializer보다 viewDidLoad()에서 초기화 할 것이라 optional로 한다. 사용하기 전에 확실히 초기화할 것이므로 편의를 위해 unwrapped optional 을 표시한다.

아래와 같이 `viewDidLoad()에 추가하고 device property를 초기화한다.

override func viewDidLoad() { super.viewDidLoad() device = MTLCreateSystemDefaultDevice() }

MTLCreateSystemDefaultDevice는 코드에서 사용해야 하는 기본 MTLDevice에 대한 참조를 반환한다.

2) Creating a CAMetalLayer

iOS에서, 우리가 스크린에서 보는 모든 것은 CALayer에 의해 지원된다. gradient layer, shape layer, replicator layer 등등 다른 효과를 위한 CALayer의 subclass들이 있다.

metal로 스크린에서 무언가를 그리고 싶다면, CAMetalLayer라고 불리는 특별한 CALayer의 서브클래스가 필요하다. 너의 view controller에 이것 중 하나를 추가할 것이다.

먼저 클래스에 이 새로운 property를 추가한다.

var metalLayer: CAMetalLayer!

그러면 너의 새 레이어에 편리한 참조가 저장될 것이다.

그 다음, viewDidLoad()의 끝에 이 코드를 추가한다.

metalLayer = CAMetalLayer()          // 1
metalLayer.device = device           // 2
metalLayer.pixelFormat = .bgra8Unorm // 3
metalLayer.framebufferOnly = true    // 4
metalLayer.frame = view.layer.frame  // 5
view.layer.addSublayer(metalLayer)   // 6
  1. 새로운 CAMetalLayer를 만든다.
  2. 레이어가 사용해야 하는 MTLDevice를 지정해야 한다. 이전에 얻은 device에 이것을 설정하기만 하면 된다.
  3. pixelFormat을 bgra8Unorm으로 설정한다. 이 포맷은 "0과 1 사이의 정규화된 값으로 blue, green, red 및 alpha의 순서의 8바이트"라고 말하는 방법이다. 이 pixelFormat은 CAMetalLayer에 사용할 수 있는 두 가지 형식 중 하나이므로 일반적으로 그대로 둔다.
  4. Apple은 이 레이어에 대해 생성된 텍스처에서 샘플링해야 하거나 레이어 드로어블 텍스처에서 컴퓨팅 커널을 활성화해야 하는 경우가 아니면 성능상의 이유로 framebufferOnly를 true로 설정할 것을 권장한다. 대부분의 경우 이렇게 할 필요가 없다.
  5. layer의 프레임을 view의 프레임과 맞춰야한다.
  6. 마지막으로 레이어를 뷰의 기본 레이어의 하위 레이어로 추가한다.

3) Creating a Vertex Buffer

metal에서 모든 것은 삼각형이다. 여기서는 하나의 삼각형을 그리지만, 어떤 복잡한 3D 모양도 일련의 삼각형들로 분해될 수 있다.

Metal에서 기본 좌표계는 정규화된 좌표계이다. 즉, 기본적으로 (0, 0, 0.5)를 중심으로 하는 2x2x1 큐브를 보고 있다.

z=0 평면에서, (-1, -1, 0)은 좌측 하단, (0, 0, 0)은 중앙, (1, 1, 0)은 오른쪽 상단이다. 이 튜토리얼에서는, 아래 세 포인트를 따라 삼각형을 그리기를 원한다.

너는 이걸위한 버퍼를 만들 것이다. 너의 클래스에 상수 property를 추가한다.

let vertexData: [Float] = [
	0.0,  1.0, 0,0,
   -1.0, -1.0, 0.0,
	1.0, -1.0, 0.0
]

이것은 CPU에서 float의 배열을 만든다. 너는 이 데이터를 MTLBuffer라는 것으로 이동하여 GPU로 보내야한다.

이것을 위해 새로운 property를 추가한다.

var vertextBuffer: MTLBuffer!

그리고 이 코드를 viewDidLoad()의 끝에 추가한다.

let dataSize = vertexData.count * MemoryLayout.size(ofValue: vertexData[0] // 1
vertextBuffer = device.makebuffer(bytes: vertexData, length: dataSize, options: []) // 2
  1. vertex data의 크기를 바이트 단위로 가져와야 한다. 첫 번째 요소의 크기에 배열의 요소 수를 곱하면 된다.
  2. MTLDevice에서 makeBuffer(bytes:length:options:)를 호출하여 GPU에서 새 버퍼를 만들고 CPU에서 데이터를 전달한다. 기본 구성을 위해 빈 배열을 전달한다.

4) Creating a Vertex Shader

3)에서 만들었던 정점들은 vertex shader라 불리는 것을 작성할 작은 프로그램의 입력이 될 것이다.

vertex shader는 간단하게 Metal Shading Language라고 불리는 C++과 유사한 언어로 쓰여진 GPU에서 실행되는 작은 프로그램이다.

vertex shader는 vertex마다 한번씩 호출되고, 정점의 위치와 같은 정보와 색상 또는 텍스처 좌표와 같은 기타 정보를 가져와 잠재적으로 수정된 위치 및 기타 데이터를 반환하는 것이다.

간단하게 유지하기 위해 단순 버텍스 셰이더는 전달된 위치와 동일한 위치를 반환한다.

vertex shaders를 이해하는 가장 쉬운 방법은 직접 보는 것이다. File -> New -> File,

참고: Metal에서는 단일 Metal 파일에 여러 셰이더를 포함할 수 있습니다. 원하는 경우 Metal이 프로젝트에 포함된 Metal 파일에서 셰이더를 로드하므로 셰이더를 여러 Metal 파일로 분할할 수도 있습니다.

Add the following code to the bottom of Shaders.metal

vertex float4 basic_vertex(                           // 1
  const device packed_float3* vertex_array [[ buffer(0) ]], // 2
  unsigned int vid [[ vertex_id ]]) {                 // 3
  return float4(vertex_array[vid], 1.0);              // 4
}
  1. 모든 vertex shader는 키워드 vertex로 시작한다. 함수는 vertex의 최종 위치를 (최소한) 반환해야 합니다. 여기서는 float4(float 4개로 구성된 벡터)를 표시하여 이 작업을 수행한다. 그런 다음 정점 셰이더의 이름을 지정한다. 나중에 이 이름을 사용하여 셰이더를 조회한다.

  2. 첫 번째 매개변수는 packed_float3(3개의 부동 소수점으로 구성된 압축된 벡터)의 배열에 대한 포인터입니다. 즉, 각 정점의 위치입니다.
    [[ ... ]] 구문을 사용하여 리소스 위치, 셰이더 입력 및 기본 제공 변수와 같은 추가 정보를 지정하는 데 사용할 수 있는 특성을 선언합니다. 여기에서 이 매개변수를 [[ buffer(0) ]]로 표시하여 Metal 코드에서 버텍스 셰이더로 보내는 데이터의 첫 번째 버퍼가 이 매개변수를 채울 것임을 나타냅니다.

  3. 버텍스 셰이더는 vertex_id 속성과 함께 특수 매개변수를 사용합니다. 즉, Metal이 버텍스 배열 내부의 이 특정 버텍스의 인덱스로 채울 것입니다.

  4. 여기에서 vertex id를 기반으로 vertex array 내부의 위치를 찾아 반환한다. 또한 벡터를 float4로 변환한다. 여기서 최종 값은 1.0이다. 간단히 말해 3D 수학에 필요하다.

5)

vertex shader가 완료된 후, Metal은 스크린에서 각각의 파편(pixel로 생각되는)을 위한 또다른 shader를 호출한다: fragment shader.

fragment shader는 vertex shader의 출력 값을 보간하여 입력 값을 가져온다. 예를 들어 삼각형의 아래쪽 두 vertex사이의 조각을 고려해보자.

이 fragment의 입력 값은 두 정점의 출력 값을 50/50으로 혼합한 것이다.

fragment shader의 역할은 각각의 gragment를 위한 최종 컬러를 반환하는 것이다. 간단하게, 우린 각 fragment를 하얗게 만들것이다.

Shader.metal의 아래에 코드를 추가하자.

fragment half basic_fragment() { // 1
	return half4(1.0);			 // 2
}
  1. 모든 fragment shader는 fragment 키워드로 시작된다. 그 함수는 fragment의 최종 color를 반환한다(최소한). 여기서는 half4(4개 구성 요소 색상 값 RGBA)를 표시하여 그렇게 한다. 더 적은 GPU 메모리에 쓰기 때문에 half4가 float4보다 더 메모리 효율적입니다.

  2. 여기 흰색을 위한 (1, 1, 1, 1)을 반환한다.

6) Creating a Render Pipeline

이제 vertex 및 fragment shader를 만들었으므로, 다른 구성 데이터와 함께 render pipeline이라는 특수 오브젝트에 결합해야 한다.

Metal의 멋진 점 중 하나는 shader가 사전 컴파일되고 render pipeline 구성이 처음 설정한 후 컴파일된다는 것이다. 이것은 모든 것을 매우 효율적으로 만든다.

먼저, ViewConroller.swift에 새로운 property를 추가한다.

var pipelineState: MTLRenderPipelineState!

이렇게 하면 생성하려는 컴파일된 렌더 파이프라인을 추적할 수 있습니다.

다음은, viewDidLoad()의 끝에 코드를 추가한다.

// 1
let defaultLibrary = device.makeDefaultLibrary()!
let fragmentProgram = defaultLibrary.makeFunction(name: "basic_fragment")
let vertexProgram = defaultLibrary.makeFunction(name: "basic_vertex")
    
// 2
let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
pipelineStateDescriptor.vertexFunction = vertexProgram
pipelineStateDescriptor.fragmentFunction = fragmentProgram
pipelineStateDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
    
// 3
pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
  1. device.makeDefaultLibrary()!를 호출하여 얻은 MTLLibrary 개체를 통해 프로젝트에 포함된 미리 컴파일된 셰이더에 액세스할 수 있다. 그런 다음 이름별로 각 셰이더를 조회할 수 있다.

  2. 여기에서 렌더 파이프라인 구성을 설정한다. 여기에는 사용하려는 셰이더와 색상 첨부를 위한 픽셀 형식(예: CAMetalLayer 자체인 렌더링 대상 출력 버퍼)이 포함된다.

  3. 마지막으로 파이프라인 구성을 여기에서 사용하기에 효율적인 파이프라인 상태로 컴파일한다.

7) Creating a Command Queue

수행해야 하는 마지막 일회성 설정 단계는 MTLCommandQueue를 생성하는 것이다.

이것을 한 번에 하나씩 실행하도록 GPU에 지시하는 순서가 지정된 명령 목록으로 생각하자.

명령 대기열을 만들려면 새 속성을 추가하기만 하면 된다.

var commandQueue: MTLCommandQueue!

그리고나서, viewDidLoad()의 끝에 추가한다.

commandQueue = device.makeCommandQueue()

이렇게 되면 일회성 설정 코드는 끝이다.

Rendering the Triangle

이제 삼각형을 렌더링하기 위해 각 프레임을 실행하는 코드로 이동할 시간이다!

렌더링은 5단계로 이루어진다.

  1. Display Link 생성

  2. Render Pass Descriptor 생성

  3. Command Buffer 생성

  4. Render Command Encoder 생성

  5. Command Buffer Commit

참고: 이론적으로 이 앱은 실제로 프레임당 한 번씩 렌더링할 필요가 없습니다. 삼각형이 그려진 후에는 움직이지 않기 때문입니다. 그러나 대부분의 앱에는 움직이는 부분이 있으므로 이 방법을 사용하여 프로세스를 학습할 수 있습니다. 이것은 또한 향후 자습서를 위한 좋은 출발점을 제공합니다.

device 화면을 새로 고칠 때마다 화면을 다시 그리는 방법이 필요하다.

CADisplayLink는 화면 재생 빈도에 동기화된 타이머이다. 이를 사용하려면 클래스에 새 property를 추가한다.

var timer: CADisplayLink!

viewDidLoad()의 끝에 초기화한다.

timer = CADisplayLink(target: self, selector: #selector(gameloop))
timer.add(to: RunLoop.main, forMode: .default)

이렇게 하면 화면이 새로 고쳐질 때마다 gameloop()라는 메서드를 호출하도록 코드가 설정된다.

마지막으로, 다음 sub method를 클래스에 추가한다.

func render() {
	// TODO
}

@objc func gameloop() {
	autoreleasepool {
    	self.render()
    }
}

여기, gameloop()는 간단히 프레임마다 render()를 호출한다. 지금은 구현부가 비어있다. 이제 여기를 구체화 해보자.

2) Creating a Render Pass Descriptor

다음 단계는 MTLRenderPassDescriptor를 생성하는 것이다. 이것은 렌더링 대상이 되는 텍스처, 클리어 색상 및 기타 구성을 구성하는 객체이다.

render()안에 // TODO의 위치에 이 라인들을 추가한다.

guard let drawable = metalLayer?.nextDrawable() else { return }
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = drawable.texture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(
  red: 0.0, 
  green: 104.0/255.0, 
  blue: 55.0/255.0, 
  alpha: 1.0)

먼저, 이전에 생성한 Metal 레이어에서 nextDrawable()을 호출하여, 화면에 어떤 것을 그리기 위해 그려야 할 텍스처를 반환한다.

다음으로, 렌더 패스 디스크립터를 구성하여 해당 텍스처를 사용하도록 설정한다. 로드 액션을 "Clear"로 설정하여 "그리기 전에 텍스처를 클리어 색상으로 설정하라"는 의미이며, 사이트에서 사용되는 녹색 색상으로 클리어 색상을 설정한다.

3) Creating a Command Buffer

다음 단계는 커맨드 버퍼를 생성하는 것이다. 이것은 현재 프레임에 실행하려는 렌더링 명령의 목록으로 생각할 수 있다. 멋진 점은 커맨드 버퍼를 커밋하기 전까지 실제로 아무 일도 일어나지 않는다는 것이다. 이를 통해 사건이 언제 발생하는지에 대한 세부적인 제어가 가능하다.

커맨드 버퍼를 생성하는 것은 쉽다. 단순히 render() 함수의 끝에 다음 라인을 추가하면 된다.

let commandBuffer = commandQueue.makeCommandBuffer()!

command buffer는 하나 또는 이상의 render command를 포함한다.

4) Creating a Render Command Encoder

render command를 만들려면 render command encoder라는 helper object를 사용한다. 이를 시도하려면 render() 끝에 다음 행을 추가한다.

let renderEncoder = commandBuffer
  .makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder
  .drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1)
renderEncoder.endEncoding()

여기서는 이전에 생성한 파이프라인과 버텍스 버퍼를 지정하여 커맨드 인코더를 생성합니다.

가장 중요한 부분은 drawPrimitives(type:vertexStart:vertexCount:instanceCount:)를 호출하는 부분입니다. 여기서 GPU에게 버텍스 버퍼를 기반으로 삼각형 집합을 그리도록 지시합니다. 간단하게 하기 위해 한 개의 삼각형만 그립니다. 메소드 인수는 각 삼각형이 세 개의 버텍스로 이루어져 있으며, 버텍스 버퍼 내에서 인덱스 0에서 시작하여 총 하나의 삼각형이 있다는 것을 Metal에게 알려줍니다.

작업이 끝나면, 간단히 endEncoding()을 호출하면 됩니다.

5) Committing Your Command Buffer

마지막 단계는 커맨드 버퍼를 커밋하는 것이다. render() 함수의 끝에 다음 라인을 추가한다.

commandBuffer.present(drawable)
commandBuffer.commit()

첫 번째 라인은 그리기가 완료되면 GPU가 새로운 텍스처를 즉시 표시하도록하기 위해 필요하다. 그런 다음 트랜잭션을 커밋하여 작업을 GPU에게 보낸다.

앱을 빌드하고 실행하면 삼각형의 모습을 확인할 수 있다.

결론

간단한 삼각형 하나 그리는데도 꽤나 복잡하고 어려운 과정들이었다.

왜 고레벨 프레임워크들을 사용해야하는지 알 수 있는 시간이었다.

그래도 그래픽에 대해 좀더 잘 이해할 수 있는 시간이었고, 조금씩 조금씩 metal을 공부해야할 것 같다고 느꼈다.

원본출처:
https://www.kodeco.com/7475-metal-tutorial-getting-started#toc-anchor-002

Metal 관련해서 추가적으로 더 공부할만한 링크 아카이브

profile
iOS Developer

0개의 댓글