momodudu.zip

Metal 스터디 (3) - Rendering Pipeline 본문

ios/Metal

Metal 스터디 (3) - Rendering Pipeline

ally10 2021. 11. 26. 11:03

 

이제 약간 본격적인 챕터다.

 

점점 프로젝트에 살이 붙을 예정이니.. 지금까지 작성했던 코드들을 분리해보자. 튜토리얼에 맞춰서 ViewController쪽과 Renderer라는 클래스를 따로 분리할것이다. 그 전에, 앞에서 훑어봤던 Metal 초기화 과정을 다시 한번 짚어보자..

 

1. MTLDevice

2. MTLCommandQueue : CommandBuffer를 생성한다.

3. MTLLibrary : 쉐이더 프로그램을 담고있음

4. MTLRenderPipelineState : 어떤 쉐이더 프로그램을 사용할지, 뎁스나 컬러 세팅 등 파이프라인에서 어떤 동작을 하는지 config지정.

5. MTLBuffer : CPU -> GPU로 보낼 데이터를 담고있는 컨테이너

 

1~2는 한번만 만들고, 4번의 경우 PipelineState를 얼마나 다양하게 정의하느냐에따라 여러가지를 만들수 있겠다. MTLBuffer도 데이터가 어떻게 되느냐에 따라 여러 종류가 만들어질 수 있겠으나, 기본적으로! 1-5는 전부 인스턴스화해서 "한번"만 만드는 객체들이다. 즉, 매프레임 생성하는 애들이 아니란 말임.. 필요한 만큼 초기 셋업에서 만들어두고, 인스턴스화해서 필요할때마다 갖다쓴다.

 

 

이제 코드를 다시 차근차근 작성해보자. Metal관련된 그리기 위한 셋업 / 그리는 코드들은 모두 Renderer로 옮긴다.

 

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        guard let metalView = self.view as? MTKView else {
            fatalError()
        }
        _renderer = Renderer(view: metalView)
    }
    
    var _renderer: Renderer?
}

 

이전까지는 CAMetalLayer를 썼는데, 애플에서 좀 더 쓰기 편하게 랩핑한게 MTKView다. 일반 다른 UIView 종류의 클래스들처럼 쓸 수 있어서, 스토리보드에서 띄우고자 하는 UIView를 MTKView로 바꾸어준다. metalView를 가져오고, Renderer를 생성하면서 initializer에 전달해준다.

 

class Renderer: NSObject {
    public init(view: MTKView) {
        _view = view
        
        // MTLDevice 및 MTLCommandQueue 생성.
        guard
            let device = MTLCreateSystemDefaultDevice(),
            let commandQueue = device.makeCommandQueue() else {
            fatalError()
        }
        
        Renderer.device = device
        Renderer.commandQueue = commandQueue
        _view.device = device
        
        super.init()
        
        _view.clearColor = MTLClearColor(red: 1.0,
                                         green: 1.0,
                                         blue: 1.0,
                                         alpha: 1.0)
        _view.delegate = self
    }
    
    // setupMetal을 위한 변수들
    static var device: MTLDevice!
    static var commandQueue: MTLCommandQueue!

    // 뷰.
    var _view: MTKView
}

extension Renderer: MTKViewDelegate {
    // resize시 발생
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        
    }
    
    // 프레임마다 호출.
    func draw(in view: MTKView) {
    
    }
         
}

 

이건 렌더러 클래스. 생성자에서 MTLDevice와 MTLCommandQueue를 생성하고, 스태틱 변수로 저장해놓는다.  뷰에도 생성한 디바이스를 지정해주고, 뷰의 클리어 컬러값과 delegate를 지정해준다. 여기서 MTLViewDelegate는.. 필수로 mtkView와 draw를 구현해야한다. mtkView는 뷰 리사이징마다 호출되고, draw는 프레임마다 호출된다. CAMetalLayer를 썼으면, 타이머를 연결해서 displayLink마다 호출되게 연결해주어야 했는데, delegate를 쓰니 알아서 되니까 편하다. ㅎㅎ

 

