Metal 둘러보기
셰이더(shader)는 무엇인가?
다음은 애플 디벨로퍼 홈페이지에 있는 Metal 의 소개입니다. 일단 Metal 을 사용하면 GPU 프로그래밍을 가능하게 해준다고는 들었는데, 셰이딩 언어는 또 뭘까요? 사실 GPU(Graphics Processing Unit)는 그
people-analysis.tistory.com
Metal에 대해 알아보기전에 그래픽 렌더링과 GPU의 발전 역사를 먼저 살펴보시면 다음에 나올 용어들에 익숙해져 내용 이해에 도움이 됩니다.
소개
애플의 Metal 프레임워크는 iOS, macOS, tvOS 기기에서 GPU를 직접 제어할 수 있는 저수준 그래픽 API입니다. 2014년에 처음 소개된 이후, Metal은 애플 플랫폼에서 OpenGL을 대체하며 최신 그래픽 및 컴퓨팅 작업을 위한 핵심 기술로 자리잡았습니다.
Metal이 특별한 이유는 다음과 같습니다:
- 낮은 오버헤드: CPU 사용량을 최소화하여 그래픽 처리 성능을 극대화합니다.
- 통합된 API: 그래픽 렌더링과 일반 컴퓨팅(GPGPU) 작업을 하나의 API로 통합했습니다.
- 하드웨어 최적화: 애플의 A 시리즈와 M 시리즈 칩에 최적화되어 있습니다.
- 현대적 설계: 멀티스레딩과 병렬 처리를 기본적으로 지원합니다.
기본 개념
1. 디바이스와 명령 큐
Metal의 모든 작업은 물리적 GPU를 나타내는 MTLDevice 객체를 통해 시작됩니다. 이 디바이스는 명령 큐(MTLCommandQueue)를 생성하며, 이 큐를 통해 GPU에 실행할 명령을 전달합니다.
// GPU 디바이스 가져오기
let device = MTLCreateSystemDefaultDevice()!
// 명령 큐 생성
let commandQueue = device.makeCommandQueue()!
명령 큐는 FIFO(First In, First Out) 방식으로 작동하며, 명령 버퍼(MTLCommandBuffer)를 순서대로 처리합니다. 각 명령 버퍼는 GPU가 실행할 작업 목록을 담고 있습니다.
2. 버퍼와 텍스처
Metal에서 데이터를 다루는 두 가지 주요 방법은 버퍼와 텍스처입니다.
- 버퍼(MTLBuffer): 구조화되지 않은 메모리 블록으로, 주로 정점 데이터, 인덱스, 균일 변수(uniform variables) 등을 저장합니다.
- 텍스처(MTLTexture): 이미지 데이터를 저장하는 구조화된 메모리로, 렌더링 대상이나 셰이더 입력으로 사용됩니다.
// 정점 데이터를 위한 버퍼 생성
let vertices = [...] // 정점 배열
let vertexBuffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Vertex>.stride, options: [])
// 텍스처 생성
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .rgba8Unorm,
width: 512,
height: 512,
mipmapped: true
)
let texture = device.makeTexture(descriptor: textureDescriptor)!
3. 셰이더
셰이더는 GPU에서 실행되는 작은 프로그램으로, Metal Shading Language(MSL)로 작성됩니다. MSL은 C++를 기반으로 한 언어로, 다음과 같은 주요 셰이더 타입이 있습니다.
- 정점 셰이더(Vertex Shader): 3D 공간의 정점 위치를 화면 좌표로 변환합니다.
- 프래그먼트 셰이더(Fragment Shader): 각 픽셀의 최종 색상을 결정합니다.
- 컴퓨트 셰이더(Compute Shader): 일반적인 병렬 계산을 수행합니다.
// 간단한 정점 셰이더 예제
vertex VertexOutput vertexShader(uint vertexID [[vertex_id]],
constant Vertex *vertices [[buffer(0)]]) {
VertexOutput output;
output.position = float4(vertices[vertexID].position, 0.0, 1.0);
output.color = vertices[vertexID].color;
return output;
}
// 간단한 프래그먼트 셰이더 예제
fragment float4 fragmentShader(VertexOutput input [[stage_in]]) {
return input.color;
}
셰이더는 .metal 파일에 작성되며, Xcode는 이를 컴파일하여 애플리케이션에 포함합니다.
4. 렌더 패스와 컴퓨트 패스
Metal에서 작업 처리는 주로 두 가지 패스를 통해 이루어집니다.
- 렌더 패스: 3D 모델이나 2D 이미지를 화면이나 텍스처에 그리는 과정입니다.
- 컴퓨트 패스: 그래픽과 관련 없는 병렬 계산을 수행합니다.
각 패스는 해당 인코더(MTLRenderCommandEncoder 또는 MTLComputeCommandEncoder)를 통해 명령 버퍼에 기록됩니다.
Metal 파이프라인
Metal에서는 두 가지 주요 파이프라인이 있습니다. 그래픽 렌더링 파이프라인과 컴퓨트 파이프라인입니다.
1. 그래픽 렌더링 파이프라인
그래픽 렌더링 파이프라인은 3D 모델이나 2D 이미지를 화면에 그리는 과정을 정의합니다. 이 파이프라인은 다음 단계로 구성됩니다:
- 정점 처리(Vertex Processing): 정점 셰이더가 각 정점의 위치와 속성을 처리합니다.
- 기본 도형 조립(Primitive Assembly): 정점들이 삼각형 등의 기본 도형으로 조립됩니다.
- 래스터화(Rasterization): 기본 도형이 화면의 픽셀로 변환됩니다.
- 프래그먼트 처리(Fragment Processing): 프래그먼트 셰이더가 각 픽셀의 최종 색상을 결정합니다.
- 출력 병합(Output Merger): 깊이 테스트, 스텐실 테스트, 블렌딩 등이 수행되어 최종 이미지가 생성됩니다.
// 그래픽 렌더링 파이프라인 상태 생성
let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
pipelineStateDescriptor.vertexFunction = vertexFunction
pipelineStateDescriptor.fragmentFunction = fragmentFunction
pipelineStateDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
let pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
2. 컴퓨트 파이프라인
컴퓨트 파이프라인은 그래픽스와 관련 없는 병렬 계산을 위한 것으로, 더 단순한 구조를 가집니다. 컴퓨트 셰이더가 스레드 그룹 단위로 실행되며, 각 스레드는 독립적인 데이터를 처리합니다.
// 컴퓨트 파이프라인 상태 생성
let computeFunction = defaultLibrary.makeFunction(name: "computeShader")!
let computePipelineState = try! device.makeComputePipelineState(function: computeFunction)
3. 데이터 흐름
Metal에서 데이터 흐름은 일반적으로 다음과 같은 순서로 진행됩니다.
- CPU에서 버퍼와 텍스처에 데이터를 기록합니다.
- 명령 버퍼를 생성하고 인코더를 통해 명령을 기록합니다.
- 명령 버퍼를 커밋하여 GPU에 제출합니다.
- GPU가 명령을 실행하고 결과를 메모리에 기록합니다.
- 필요한 경우 CPU가 결과 데이터를 읽습니다.
// 데이터 흐름의 예
let commandBuffer = commandQueue.makeCommandBuffer()!
// 렌더 패스 설정 및 인코딩
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
renderEncoder.endEncoding()
// 완료 핸들러 설정
commandBuffer.addCompletedHandler { _ in
print("렌더링 완료!")
}
// GPU에 제출
commandBuffer.commit()
4. 동기화
Metal은 다양한 동기화 메커니즘을 제공합니다.
- 명령 버퍼 완료 핸들러: 명령 버퍼 실행이 완료되면 호출되는 콜백 함수입니다.
- 리소스 배리어(Resource Barriers): 특정 리소스에 대한 작업이 완료될 때까지 기다립니다.
- 울타리(Fences): 여러 명령 버퍼 간의 의존성을 관리합니다.
간단한 Metal 애플리케이션 예제
다음은 삼각형을 그리는 간단한 Metal 애플리케이션의 핵심 코드입니다:
import Metal
import MetalKit
class TriangleRenderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let commandQueue: MTLCommandQueue
let pipelineState: MTLRenderPipelineState
let vertexBuffer: MTLBuffer
init(mtkView: MTKView) {
// 디바이스 및 명령 큐 설정
device = MTLCreateSystemDefaultDevice()!
commandQueue = device.makeCommandQueue()!
// Metal 뷰 설정
mtkView.device = device
mtkView.clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
// 정점 데이터 생성
let vertices: [Float] = [
0.0, 0.5, 0.0, // 상단 정점
-0.5, -0.5, 0.0, // 좌측 하단 정점
0.5, -0.5, 0.0 // 우측 하단 정점
]
vertexBuffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Float>.size, options: [])!
// 셰이더 로드
let library = device.makeDefaultLibrary()!
let vertexFunction = library.makeFunction(name: "vertexShader")!
let fragmentFunction = library.makeFunction(name: "fragmentShader")!
// 파이프라인 상태 생성
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineDescriptor)
super.init()
mtkView.delegate = self
}
// MTKViewDelegate 메서드
func draw(in view: MTKView) {
guard let renderPassDescriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable else { return }
let commandBuffer = commandQueue.makeCommandBuffer()!
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
// 뷰 크기 변경 시 호출됨
}
}
그리고 이 코드에서 사용된 셰이더는 다음과 같습니다.
#include <metal_stdlib>
using namespace metal;
// 정점 셰이더
vertex float4 vertexShader(uint vertexID [[vertex_id]],
constant float3* vertices [[buffer(0)]]) {
return float4(vertices[vertexID], 1.0);
}
// 프래그먼트 셰이더
fragment float4 fragmentShader() {
return float4(1.0, 0.0, 0.0, 1.0); // 빨간색
}