零. 前言
OpenGL,一個(gè)被廣大語(yǔ)言運(yùn)用的庫(kù),在iOS12后,被蘋(píng)果打上了Deprecated的標(biāo)簽,如果現(xiàn)在的工程還引用著這個(gè)庫(kù),則會(huì)被不勝其煩地提示:該庫(kù)已過(guò)期。
把OpenGL踢出蘋(píng)果渲染舞臺(tái)的庫(kù),叫作Metal,官方也聲明已經(jīng)把底層渲染支持替換為Metal,并鼓勵(lì)開(kāi)發(fā)者使用Metal渲染,以取代工程中的OpenGL。雖然這個(gè)庫(kù)只有蘋(píng)果所用,但他的易讀性高、維護(hù)性強(qiáng)、Debug能力好、性能棒,足以成為蘋(píng)果推薦該庫(kù)的理由,蘋(píng)果特地在WWDC19介紹了Metal,并給出了如何將OpenGL遷移到Metal的指引,詳情看這個(gè)視頻:Bringing OpenGL Apps to Metal。
作為一個(gè)對(duì)圖形渲染一竅不通的小白,突然接到一個(gè)Metal相關(guān)的需求,一開(kāi)始過(guò)于急躁,像個(gè)無(wú)頭蒼蠅一樣亂撞,但遲遲找不到通往Metal開(kāi)發(fā)大門(mén)的入口,索性靜下心來(lái),好好從圖形渲染開(kāi)始理解,慢慢入門(mén),強(qiáng)迫自己書(shū)寫(xiě)Demo更新博客,慢工出細(xì)活地成長(zhǎng),也把自己理解的心路歷程與還沒(méi)入門(mén)的同學(xué)分享,希望能一舉兩得。
這是我對(duì)Metal的第一篇文章,目前的狀態(tài)是對(duì)Metal也是剛剛?cè)腴T(mén),希望自己能通過(guò)書(shū)寫(xiě)博客更好地成長(zhǎng),一步一個(gè)腳印,以完成自己的目標(biāo)。第一篇文章主要講解一下這些天來(lái)摸爬滾打搜集的一些資料和自己的一些入門(mén)級(jí)見(jiàn)解,如果有不對(duì)的地方歡迎指出探討。
一. 圖形渲染
工欲善其事必先利其器,如果對(duì)圖形學(xué)沒(méi)有一點(diǎn)入門(mén)理解,還是好好先看一看圖形渲染的步驟,最好了解一下OpenGL的工作原理,不要因?yàn)镺penGL在蘋(píng)果被廢棄掉了就對(duì)其嗤之以鼻,因?yàn)檫@個(gè)庫(kù)在蘋(píng)果以外的很多地方還是被廣泛應(yīng)用到的,學(xué)會(huì)了圖形渲染,對(duì)Metal的理解會(huì)有很大幫助。該篇章取自Learn OpenGL中文文檔。
1. 基本原理概括
在OpenGL中,任何事物都在3D空間中,而屏幕和窗口卻是2D像素?cái)?shù)組,這導(dǎo)致OpenGL的大部分工作都是關(guān)于把3D坐標(biāo)轉(zhuǎn)變?yōu)檫m應(yīng)你屏幕的2D像素。3D坐標(biāo)轉(zhuǎn)為2D坐標(biāo)的處理過(guò)程是由OpenGL的圖形渲染管線(Graphics Pipeline,大多譯為管線,實(shí)際上指的是一堆原始圖形數(shù)據(jù)途經(jīng)一個(gè)輸送管道,期間經(jīng)過(guò)各種變化處理最終出現(xiàn)在屏幕的過(guò)程)管理的。圖形渲染管線可以被劃分為兩個(gè)主要部分:第一部分把你的3D坐標(biāo)轉(zhuǎn)換為2D坐標(biāo),第二部分是把2D坐標(biāo)轉(zhuǎn)變?yōu)閷?shí)際的有顏色的像素。
圖形渲染管線接受一組3D坐標(biāo),然后把它們轉(zhuǎn)變?yōu)槟闫聊簧系挠猩?D像素輸出。圖形渲染管線可以被劃分為幾個(gè)階段,每個(gè)階段將會(huì)把前一個(gè)階段的輸出作為輸入。所有這些階段都是高度專(zhuān)門(mén)化的(它們都有一個(gè)特定的函數(shù)),并且很容易并行執(zhí)行。正是由于它們具有并行執(zhí)行的特性,當(dāng)今大多數(shù)顯卡都有成千上萬(wàn)的小處理核心,它們?cè)贕PU上為每一個(gè)(渲染管線)階段運(yùn)行各自的小程序,從而在圖形渲染管線中快速處理你的數(shù)據(jù)。這些小程序叫做著色器(Shader)。
以下是圖形渲染管線的每個(gè)階段的抽象展示,也是渲染圖片的一個(gè)重要步驟,相當(dāng)于給一幅畫(huà)勾勒出線條,再上色,三維混合(如有必要),以達(dá)到我們想要的圖畫(huà)效果。