class Primitive {
    static func makeCube(device: MTLDevice, size: Float) -> MDLMesh {
        let allocator = MTKMeshBufferAllocator(device: device)
        let mesh = MDLMesh(sphereWithExtent: [size, size, size],
                           segments: [1, 1, 1],
                           inwardNormals: false,
                           geometryType: .triangles,
                           allocator: allocator)
        
        return mesh
    }
}

 

이건 편의성을 위해 만들어두는 static func인데, 여러개의 mesh를 만들기 위해서 MDLMesh를 쓰는데, 코드가 좀 더러워서.. 위처럼 분리해놓는다. MDLMesh를 이용하면 원뿔형이나 원형등 여러가지 매쉬를 쉽게 사이즈별로 얻어올 수 있다.

 

// Mesh 생성 및 MTLVertexBuffer 생성
func setupData() {
    let mdlMesh = Primitive.makeCube(device: Renderer.device,
                                     size: 1)

    do {
        _mesh = try MTKMesh(mesh: mdlMesh, device: Renderer.device)
    } catch let error {
        print(error.localizedDescription)
    }

    _vertexBuffer = _mesh.vertexBuffers[0].buffer

    // 4. MTLRenderPipelineState 생성. 각 쉐이더를 디스크립터로 지정해서 config를 생성하고, 이 config(descriptor)를 바탕으로 렌더링 파이프라인을 생성한다.
    let defaultLibrary = Renderer.device.makeDefaultLibrary()!      // 미리 컴파일한 쉐이더에 접근가능하게 만들어줌.
    let fragmentProgram = defaultLibrary.makeFunction(name: "fragment_main")   // frag shader. shader function name설정
    let vertexProgram = defaultLibrary.makeFunction(name: "vertex_main")       // vert shader. shader function name설정

    let pipelineStateDescriptor = MTLRenderPipelineDescriptor() // 렌더링 파이프라인의 config를 설정해줌.
    pipelineStateDescriptor.vertexFunction = vertexProgram      // 파이프라인에서 사용하는 각각의 쉐이더를 지정해주고,
    pipelineStateDescriptor.fragmentFunction = fragmentProgram
    pipelineStateDescriptor.colorAttachments[0].pixelFormat = _view.colorPixelFormat   //  color attachment의 포맷을 설정해줌.
    pipelineStateDescriptor.vertexDescriptor = MTKMetalVertexDescriptorFromModelIO(_mesh.vertexDescriptor)


    do {
        _pipelineState = try Renderer.device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
    } catch let error {
        print(error.localizedDescription)
    }
}

이제 그 외에 필요한 애들을 만들어준다.  아까 생성한 Primitive 스태틱 함수로 큐브 MDLMesh를 얻어와서, 버텍스 버퍼에 넣어놓는다. 그리고 vertex shader및 fragment shader를 저장할 라이브러리를 만들고, pipeline descriptor를 생성한다. 여기서 vertex descriptor는 생성한 mdl mesh에서 바로 가져와서 지정할 수 있다.

 

 

// 프레임마다 호출.
func draw(in view: MTKView) {
    guard
        let descriptor = _view.currentRenderPassDescriptor,
        let commandBuffer = Renderer.commandQueue.makeCommandBuffer(),
        let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

    // drawing code


    renderEncoder.endEncoding()

    guard let drawable = _view.currentDrawable else {
        return
    }

    commandBuffer.present(drawable)
    commandBuffer.commit()
}

 

매 프레임 돌아가는 draw코드.  이전과 다르게 많이 간결해졌다... 커맨드 버퍼를 만들고, 렌더 인코더를 만든다. 이중 하나라도 만족되지 않으면 draw는 실행하지 않는다. //drawing code 주석 부분에 실제 draw어쩌구저쩌구~ 같은 콜을 넣어주면 된다.

 

 

