본문 바로가기
IT/Graphics

Metal 스터디 2주차 (22.12.15)

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

Metal Buffers

GPU의 Shader 파일에서 위치 및 색상 배열을 하드 코딩하는 대신 CPU에서 하드 코딩하고 vertexBuffer에서 GPU로 보낸다. 이것은 큰 차이가 아닌 것처럼 보이지만 하드 코딩된 정점 배열을 파일에서 가져온 3D 모델로 대체하게 만든다.

이와 같이 삼각형을 그릴 때 Swift에서는 세 개의 정점 위치로 배열을 만든다.
GPU가 이 배열을 읽을 수 있도록 MTLBuffer 개체에 복사한다. 적절한 길이의 이 버퍼를 생성하도록 GPU 장치에 요청한다. 인수 테이블(Arugment Table)을 사용하여 MTLBuffer를 Shader에 Madpping한다. Metal에는 Buffer, Texture 등 다양한 유형의 리소스가 있으며 각 리소스 유형에는 고유한 인수 테이블이 있다.

예를 들어 두 개의 MTLBuffer를 Indexing하는 방법이 있다.

positionBuffer의 인덱스는 0이고 colorBuffer의 인덱스는 1이다.

장치 및 리소스 유형에 따라 각 인수 테이블에는 31개의 Slot이 있으므로 한 번에 최대 31개의 버퍼와 31개의 Texutre를 가질 수 있다.

 

Renderer.swift

let positionLength = MemoryLayout<SIMD4<Float>>.stride * positionArray.count
positionBuffer = device.makeBuffer(bytes: positionArray, length: positionLength, options: [])!
let colorLength = MemoryLayout<SIMD3<Float>>.stride * colorArray.count
colorBuffer = device.makeBuffer(bytes: colorArray, length: colorLength, options: [])!

먼저 필요한 길이를 계산한다.
과거에 sizeOf를 사용하여 개체의 크기를 얻었을 수도 있지만 Buffer의 필요한 길이를 계산하는 것보다 정확한 방법은 객체의 stride를 사용하는 것이다.

float4의 stride는 두 float4 사이의 offset이며 각 괄호 안에 있는 type과 함께 MemoryLayout을 사용한다.

그리기에서는 이제 RenderCommandEncoder에서 명령을 생성하여 이러한 Buffer를 GPU로 보낸다.
Index는 모든 Buffer가 메모리에 있는 위치를 추적하는 인수 테이블의 항목이다.

GPU에서 Buffer 0을 참조하는 Vertex Function에 매개변수를 전달하면 정점 positionBuffer를 가리킨다.
따라서 다음으로 할 일은 정점 함수의 프로토타입을 변경하는 것이다.
위치 배열을 제거하고 색상 배열을 주석 처리한다.

 

commandEncoder.setVertexBuffer(positionBuffer, offset: 0, index: 0)
commandEncoder.setVertexBuffer(colorBuffer, offset: 0, index: 1)

이 Buffer를 장치 주소 공간에 넣는다.
GPU 칩 내부에는 다양한 메모리 캐시가 있다.
나중에 상수 메모리와 텍스처 메모리를 사용하게 된다.
상수 주소 공간은 여러 정점 함수를 통해 동일한 변수에 병렬로 액세스하는 데 최적화되어 있다.
장치 주소 공간은 병렬 기능을 통해 버퍼의 다른 부분에 액세스할 때 가장 적합하다.

const를 사용하지 않으면 장치 주소 공간을 읽기/쓰기 할 수 있다.

 

Shaders.metal

vertex VertexOut vertex_main(device const float4 *positionBuffer [[buffer(0)]],
                             device const float3 *colorBuffer [[buffer(1)]],
                             constant float &timer [[buffer(2)]],
                             uint vertexId [[vertex_id]]) {
    VertexOut out {
        .position = positionBuffer[vertexId],
        .color = colorBuffer[vertexId]
    };
    out.position.x += timer;
    return out;
}

인수 테이블에서 positionBuffer는 인덱스 0이고 colorBuffer는 인덱스 1이다.
Buffer에서 위치와 색상을 검색한다.
Buffer에는 배열이 포함되어 있으므로 올바른 Vertex를 얻기 위해 배열을 Indexing할 수 있다.
point_size는 더 이상 필요하지 않으므로 제거해도 이전과 동일한 결과를 얻는다.

Camera 아이콘을 클릭하면 GPU Debugger가 열린다.

 

timer += 0.05
var currentTime = sin(timer)

commandEncoder.setVertexBytes(&currentTime,
                              length: MemoryLayout<Float>.stride,
                              index: 2)