2. 圖形渲染的根基——三角形與像素點(diǎn)
在圖形渲染中,有個(gè)非常非常非常重要的概念——三角形,可以這樣說(shuō),如果呈現(xiàn)在屏幕上的圖像是一座美麗的布達(dá)拉宮,那么三角形就是里面的一座地基、一根根柱子。
而你所看到的前三個(gè)步驟,就是從幾個(gè)點(diǎn),以三角形的方式勾勒出了整個(gè)線條。而第四個(gè)步驟則把線條做成一格一格的像素點(diǎn)。
頂點(diǎn)著色器:該階段的輸入是頂點(diǎn)數(shù)據(jù)(Vertex Data) 數(shù)據(jù),比如以數(shù)組的形式傳遞 3 個(gè) 3D 坐標(biāo)用來(lái)表示一個(gè)三角形。頂點(diǎn)數(shù)據(jù)是一系列頂點(diǎn)的集合。頂點(diǎn)著色器主要的目的是把 3D 坐標(biāo)轉(zhuǎn)為另一種 3D 坐標(biāo),同時(shí)頂點(diǎn)著色器可以對(duì)頂點(diǎn)屬性進(jìn)行一些基本處理。
形狀(圖元)裝配:該階段將頂點(diǎn)著色器輸出的所有頂點(diǎn)作為輸入,并將所有的點(diǎn)裝配成指定圖元的形狀。圖中則是一個(gè)三角形。圖元(Primitive) 用于表示如何渲染頂點(diǎn)數(shù)據(jù),如:點(diǎn)、線、三角形。
幾何著色器:該階段把圖元形式的一系列頂點(diǎn)的集合作為輸入,它可以通過(guò)產(chǎn)生新頂點(diǎn)構(gòu)造出新的(或是其它的)圖元來(lái)生成其他形狀。例子中,它生成了另一個(gè)三角形。(其實(shí)個(gè)人覺(jué)得這里應(yīng)該加多個(gè)頂點(diǎn)才對(duì),不然好像有點(diǎn)讓人誤解多出來(lái)的那條線是怎么來(lái)的)
光柵化階段(Rasterization Stage):根據(jù)幾何著色器的輸出,把圖元映射為最終屏幕上相應(yīng)的像素,生成供片段著色器(Fragment Shader)使用的片段(Fragment)。
3. 紋理、采樣與著色
到光柵化這一步,我們已經(jīng)可以獲取到未被上色的像素了,一個(gè)圖像有了初步的一些輪廓,那么他是怎么被上色,甚至被組合形成一個(gè)三維圖案的呢?片段著色器就是上色的重要一環(huán)了。
- 片段著色器的主要目的是計(jì)算一個(gè)像素的最終顏色,這也是所有OpenGL高級(jí)效果產(chǎn)生的地方。通常,片段著色器包含3D場(chǎng)景的數(shù)據(jù)(比如光照、陰影、光的顏色等等),這些數(shù)據(jù)可以被用來(lái)計(jì)算最終像素的顏色。
那么,他的顏色從哪里來(lái)呢?程序員可以根據(jù)自己想要的顏色進(jìn)行上色,即直接在片段著色器寫(xiě)死顏色的rgba值,比如生成一個(gè)橘色的三角形:

那如果我們想讀取一張圖片渲染到上面去呢?像下面一樣,把羅伊斯的照片貼到屏幕上去。

這時(shí)候需要引入一個(gè)同樣重要的概念:紋理。
- 紋理是一個(gè)2D圖片(甚至也有1D和3D的紋理),它可以用來(lái)添加物體的細(xì)節(jié);你可以想象紋理是一張繪有磚塊的紙,無(wú)縫折疊貼合到你的3D的房子上,這樣你的房子看起來(lái)就像有磚墻外表了。因?yàn)槲覀兛梢栽谝粡垐D片上插入非常多的細(xì)節(jié),這樣就可以讓物體非常精細(xì)而不用指定額外的頂點(diǎn)。
上面的概念可能有點(diǎn)籠統(tǒng),在渲染的知識(shí)里面,你需要暫時(shí)先將一張圖片看成一個(gè)一個(gè)像素點(diǎn),采樣器(sampler)將圖片上的像素點(diǎn)一一采樣,再映射到已經(jīng)光柵化的像素點(diǎn)中,使其上色,最終得到一個(gè)個(gè)上色后的像素點(diǎn)。后文會(huì)著重介紹怎么采樣紋理和給光柵化像素上色。
最后,如果涉及到3D渲染(本文暫不涉及),該階段會(huì)檢測(cè)片段的對(duì)應(yīng)的深度值(z 坐標(biāo)),判斷這個(gè)像素位于其它物體的前面還是后面,決定是否應(yīng)該丟棄。此外,該階段還會(huì)檢查 alpha 值( alpha 值定義了一個(gè)物體的透明度),從而對(duì)物體進(jìn)行混合。因此,即使在片段著色器中計(jì)算出來(lái)了一個(gè)像素輸出的顏色,在渲染多個(gè)三角形的時(shí)候最后的像素顏色也可能完全不同。
4. 重點(diǎn)——頂點(diǎn)著色器與片段著色器
前面主要給大家介紹了從0到1的渲染過(guò)程,那么本文則會(huì)著重介紹一下MSL(Metal Shader Language) 給我們提供的接口,也就是說(shuō),我們只需要著手這兩個(gè)著色器的開(kāi)發(fā),其他步驟無(wú)需我們動(dòng)手。
在基于Metal介紹這兩個(gè)著色器之前,請(qǐng)大家再著重復(fù)習(xí)一下幾個(gè)重要的概念:
像素:一個(gè)圖像由許多許多像素組成。
頂點(diǎn)著色器:將原圖像的3D坐標(biāo)轉(zhuǎn)換成適應(yīng)屏幕的3D坐標(biāo),同時(shí)建立需要繪制的頂點(diǎn)坐標(biāo) 與 需要采樣的紋理坐標(biāo)的映射關(guān)系。在開(kāi)發(fā)中,我們需要預(yù)先設(shè)好頂點(diǎn)坐標(biāo)與紋理坐標(biāo)的映射,供系統(tǒng)內(nèi)部光柵化處理,最后傳到片段著色器中。
紋理:用于被采樣器采樣,給片段著色器上色的圖像。在開(kāi)發(fā)中,我們需要讀取圖像的字節(jié),調(diào)用接口生成紋理。
片段著色器:基于頂點(diǎn)著色器的輸出、紋理的采樣結(jié)果,輸出一個(gè)個(gè)著色后的像素,這些像素組成了一整個(gè)圖像。在開(kāi)發(fā)中,我們需要根據(jù)頂點(diǎn)著色器輸出(光柵化處理后)的數(shù)據(jù)、紋理數(shù)據(jù),對(duì)紋理進(jìn)行采樣,并輸出該光柵化像素對(duì)應(yīng)的rgba。(多個(gè)像素即為一張圖片)
接下來(lái)將會(huì)介紹Metal如何運(yùn)用上面幾個(gè)概念,在屏幕上渲染出一張圖片出來(lái),如果讀到后面有疑惑,不妨回頭再看看這幾個(gè)概念和他們的職能。
二. Why Metal?
在進(jìn)行Metal開(kāi)發(fā)之前,需要思考一個(gè)問(wèn)題,為什么要用Metal進(jìn)行開(kāi)發(fā),這句疑問(wèn)代表著兩個(gè)含義:第一,Metal在蘋(píng)果開(kāi)發(fā)中承擔(dān)著什么樣的角色;第二,為什么是Metal而不是OpenGL,下面會(huì)對(duì)這兩個(gè)疑問(wèn)進(jìn)行解答。
1. Metal在蘋(píng)果開(kāi)發(fā)中承擔(dān)著什么樣的角色
理解Metal擔(dān)任的角色之前,需要先了解一下CPU、GPU和顯示器的概念:
手機(jī)包含兩個(gè)不同的處理單元,CPU 和 GPU。CPU 是個(gè)多面手,并且不得不處理所有的事情,而 GPU 則可以集中來(lái)處理好一件事情,就是并行地做浮點(diǎn)運(yùn)算。事實(shí)上,圖像處理和渲染就是在將要渲染到窗口上的像素上做許許多多的浮點(diǎn)運(yùn)算。通過(guò)有效的利用 GPU,可以成百倍甚至上千倍地提高手機(jī)上的圖像渲染能力。下面的流程圖顯示了一個(gè)圖像渲染到屏幕的流程。

