Metal 系列教程(1)- Metal 介紹及基本使用

劉小蠻發表於2017-09-04

Metal 介紹及基本使用

最近做的一個技術研究,metal 的國內相關資料很少,所以整理了這一系列文章,希望能幫到有用的人。

什麼是 Metal

Metal 是一個和 OpenGL ES 類似的面向底層的圖形程式設計介面,通過使用相關的 api 可以直接操作 GPU ,最早在 2014 年的 WWDC 的時候釋出,並於今年釋出了 Metal 2。
Metal 是 iOS 平臺獨有的,意味著它不能像 OpenGL ES 那樣支援跨平臺,但是它能最大的挖掘蘋果移動裝置的 GPU 能力,進行復雜的運算,像 Unity 等遊戲引擎都通過 Metal 對 3D 能力進行了優化, App Store 還有相應的運用 Metal 技術的遊戲專題。

Metal 具有特點

  • GPU 支援的 3D 渲染
  • 和 CPU 並行處理資料 (深度學習)
  • 提供低功耗介面
  • 可以和 CPU 共享資源記憶體

這樣可能有些抽象,層級的關係大概如下,我們平時更多的接觸的上面兩層。:
UIKit -> Core Graphics -> Metal/OpenGL ES -> GPU Driver -> GPU

GPU 相關知識

為了更好的理解 Metal 的工作流程和機制,這裡補充一些 GPU 工作相關流程。

手機包含兩個不同的處理單元,CPU 和 GPU。CPU 是個多面手,並且不得不處理所有的事情,而 GPU 則可以集中來處理好一件事情,就是並行地做浮點運算。事實上,影象處理和渲染就是在將要渲染到視窗上的畫素上做許許多多的浮點運算。
通過有效的利用 GPU,可以成百倍甚至上千倍地提高手機上的影象渲染能力。如果不是基於 GPU 的處理,手機上實時高清視訊濾鏡是不現實,甚至不可能的。
精細到螢幕繪製的每一幀上,每次準備畫下一幀前,螢幕會發出一個垂直同步訊號(vertical synchronization),簡稱 VSync
螢幕通常以固定頻率進行重新整理,這個重新整理率就是 VSync 訊號產生的頻率。

一般來說,計算機系統中 CPU、GPU、螢幕是以上面這種方式協同工作的。CPU 計算好顯示內容提交到 GPU,GPU 渲染完成後將渲染結果放入幀緩衝區,隨後視訊控制器會按照 VSync 訊號逐行讀取幀緩衝區的資料,經過可能的數模轉換傳遞給螢幕顯示。

基礎流程

這邊以通過 Metal 渲染一個三角形作為例子,來介紹一下基本的使用。

Xcode 版本 8.3.3 ,語言 Objective-C

需要注意的是 Metal 必須在真機上執行,並且至少要是 A7 處理器,就是 5s 或者以上。

初始化

新建一個普通的工程 Single View Application,在 VC 中匯入 Metal Framework。

#import <Metal/Metal.h>複製程式碼
MTLDevice

都說是操作 GPU 了,當然我們要拿到 GPU 物件,Metal 中提供了 MTLDevice 的介面,代表了 GPU。


//獲取裝置
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
if (device == nil) {
    NSLog(@"don't support metal !");
    return;
}複製程式碼

當裝置不支援 Metal 的時候會返回空。

MTLDevice 代表 GPU 的介面,提供瞭如下的能力:

  • 查詢裝置狀態
  • 建立 buffer 和 texture
  • 指令轉換和佇列化渲染進行指令的計算
MTLCommandQueue

有了 GPU 之後,我們需要一個渲染佇列 MTLCommandQueue,佇列是單一佇列,確保了指令能夠按順序執行,裡面的是將要渲染的指令 MTLCommandBuffer,這是個執行緒安全的佇列,可以支援多個 CommandBuffer 同時編碼。
通過 MTLDevice 可以獲取佇列


id<MTLCommandQueue> queue = self.device.newCommandQueue;複製程式碼
MTKView