이제 정점을 포함하는 모든 버퍼를 정점 함수로 보낼 수 있으며 정점 함수가 이를 처리한다.
곧 정점을 하드 코딩하는 대신 정점이 많은 3D 모델 파일에서 정점을 읽게 된다.
정점 함수는 정점의 최종 위치를 결정하는 곳이다.
우리는 배열의 값을 사용했지만 정점 함수에서 이 값을 변경할 수 있다.
Quad의 위치를 약간 더 오른쪽으로 변경한다. 이렇게 하면 모든 정점이 x축에서 0.3만큼 이동한다.
정점 이동을 변환Translation이라고 한다.

 

Indexed Drawing

Untitled 1

Buffer의 위치를 사용하여 정점을 Rendering하고 두 개의 삼각형을 그렸다. 그러나 2개의 삼각형을 Rendering하는 데 6개의 정점이 필요했고 그 중 2개의 정점이 겹쳤다는 사실을 알았을 것이다.

여기서는 그다지 중요하지 않지만 수백만 개의 기차가 있는 복잡한 3D Model이 있다고 상상해보자.

정점 위치뿐만 아니라 Texture 정보와 같은 다른 정점 데이터도 저장할 수 있다.

따라서 정점당 가능한 적은 데이터를 저장하려고 한다. 우리는 확실히 거기에 중복이 되지 않길 바란다. 이때는 Indexing을 사용하여 이를 개선할 수 있다.

꼭짓점을 복제하는 대신 고유한 꼭짓점만 나열하는 배열을 만들 수 있다.

그런 다음 그리려는 정점의 순서를 나열하는 별도의 Index 배열을 가질 수 있다.
이 배열에서는 두 개의 삼각형을 설명한다.

첫 번째 삼각형은 정점 0, 1, 2를 사용한다.

두 번째 삼각형은 정점 2, 1, 3을 사용한다.

배열에서 중복된 vervices를 제거하고 Index 배열로 인덱스화한다.

 

let indexArray: [uint16] = [
        0, 1, 2,
        2, 1, 3
    ]

이를 Order Drawing과 달리 Indexing Drawing이라고 한다.

정점이 많은 Model의 경우 훨씬 더 효율적이다.

다행스럽게도 Indexing은 Metal이 가지고 있는 Draw Call Method 중 Index에 대한 Index Array Buffer를 사용하여 Indexing된 Primitives를 그리는 일반적인 경우이다.

 

Vertex Descriptors

Untitled 6

위치 및 색상의 정점 데이터로 정점을 Rendering할 때 두 개의 Metal Buffer가 있고 이 두 Buffer를 GPU에 전달한다.

그러나 정점 데이터는 위치와 색상보다 훨씬 더 많은 정보를 보유할 수 있다. 과정 후반에 각 정점에 대한 Normal과 Texture 좌표도 갖게된다. Metal을 사용하면 정점 데이터를 저장하는 방식이 매우 유연해진다.

위치와 색상에 대해 서로 다른 두 개의 버퍼를 사용하는 대신 위치와 색상을 Interleave(*각각 하나씩 추출해서 일렬로 배열하는 과정, 끼워넣기)하여 둘 다 하나의 Buffer에 저장할 수 있다. 각 정점의 시작점 사이의 거리를 보폭Stride이라고 한다. vertexDescriptor를 사용하여 정점을 배치한 방법을 Metal에 알린다.

위치 및 색상 Buffer를 설명하기 위해 vertexDescriptor를 만드는 방법을 살펴보자.

 

vertexDescriptor는 최대 31개의 속성Attribute을 가질 수 있다.

위치 Attribute와 색상 Attribute가 있다. 위의 Code의 Attribute 0은 float3인 위치이다.

Offset은 Buffer 시작 부분의 Offset이다. bufferIndex는 Buffer Argument Table에서 0이다.

Attribute 1은 float3인 색상이다. 색상이 색상 Buffer의 시작 부분에서 시작하므로 Offset도 0이다. 이 경우 bufferIndex는 1이다. 그런 다음 각 Buffer의 Layout을 설명한다.

두 Buffer 모두 float3의 크기만큼 Stride를 갖는다. 이것은 VertexDescriptor를 layout할 수 있는 한 가지 방법이다.

 

VertexDescriptor를 layout하는 다른 방법은 다음과 같다.

하나의 Buffer에 위치와 색상을 모두 유지하고 Interleave한다. Attribute 0과 1은 모두 bufferIndex 0으로 들어간다. Stride은 위치 크기에 색상 크기를 더한 값이다.

두 가지 다른 형식으로 layout된 데이터를 설명하는 두 가지 vertexDescriptor이다.

