본문 바로가기
IT/Graphics

Metal 스터디 1주차 (22.12.09)

by YEON-DU 2022. 12. 13.
반응형

Notion Link

Introduction

Screenshot_20221206-192141_Kodeco

Metal이란?

간단히 말해서, MetalGPU로 데이터를 전송하고 GPU 명령을 스케줄링하는 일을 하는 Swift, Objective-C API이다.

많은 스레드에서 병렬로 실행하기 위해 큰 작업을 분할할 수 있는 경우,
CPU로 더 적은 수의 스레드에서 처리하도록 하는 것보다 GPU에 배치하는 것이 가장 좋다.

Metal을 사용하면 작은 프로세스들을 보내서 GPU에서 병렬로 실행하고 하드웨어와 직접 통신할 수 있다.

Metal은 기기 화면에 표시되는 모든 것을 뒷받침한다. 대부분의 Apple Visual framework(Core Animation, SpriteKit, SceneKit 등)는 Metal을 기반으로 구축된다.

Metal은 Apple 기기에서 OpenGL을 대체하며 OpenGL은 이제 더 이상 사용되지 않는다.

Metal은 모든 사람을 위한 것이 아니라 Low Level부터 설계하려는 사람에게 맞다.

(메모리 처리 및 명령 스케줄링 등 세부 사항을 지정해야 한다)

Initialize Metal

Untitled 2

Swift로 IOS 애플리케이션을 개발할 때 대부분은 GPU가 어떤 중요한 역할을 하는지 이해하지 못할 수 있다.

먼저 어떻게 GPU가 들어가는 지를 이해해보자.

CPU에는 대부분의 앱 처리를 수행하는 몇 개의 강력한 코어가 있지만 GPU에는 복잡한 병렬 계산을 수행할 수 있는 수백 또는 수천 개의 빠르고 효율적인 코어가 있다.

즉, CPU는 여러 스레드에서 하나의 작업을 정말 잘 수행하고 GPU는 동시에 작은 작업을 모두 수행할 수 있다는 것을 말한다.

단순히 배열에 많은 숫자가 있다고 가정해보자.

이 배열의 각 숫자에 1을 증가시키거나 추가하려고 한다.

Swift에서는 for 루프를 수행하거나 배열에서 맵을 사용할 수 있다. 각 항목에 하나씩 추가하라는 코드가 있다. 이 명령을 실행하면 아래의 이미지처럼 배열의 각 항목이 한 번에 하나씩 업데이트된다.

하지만 이보다 더 빠르게 문제를 해결하는 방법은 전체 배열을 GPU로 보내는 것이다.

GPU의 각 코어는 배열에서 숫자를 가져와 모든 숫자에 대해 기능을 수행한다. 명령이 실행되면 이러한 모든 프로그램이 동시에 실행되므로 배열의 각 항목이 동시에 증가하게 된다.

이것이 병렬 처리의 본질이다.

물론 실생활에서는 이렇게 간단하지 않다.

합리적인 크기의 배열의 경우 GPU에서 명령을 실행하는 속도는 CPU에서 루프를 실행하는 것보다 느리지만 3D 모델을 렌더링하고 정점 위치를 계산할 때 빛을 발한다.

최근 몇 년 동안 CPU 성능 향상은 느려졌지만 GPU 성능은 기하급수적으로 증가하고 있다. ****GPU 프로그래밍은 그래픽뿐만 아니라 기계 학습 및 AI 응용 프로그램에도 사용된다.

Graphics Pipeline

파이프라인은 단순히 GPU에서 차례로 발생하는 단계를 설명한다.

파이프라인의 시작 부분에는 입력이 있으며 이러한 입력은 파이프라인을 따라 이동하며 파이프라인의 각 단계에서 처리된다.

3D 모델은 점 또는 정점으로 구성된다.

이 정점은 파이프라인을 따라 이동하는 입력이다. GPU는 이러한 각 정점에서 작업을 수행하고 최종적으로 모델을 화면에 렌더링한다.