要用 Metal 來直接繪製的話,需要用特殊的介面 MTKView,同時給它設定對應的 device 為我們上面獲取到 MTLDevice,並把它新增到當前的介面中。

_mtkView = [[MTKView alloc] initWithFrame:self.view.frame device:_device];
[self.view addSubview:_mtkView];複製程式碼

渲染

我們配置好 MTLDevice,MTLCommandQueue 和 MTKView 之後,我們開始準備需要渲染到介面上的內容了,就是要塞進佇列中的緩衝資料 MTLCommandBuffer 。
簡單的流程就是先構造 MTLCommandBuffer ,再配置 CommandEncoder ,包括配置資原始檔,渲染管線等,再通過 CommandEncoder 進行編碼,最後才能提交到佇列中去。

MTLCommandBuffer

有了佇列之後,我們開始構建佇列中的 MTLCommandBuffer,一開始獲取的 Buffer 是空的,要通過 MTLCommandEncoder 編碼器來 Encode ,一個 Buffer 可以被多個 Encoder 進行編碼。

MTLCommandBuffer 是包含了多種型別的命令編碼 - 根據不同的 編碼器 決定 包含了哪些資料。 通常情況下,app 的一幀就是渲染為一個單獨的 Command Buffer。MTLCommandBuffer 是不支援重用的輕量級的物件,每次需要的時候都是獲取一個新的 Buffer。

Buffer 有方法可以 Label ,用來增加標籤,方便除錯時使用。

臨時物件,在執行之後,唯一有效的操作就是等到被執行或者完成的時候的回撥,同步或者通過 block 回撥,檢查 buffer 的執行結果。

建立

  • MTLCommandQueue - commandBuffer 方法 ,只能加到建立它的佇列中。
  • 獲取 retain 的物件 commandBufferWithUnretainedReferences 能夠重用 一般不推薦

這裡我們通過如下方法建立

//command buffer
    id<MTLCommandBuffer> commandBuffer = [_queue commandBuffer];複製程式碼

執行

  • enqueue 順序執行
  • commit 插隊儘快執行 (如果前面有 commit 就還是排隊等著)

監聽結果

commandBuffer.addCompletedHandler { (buffer) in
}
commandBuffer.waitUntilCompleted()

commandBuffer.addScheduledHandler { (buffer) in
}
commandBuffer.waitUntilScheduled()複製程式碼
建立 Metal 資源

接下來我需要把我們需要繪製的內容 encode 到我們上面生成 MTLCommandBuffer 中。

現在我們要配置需要繪製的內容,即資源。
在 Metal 中資源分為兩種:

  • MTLBuffer 代表著未格式化的記憶體,可以是任何型別的資料。 Buffer 用來做頂點著色和計算狀態。
  • MTLTexture 代表著有著特殊紋理型別和畫素格式的格式化的影象資料。用來做頂點,面和計算的源

我們這裡是要畫一個三角形,所以要有三個頂點,然後需要繪製三角形的圖片。
分別用 MTLBuffer 來讀入三個頂點。

在 Metal 中是歸一化的座標系,以螢幕中心為原點(0, 0, 0),且是始終不變的。面對螢幕,你的右邊是x正軸,上面是y正軸,螢幕指向你的為z正軸。長度單位這樣來定:視窗範圍按此單位恰好是(-1,-1)到(1,1),即螢幕左下角座標為(-1,-1),右上角座標為(1,1)。

所以我們要畫在中間一個正三角形的話,三個頂點分別為

(0.577, -0.25, 0.0, 1.0)
(-0.577, -0.25, 0.0, 1.0)
(0.0, 0.5, 0.0, 1.0)

在 Metal 裡面代表頂點需要 4 個 float ,代表 x,y,z,w。最後二位我們繪製 2D 介面的時候預設為0.0 和 1.0,w 是為了方便 3D 計算的。

我們要把頂點資料轉為位元組,通過 MTLDevice 的 - (id )newBufferWithBytes:(const void *)pointer length:(NSUInteger)length options:(MTLResourceOptions)options;
方法構造為 MTLBuffer 。

