참조 : https://developer.apple.com/documentation/metal/using_a_render_pipeline_to_render_primitives?language=objc
metal의 파이프 라인 및 metal이 어떻게 그림을 그리는 지에 대해 저번화 내용을 포함하여 자세히 알아보도록 하겠습니다.
[1] Base Setup
앞선 00에서는 화면을 구성하는데 필요한 MTLView, MTLDevice를 이용하였습니다. 이러한 바탕에 화면을 그리기 위해서는 Renderer 클래스를 구현해야 합니다. 이는 MTLLibrary 및 MTLRenderPipelineState를 통해 파이프라인이 GPU 내에서 기하학 구조체에 대해 어떤 작업을 하는 지를 정의하고 이를 컴파일하여 저장하고 실행시킵니다. 이때 크게 vertex shader와 fragment shader가 파이프라인으로서 필요시됩니다. 이들은 Device로부터 생성된 MTLLibrary에 컴파일되어 저장됩니다. 이러한 동작은 Renderer 클래스의 buildShader() 멤버 함수에서 다음과 같은 코드로 구현이 됩니다.
MTL::Library* pLibrary = _pDevice->newLibrary( NS::String::string(shaderSrc, UTF8StringEncoding), nullptr, &pError );
if ( !pLibrary )
{
__builtin_printf( "%s", pError->localizedDescription()->utf8String() );
assert( false );
}
MTL::Function* pVertexFn = pLibrary->newFunction( NS::String::string("vertexMain", UTF8StringEncoding) );
MTL::Function* pFragFn = pLibrary->newFunction( NS::String::string("fragmentMain", UTF8StringEncoding) );
그 다음에 Render에서 MTLRenderPipelineDescriptor 객체를 생성하여 vertex shader 및 fragment shader를 지정하며, MTKView에서 사용하는 픽셀 형식을 지정합니다.
MTL::RenderPipelineDescriptor* pDesc = MTL::RenderPipelineDescriptor::alloc()->init();
pDesc->setVertexFunction( pVertexFn );
pDesc->setFragmentFunction( pFragFn );
pDesc->colorAttachments()->object(0)->setPixelFormat( MTL::PixelFormat::PixelFormatBGRA8Unorm_sRGB );
Device에서 만든 MTLRenderPipelineState 객체에 집어넣어 RenderPipeline을 정의하게 됩니다.
_pPSO = _pDevice->newRenderPipelineState( pDesc, &pError );
if ( !_pPSO )
{
__builtin_printf( "%s", pError->localizedDescription()->utf8String() );
assert( false );
}
MTLRenderPipelineState 객체는 Metal이 셰이더를 GPU 기계 코드로 변환하기 위해 컴파일러를 호출해야 하기 때문에 만드는 데 비용이 많이 들기에 프로그램 시작하거나 load 작업이 예상되는 지점에 파이프라인 구축을 하는 것이 좋습니다.
이전 정리와 이번 것을 합치게 되면 전체적인 도면은 다음과 같은 구성을 띄게 됩니다.

- MTLDevice
GPU 하드웨어 장치에 대한 소프트웨어 참조 형태를 띕니다. GPU의 인터페이스로 함수 라이브러리 및 텍스처와 같은 객체를 만드는 방법을 지원합니다. - MTLCommandQueue
모든 프레임에 MTLCommand Buffers를 생성하고 명령을 대기열에 넣고 장치에 submit하여 이를 실행하도록 돕습니다. - MTLCommandBuffer
실행을 위해 장치에 종속된 인코딩된 명령어들을 저장합니다. - MTLLibrary
정점 및 조각 셰이더 함수의 소스 코드를 포함합니다.MTL 라이브러리는 일반적으로 Xcode에 의해 애플리케이션에 컴파일되는 커널 함수(이 경우 컴퓨팅 셰이더)의 저장소입니다. - MTLRenderPipelineState
MTLLibrary가 생성되면 이에 대한 Metal Shader를 MTLFunction 형태로 저장하는데 이에 필요한 깊이 및 색상 설정, vertex 데이터 읽기 방법과 같은 것을 설정하여 수행되도록 합니다. - MTLBuffer
꼭지점 정보와 같은 데이터를 GPU로 보낼 수 있는 형식으로 저장합니다. - MTLCommandEncoder
MTLCommand Encoder는 명령어와 리소스를 명령 버퍼에 쓸 수 있는 바이트 코드로 인코딩하는 역할을 합니다.
여기서 하나의 MTLDevice, 하나의 MTLCommandQueue, 하나의 MTLLibrary 객체를 가지게 됩니다. 또한 다양한 파이프라인 상태를 정의하는 여러 MTLRenderPipelineState 개체와 데이터를 보관하는 여러 MTLBuffer가 존재하며, 파이프라인을 동작시키기 위해서는 이러한 객체들의 초기화를 위와 같이 수행해주어야만 합니다.
[2] Metal Render Pipeline