// drawing code
renderEncoder.setRenderPipelineState(_pipelineState)
renderEncoder.setVertexBuffer(_vertexBuffer, offset: 0, index: 0)
for submesh in _mesh.submeshes {
    renderEncoder.drawIndexedPrimitives(type: .triangle,
                                        indexCount: submesh.indexCount,
                                        indexType: submesh.indexType,
                                        indexBuffer: submesh.indexBuffer.buffer,
                                        indexBufferOffset: submesh.indexBuffer.offset)
}

주석이 있는 부분에 이걸 추가해준다. pipeline state를 지정해주고, 버텍스 데이터도 지정해준다. 그리고 이 버텍스를 가지고 어떻게 그려라~ 하는 코드를 넣어주면 된다.

 

 

 

이제 렌더링 파이프라인에 대한 설명이 나오는데.. Metal에서의 파이프라인은 이렇다.

 

1. VertexFetch

2. Vertex Processing

3. Primitive Assembly

4. Rasterization

5. Fragment Processing

6. FrameBuffer

 

2,5는 당연히 programmable하다! 설명을 쭉 읽어보면 크게 다른건 없는듯하다. 읽을때마다 새로운(;;) 렌더링 파이프라인에 대해서 다시 짚어보자.

 

1. Vertex Fetch

Input Assembly가 더 익숙한 1단계. 

 

GPU에서 버텍스를 가져올 때, RenderCommandEncoder의 draw call을 하는 함수들을 보면 Indexed가 붙는게 있고 아닌게 있다. 즉, RenderCommandEncoder에서 이 버텍스 버퍼가 Indexed Buffer인지 아닌지 명시를 해놓는다.

그래서 만약 buffer가 indexed가 아니라면, GPU는 이 버퍼를 한번에 한개씩 차례대로 읽는다. Indexed가 붙어있으면 인덱스를 참조해서 보낸 버텍스 버퍼에서 정점을 가져와서 말그대로 Input data들을 조립한다. 이 조립한 데이터들은 Vertex Processing 단계로 넘어간다.

 

2. Vertex Processing

Vertex Fetch단계에서 하나씩 보내진 정점들은 모두 이 Vertex Processing 단계를 거친다. 미리 만들어둔 vertex shader program을 정점마다 수행한다.

 

// 1
struct VertexIn {
  float4 position [[attribute(0)]];
};

// 2
vertex float4 vertex_main(const VertexIn vertexIn [[stage_in]]) {
  return vertexIn.position;
}

요건 MSL로 작성한 아무것도 안하는 간단한 버텍스 쉐이더다. 인풋으로 들어가는 vertex attribute를 VertexIn struct로 정의하고, vertex descriptor에서 정의해놓은 attritube(0)을 명시해준다. 즉, vertex Descriptor의 attribute[0]과 VertexIn의 position은 일치해야된다. vertex_main은 버텍스마다 돌아갈 쉐이더 프로그램이다. 들어오는 버텍스 포지션을 그대로 리턴한다. stage_in은 현재 vertex buffer의 index를 저걸로 가져온다.

Distributer라는 하드웨어가 이 처리된 정점들을 그룹화해서 다음단계인 Primitive Assembly로 보낸다.

 

3. Primitive Assembly

vertex shader에서 넘어온 그룹화된 정점들을 이 단계에서 처리한다. 중요한점은, 같은 Primitive 타입의 버텍스 그룹은 항상 같은 block안에 존재한다. 즉, 세개의 버텍스로 구성된 트라이앵글에서 세개의 버텍스는 하나의 블락안에 존재한다는 것이다. CPU에서 이미 index정보를 넘겨주면서 정점간의 순서가 어떻게 되는지? 어떻게 구성될것인지를 받아왔다. 그래서 이 인덱스 정보를 가지고 버텍스들을 조립한다. 그래서 말그대로 "Primitive Assembly" 스테이지다.