파이프라인에는 프로그래밍 가능한 두 부분(Vertex Processing과 Fragment Processing)이 있다. 이들은 GPU에서 실행되는 셰이더 함수라고 하는 작은 프로그램이다.

GPU가 이러한 정점을 처리하기 전에 Swift에서 약간의 Metal 초기화를 수행해야 한다.

Metal App 시작 시 GPU를 나타내는 Device 객체를 생성한다.

GPU가 Command List를 받을 수 있도록 앱이 시작될 때 CommandQueue도 설정한다.

GPU 파이프라인 옵션을 설정하는 PipelineState를 생성하고 작성할 Shader 함수의 이름을 GPU에 제공한다.

Shader 함수는 파이프라인의 프로그래밍 가능한 부분이다.

일반적으로 앱을 시작할 때 앱에서 사용할 Model 및 Texutre에 대한 Buffer 또는 메모리 영역도 설정한다.

위의 모든 것은 우리가 한 번만 만드는 객체이다. 이러한 객체들은 모두 생성하는 데 비용이 많이 드므로 메인 Run Loop가 시작되기 전에 이 작업을 수행한다.

Run Loop는 1 Frame에 한 번(1초에 약 60번) draw 함수를 호출한다.

그리고 각 프레임 내에서 GPU가 이 프레임에 대해 표시할 draw 명령들을 만들어야 한다.

따라서 이러한 명령을 저장할 CommandBuffer를 만든다.

각 명령은 ReunderCommandEncoder에 저장된다.

여기에는 GPU가 그리기를 실행하는 데 필요한 모든 단계 정보와 리소스가 포함된다.

마지막 프레임에는 CommandBuiffer를 커밋하고 상태 정보와 리소스가 포함된 명령을 GPU에 보낸다.

Set Up Metal in Swift

Untitled 6

Cocoa ⇒ App로 변경. (X-Code 버전 차이)

Storyboard 상 Control + drag to Source ⇒ Create an Outlet

//
//  ViewController.swift
//  MetalRenderer
//
//  Created by Lee SeungYeon on 2022/12/05.
//

import Cocoa
import MetalKit

class ViewController: NSViewController {

    @IBOutlet var metalView: MTKView!
    var renderer: Renderer?

    override func viewDidLoad() {
        super.viewDidLoad()

        renderer = Renderer(view: metalView)
        metalView.device = Renderer.device
        metalView.delegate = renderer

        metalView.clearColor = MTLClearColor(red: 1.0, green: 1.0, blue: 0.8, alpha: 1.0)
    }
}
//
//  Renderer.swift
//  MetalRenderer
//
//  Created by Lee SeungYeon on 2022/12/05.
//

import Foundation
import MetalKit

class Renderer: NSObject {
    static var device: MTLDevice!
    let commandQueue: MTLCommandQueue
    static var library: MTLLibrary!
    let pipelineState: MTLRenderPipelineState

    init(view: MTKView) {
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Unable to connect to GPU")
        }
        Renderer.device = device
        self.commandQueue = commandQueue
        Renderer.library = device.makeDefaultLibrary()!
        pipelineState = Renderer.createPipelineState()
        super.init()
    }

    static func createPipelineState() -> MTLRenderPipelineState {
                // GPU에 대한 Pipeline State를 설정한다.
        let pipelineStateDescriptor = MTLRenderPipelineDescriptor()

        // pipeline state properties
        pipelineStateDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm

                // Vertex Descriptor를 사용하여 정점이 메모리에 배치되는 방식을 GPU에 설명한다.
        let vertexFunction = Renderer.library.makeFunction(name: "vertex_main")
        let fragmentFunction = Renderer.library.makeFunction(name: "fragment_main")
        pipelineStateDescriptor.vertexFunction = vertexFunction
        pipelineStateDescriptor.fragmentFunction = fragmentFunction

        return try! Renderer.device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
    }
}