먼저 metal은 metal3에서 추가된 metalFX로 수행되는 geometry shader와 tessellation 단계를 빼고 얘기하면 크게 위와 같은 3단계로 나뉘어집니다.
- vertex stage
- rasterization stage
- fragment stage
이는 통상적인 그래픽 파이프라인과 동일하게 수행됩니다. 이때, Rasterization은 하드웨어에서 정의된 형식으로 수행되기에 fixed function이고, 나머지 vertex function 및 fragment function은 MSL 언어로 정의됩니다. 이번 예시는 삼각형을 구성하는 3개 vertex를 파이프라인에 통과시키기 위해 어떻게 코드를 구성하는 지에 대해 알아보겠습니다.
[Decide How Data is Processed by Your Custom Render Pipeline]
먼저 vertex에 대한 위치와 색 정보가 다음과 같이 주어집니다.
simd::float3 positions[NumVertices] =
{
{ -0.8f, 0.8f, 0.0f },
{ 0.0f, -0.8f, 0.0f },
{ +0.8f, 0.8f, 0.0f }
};
simd::float3 colors[NumVertices] =
{
{ 1.0, 0.3f, 0.2f },
{ 0.8f, 1.0, 0.0f },
{ 0.8f, 0.0f, 1.0 }
};
이를 MTLBuffer에 저장하여 CPU 및 GPU 양쪽에서 둘다 접근 가능하도록 MTL::ResourceStorageModeManaged 인자를 넣어 생성합니다. 그리고 이 공간에 정보를 memcpy를 통해 옮깁니다.
const size_t positionsDataSize = NumVertices * sizeof( simd::float3 );
const size_t colorDataSize = NumVertices * sizeof( simd::float3 );
MTL::Buffer* pVertexPositionsBuffer = _pDevice->newBuffer( positionsDataSize, MTL::ResourceStorageModeManaged );
MTL::Buffer* pVertexColorsBuffer = _pDevice->newBuffer( colorDataSize, MTL::ResourceStorageModeManaged );
_pVertexPositionsBuffer = pVertexPositionsBuffer;
_pVertexColorsBuffer = pVertexColorsBuffer;
렌더러는 버퍼의 didModifyRange() 메서드를 호출하여 CPU가 버퍼 내용에 데이터를 기록했음을 Metal에 나타냅니다. 렌더러가 렌더 파이프라인과 버퍼 객체를 만들면 삼각형을 그리기 위한 명령 인코딩을 시작할 수 있습니다. 이 샘플은 명령어를 명시적으로 인코딩하여 draw() 함수를 확장합니다.
void Renderer::draw( MTK::View* pView )
{
NS::AutoreleasePool* pPool = NS::AutoreleasePool::alloc()->init();
MTL::CommandBuffer* pCmd = _pCommandQueue->commandBuffer();
MTL::RenderPassDescriptor* pRpd = pView->currentRenderPassDescriptor();
MTL::RenderCommandEncoder* pEnc = pCmd->renderCommandEncoder( pRpd );
pEnc->setRenderPipelineState( _pPSO );
pEnc->setVertexBuffer( _pVertexPositionsBuffer, 0, 0 );
pEnc->setVertexBuffer( _pVertexColorsBuffer, 0, 1 );
pEnc->drawPrimitives( MTL::PrimitiveType::PrimitiveTypeTriangle, NS::UInteger(0), NS::UInteger(3) );
pEnc->endEncoding();
pCmd->presentDrawable( pView->currentDrawable() );
pCmd->commit();
pPool->release();
}
draw() 함수는 MTLRenderCommandEncoder를 생성한 후 인코더의 setRenderPipelineState() 메서드를 호출합니다. 그런 다음 정점 위치와 색상을 포함하는 버퍼를 설정하여 metal이 vertex shader에 인자로서 전달하도록 합니다.
[Write the Vertex Function]
struct v2f
{
float4 position [[position]];
half3 color;
};
v2f vertex vertexMain( uint vertexId [[vertex_id]],
device const float3* positions [[buffer(0)]],
device const float3* colors [[buffer(1)]] )
{
v2f o;
o.position = float4( positions[ vertexId ], 1.0 );
o.color = half3 ( colors[ vertexId ] );
return o;
}
vertex_id 속성 한정자를 사용한 첫번째 인자인 vertexId는 렌더 명령을 실행하면 GPU는 정점 함수를 여러 번 호출하여 각 vertex에 대해 고유한 값을 생성합니다.
buffer 한정자를 사용한 두 번째 인자와 세 번째는 각각 위에서 설정한 1번 buffer와 2번 buffer에서 가져오는 위치과 색 값의 배열입니다. 기본적으로 Metal은 각 매개변수에 대해 인수 테이블의 슬롯을 자동으로 할당하거나 명시적으로 알려줄 수도 있습니다. vertexId를 통해 해당 정점의 위치와 색 값을 가져오고, 이번 샘플의 경우는 그 값을 v2f라는 구조체 형태로 담아 다음 단계로 넘겨줍니다. 보통은 정점의 위치를 카메라에서 보이는 좌표로 옮겨주는 역할을 수행합니다.
[Primitive Assembly]