Metal에서의 Primitive type은 Point, Line, Line Strip, Triangle, Traingle Strip이 존재한다. 그 외에도 "Patch"라는 특별한 Primitive type이 존재하는데, 이건 좀 이후의 챕터에서 다룬다. 또한 여기서 정점 순서를 구성하면서, CCW(front-face), CW(back-face)를 판단하게 되는데, 정점 구성 순서에 따른 컬링도 이 단계에서 이루어진다. 백페이스 컬링은 pipeline state에서 설정할 수 있다.

 

 

4. Rasterization

aseembly에서 넘어온 Primitive를 fragment단위로 쪼개는 작업을 한다. 이 때, 흔히 말하는 Z-test, Stencil test도 이 스테이지에서 이루어진다. 프레그먼트를 쪼개서, depth buffer와 stencil 버퍼를 보고 필요 없는 프래그먼트는 버린다.

 

5. Fragment Processing

이 역시 programmable stage다. 위에서 버텍스 processing은 하나의 버텍스마다 이루어진다면, 프래그먼트 쉐이더는 위 4단계 레스터라이저 단계에서 생성된 output인 하나의 프래그먼트마다 실행된다. 삼각형을 그린다 하면 CPU단에서 입력은 정점 3개와 각 정점에 대한 rgba 3개의 값인데, 프래그먼트 쉐이더에서는 이 3개의 컬러값을 가지고 fragment의 interpolation을 수행한다. 

 

또한, 이 단계에서는 알파테스트, 알파 블렌딩, 시저테스트, 스텐실 테스트, Z-테스트, Anti-aliasing같은 좀 더 복잡하고 고급지게 보이는 post-processing도 가능하다.

 

 

6. FrameBuffer

프래그먼트가 pixel로 처리되고나면, Distributer unit은 이 픽셀들을 Color Writing Unit으로 보낸다. 이 Unit은 프레임버퍼에 최종 컬러값을 쓰는 역할을 맡고 있는데, 이 바로 써진 컬러값들이 매 프레임 화면에 보이는건 아니다.

그 유명한.. 더블 버퍼링이라는 테크닉이 여기서 쓰인다. 화면에 보이는 버퍼가 화면에 띄우고 있는 동안, 다른 뒷버퍼에 color unit이 다음프레임에 그릴 컬러값을 열심히 쓴다. 그리고 다 쓰고 나면, 앞버퍼랑 뒷버퍼를 swap한다. 이렇게 매 프레임마다 버퍼가 swap되고, 하나가 화면에 띄워지는 동안 다른애는 뒤에서 열심히 다음 프레임을 업데이트하고있다.

 

 

 

이제 CPU에서 버텍스 데이터 말고도, 다른걸 더 보내보자.

원하는건 프레임마다 0.05씩을 추가해서 sin(0.05n)의 값을 파이프라인으로 보내서, y값을 조절하는것이다.

렌더러 draw 코드에 아래와 같은 코드를 추가한다.

 

timer += 0.05
var currentTime = sin(timer)
renderEncoder.setVertexBytes(&currentTime,
                             length: MemoryLayout<Float>.stride,
                             index: 1)

4kb 이하의 데이터를 파이프라인으로 보내고 싶을땐, 굳이 MTLBuffer를 쓰지 않고 setVertexBytes로 보내면 된다고 한다. 버퍼 argumnet table의 0번으로는 버텍스들을 보냈으니, 1번의 인덱스에 sin(timer)의 값을 보내줄것이다.

 

그럼 이제 쉐이더의 수정이 필요하다.

 

vertex float4 vertex_main(const VertexIn vertex_in[[ stage_in ]],
                          constant float &timer [[ buffer(1) ]]) {
    float4 position = vertex_in.position;
    position.y += timer;
	return position;
}

 

새로 들어갈 timer를 버텍스 메인 파라미터에 추가해주고, 1번째 argumnet table이었으니 buffer(1)로 명시해준다. 그리고 받아온 버텍스 정점의 값에서 timer값을 추가해준다.

 

 

그러면 이제 프레임마다 y값이 바뀌어서 위아래로 움직이는 큐브를 볼 수 있다 ㅎㅎ