通過(guò)流程圖我們可以看到,在我們?nèi)粘5匿秩局?,OpenGL/Metal已經(jīng)默默地替我們承擔(dān)了很多渲染的操作,如果感興趣可以在iOS 圖像渲染原理看看這些圖像是怎么一步步渲染下去的。


總的來(lái)說(shuō),Metal擔(dān)任的就是CPU和GPU交互的一個(gè)橋梁,他負(fù)責(zé)一個(gè)管理圖形渲染的隊(duì)列,在屏幕刷新一幀的時(shí)候,將隊(duì)列的內(nèi)容提交給GPU,以及時(shí)地渲染到屏幕上。
即:CPU => Metal => GPU => 顯示器
2. 為什么是Metal而不是OpenGL
對(duì)于有著超過(guò)25年歷史的 OpenGL 技術(shù)本身,隨著現(xiàn)代圖形技術(shù)的發(fā)展,遇到了一些問(wèn)題:
- 現(xiàn)代 GPU 的渲染管線已經(jīng)發(fā)生變化。
- 不支持多線程操作。
- 不支持異步處理。
- 較為復(fù)雜的開(kāi)發(fā)語(yǔ)言。
隨著圖形學(xué)的發(fā)展,OpenGL 本身設(shè)計(jì)上存在的問(wèn)題已經(jīng)影響了 GPU 真正性能的發(fā)揮,因此 Apple 設(shè)計(jì)了 Metal。
為了解決這些問(wèn)題,Metal 誕生了。
它為現(xiàn)代 GPU 設(shè)計(jì),并面向 OpenGL 開(kāi)發(fā)者。它擁有:
- 更高效的 GPU 交互,更低的 CPU 負(fù)荷。
- 支持多線程操作,以及線程間資源共享能力。
- 支持資源和同步的控制。
- 語(yǔ)言更符合開(kāi)發(fā)者的開(kāi)發(fā)習(xí)慣。
- 可逐幀調(diào)試。
Metal 簡(jiǎn)化了 CPU 參與渲染的步驟,盡可能地讓 GPU 去控制資源。與此同時(shí),擁有更現(xiàn)代的設(shè)計(jì),使操作處于可控,結(jié)果可預(yù)測(cè)的狀態(tài)。在優(yōu)化設(shè)計(jì)的同時(shí),它仍然是一個(gè)直接訪問(wèn)硬件的框架。與 OpenGL 相比,它更加接近于 GPU,以獲得更好的性能。
Metal早在2014年就已經(jīng)被蘋(píng)果推出,并在WWDC2018宣稱(chēng)OpenGL ES 將于 iOS 12 棄用。當(dāng)在真機(jī)上調(diào)試 OpenGL 程序時(shí),控制臺(tái)會(huì)打印出啟用 Metal 的日志。根據(jù)這一點(diǎn)可以猜測(cè),Apple 已經(jīng)實(shí)現(xiàn)了一套機(jī)制將 OpenGL 命令無(wú)縫橋接到 Metal 上,由 Metal 擔(dān)任真正于硬件交互的工作。而OpenGL未來(lái)會(huì)不會(huì)被永久拋棄,我們不得而知。
三. How Metal?
好了,鋪墊了這么多理論知識(shí),下面應(yīng)該開(kāi)始手動(dòng)實(shí)操了,我們今天的目的,是用Metal語(yǔ)言,將一張圖片繪制到屏幕上去:

1. Metal API
Metal的簡(jiǎn)要流程圖如下:

- 命令緩存區(qū)(Command Buffer)是從命令隊(duì)列(Command Queue)創(chuàng)建的
- 命令編碼器(Command Encoder)將命令編碼到命令緩存區(qū)中
- 提交命令緩存區(qū)并將其發(fā)送到GPU
- GPU執(zhí)行命令并將結(jié)果呈現(xiàn)為可繪制
那么,我們要實(shí)現(xiàn)一個(gè)Metal圖像的繪制,需要用到哪些API呢?
(1) MTKView與MTLDevice
在MetalKit中提供了一個(gè)視圖類(lèi)MTKView,類(lèi)似于GLKit中GLKView,它是NSView(macOS中的視圖類(lèi))或者UIView(iOS、tvOS中的視圖類(lèi))的子類(lèi)。用于處理metal繪制并顯示到屏幕過(guò)程中的細(xì)節(jié)。
MTLDevice代表GPU設(shè)備,提供創(chuàng)建緩存、紋理等的接口,在初始化時(shí)候需要賦給MTKView
// 初始化MTKView
self.mtkView = [[MTKView alloc] init];
self.mtkView.delegate = self;
self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
self.mtkView.frame = self.view.bounds;
[self.view addSubview:self.mtkView];
MTKView的Delegate是MTKViewDelegate,我們必須實(shí)現(xiàn)這個(gè)協(xié)議的方法:
- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
// MTKView的大小改變
self.viewportSize = (vector_uint2){size.width, size.height};
}
- (void)drawInMTKView:(MTKView *)view {
// 用于向著色器傳遞數(shù)據(jù)
...具體實(shí)現(xiàn)
}
(2) MTLCommandQueue
在獲取了GPU后,還需要一個(gè)渲染隊(duì)列,即命令隊(duì)列Command Queue類(lèi)型是MTLCommandQueue,該隊(duì)列是與GPU交互的第一個(gè)對(duì)象,隊(duì)列中存儲(chǔ)的是將要渲染的命令MTLCommandBuffer。
隊(duì)列的獲取需要通過(guò)MTLDevice對(duì)象獲取,且每個(gè)命令隊(duì)列的生命周期很長(zhǎng),因此commandQueue可以重復(fù)使用,而不是頻繁創(chuàng)建和銷(xiāo)毀。
_commandQueue = [_device newCommandQueue];
(3) MTLRenderPipelineState
渲染管道狀態(tài) Render Pipeline State是一個(gè)協(xié)議,定義了圖形渲染管道的狀態(tài),包括放在.metal文件的頂點(diǎn)和片段函數(shù)。
(4) MTLTexture
紋理 MTLTexture表示一個(gè)圖片數(shù)據(jù)的紋理,關(guān)于紋理前面的介紹已經(jīng)很多了,可以往前回顧一下。我們可以根據(jù)紋理描述器 MTLTextureDescriptor來(lái)生成MTLTexture
(5) MTLBuffer
代表一個(gè)我們自定義的數(shù)據(jù)存儲(chǔ)資源對(duì)象,在本章中,用于存儲(chǔ)頂點(diǎn)與紋理坐標(biāo)數(shù)據(jù),通過(guò)MTLDevice獲取。
(6) MTLCommandBuffer
命令緩存區(qū) Command Buffer主要是用于存儲(chǔ)編碼的命令,其生命周期是知道緩存區(qū)被提交到GPU執(zhí)行為止,單個(gè)的命令緩存區(qū)可以包含不同的編碼命令,主要取決于用于構(gòu)建它的編碼器的類(lèi)型和數(shù)量。
命令緩存區(qū)的創(chuàng)建可以通過(guò)調(diào)用MTLCommandQueue的commandBuffer方法。且command buffer對(duì)象的提交只能提交至創(chuàng)建它的MTLCommandQueue對(duì)象中
commandBuffer在未提交命令緩存區(qū)之前,是不會(huì)開(kāi)始執(zhí)行的,提交后,命令緩存區(qū)將按其入隊(duì)的順序執(zhí)行,使用[commandBuffer commit]提交命令。
- (void)drawInMTKView:(MTKView *)view {
// 用于向著色器傳遞數(shù)據(jù)
id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
... 設(shè)置MTLRenderCommandEncoder進(jìn)行Encode
// 提交
[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];
}
(7) MTLRenderCommandEncoder
渲染命令編碼器 Render Command Encoder表示單個(gè)渲染過(guò)程中相關(guān)聯(lián)的渲染狀態(tài)和渲染命令,有以下功能:
- 指定圖形資源,例如緩存區(qū)和紋理對(duì)象,其中包含頂點(diǎn)、片元、紋理圖片數(shù)據(jù)
- 指定一個(gè)
MTLRenderPipelineState對(duì)象,表示編譯的渲染狀態(tài),包含頂點(diǎn)著色器和片元著色器的編譯&鏈接情況 - 指定固定功能,包括視口、三角形填充模式、剪刀矩形、深度、模板測(cè)試以及其他值
- 繪制3D圖元
由當(dāng)前隊(duì)列的緩沖MTLCommandBuffer根據(jù)描述器MTLRenderPassDescriptor的接口獲?。ㄟ@個(gè)可以通過(guò)MTKView的currentRenderPassDescriptor拿到,代表每一幀當(dāng)前渲染視圖的一些紋理、緩沖、大小等數(shù)據(jù)的描述器)。
id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
if (!renderDesc) {
[commandBuffer commit];
return;
}
// 獲取MTLRenderCommandEncoder
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
然后需要對(duì)之前提到的MTLRenderPipelineState(映射.metal文件用)、MTLTexture(讀取圖片獲得的紋理數(shù)據(jù))、MTLBuffer(頂點(diǎn)坐標(biāo)和紋理坐標(biāo)構(gòu)成的緩沖)進(jìn)行設(shè)置,最后調(diào)用drawPrimitives進(jìn)行繪制,再endEncoding。
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
[renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
// 映射.metal文件的方法
[renderEncoder setRenderPipelineState:self.pipelineState];
// 設(shè)置頂點(diǎn)數(shù)據(jù)
[renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
// 設(shè)置紋理數(shù)據(jù)
[renderEncoder setFragmentTexture:self.texture atIndex:0];
// 開(kāi)始繪制
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
// 結(jié)束渲染
[renderEncoder endEncoding];
// 提交
[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];
和OpenGL一樣,我們可以使用4個(gè)頂點(diǎn)來(lái)繪制一個(gè)矩形,修改drawPrimitives:的參數(shù)為MTLPrimitiveTypeTriangleStrip,然后頂點(diǎn)順序?yàn)閦字形即可。
2. Metal在OC/Swift層的渲染步驟
了解到以上用到的API后,我們就可以開(kāi)始介紹一下渲染步驟了:
首先我們需要初始化,把本次渲染只需創(chuàng)建一次的內(nèi)容初始化出來(lái),包括:MTKView、MTLCommandQueue、MTLRenderPipelineState。
- (void)setupMTKView {
// 初始化MTKView
self.mtkView = [[MTKView alloc] init];
self.mtkView.delegate = self;
self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
self.mtkView.frame = self.view.bounds;
[self.view addSubview:self.mtkView];
}
- (void)setupPineline {
// 初始化pipelineState
MTLRenderPipelineDescriptor *pinelineDesc = [MTLRenderPipelineDescriptor new];
id <MTLLibrary> library = [_device newDefaultLibrary];
pinelineDesc.vertexFunction = [library newFunctionWithName:@"vertexShader"];
pinelineDesc.fragmentFunction = [library newFunctionWithName:@"fragmentShader"];
pinelineDesc.colorAttachments[0].pixelFormat = self.mtkView.colorPixelFormat;
self.pipelineState = [_device newRenderPipelineStateWithDescriptor:pinelineDesc error:nil];
}
- (void)setupCommandQueue {
// 初始化commandQueue
self.commandQueue = [_device newCommandQueue];
}
然后需要預(yù)先加載好紋理數(shù)據(jù),因?yàn)檫@里我們用到了圖片,所以需要讀取圖片對(duì)應(yīng)的字節(jié)
- (Byte *)loadImage:(UIImage *)image {
CGImageRef imageRef = image.CGImage;
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
Byte *data = (Byte *)calloc(width * height * 4, sizeof(Byte)); // rgba 4個(gè)字節(jié)
CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, CGImageGetColorSpace(imageRef), kCGImageAlphaPremultipliedLast);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
CGContextRelease(context);
return data;
}
再根據(jù)圖像字節(jié)獲取到id <MTLTexture>類(lèi)型的紋理數(shù)據(jù):
- (void)setupFragment {
UIImage *image = self.image;
MTLTextureDescriptor *textureDesc = [MTLTextureDescriptor new];
textureDesc.pixelFormat = MTLPixelFormatRGBA8Unorm;
textureDesc.width = image.size.width;
textureDesc.height = image.size.height;
self.texture = [_device newTextureWithDescriptor:textureDesc];
MTLRegion region = {
{0, 0, 0},
{textureDesc.width, textureDesc.height, 1}
};
Byte *imageBytes = [self loadImage:image];
if (imageBytes) {
[self.texture replaceRegion:region mipmapLevel:0 withBytes:imageBytes bytesPerRow:image.size.width * 4];
free(imageBytes);
imageBytes = NULL;
}
}
然后我們需要設(shè)置頂點(diǎn)數(shù)據(jù),這里需要說(shuō)明一下Metal的坐標(biāo)系:
頂點(diǎn)坐標(biāo)系是四維的(x, y, z, w),原點(diǎn)在圖片的正中心。

