아반떼오우너의 개발블로그 ㅋㅋ

iOS Metal을 이용한 렌더링 동작 구현 - [1] 본문

iOS

iOS Metal을 이용한 렌더링 동작 구현 - [1]

Avante 2021. 12. 9. 14:05

iOS에서 OpenGL을 사용하셨던 분들이라면 다 알고계시는 OpenGL ES의 Deprecated가 있었습니다.

현재까지는 OpenGL ES를 쓸수있는것같긴한데.. Apple이 Deprecated 시킨만큼 언제 없어질지 모르기때문에 되도록이면

Apple에서 제공하는 Metal이라는 Graphics API를 쓰는게 좋아보입니다.

제가 아는선에선 유니티나 언리얼같은 게임엔진에서는 옵션에서 변경이 가능한것으로 알고있는데

Metal을 직접 구현하여 렌더링 작업을 하시는분들은 기존 OpenGL 코드를 모드 Metal로 변경해야하는 문제 아닌 문제가 생겼습니다 ㅠ

그래서 제가 작업했던 내용들을 기록 및 공유차 한번 포스팅 해봅니당.

 

목표! - Metal을 이용하여 내가 원하는 이미지 하나를 렌더링해보자.

 

[1] MTLDevice

MTLDevice는 Metal 렌더링을 구현하기 위해 가장 기본이 되는 인스턴스입니다.

Metal도 결국 OpenGL과 같이 GPU를 제어하는 API이기 때문에 GPU와의 연결이 되는 인터페이스 개념이 필요한데 이를 MTLDevice를 통해 정의가 가능합니다.

추후에 설명하겠지만 Rendering을 하기위해 필요한 많은 객체들을 모두 이 MTLDevice을 통해 생성하게 됩니다.

많은 예제를 보면 아시겠지만 해당 객체는 MetalLayer생성시 한번만 생성하고 앱 종료시까지 영구적으로 사용하게 됩니다.

 

먼저 아래와같이 MTLDevice 객체를 생성해봅니다.

import UIKit
import Metal

class ViewController: UIViewController {
  private var device: MTLDevice!

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

 

[2] CAMetalLayer or MTKView

Metal을 이용해 렌더링하기위해선 이를 위한 일종의 판(?)이 필요합니다.

쉽게 표현하자면 Metal이라는 붓을 사용하기위한 전용 그림판으로 생각하시면 될듯합니다.

크게 2가지로 구현이 가능한데 하나는 CAMetalLayer를 이용하는 방법 또 하나는 MTKView를 이용하는 방법입니다.

MTKView를 이용하는 방법은 필수적으로 구현해야하는 아래와 같이 Delegate가 있는데 이안에 Draw메서드가 들어있습니다.

해당 draw메서드는 시스템이 호출해주는것으로 GPU가 그릴준비가 되면 이 메서드를 호출하여 내부에 구현된 Metal 로직을 동작시켜줍니다.

extension ViewController: MTKViewDelegate {
  func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
  }
  
  func draw(in view: MTKView) {
  }
}

그러나 시스템이 호출 타이밍을 잡아주는것에 대해 로직 구현이 조금 애매할수있을것같았고

예전 Apple문서에서 좀 더 커스터마이징을 하기 위해선 MTKView보단 후술할 CAMetalLayer를 사용하는것을 더 권장한다고 적혀있던것을 보아, 이 포스팅에선 MTKView보단 CAMetalLayer를 이용해 보도록 하겠습니다.

 

우선 CAMetalLayer를 생성하여 View에 붙여보겠습니다.

주의할점은 CAMetalLayer의 경우 View가 아닌 Layer이기 때문에 Snapkit등과 같은 AutoLayout의 적용이 어렵습니다.

저는 해당코드를 ViewDidLoaded에 작성하였습니다.

self.metalLayer = CAMetalLayer() // 객체생성
self.metalLayer.device = self.device // 위에서 생성한 Device 등록
self.metalLayer.pixelFormat = .bgra8Unorm // 픽셀포맷을 BGRA으로 설정
self.metalLayer.frame = CGRect.init(x: 45.0, y: 100.0, width: 300, height: 600) // 레이어의 사이즈 설정
self.view.layer.addSublayer(self.metalLayer) // CAMetalLayer를 뷰에 붙임.

어찌되었든 위와 같이 CAMetalLayer를 생성하고 부모뷰에 붙여봤습니다.

 

[3] Rendering을 위한 Vertex 데이터 준비

여기까지 우리는 Metal을 사용하여 그림을 그리기 위한 판 준비를 마쳤습니다.

이제 해야할 작업은 렌더링을 하기위해 필요한 정보들을 준비해보도록 하겠습니다.

필요한 정보는 아래와 같습니다.

- Vertex정보 : 렌더링을 할 위치의 정점 정보.

- Coordinate정보 : 정점들에대한 좌표계.

- Index정보 : 정점정보들의 순서.

단순히 그림만 그릴 예정이라면 Vertex정보만으로 그릴수있으나, 추후 실제 이미지를 렌더링하고 이것에 대해 가공(ZoomInout 등)을 가할것이라면

위와같이 3가지의 정보로 분리해서 렌더링하는것이 바람직합니다.

 