static const float vertexArrayData[] = {
        // 前 4 位 位置 x , y , z ,w
        0.577, -0.25, 0.0, 1.0,
        -0.577, -0.25, 0.0, 1.0,
        0.0,  0.5, 0.0, 1.0,
    };

id<MTLBuffer> vertexBuffer = [_device newBufferWithBytes:vertexArrayData
                                         length:sizeof(vertexArrayData)
                                        options:0];複製程式碼

有了頂點 Vertex 之後,我們來構建面 Fragment。這裡我們用一張圖片作為我們的三角形的貼圖。
首先獲取圖片的 image 物件:

UIImage *image = [UIImage imageNamed:name];複製程式碼

接下來通過 MTKTextureLoader 來構建 MTLTexture

 MTKTextureLoader *loader = [[MTKTextureLoader alloc]initWithDevice:self.device];
    NSError* err;
    id<MTLTexture> sourceTexture = [loader newTextureWithCGImage:image.CGImage options:nil error:&err];

    return sourceTexture;複製程式碼
Shader (著色器) 和 Pipeline (渲染管線)

資源有了,我們要告訴 GPU 怎麼去使用這些資料,這裡就需要 Shader 了,這部分程式碼是在 GPU 中執行的,所以要用特殊的語言去編寫,即 Metal Shading Language,它是 C++ 14的超集,封裝了一些 Metal 的資料格式和常用方法。
你可以新增多個 Metal 檔案,最後都會編譯到二進位制檔案default.metallib 中。
通過 Xcode 的 File - New - File 選單,新建一個 Metal 檔案。

meta
meta

新增下面兩個函式,分別代表頂點的處理函式,和 片段處理函式。

#include <metal_stdlib>

using namespace metal;


typedef struct
{
    float4 position;
    float2 texCoords;
} VertexIn;


typedef struct
{
    float4 position [[position]];
    float2 texCoords;
}VertexOut;



vertex VertexOut myVertexShader(const device VertexIn* vertexArray [[buffer(0)]],
                                unsigned int vid  [[vertex_id]]){

    VertexOut verOut;
    verOut.position = vertexArray[vid].position;
    verOut.texCoords = vertexArray[vid].texCoords;
    return verOut;

}






fragment float4 myFragmentShader(
                                VertexOut vertexIn [[stage_in]],
                            texture2d<float,access::sample>   inputImage   [[ texture(0) ]],
                                 sampler textureSampler [[sampler(0)]]
                             )
{
    float4 color = inputImage.sample(textureSampler, vertexIn.texCoords);
    return color;

}複製程式碼

兩個結構體
VertexIn 和 VertexOut
裡面的 float4 和 float2 代表著 4 個和 2 個浮點數的向量。
可以通過如下方式構造和取值,具體的不展開可以檢視相關文件。

float4(1.0) = float4(1.0,1.0,1.0,1.0)
float4 test = float4(1,2,3,4)
test.x = test.r = 1
test.y = test.g = 2
test.z = test.b = 3
test.w = test.a = 4
...複製程式碼

myVertexShader 為方法名,vertex 代表是一個頂點函式 VertexOut 代表返回值,該方法有兩個入參。

  • vertexArray 後面的 buff(0) 代表去後面配置的 index 為 0 的 MTLBuffer 資源
  • vid 代表著進入的頂點的 id 即順序。
    其實還有很多入參通過查閱文件可以看到

    • [[vertex_id]]
    • [[instance_id]]
    • [[base_vertex]]
    • [[base_instance]]

這裡可以對頂點進行處理,如轉向,3D 場景下的光影的計算等等,然後返回處理之後的頂點資訊,這裡直接返回,並沒有做額外的處理。

myFragmentShader 同上,fragment 代表是一個處理片段的方法,方法有三個入參

  • VertexOut vertexIn [[stage_in]] 代表著從頂點返回的頂點資訊

  • texture2d inputImage [[ texture(0) ]] 讀入的圖片資源

  • sampler textureSampler 取樣器