이전 단계에서는 데이터 블록으로 그룹화된 처리된 정점들이 현 단계를 거치게 되는데, 기억해야 할 중요한 점은 같은 기하학적 형태에 속하는 정점은 항상 같은 블록에 있어야 하는 것입니다. 즉, 점의 한 정점, 선의 두 정점, 삼각형의 세 정점은 항상 같은 블록에 있어야 합니다. 이때 가능한 기하학적 형태는, 점, 선, 삼각형이 존재하는데, 삼각형의 경우 정점이 시계 방향인지 반 시계인지에 따라서 면이 뒤를 향하는지 정면을 향하는지를 결정하게 되고,이에 따라 culling될 수도 있으며, 화면 범위를 벗어난다면 clipping됩니다.
[Rasterization]
래스터 단계에서는 화면을 픽셀로 변환하고 전달받은 정점을 연결한 모양을 만듭니다. 그리고 만든 모양만큼만 픽셀을 채웁니다.
아래는 삼각형 모양의 정점 데이터를 연결한 부분의 픽셀만 채워져 있는 그림입니다.

[Fragment Processing]
fragment 함수에서는 래스터화된 데이터를 받아서 각 픽셀의 색상을 결정해 주는 역할을 수행합니다. fragment shader는 vertex shader에서 반환한 값과 동일한 형태를 인자로 받습니다. 여기서 [[stage_in]] 한정사는 구조체로 생성된 데이터임을 명시합니다. 이번 샘플의 경우는 인자로 들어온 색을 그대로 반환하도록 함수가 작성되었습니다.
half4 fragment fragmentMain( v2f in [[stage_in]] )
{
return half4( in.color, 1.0 );
}
위는 예시와 코드를 살펴 보았고, 실제로 파이프라인이 구성되는 것을 보면 다음과 같습니다.

- Vertex Fetch은 메모리에서 정점을 가져와 Schduler 유닛으로 전달합니다.
- Scheduler 유닛은 사용 가능한 셰이더 코어를 알고 있기 때문에 이 코어에 대한 작업을 보냅니다.
- Distributer가 작업에 따라 Vertex Processing을 수행시키고 Primitive Assembly -> Rasterization를 차례로 수행시키고 다시 Scheduler로 보낼 것인지, Fragment Processing을 수행하여 Color Writing을 할 것인지 정합니다.
- 최종적으로는, 색이 칠해진 픽셀을 내보내게 됩니다.
'Projects > Metal - Document' 카테고리의 다른 글
| Metal-cpp 04 : Draw Multiple Instances of an Object (0) | 2022.08.25 |
|---|---|
| Customizing Render Pass Setup (0) | 2022.08.22 |
| Tailor Your Apps for Apple GPUs and Tile-Based Deferred Rendering (0) | 2022.08.22 |
| Metal-cpp 02 : Store Shader Arguments in a Buffer (0) | 2022.08.07 |
| Metal-cpp 00 : Create a Window for Metal Rendering (0) | 2022.08.04 |