OpenGL render theory on iOS
iOS 底層渲染原理
寫在前面
下半年做過一次分享會,是以板書的形式分享。當時留下了一些手稿,最近整理一下分享給更多的人。
iOS 系統是如何實現 AddSubView 的?
在 iOS 開發中絕大部分的 UI 操作都靠 UIKit 完成,蘋果已經讓 UIKit 足夠好用。
在你使用 UIKit 的 AddSubView 時,有沒有想過 iOS 是如何更改屏幕上的對應像素的?蘋果為什么不讓開發者在輔助線程使用UIKit?
本文將從 AddSubView 出發,帶你了解 iOS 底層是如何完成渲染的。
我用 OpenGL 來描述系統的渲染行為,雖然新的系統中 OpenGL 已經被 Metal 代替,但原理是類似的。
iOS 渲染框架
iOS 的渲染渲染框架有 UIKit,CoreAnimation/Quartz,OpenGL,Metal。
UIKit,CoreAnimation 可以看做是底層渲染技術的 shell 層,它們并不直接提交渲染命令給 GPU,而是做數據的組裝和傳遞。此外 OpenGL 在 A11 以后已經不再直接調用系統驅動,而是調用 Metal 完成渲染。
UIKit 已經幫你做了數據的抽象,UI 工作可以很簡單地完成。CoreAnimation 層能接觸到更多的數據,如動畫時間曲線,CAlayer 等。UIView 的操作單位是 Point,CALayer 的是 Pixels。
UIKit 不用關注 Pixels 的變化,而 Pixels 的操作流程正是整個渲染流程的關鍵,也是這次分享的主題。
硬件相關
為了更容易理解底層渲染技術,了解一些 GPU 的硬件知識是有必要的。
GPU 與 CPU 的最大區別就是它是高度專用化的,它的目的就是更快地渲染。這個問題等效于更快計算出每個像素的值,所以 GPU 在硬件結構上做了對應的取舍。
GPU 大大弱化了邏輯控制的單元,把大部分芯片空間都讓給了算術邏輯單元(ALU),Excution context 管理計算的上下文。這樣 SIMD(單指令多數據)架構上保證了一批像素的同一個計算,只需要一次操作就可以完成,這也是數據并行加速的原理。
ARM 架構下,GPU 主要由 Imagination 和高通壟斷,其中 A11 之后 iOS 上 GPU 由蘋果自研。
CPU 和 GPU 之間靠總線傳遞數據,而移動端的總線帶寬相比 PC 低很多,所以經常出現 GPU 空等 CPU 的情況。
雖然 GPU 自身的處理能力很強,但帶寬限制了數據的吞吐量,所以壓縮紋理和 TBR/TBDR 的應運而生。
需要關注什么?
除了壓縮紋理和 TBR/TBDR,各個廠家同樣在驅動層為了同樣的目的做了一些優化。對于 UI 操作需要關注的是:
- 渲染命令異步提交
你在 CPU 提交的渲染命令是異步提交給 GPU 的(前提是可以異步的命令,同步命令 CPU 要等 GPU 執行完)。具體流程是
CPU -> CPU command queue -> 系統調度 -> GPU command queue -> GPU
所以在更改 UI 的當前 RunLoop ,GPU 實際還沒有開始繪制,不要預期有同步的渲染結果。
因為是異步提交命令,所以OpenGL Crash的現場并不是真正的問題所在,而是之前的命令導致狀態機錯誤,引發了Crash。
- 多幀緩存
系統底層驅動一般至少會做兩幀的緩沖,保證當前的渲染命令不影響當前上屏。但一些驅動會做更多的緩沖,iPhone8 以上至少會做 3 份緩沖,而這些緩沖是拿不到的,所以理論上從你的 UI 操作提交到 GPU,到真正展示會隔一小段時間。所以不要試圖精準地拿到對應的 CALayer 數據。
- 每一幀清屏是個好習慣
現在大部分移動端GPU都是TBR/TBDR的,如果要保留上一幀渲染結果,會有額外的GPU內存讀取操作,每一幀都清空畫布會帶來更高的性能,這也是蘋果推薦用不透明視圖的原因(部分原因)。蘋果默認會這樣設置CALayer,這也是開啟多幀緩沖的先決條件。
render pipeline
渲染管線是一次 GPU 渲染數據的操作流程,這個過程中 OpenGL 完成了屏幕 坐標的確認和對應屏幕像素值得計算。
渲染管線可以分為兩部分:
1. geometry pipeline
這個過程確定了輸入的 3D 坐標在屏幕上對應的 2D 坐標。
對于 CPU 傳來的 3D 頂點數據,會經過 Vertex processing 和 vertex post processing 兩個過程,轉換成 NDC 坐標(標準化設備坐標),經過圖元組裝后,就可以知道當前圖形影響屏幕上哪些像素。
對于 AddSubView 來說,就是通過 subView 的 frame,確認了 subView 對應的像素。
下面講一下 3D 坐標轉 2D 坐標的過程,不感興趣的可以跳過。
geometry pipeline 可以分為 3 個部分:
1. vertex processing
頂點輸入后,要在 Vertex shader 中做一系列坐標變換,最終將 3D 坐標轉換到一個[-1, 1]的空間內。
之后有可選的 Tessllation 和 Geometry shader 階段,它們可以動態改變圖元:比如改變圖元的形狀,增加圖元等。(使用 Tesslation 或 Geometry shader 后,管線會提前一部分圖元裝配的工作,這樣就保證了它們可以操作圖元)。
這一部分在 OpenGL 中是可編程的,我們可以控制頂點數據如何變換。
2. vertex post processing
這一部分是不可編程的,渲染管線會自動調用。
經過前面的處理,頂點坐標被變換成各分量都在[-1,1]之間的裁剪坐標,不在此區間的坐標被丟棄。
接下來會做透視除法,將坐標轉換為 NDC 坐標,你可以理解為完成了近大遠小的縮放。
最后做 viewport 變換,將 NDC 坐標根據屏幕的像素大小(viewport 參數),映射成屏幕坐標。
變換管線
你可能注意到在 geometry pipeline 階段我說到了多種不同的坐標,這其實正是 OpenGL 確定 3D 坐標到 2D 坐標的過程,實際上每一種坐標和相應的變換都是固定的。
在第一部分,CPU 傳給 GPU 的頂點坐標為局部坐標,所有位置都是相對于自身原點的。
之后通過 vertex shader 中的model transfrom
, view transfrom
, projection transfrom
,變為裁剪坐標,這一部分是決定如何展示頂點的主要工作,。
然后通過 vertex post-processing 階段,做透視除法和viewport transform
,最終得到屏幕坐標。
最終輸出的屏幕坐標,會作為下一步圖元裝配的輸入。
這一部分可能比較難理解,你可以認為這一階段是在 3D 世界中,如何用照相機照相。
圖元裝配
圖元裝配即完成用圖元來描述你要繪制的形狀。圖元一般是三角形,因為在 3D 中,三角形能唯一確定一個平面。
對 AddSubView 來說,subView 會被組裝成兩個三角形圖元。圖元裝配之后,就可以確定當前圖形的屏幕區域。
2. pixels pipeline
pixels pipeline 主要完成了計算每個像素值的工作。
主要是下面三步:
格柵化
圖元裝配確定了圖形的屏幕區域,格柵化是在硬件層面,確定當前圖形會影響屏幕上哪些像素。所有包含在圖形內的像素,作為下一階段的輸入。
fragment processing
格柵化之后,渲染管線會將這一批像素作為 fragment shader 的輸入。這一過程是可編程的。
在 fragment shader 中,需要計算出每個像素是什么值。對 AddSubView 來說,如果 subView 的背景是圖像,需要把圖像作為紋理傳到 GPU,在計算對應像素時去采樣紋理。
也就是說這一階段確定了 subView 的樣子。
test & blend
最后這個決定了像素是否應該被展示。
stencil test
如果開啟了 stencil test,那么像素對應的 stencil 值不為 0,像素才會被展示。
depth test
如果開啟了 depth test,像素必須沒有被遮擋(z 軸的前后關系),才會被展示。
blend
如果開啟了 blend,像素的會根據 blend function 和 alpha 值,確定最終的像素值。
對 AddSubView 來說,如果 subView 設置了 mask,那么就要設置 stencil 來剔除 mask 之外的像素。如果 subView 是半透明,那么要根據 alpha 值和背景的顏色做 blend 操作。
UIKit 如何實現 addSubView
現在,再來說說 UIKit 如何實現 addSubView 的。
首先計算出 subView 的 frame,獲取背景圖像并解碼成位圖,獲取 mask,alpha 等屬性。
創建 GLContext。
取得當前 CALayer,作為當前 GLContext FBO 的 RBO。
將 frame 的四個頂點傳給 vertex shader。將背景圖像的位圖傳給 fragment shader。
在 vertex shader 中,由于是純 2D 的界面,可以直接將頂點 z 軸坐標都設為 1。將坐標轉為相對于 window 的坐標(或者使用正視投影)。
在 fragment shader 中,每個 SubView 的像素,都從圖像中采樣,作為輸出的顏色值。
在最后的 test & blend 中,對 subView 的 alpha,mask 屬性做對應處理。
展示當前 GLContext RBO。
這個流程也是系統 UIKit -> CoreAnimation -> render server 的過程。
OpenGL 狀態機
你可以把 GLContext 理解為 OpenGL 的狀態機,但實際上它是狀態機的超集。
GLContext 可以看做是一個巨大的結構體,它維護著當前的渲染狀態,包括 GLObject 也是一個狀態的子集。
每一條渲染命令,其實都是在更改當前的渲染狀態,從而最終影響渲染流程。
而蘋果會自動在主線程設置一個 GLContext,來實現 UI 的渲染。
最后
每個 iOS 開發者都知道不能在輔助線程做 UI 操作,絕大多數卻不知道為什么蘋果要做這個限制。
看完了這篇文章,你應該能明白為什么:多線程操作 UI,意味著多線程在一個GLContext上渲染,蘋果默認不會提交輔助線程的渲染命令到GPU(雖然蘋果現在用的是Metal,并且渲染是單獨一個進程,但原理類似,操作GPU的接口不是線程安全的)。