紋理坐標(biāo)系是二維的(x, y),原點(diǎn)在圖片的左上角。

得結(jié)構(gòu)體:
typedef struct {
vector_float4 position;
vector_float2 textureCoordinate;
} HobenVertex;
當(dāng)我們需要繪制一個(gè)矩形圖片時(shí),需要將頂點(diǎn)坐標(biāo)和紋理坐標(biāo)一一對(duì)應(yīng)
float heightScaling = 1.0;
float widthScaling = 1.0;
HobenVertex vertices[] = {
// 頂點(diǎn)坐標(biāo) x, y, z, w --- 紋理坐標(biāo) x, y
{ {-widthScaling, heightScaling, 0.0, 1.0}, {0.0, 0.0} },
{ { widthScaling, heightScaling, 0.0, 1.0}, {1.0, 0.0} },
{ {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
{ { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
};
配置好MTKView、MTLDevice以及MTLCommandQueue后,也設(shè)置好紋理數(shù)據(jù)后,接下來(lái)我們就開(kāi)始處理渲染回調(diào)了。
前文有提到,渲染回調(diào)主要是設(shè)置好MTLCommandBuffer的數(shù)據(jù),并且commit掉,而這個(gè)過(guò)程中,主要是把紋理、頂點(diǎn)等數(shù)據(jù)放進(jìn).metal文件處理,獲取到對(duì)應(yīng)像素的顏色。
#pragma mark - MTKViewDelegate
- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
// MTKView的大小改變
self.viewportSize = (vector_uint2){size.width, size.height};
}
- (void)drawInMTKView:(MTKView *)view {
// 用于向著色器傳遞數(shù)據(jù)
id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
if (!renderDesc) {
[commandBuffer commit];
return;
}
renderDesc.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.5, 0.5, 1);
[self setupVertex:renderDesc];
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
[renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
// 映射.metal文件的方法
[renderEncoder setRenderPipelineState:self.pipelineState];
// 設(shè)置頂點(diǎn)數(shù)據(jù)
[renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
// 設(shè)置紋理數(shù)據(jù)
[renderEncoder setFragmentTexture:self.texture atIndex:0];
// 開(kāi)始繪制
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
// 結(jié)束渲染
[renderEncoder endEncoding];
// 提交
[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];
}
簡(jiǎn)要回顧介紹一下流程圖:
--- 初始化階段 ---
- 配置 Device 、 Queue、MTKView(初始化階段,只初始化一次)
- 配置 PipelineState (設(shè)置和.metal文件映射方法,只初始化一次)
- 創(chuàng)建資源,讀取紋理MTLTexture(只初始化一次)
- 設(shè)置頂點(diǎn)MTLBuffer(最好只初始化一次)
--- 渲染階段,drawInMTKView回調(diào),每幀渲染一次 ---
- 根據(jù)Queue獲取 CommandBuffer
- 根據(jù)CommandBuffer和RenderPassDescriptor配置 CommandBufferEncoder
- Encoder Buffer 【如有需要的話可以用 Threadgroups 來(lái)分組 Encoder 數(shù)據(jù)】
--- 結(jié)束,提交渲染命令,在完成渲染后,將命令緩存區(qū)提交至GPU ---
-
提交到 Queue 中
3. Metal在Shader層的渲染步驟
Metal Shader語(yǔ)言,即MSL,是基于C++ 11.0設(shè)計(jì)的,關(guān)于語(yǔ)言規(guī)范有個(gè)超詳細(xì)的官方文檔,也有別人博客總結(jié)的太長(zhǎng)不看版,當(dāng)你讀到這里的時(shí)候可能會(huì)比較懵,可以再回到第一章節(jié)復(fù)習(xí)一下渲染的步驟和概念,著重看一下頂點(diǎn)著色器、片段著色器和紋理的概念,再繼續(xù)看。這一節(jié)簡(jiǎn)單地講講本次需求需要的.metal文件。
1) 結(jié)構(gòu)體
MSL的結(jié)構(gòu)體可以自定義,但是對(duì)于渲染來(lái)說(shuō),一般至少需要這兩種數(shù)據(jù):頂點(diǎn)坐標(biāo)(xyzw四維)、紋理坐標(biāo)(xy兩維),這里我們定義一個(gè)包含上述兩個(gè)變量的數(shù)據(jù)結(jié)構(gòu):
typedef struct {
float4 vertexPosition [[ position ]];
float2 textureCoor;
} RasterizerData;
[[ position ]]是一個(gè)句柄,即聲明了vertexPosition這個(gè)變量是[[ position ]]類(lèi)型的,這個(gè)類(lèi)型的變量表明:
在頂點(diǎn)著色函數(shù)中,表示當(dāng)前的頂點(diǎn)信息,類(lèi)型是float4
還可以表示描述了片元的窗口的相對(duì)坐標(biāo)(x,y,z,1/w),即該像素點(diǎn)在屏幕上的位置信息
我們聲明了一個(gè)結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體會(huì)在頂點(diǎn)著色器(Vertex Shader)生成,經(jīng)過(guò)系統(tǒng)處理(形狀裝配、幾何著色器、光柵化)后,作為結(jié)構(gòu)體來(lái)到片段著色器(Fragment Shader)。
2) 頂點(diǎn)著色器
著色器函數(shù)和C++函數(shù)大同小異,有一個(gè)聲明,有一個(gè)返回值,一個(gè)函數(shù)名,n個(gè)輸入。
vertex RasterizerData vertexShader(uint vertexId [[ vertex_id ]],
constant HobenVertex *vertexArray [[ buffer(0) ]]) {
RasterizerData out;
out.vertexPosition = vertexArray[vertexId].position;
out.textureCoor = vertexArray[vertexId].textureCoordinate;
return out;
}
頂點(diǎn)著色器以vertex為修飾符,返回RasterizerData數(shù)據(jù)結(jié)構(gòu)并作為片段著色器的輸入,需要輸入索引和頂點(diǎn)緩存數(shù)組。
[[ vertex_id ]] 是頂點(diǎn)id標(biāo)識(shí)符,即索引,他并不由開(kāi)發(fā)者傳遞;
[[buffer(index)]] 是index的緩存類(lèi)型,對(duì)應(yīng)OC語(yǔ)言的
[renderEncoder setVertexBuffer:buffer offset:0 atIndex:index];
這里的buffer就是我們事先設(shè)置好的坐標(biāo)映射:
HobenVertex vertices[] = {
// 頂點(diǎn)坐標(biāo) x, y, z, w --- 紋理坐標(biāo) x, y
{ {-widthScaling, heightScaling, 0.0, 1.0}, {0.0, 0.0} },
{ { widthScaling, heightScaling, 0.0, 1.0}, {1.0, 0.0} },
{ {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
{ { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
};
我們根據(jù)OC傳入的一堆HobenVertex類(lèi)型的頂點(diǎn)和對(duì)應(yīng)的索引,將其轉(zhuǎn)化為MSL對(duì)應(yīng)的結(jié)構(gòu)體RasterizerData,頂點(diǎn)著色器渲染完畢。
3) 片段著色器
當(dāng)系統(tǒng)處理好一切,返回給我們一個(gè)光柵化后的數(shù)據(jù)時(shí),我們需要根據(jù)OC傳入的紋理數(shù)據(jù)進(jìn)行采樣、上色。
fragment float4 fragmentShader(RasterizerData input [[ stage_in ]],
texture2d <float> colorTexture [[ texture(0) ]]) {
constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);
float4 colorSample = colorTexture.sample(textureSampler, input.textureCoor);
return float4(colorSample);
}
片段著色器以fragment為修飾符,返回float4數(shù)據(jù)結(jié)構(gòu)(即該像素的rgba),需要輸入光柵化處理好的數(shù)據(jù)和紋理數(shù)據(jù)。
[[ stage_in ]]是由頂點(diǎn)著色函數(shù)輸出然后經(jīng)過(guò)光柵化生成的數(shù)據(jù),這是系統(tǒng)生成的,無(wú)需我們進(jìn)行設(shè)置和輸入。
[[ texture(index) ]]代表紋理數(shù)據(jù),index對(duì)應(yīng)OC語(yǔ)言設(shè)置里面的
// 設(shè)置紋理數(shù)據(jù)
[renderEncoder setFragmentTexture:texture atIndex:index];
texture2d<T, access a = access::sample>代表這是一個(gè)紋理數(shù)據(jù),其中T可以是half、float、short、int等,access表示紋理訪問(wèn)權(quán)限,當(dāng)access沒(méi)寫(xiě)時(shí),默認(rèn)是sample,還可以設(shè)置為sample(可讀寫(xiě)可采樣)、read(只讀)、write(可讀寫(xiě))。
當(dāng)然我們還需要設(shè)置一個(gè)采樣器去對(duì)紋理進(jìn)行采樣,在Metal程序中初始化的采樣器必須使用constexpr修飾符聲明,所以需要用constexpr sampler聲明。采樣器的其他設(shè)置看下圖:

最后根據(jù)光柵化數(shù)據(jù)的紋理坐標(biāo)進(jìn)行采樣即可。至此,片段著色器著色結(jié)束,我們所有的渲染流程也結(jié)束了。
四. 總結(jié)
可能看到這里,你已經(jīng)懵掉了,怎么畫(huà)個(gè)圖片也這么難?這是很正常的,如果你一點(diǎn)圖形渲染的知識(shí)都沒(méi)有掌握的話,看完這篇文章并好好消化一下,你就可以初步認(rèn)識(shí)圖形渲染、Metal渲染的相關(guān)知識(shí)了。這也是我根據(jù)多篇文章摸爬滾打探索出來(lái)的一些知識(shí),如果對(duì)你有幫助的話不妨點(diǎn)個(gè)贊吧~
好久好久沒(méi)更新博客了,最近幾個(gè)月忙,也遇到了一些小困難,需要掙扎掙扎著慢慢前行,希望自己能夠放下浮躁的心,務(wù)實(shí)地成長(zhǎng)吧!接下來(lái)有幾個(gè)小目標(biāo):用Metal處理視頻流、學(xué)會(huì)Metal調(diào)試、完成老大給的需求、做一些比較炫酷的特效,希望自己能繼續(xù)加油!
附源碼,多敲幾遍就熟了:
//
// ViewController.m
// HobenLearnMetal
//
// Created by Hoben on 2021/1/4.
//
#import "HobenMetalImageController.h"
#import <MetalKit/MetalKit.h>
#import "HobenShaderType.h"
#import <AVFoundation/AVFoundation.h>
typedef NS_ENUM(NSUInteger, HobenRenderingResizingMode) {
HobenRenderingResizingModeScale = 0,
HobenRenderingResizingModeAspect,
HobenRenderingResizingModeAspectFill,
};
@interface HobenMetalImageController () <MTKViewDelegate>
@property (nonatomic, strong) MTKView *mtkView;
@property (nonatomic, strong) id <MTLRenderPipelineState> pipelineState;
@property (nonatomic, strong) id <MTLCommandQueue> commandQueue;
@property (nonatomic, strong) id <MTLBuffer> vertices;
@property (nonatomic, assign) NSUInteger numVertices;
@property (nonatomic, strong) id <MTLTexture> texture;
@property (nonatomic, strong) UIImage *image;
@property (nonatomic, assign) vector_uint2 viewportSize;
@property (nonatomic, weak) id <MTLDevice> device;
@end
@implementation HobenMetalImageController
- (void)viewDidLoad {
[super viewDidLoad];
self.image = [UIImage imageNamed:@"reus"];
[self setupMTKView];
[self setupCommandQueue];
[self setupFragment];
[self setupPineline];
}
- (void)setupFragment {
UIImage *image = self.image;
MTLTextureDescriptor *textureDesc = [MTLTextureDescriptor new];
textureDesc.pixelFormat = MTLPixelFormatRGBA8Unorm;
textureDesc.width = image.size.width;
textureDesc.height = image.size.height;
self.texture = [_device newTextureWithDescriptor:textureDesc];
MTLRegion region = {
{0, 0, 0},
{textureDesc.width, textureDesc.height, 1}
};
Byte *imageBytes = [self loadImage:image];
if (imageBytes) {
[self.texture replaceRegion:region mipmapLevel:0 withBytes:imageBytes bytesPerRow:image.size.width * 4];
free(imageBytes);
imageBytes = NULL;
}
}
- (Byte *)loadImage:(UIImage *)image {
CGImageRef imageRef = image.CGImage;
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
Byte *data = (Byte *)calloc(width * height * 4, sizeof(Byte)); // rgba 4個(gè)字節(jié)
CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, CGImageGetColorSpace(imageRef), kCGImageAlphaPremultipliedLast);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
CGContextRelease(context);
return data;
}
- (void)setupMTKView {
// 初始化MTKView
self.mtkView = [[MTKView alloc] init];
self.mtkView.delegate = self;
self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
self.mtkView.frame = self.view.bounds;
[self.view addSubview:self.mtkView];
}
- (void)setupPineline {
// 初始化pipelineState
MTLRenderPipelineDescriptor *pinelineDesc = [MTLRenderPipelineDescriptor new];
id <MTLLibrary> library = [_device newDefaultLibrary];
pinelineDesc.vertexFunction = [library newFunctionWithName:@"vertexShader"];
pinelineDesc.fragmentFunction = [library newFunctionWithName:@"fragmentShader"];
pinelineDesc.colorAttachments[0].pixelFormat = self.mtkView.colorPixelFormat;
self.pipelineState = [_device newRenderPipelineStateWithDescriptor:pinelineDesc error:nil];
}
- (void)setupCommandQueue {
// 初始化commandQueue
self.commandQueue = [_device newCommandQueue];
}
#pragma mark - MTKViewDelegate
- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
// MTKView的大小改變
self.viewportSize = (vector_uint2){size.width, size.height};
}
- (void)drawInMTKView:(MTKView *)view {
// 用于向著色器傳遞數(shù)據(jù)
id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
if (!renderDesc) {
[commandBuffer commit];
return;
}
renderDesc.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.5, 0.5, 1);
[self setupVertex:renderDesc];
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
[renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
// 映射.metal文件的方法
[renderEncoder setRenderPipelineState:self.pipelineState];
// 設(shè)置頂點(diǎn)數(shù)據(jù)
[renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
// 設(shè)置紋理數(shù)據(jù)
[renderEncoder setFragmentTexture:self.texture atIndex:0];
// 開(kāi)始繪制
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
// 結(jié)束渲染
[renderEncoder endEncoding];
// 提交
[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];
}
- (void)setupVertex:(MTLRenderPassDescriptor *)renderPassDescriptor {
if (self.vertices) {
return;
}
UIImage *image = self.image;
float heightScaling = 1.0;
float widthScaling = 1.0;
CGSize drawableSize = CGSizeMake(renderPassDescriptor.colorAttachments[0].texture.width, renderPassDescriptor.colorAttachments[0].texture.height);
CGRect bounds = CGRectMake(0, 0, drawableSize.width, drawableSize.height);
CGRect insetRect = AVMakeRectWithAspectRatioInsideRect(image.size, bounds);
HobenRenderingResizingMode fillMode = HobenRenderingResizingModeAspect;
switch (fillMode) {
case HobenRenderingResizingModeScale: {
widthScaling = 1.0;
heightScaling = 1.0;
};
break;
case HobenRenderingResizingModeAspect:
{
widthScaling = insetRect.size.width / drawableSize.width;
heightScaling = insetRect.size.height / drawableSize.height;
};
break;
case HobenRenderingResizingModeAspectFill:
{
widthScaling = drawableSize.height / insetRect.size.height;
heightScaling = drawableSize.width / insetRect.size.width;
};
break;
}
HobenVertex vertices[] = {
// 頂點(diǎn)坐標(biāo) x, y, z, w --- 紋理坐標(biāo) x, y
{ {-widthScaling, heightScaling, 0.0, 1.0}, {0.0, 0.0} },
{ { widthScaling, heightScaling, 0.0, 1.0}, {1.0, 0.0} },
{ {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
{ { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
};
self.vertices = [_device newBufferWithBytes:vertices length:sizeof(vertices) options:MTLResourceStorageModeShared];
self.numVertices = sizeof(vertices) / sizeof(HobenVertex);
}
@end
//
// HobenShaderType.h
// HobenLearnMetal
//
// Created by Hoben on 2021/1/4.
//
#ifndef HobenShaderType_h
#define HobenShaderType_h
typedef struct {
vector_float4 position;
vector_float2 textureCoordinate;
} HobenVertex;
#endif /* HobenShaderType_h */
//
// Shaders.metal
// HobenLearnMetal
//
// Created by Hoben on 2021/1/4.
//
#include <metal_stdlib>
#import "HobenShaderType.h"
using namespace metal;
typedef struct {
float4 vertexPosition [[ position ]];
float2 textureCoor;
} RasterizerData;
vertex RasterizerData vertexShader(uint vertexId [[ vertex_id ]],
constant HobenVertex *vertexArray [[ buffer(0) ]]) {
RasterizerData out;
out.vertexPosition = vertexArray[vertexId].position;
out.textureCoor = vertexArray[vertexId].textureCoordinate;
return out;
}
fragment float4 fragmentShader(RasterizerData input [[ stage_in ]],
texture2d <float> colorTexture [[ texture(0) ]]) {
constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);
float4 colorSample = colorTexture.sample(textureSampler, input.textureCoor);
return float4(colorSample);
}
參考文章:
WWDC 2018:寫(xiě)給 OpenGL 開(kāi)發(fā)者們的 Metal 開(kāi)發(fā)指南