頂點著色器返回了 VertexOut 結構體,通過 [[stage_in]] 入參,它的值會是根據你的渲染的位置來插值。所以這個方法的主要內容就是根據,之前返回的頂點資訊,去影象中取樣得到相應位置的樣色,並返回顏色。

渲染管線

著色器這邊的工作已經完成,下面我們需要把它和我們的 CommandBuffer 關聯起來,就需要我們的 PipelineState 渲染管線了。

渲染管線就好比是 CPU 和 GPU 直接的管道,通過它來配置執行在 GPU 中的頂點和段著色器,就是我們寫在 metal 中的編譯好的程式碼,多個 c++ 函式的組合。

PipelineState 物件是執行緒安全的,所以這個物件是可以複用的,不同的 CommandBuffer 都可以使用它,建立它是有效能消耗的,建議和 Device 和 Queue 一起初始化並作為全域性物件。

生成 PipelineState 物件需要獲取我們剛剛寫在 Metal 中的幾個函式。
通過下面的方法,我們可以得到代表整個 Metal 的函式庫 MTLLibrary 物件。

id<MTLLibrary> library = [_device newDefaultLibrary];複製程式碼

通過 MTLLibrary 的 newFunctionWithName 方法,可以得到對應的方法。

[library newFunctionWithName:@"myVertexShader"];複製程式碼

下面我們開始構造我們的 MTLRenderPipelineState

    //構造Pipeline
    MTLRenderPipelineDescriptor *des = [MTLRenderPipelineDescriptor new];


    //獲取 shader 的函式
    id<MTLLibrary> library = [_device newDefaultLibrary];
    des.vertexFunction = [library newFunctionWithName:@"myVertexShader"];
    des.fragmentFunction = [library newFunctionWithName:@"myFragmentShader"];
    des.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;

    //生成 MTLRenderPipelineState
    NSError *error;
    _pipelineState = [_device newRenderPipelineStateWithDescriptor:des
                                                             error:&error];複製程式碼
MTLCommandEncoder 編碼器

有了資原始檔,渲染管線之後,我們可以開始做最後的步驟了,構造 MTLCommandEncoder 編碼器。
指令編碼器包括 渲染 計算 點陣圖複製三種編碼器。

  • MTLRenderCommandEncoder 渲染 3D 編碼器
  • MTLComputeCommandEncoder 計算編碼器
  • MTLBlitCommandEncoder 點陣圖複製編碼器 拷貝 buffer texture 同時也能生成 mipmap

mipmap 指的是一種紋理對映技術,將低一級影象的每邊的解析度取為高一級影象的每邊的解析度的二分之一,而同一級解析度的紋理組則由紅、綠、藍三個分量的紋理陣列組成。由於這一個查詢表包含了同一紋理區域在不同解析度下的紋理顏色值,因此被稱為 Mipmap。比如一張 64x64 的圖片,會生成 32x32,16x16 等,需要 20x20 的話就會用 32x32 和 16x16 的進行計算,大大的提高渲染的效率。

這裡我們是為了渲染一個三角形,所以這裡用的是 MTLRenderCommandEncoder 。
相關程式碼如下

  1. 建立 MTLRenderPassDescriptor 描述符 配置一些基本引數
  2. 通過描述符構建 Encoder
  3. 配置 VertexBuffer 後面的 index 就是 Shader 裡面對應 [[buffer[0]]] 的 0 【index 最多是 31 個】
  4. 配置 FragmentTexture
  5. 設定渲染的頂點配置(這裡設定為三角 從第一個頂點開始取 取 3 個)
  6. 編碼結束
 //render des
    MTLRenderPassDescriptor *renderDes = [MTLRenderPassDescriptor new];
    renderDes.colorAttachments[0].texture = drawable.texture;
    renderDes.colorAttachments[0].loadAction = MTLLoadActionClear;
    renderDes.colorAttachments[0].storeAction = MTLStoreActionStore;
    renderDes.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.65, 0.8, 1); //background color


    //command encoder
    id<MTLRenderCommandEncoder> encoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDes];
    [encoder setCullMode:MTLCullModeNone];
    [encoder setFrontFacingWinding:MTLWindingCounterClockwise];
    [encoder setRenderPipelineState:self.pipelineState];
    [encoder setVertexBuffer:self.vertexBuffer offset:0 atIndex:0];
    [encoder setFragmentTexture:textture atIndex:0];

   //set render vertex
    [encoder drawPrimitives:MTLPrimitiveTypeTriangle
                vertexStart:0
                vertexCount:3];

    [encoder endEncoding];複製程式碼