extension Renderer: MTKViewDelegate {
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {

    }

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable,
              let descriptor = view.currentRenderPassDescriptor,
              let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        commandEncoder.setRenderPipelineState(pipelineState)

        // draw call
        commandEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: 1)

                // RenderEncoder에 그리기 호출이 더 이상 없다고 알리고 Render Pass를 종료한다.
        commandEncoder.endEncoding()

                // Command Buffer에 MTKView의 drawable을 표시하고 GPU에 커밋하도록 요청한다.
        commandBuffer.present(drawable)
        commandBuffer.commit()
    }
}

Queues, Buffers and Encoders

각 프레임은 GPU로 보내는 명령으로 구성된다. Render Command Encoder에서 이러한 명령을 마무리 한다.

Command Buffer ⇒ Command Encoder를 구성

Command Queue ⇒ Command Buffer를 구성

앱 시작 시 device와 Command Queue를 설정해야 하며 일반적으로 전체에서 동일한 device와 Command Queue를 사용해야 한다.

각 프레임에서 Command Buffer와 적어도 하나의 Render Command Encoder를 생성한다. 두 객체들은 앱 시작 시 한 번만 설정하는 Shader 함수 및 Pipeline State와 같은 다른 개체를 가리키는 경량 객체(lightweight object)이다.

Pipeline State

Metal 에서 GPU에 대한 Pipeline State 를 설정한다. 상태를 설정하면 상태가 변경될 때까지 아무 것도 변경되지 않는다고 GPU에 알리는 것이다. 이처럼 고정된 상태의 GPU를 사용하면 보다 효율적으로 실행할 수 있다. Pipeline State에는 사용해야 하는 픽셀 형식 및 깊이를 포함하여 렌더링해야 하는지 여부와 같이 GPU에 필요한 모든 종류의 정보가 포함되어 있다. Pipeline State에는 방금 생성한 Vertex 및 Fragment Function도 포함된다.

또한, Pipeline State는 직접 생성하지 않고 descriptor를 통해 생성한다. descriptor는 파이프라인이 알아야 하는 모든 것을 보유하고 있으며 특정 렌더링 상황에 필요한 속성만 변경한다.

Rendering

MTKView에는 모든 프레임을 실행하는 delegate 메서드가 있지만 Static View를 채우는 간단한 렌더링을 수행하므로 매 프레임마다 화면을 새로 그릴 필요가 없다.

그래픽 렌더링을 수행할 때 GPU의 궁극적인 작업은 3D 장면에서 단일 텍스처를 출력하는 것이다. 이 텍스처는 매 프레임 장치의 화면에 표시된다.

Render Passes

사실적인 렌더링을 하기 위해서는 그림자, 조명 및 반사를 고려해야 한다. 각각을 위해서는 많은 계산이 필요고 일반적으로 별도의 렌더링 패스에서 수행된다. (EX. 그림자 렌더 패스는 3D 모델의 전체 장면을 렌더링하지만 그레이스케일 그림자 정보만 유지)

두 번째 렌더 패스는 모델을 풀 컬러로 렌더링한다. 그런 다음 그림자와 색상 텍스처를 결합하여 화면에 표시될 최종 출력 텍스처를 생성할 수 있다.

Metal on the GPU

Untitled 7

3D 콘텐츠 렌더링을 시작하려면 먼저 일부 Vertex가 있는 3D Model이 필요하다.

3D 모델은 이와 같은 삼각형으로 구성된다. 각 삼각형에는 세 개의 꼭지점, 세 개의 모서리 및 하나의 면이 있다.

삼각형은 2차원에서 그릴 수 있는 점의 수가 가장 적은 다각형이기 때문에 GPU는 삼각형에 최적화되어 있다. 즉, 모든 점은 동일한 평면에 있고 삼각형은 항상 평평하다.

3D 공간에서 일부 점과 삼각형을 렌더링해보자.

삼각형의 각 정점에는 X-Y-Z 좌표가 있다. X축은 수평이고 Y축은 수직, Z축은 화면을 가리킨다.