일반적으로 모든 텍스쳐(쉽게 표현하면 그리고자 하는 그림)은 수많은 삼각형 폴리곤들의 집합입니다.

우리가 게임을 할때 캐릭터, 건물 등은 모두 삼각형 폴리곤으로 그려져있다는 뜻이죠.

따라서 우리도 렌더링시 삼각형의 집합으로 그려보도록 하겠습니다.

우리는 2차원평면에 그림을 그릴예정이므로 삼각형 두개만 있으면 됩니다.

 

그렇다면 하나씩 정의해보도록 하겠습니다.

저는 각 정보들을 아래와 같이 정의하였습니다.

let vertexInfo: [Float] = [
   1.0, -1.0,
   1.0, 1.0,
   -1.0, -1.0,
   -1.0, 1.0
 ] 
 let coordinateInfo: [Float] = [
   0.0, 0.0,
   1.0, 0.0,
   0.0, 1.0,
   1.0, 1.0
 ]
 let indexInfo: [ushort] = [
   0, 2, 1, 2, 1, 3
 ]

아래의 그림과 같은 좌표평면부터 보면, Vertex정보들은 각 꼭지점에 해당하는 좌표들로 볼수있습니다.

vertexInfo값의 첫 2개의 값 [1.0, -1.0]은 2사분면의 점, 두번째 2개의 값 [1.0, 1.0]은 1사분면의 점, 이런식으로 말이죠.

두번째 coordinateInfo의 값도 비슷하게 보시면 됩니다. coordinate값은 좌표계라는 뜻으로 우리는 렌더링을 해당표면의 원본스케일로 그릴것이기 때문에 coordinate정보를 좌표 평면의 전체 사이즈로 잡았습니다.

만약 Zoom-Out 된다면 coordinate정보는 1.0보다 커질것이고, Zoom-In이 된다면 coordinate정보는 1.0보다 작아질것입니다.

어쨌든 coordinate정보도 위와 동일한 좌표평면으로 보시면 되지만 차이점은 0.0~1.0사이의 값을 가진다는 점입니다.

수학적으로 표현해보면 1사분면에서만 나타나는 4개의 각 꼭지점으로 생각하시면 될것같습니다.

 

마지막으로 indexInfo값의 이해가 중요합니다.

배열의 순서가 0,2,1,2,1,3 으로 되어있는데 이순서는 의미를 가지는 순서입니다.

아까 우리는 삼각형 2개로 2차원 평면을 그릴수있다고 정의했는데요, 삼각형은 점 3개로 이루어지고 2개의 삼각형을 그리기위해선 점 6개가 필요하다는 사실을 우리는 알수있습니다. 

따라서 위 순서는 삼각형을 이루기위한 index라고 볼수있는데 배열의 첫 3개의 값 0,2,1의 뜻은 Vertex정보배열의 0번째,2번째,1번째의 값으로 첫 삼각형을 만들겠다 라는 뜻이됩니다.

즉, [1.0, -1.0], [-1.0, -1.0], [1.0, 1.0] 으로 하나의 삼각형을 그리는것이고 이를 그림으로 다시 표현하면 아래와 같습니다.

그리고 index배열의 나머지 뒤 3개의 값도 동일하게 [-1.0, -1.0], [1.0, 1.0], [-1.0, 1.0] 으로 삼각형을 그리는 것이고

나머지 상단에 위치하는 삼각형 1개가 그려지는것을 확인할수있고, 이를통해 2차원 사각형 평면 하나가 완성되었습니다.

우리는 이를통해 2차원 Vertex평면을 정의했다고 볼수있습니다.

우리가 앞으로 그리는 모든 렌더링 작업은 이 Vertex평면 위에 그려질것입니다.

 

이제 코드로 작성하여 GPU가 알아들을수있는 Metal Buffer로 전환해보겠습니다.

추후에 서술할 Shader에 위 정보들을 전달하게 되는데 이는 모두 MTLBuffer라는 형태로 전달됩니다.

그렇다면 위의 정보를 토대로 MTLBuffer를 만들어보겠습니다.

private var vertexBuffer: MTLBuffer!
private var coordinateBuffer: MTLBuffer!
private var indexBuffer: MTLBuffer!

let vertexSize = vertexInfo.count * MemoryLayout<Float>.size
let coordinateSize = coordinateInfo.count * MemoryLayout<Float>.size
let indexSize = indexInfo.count * MemoryLayout<ushort>.size

self.vertexBuffer = self.device.makeBuffer(bytes: vertexInfo, length: vertexSize, options: [])!
self.coordinateBuffer = self.device.makeBuffer(bytes: coordinateInfo, length: coordinateSize, options: [])!
self.indexBuffer = self.device.makeBuffer(bytes: indexInfo, length: indexSize, options: [])!

 

vertexBuffer, coordinateBuffer, indexBuffer를 생성하였고

이 값들이 나중에 Shader로 넘어가서 해당 Vertex영역에 렌더링을 진행하게 될것입니다.

 

 

다음 편에서는 렌더링을 하기위한 Shader정의 및 GPU에 렌더작업을 전달하는 방법에 대해 포스팅하겠습니다.