繪製

編碼結束之後,就可以開始準備提交到 GPU 了。
配置需要繪製的 Layer,獲取 MTKView 的 Layer 就可以。

CAMetalLayer *metalLayer = (CAMetalLayer*)[_mtkView layer];
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];

//commit
[commandBuffer presentDrawable:drawable];
[commandBuffer commit];複製程式碼

現在所有的工作就都完成了,執行專案就可以看到如下的三角形了,裡面填充的是我之前匯入的圖片。

除錯

如何進行除錯和評估效能呢?
這裡 iOS 提供了兩個工具

  • Xcode 中的 Capute GPU Frame
  • Instruments 中的 Metal System Trace

Capute GPU Frame
第一個是用來 Debug 的工具,執行的時候點選 Debug ,選擇 Capute GPU Frame,就會看到如下的介面,相關的說明我已經附在圖上了,用法和 Capute View Hierachy 很像。


比較強大的一個功能是點選動態更新的按鈕可以在修改完之後直接應用,避免了 app 編譯帶來的時間消耗。

Metal System Trace

  1. 開啟 Instruments 之後選擇需要除錯的應用
  2. 點選 record 之後開始錄製
  3. 完成之後點選停止,分析之後會有如下介面

從上到下分別是 Application 在 CPU 中執行,對應的是 Buffer 和 Encoder 的初始化工作
隨著箭頭往下是 Graphic Driver Activity ,在 GPU 驅動處理,這部分操作也是在 CPU 中。
再往下就是進入到 GPU 了,就部分才是真正的工作。
最後是到 Display 就是展示介面了,在 Display 下面是 Vsync 訊號,代表著同步訊號,用來重新整理介面。

放大之後可以看到詳細的 Buffer / Render ,而且上面顯示的名字,正是 之前設定的 Label 的名字。

總結

流程總結

最後我們再來通過下面這個圖來梳理下的流程。

  1. 配置 Device 和 Queue
  2. 獲取 CommandBuffer
  3. 配置 CommandBufferEncoder
  4. 配置 PipelineState
  5. 建立資源
  6. Encoder Buffer 【如有需要的話可以用 Threadgroups 來分組 Encoder 資料】
  7. 提交到 Queue 中

Metal 能力

根據不同的 CommandBufferEncoder 可以提供不同的能力,除了優秀的 3D 渲染能力,Metal 還能提供強大的計算能力。

在 WWDC 2015,蘋果釋出了 Metal Performance Shaders (MPS) 框架,iOS 9 上的一組高效能的影象濾鏡,其實就是邊寫好的 Shaders,提供了優秀的影象處理能力。同時還提供了高效能的矩陣運算的 Shaders ,能用來做機器學習的運算,在 GPU 上執行卷積神經網路。

而且非常棒的是,今年的 WWDC 2017 上 Metal 也將開始支援 macOS 。
更多的實踐可以參考蘋果的官方文件:
Metal 的最佳實踐

我們可以用來做什麼?

  • 圖片處理 濾鏡/調整
  • 視訊處理
  • 機器學習
  • 大計算工作 分擔 CPU 壓力

參考

MetalProgrammingGuide : developer.apple.com/library/con…
metal-image-processing : www.invasivecode.com/weblog/meta…
Metal Shading Language : developer.apple.com/metal/Metal…
the-metal-shading-language-in-practice : www.objc.io/issues/18-g…
metal-performance-shaders-in-swift : metalbyexample.com/metal-perfo…

相關文章