vertexDescriptor 사용의 이점은 Vertex Function이 Buffer의 물리적 Layout이 아닌 vertexDescriptor의 속성을 확인하므로 정점 함수가 정확히 동일한 Code를 사용하여 두 형식 중 하나로 데이터를 읽을 수 있다는 것이다. vertexDescriptors를 만들고 사용하는 방법을 살펴본다.

 

Extension.swift

extension MTLVertexDescriptor {
    static func defaultVertexDescriptor() -> MTLVertexDescriptor {
        let vertexDescriptor = MTLVertexDescriptor()
        // position
        vertexDescriptor.attributes[0].format = .float3
        vertexDescriptor.attributes[0].offset = 0
        vertexDescriptor.attributes[0].bufferIndex = 0

        // color
        vertexDescriptor.attributes[1].format = .float3
        vertexDescriptor.attributes[1].offset = MemoryLayout<SIMD3<Float>>.stride
        vertexDescriptor.attributes[1].bufferIndex = 0

        vertexDescriptor.layouts[0].stride = MemoryLayout<Vertex>.stride

        return vertexDescriptor
    }
}

 

Renderer.swift

let vertexLength = MemoryLayout<Vertex>.stride * vertices.count
vertexBuffer = device.makeBuffer(bytes: vertices, length: vertexLength, options: [])!

 

3D Models

Untitled 10

Blender를 통해 3D Train Model 살펴보기.

Blender는 Quad Mesh를 사용한다.

각 Vertex와 Face는 Mesh로 구성되며, Mesh들은 Submesh들로 나뉘어서 그룹핑된다.

 

Render a Model

Untitled 13

 

Renderer.swift

for mtkMesh in train.mtkMeshes {
            for vertexBuffer in mtkMesh.vertexBuffers {
                commandEncoder.setVertexBuffer(vertexBuffer.buffer, offset: 0, index: 0)
                for submesh in mtkMesh.submeshes {
                    // draw call
                    commandEncoder.drawIndexedPrimitives(type: .triangle,
                                                         indexCount: submesh.indexCount,
                                                         indexType: submesh.indexType,
                                                         indexBuffer: submesh.indexBuffer.buffer,
                                                         indexBufferOffset: submesh.indexBuffer.offset)
                }
            }
        }

가져온 train Model을 순차적으로 그려준다.

 

Challenge: Add Color

Untitled 14

 

Renderer.swift

var color = 0
for submesh in mtkMesh.submeshes {
    commandEncoder.setVertexBytes(&color, length: MemoryLayout<Int>.stride, index: 11)
    // draw call
    commandEncoder.drawIndexedPrimitives(type: .triangle,
                                         indexCount: submesh.indexCount,
                                         indexType: submesh.indexType,
                                         indexBuffer: submesh.indexBuffer.buffer,
                                         indexBufferOffset: submesh.indexBuffer.offset)
    color += 1
}

직접 만들어둔 Color 변수를 사용하여 Shader에서 사용하던 것을 Renderer에서 그려줄 때 넘겨주는 Color 값으로 변경하여 Render하도록 변경한다.

 

Conclusion

Untitled 16

  1. GPU Device 및 Command Queue를 설정하여 Swift에서 Metal을 설정한다.
  2. 실행할 Shader 함수와 함께 Render Pipeline State를 설정하여 GPU에서 Pipeline이 작동하는 방식을 설명한다.
  3. Metal View Delegate Draw 함수 기능을 사용하여 3D Model Vertex Buffer를 각각의 Frame 마다 GPU로 보낸다.
  4. Render Command Encoder는 Drawing Pass를 설명하고 GPU는 정점 함수를 실행하여 모든 정점의 위치를 계산한다.
  5. Rendering된 모든 Fragments의 색상을 설정하는 Fragment Function을 실행한다.

 

초기 설정을 수행한 다음 매 프레임마다 GPU 명령이 포함된 Encoder를 설정한다.

우리는 단 하나의 CommandEncoder, 즉 하나의 Render Pass만 가지고 있지만, 단일 Frame에 대한 그림자, 조명 및 반사를 구축하기 위해 많은 Render Pass를 가질 수 있다.

각 Render Pass는 Texutre로 Rendering되며 현재 View의 Default Texture로 Rendering하고 있지만 각 Pass에서 Rendering할 고유한 Metal Texture를 만들 수 있다.

Boilerplate Code가 많다고 생각할 수 있지만 한 번만 설정하면 된다. 또한 모든 부분을 조정할 수 있기 때문에 3D Rendering을 크게 제어할 수 있다.

반응형

댓글