이것이 Half Cube이다. X 및 Y 값은 -1에서 1로 이동하지만 Z축은 0에서 1로 이동한다.

이 좌표 집합을 Normalized Device Coordinates 또는 NDC라고 한다.

Z가 0으로 시작하는 공간 앞에 삼각형을 배치할 수 있다.

Blender에서 볼 수 있는 Quad mesh가 있다.

Quad에는 4개의 정점이 있다. Model 파일을 앱으로 읽어들일 때 모든 Quad가 mesh 2개로 분할되고 오른쪽에 보이는 Model로 삼각 분할된다.

그래픽 파이프라인을 따라 이 삼각형이 어떻게 그려지는 지 살펴보자.

그리기 전에 모든 정점 위치로 Buffer를 설정하고 GPU로 보낸다.

GPU는 삼각형을 그리기 전에 이 Buffer를 가져온 다음 Input Assembler가 정점을 3개의 그룹으로 그룹화한다. 선을 그리면 두 그룹이 된다.

GPU 스케줄러는 이러한 그룹화된 정점을 Vertex Processing 단계로 전달한다.

Vertex Processing은 정점 함수(Vertex function)를 사용하여 정점을 배치할 수 있는 프로그래밍 가능한 단계이다. 이 함수는 GPU에 저장된다.

사진의 코드는 C++ 기반 언어인 Metal의 Shading 언어로 작성된 코드이다.

(return 문 끝에 있는 세미콜론을 볼 수 있다.)

Primitive Assembly 단계는 그룹을 Primitive들로 만든다.

Primitive는 그리는 유형, 점, 선 또는 삼각형에 따라 결정된다. 일부 정점이 화면 좌표 내부에 맞지 않으면 잘려 보일 수도 있다.

다음으로 남은 Primitive들은 Rasterizer에 전달된다.

Rasterizer는 Primitive의 보이는 부분을 Fragment로 변환한다.

각 Fragment들은 화면의 한 Pixel에 기여한다.

Depth 테스트를 활성화한 경우 Rasterizer는 이 Fragment가 다른 Primitive의 모든 Fragment 앞에 있는지 여부를 테스트하고, 그렇지 않은 경우 Rasterizer는 Fragment를 버린다.

화면에 표시되는 Fragment만 Pipeline을 따라 Fragment Processing을 진행한다.

Fragment Processing에서는 각 Fragment에 최종 색상을 지정한다.

Fragment Function은 화면에 보이는 파란색을 RGBA(float 4)로 반환한다.

매우 간단한 Fragment Function이지만 여기에서 3D 조명을 계산할 수 있다.

Fragment가 빛을 향하고 있으면 빛에서 멀리 있는 Fragment 보다 밝은 색상을 반환해야 한다.

모든 Fragment들은 Frame Buffer라는 특별한 메모리 위치에 기록된다.

Frame Buffer는 최종적으로 화면에 표시되는 것을 의미한다.

Shaders

Untitled 15

//
//  Shaders.metal
//  MetalRenderer
//
//  Created by Seungyeon Lee on 2022/12/08.
//

#include <metal_stdlib>
using namespace metal;

constant float4 position[3] = {
    float4(-0.5, -0.2, 0, 1),
    float4(0.2, -0.2, 0, 1),
    float4(0, 0.5, 0, 1),
};

constant float3 color[3] = {
    float3(1, 0, 0),
    float3(0, 1, 0),
    float3(0, 0, 1),
};

struct VertexOut {
    float4 position [[position]];
    float point_size [[point_size]];
    float3 color;
};

vertex VertexOut vertex_main(uint vertexId [[vertex_id]]) {
    VertexOut out {
        .position = position[vertexId],
        .color = color[vertexId],
        .point_size = 60
    };
    return out;
}

fragment float4 fragment_main(VertexOut in [[stage_in]]) {
    return float4(in.color, 1);
}

Challenge: Render a Quad

Untitled 16

반응형

댓글