這個(gè)公眾號(hào)會(huì)路線圖 式的遍歷分享音視頻技術(shù):音視頻基礎(chǔ) → 音視頻工具 → 音視頻工程示例 → 音視頻工業(yè)實(shí)戰(zhàn)。
這篇文章是音視頻基礎(chǔ)專欄系列關(guān)于渲染的第一篇文章,我們來聊一聊 OpenGL,希望能做到讓沒接觸過 OpenGL 的同學(xué)能比較容易的建立起一個(gè)初步的印象。
這篇文章的內(nèi)容包括:
- 常見的移動(dòng)端圖形渲染技術(shù)
- OpenGL 在圖形應(yīng)用程序中的角色
- OpenGL 的渲染架構(gòu)
- OpenGL 狀態(tài)機(jī)思想
- OpenGL 的圖形渲染管線
1、圖形渲染方案
提到移動(dòng)設(shè)備的圖形渲染,我們經(jīng)常會(huì)聽到 OpenGL、OpenGL ES、Metal、Vulkan 等方案,它們有什么差別呢?
- OpenGL 是一套跨語言、跨平臺(tái),支持 2D、3D 圖形渲染接口。這套接口由一系列的函數(shù)組成,定義了如何對(duì)簡單及復(fù)雜的圖形進(jìn)行繪制。這套接口涉及到對(duì)設(shè)備的圖像硬件進(jìn)行調(diào)用,因此在不同的平臺(tái)基于這套統(tǒng)一接口做了對(duì)應(yīng)的實(shí)現(xiàn)。
- OpenGL ES 是 OpenGL 的子集,是針對(duì)手機(jī)和游戲主機(jī)等嵌入式設(shè)備而設(shè)計(jì),去除了許多不必要和性能較低的 API 接口。
- Metal 是蘋果為了解決 3D 渲染性能問題而推出的框架,該技術(shù)將 3D 圖形渲染性能提高了 10 倍。
- Vulkan 是一套新的跨平臺(tái)支持 2D、3D 圖形渲染的接口。Vulkan 針對(duì)全平臺(tái)即時(shí) 3D 程序(如電子游戲和交互媒體)設(shè)計(jì),并提供高性能與更均衡的 CPU/GPU 使用。
這些渲染方案之間還有著一定的歷史淵源:
OpenGL 已經(jīng)發(fā)展了 25 年以上,不斷滿足著行業(yè)需求。但是,隨著 CPU、GPU 等硬件技術(shù)的發(fā)展和 3D 等更復(fù)雜場景對(duì)性能的需要,OpenGL 已經(jīng)逐漸滿足不了行業(yè)的需要了。后來在 2013 年,AMD 主導(dǎo)開發(fā)了 Mantle。Mantle 是面向 3D 游戲的新一代圖形渲染接口,可以讓開發(fā)人員直接操作 GPU 硬件底層,從而提高硬件利用率和游戲性能,效果顯著。Mantle 很好的帶動(dòng)了圖形行業(yè)發(fā)展,微軟參考 AMD Mantle 的思路開發(fā)了 DirectX 12,蘋果則提出了 Metal。但是因?yàn)?AMD 行業(yè)影響力和領(lǐng)導(dǎo)力不足,Mantle 沒有發(fā)展成為全行業(yè)的標(biāo)準(zhǔn)。2015 年,AMD 宣布不在維護(hù) Mantle,Mantle 功成身退。Khronos 接過 AMD 手中的接力棒,在 Mantle 的基礎(chǔ)上推出了 Vulkan,Khronos 最先把 Vulkan API 稱為『下一代 OpenGL 行動(dòng)(glNext)』,但在正式宣布 Vulkan 之后這些名字就沒有再使用了。
2014 年之前蘋果一直是使用 OpenGL ES 來處理底層渲染,之后慢慢的把渲染框架遷移到了 Metal。到 iOS 12 蘋果已經(jīng)開始棄用 OpenGL,完全使用 Metal 實(shí)現(xiàn)底層渲染。不過 OpenGL 是跨平臺(tái)的且相當(dāng)穩(wěn)定,目前 Metal 還只是用于蘋果體系。
谷歌則是從 2016 年的 Android N(安卓 7.0)開始支持 Vulkan API。當(dāng)然 OpenGL ES 也仍是持續(xù)支持的。
可以看到移動(dòng)設(shè)備的渲染方案基本上都是從 OpenGL 的思想上繼承和發(fā)展而來的,所以了解 OpenGL 就變得很有必要,我們接著往下講。
2、OpenGL 的角色
要了解 OpenGL,首先可以看看它在一個(gè)應(yīng)用程序中的位置和角色。
OpenGL 不能開發(fā)程序、構(gòu)建后臺(tái),它只是一套處理圖形圖像的統(tǒng)一規(guī)則。它在一個(gè)圖形應(yīng)用程序中的角色大致如下圖所示:

OpenGL 在圖形應(yīng)用中的角色(iOS)
上圖是基于 iOS 平臺(tái)的,圖中的 Core Graphics、Core Animation、Core Image 是 iOS 平臺(tái)封裝的繪制相關(guān)的上層 API,在 Android 平臺(tái)則是其他的 API,這里不必深究。
在日常開發(fā)中,開發(fā)者一般通過使用上層 API 來構(gòu)建和繪制界面,而調(diào)用 API 時(shí)系統(tǒng)最終還是通過 OpenGL/Metal/Vulkan 來實(shí)現(xiàn)視圖的渲染。開發(fā)者也可以直接使用 OpenGL/Metal/Vulkan 來驅(qū)動(dòng) GPU 芯??效渲染圖形圖像以滿足一些特殊的需求。
3、OpenGL 的渲染架構(gòu)
知道了 OpenGL 在整個(gè)應(yīng)用程序中的定位和角色后,那它在內(nèi)部是怎么實(shí)現(xiàn)串聯(lián)上下游的呢?這就涉及到其渲染架構(gòu)的設(shè)計(jì)了。
OpenGL 的渲染架構(gòu)是 Client/Server 模式:Client(客戶端)指的是我們在 CPU 上運(yùn)行的一些代碼,比如我們會(huì)編寫 OC/C++/Java 代碼調(diào)用 OpenGL 的一些 API;而 Server(服務(wù)端)則對(duì)應(yīng)的是圖形渲染管線,會(huì)調(diào)用 GPU 芯片。我們開發(fā)的過程就是不斷用 Client 通過 OpenGL 提供的通道去向 Server 端傳輸渲染指令,來間接的操作 GPU 芯片。

OpenGL 渲染架構(gòu)
渲染架構(gòu)的 Client 和 Server 是怎么通信和交互的呢?這又涉及到 C/S 通道的設(shè)計(jì),下面我們來接著介紹,不過這里會(huì)提到一些你可能不太熟悉的名詞,可以先不用深究,有個(gè)印象就可以了。
OpenGL 提供了 3 個(gè)通道來讓我們從 Client 向 Server 中的頂點(diǎn)著色器(Vertex Shader)和片元著色器(Fragment Shader)傳遞參數(shù)和渲染信息,如下圖所示:

OpenGL 渲染架構(gòu)及數(shù)據(jù)交互通道
這 3 個(gè)通道分別是:
- Attribute(屬性通道) :通常用來傳遞經(jīng)常可變參數(shù)。比如顏色數(shù)據(jù)、頂點(diǎn)數(shù)據(jù)、紋理坐標(biāo)、光照法線這些變量。
- Uniform(統(tǒng)一變量通道) :通常用來傳遞不變的參數(shù)。比如變化矩陣。一個(gè)圖形做旋轉(zhuǎn)的時(shí)候,實(shí)質(zhì)上是這個(gè)圖形的所有頂點(diǎn)都做相應(yīng)的變化,而這個(gè)變化的矩陣就是一個(gè)常量,可以用 Uniform 通道傳遞參數(shù)到頂點(diǎn)著色器的一個(gè)實(shí)例。再比如視頻的顏色空間通常用 YUV,但是 YUV 顏色空間想要正常渲染到屏幕上面,需要轉(zhuǎn)化成 RGBA 顏色空間,這個(gè)轉(zhuǎn)換就需要把 YUV 的顏色值乘以一個(gè)轉(zhuǎn)換矩陣轉(zhuǎn)換為 RGBA 顏色值,這個(gè)轉(zhuǎn)換矩陣也是一個(gè)常量,可以用 Uniform 通道傳遞參數(shù)到片元著色器的一個(gè)實(shí)例。
- Texture Data(紋理通道) :專門用來傳遞紋理數(shù)據(jù)的通道。
需要注意的是,這 3 個(gè)通道中 Uniform 通道和 Texture Data 通道都可以直接向頂點(diǎn)著色器和片元著色器傳遞參數(shù),但是 Attribute 只能向頂點(diǎn)著色器傳遞參數(shù),因?yàn)?OpenGL 架構(gòu)在最初設(shè)計(jì)的時(shí)候,Attribute 屬性通道就是頂點(diǎn)著色器的專用通道。片元著色器中是不可能有 Attribute 的,但是我們可以使用 GLSL 代碼,通過頂點(diǎn)著色器把 Attribute 信息間接傳遞到片元著色器中。
另外,雖然 Texture Data 通道能直接向頂點(diǎn)著色器傳遞紋理數(shù)據(jù),但是向頂點(diǎn)著色器傳遞紋理數(shù)據(jù)本身是沒有實(shí)質(zhì)作用的,因?yàn)轫旤c(diǎn)著色器并不處理太多關(guān)于紋理的計(jì)算,紋理更多是在片元著色器中進(jìn)行計(jì)算。
參考:了解 OpenGL 渲染架構(gòu)[1]
4、OpenGL 狀態(tài)機(jī)
在 Client/Server 的渲染架構(gòu)下,OpenGl 的渲染流程其實(shí)是基于一個(gè)狀態(tài)機(jī)來工作的。
我們先舉個(gè)例子說明什么是狀態(tài)機(jī)。我們都坐過電梯,一般來說電梯有這樣幾個(gè)狀態(tài):開門、關(guān)門、運(yùn)行(上升/下降)、靜止。
它們有什么特點(diǎn)呢?
電梯只有靜止的時(shí)候才能開門,只有開門之后才能關(guān)門,只有關(guān)門之后才可以運(yùn)動(dòng),只有運(yùn)動(dòng)之后才可以靜止,所以,可以說電梯的各個(gè)狀態(tài)是有依賴關(guān)系的,換種更專業(yè)的說法,就是各種狀態(tài)可以通過有向圖來表示。

電梯狀態(tài)圖
電梯不能隨意從一個(gè)狀態(tài)跳轉(zhuǎn)到另一個(gè)狀態(tài),比如:不能在運(yùn)動(dòng)過程中開門。
關(guān)于 OpenGL 狀態(tài)機(jī),Learn OpenGL[2] 中有概述:
OpenGL 自身是一個(gè)巨大的狀態(tài)機(jī)(State Machine):一系列的變量描述 OpenGL 此刻應(yīng)當(dāng)如何運(yùn)行。OpenGL 的狀態(tài)通常被稱為 OpenGL 上下文(Context)。我們通常使用如下途徑去更改 OpenGL 狀態(tài):設(shè)置選項(xiàng),操作緩沖。最后,我們使用當(dāng)前 OpenGL 上下文來渲染。
假設(shè)當(dāng)我們想告訴 OpenGL 去畫線段而不是三角形的時(shí)候,我們通過改變一些上下文變量來改變 OpenGL 狀態(tài),從而告訴 OpenGL 如何去繪圖。一旦我們改變了 OpenGL 的狀態(tài)為線段繪制模式,下一個(gè)繪制命令就會(huì)畫出線段而不是三角形。
當(dāng)使用 OpenGL 的時(shí)候,我們會(huì)遇到一些狀態(tài)設(shè)置函數(shù)(State-changing Function),這類函數(shù)將會(huì)改變上下文。以及狀態(tài)使用函數(shù)(State-using Function),這類函數(shù)會(huì)根據(jù)當(dāng)前 OpenGL 的狀態(tài)執(zhí)行一些操作。只要你記住 OpenGL 本質(zhì)上是個(gè)大狀態(tài)機(jī),就能更容易理解它的大部分特性。
基于上面的理解,我們來看一段 OpenGL 的代碼:
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
初看這段代碼,我們最深的印象可能是各種 glBind...,字面上是綁定的意思,如果從狀態(tài)機(jī)的角度理解,其實(shí) glBind... 就意味著進(jìn)入了某個(gè)狀態(tài)。
所以我們可以用狀態(tài)圖來表示上面的代碼如下:

示例代碼狀態(tài)圖
不過 OpenGL 的狀態(tài)是可以嵌套的,所以細(xì)看上面的代碼,我們還能看到這里狀態(tài)存在包含關(guān)系,因?yàn)橐粋€(gè) VBO 會(huì)被綁定于一個(gè) VAO 中,所以用下圖來看會(huì)更加直觀:

狀態(tài)嵌套示例
通俗來說就是,執(zhí)行了綁定 X 到解綁 X 之間的任何操作,都會(huì)影響到 X。
明白了上面的狀態(tài)機(jī)機(jī)制后,相信后面學(xué)習(xí) OpenGL 的代碼就能降低不少難度了。
參考:OpenGL 工作機(jī)制[3]
5、圖形渲染管線
一個(gè)一個(gè)狀態(tài)的切換以及在不同狀態(tài)中的渲染邏輯和數(shù)據(jù)處理構(gòu)成了 OpenGL 的渲染管線。
什么是管線?其實(shí)也可理解為一個(gè)流程。理解圖像渲染管線前,我們可以想象一下如果讓你在屏幕上繪制一個(gè)三角形,你要怎么做呢?
第一步,可能是先確定三角形三個(gè)頂點(diǎn)的位置:

第二步,自然是將三個(gè)點(diǎn)用線段連起來:

第三步,你可能覺得這樣的三角形太過于單調(diào),于是準(zhǔn)備給三角形上色,因?yàn)槭窃谄聊簧系?,而屏幕本質(zhì)用是一個(gè)個(gè)像素來顯示顏色的,所以上色之前要先確定好哪些像素是屬于三角形的,于是你叫計(jì)算機(jī)把屬于三角形內(nèi)部的像素一個(gè)個(gè)圈出來:

三角形繪制流程 3
第四步,你想畫一個(gè)帶漸變色的炫酷三角形,所以需要給每個(gè)像素都上不同的顏色,于是你給一個(gè)個(gè)像素精心上色:

三角形繪制流程 4
這樣下來,一個(gè)漂亮的三角形就畫出來了。回想這個(gè)過程,其實(shí)就像工廠的流水線一樣,將整個(gè)工作拆解成一步一步實(shí)現(xiàn)即可。
OpenGL 的渲染管線其實(shí)也是類似的一個(gè)過程,它的工序包括:頂點(diǎn)著色器 → 圖元裝配 → 幾何著色器 → 光柵化 → 片段著色器 → 測試與混合。

OpenGL 渲染管線
這些工序是將輸入的 3D 的坐標(biāo),轉(zhuǎn)化為顯示在屏幕上的 2D 的像素的一個(gè)處理流程。
早期的 OpenGL 使用立即渲染模式(Immediate Mode,也就是固定渲染管線) 。這種模式下繪制圖形很方便,OpenGL 的大多數(shù)功能都被庫隱藏起來,是一種配置化(Configurable) 的管線,開發(fā)者很少有控制 OpenGL 如何進(jìn)行計(jì)算的自由。而隨著需求場景變的多樣和復(fù)雜,開發(fā)者迫切希望能有更多的靈活性。隨著時(shí)間推移,規(guī)范越來越靈活,開發(fā)者對(duì)繪圖細(xì)節(jié)有了更多的掌控,現(xiàn)代 OpenGL 轉(zhuǎn)變?yōu)?strong>可編程(Programmable) 渲染管線,而這里的編程語言就是 GLSL 語言,它是一種類 C 的語言,專為圖形計(jì)算量身定制,包含了一些針對(duì)向量和矩陣操作的有用特性,我們用它編寫我們自己的頂點(diǎn)著色器和片段著色器。
上面的介紹中我們多次提到了一個(gè)詞:著色器(Shader) ,它是什么呢?
著色器就是一段運(yùn)行在 GPU 中的程序,這段程序由開發(fā)者編寫,所以說為開發(fā)者提供了很大的靈活度和可掌控度?,F(xiàn)在 OpenGL 主要有三種著色器:頂點(diǎn)著色器、幾何著色器、片段著色器,其中頂點(diǎn)著色器和片段著色器為開發(fā)者必須提供,幾何著色器為可選提供。
下面我們介紹一下 OpenGL 渲染管線的幾個(gè)重要工序:
1)頂點(diǎn)著色器(Vertex Shader)
頂點(diǎn)著色器主要用于確定繪制圖形的形狀,以及接收開發(fā)者傳入的數(shù)據(jù)并傳給后面階段。接收外部傳入的頂點(diǎn)數(shù)據(jù),根據(jù)需要對(duì)頂點(diǎn)數(shù)據(jù)進(jìn)行變換處理之后,再將頂點(diǎn)數(shù)據(jù)傳入下一個(gè)階段圖元裝配。另外頂點(diǎn)著色器也接收外部傳進(jìn)來的顏色值以及紋理采樣器,然后再傳遞給下一個(gè)階段進(jìn)行圖元裝配處理。
每個(gè)頂點(diǎn)著色器只接收處理一個(gè)頂點(diǎn)坐標(biāo),有多少個(gè)頂點(diǎn)就會(huì)執(zhí)行多少次。
2)圖元裝配
圖元裝配階段是接收頂點(diǎn)著色器的輸出數(shù)據(jù),將頂點(diǎn)著色器傳來的頂點(diǎn)數(shù)據(jù)組裝為圖元。就如上面畫三角形中所說的將三角形三個(gè)頂點(diǎn)連接起來,具體連接方式需要開發(fā)者指定。所謂圖元,指的就是點(diǎn)、線、三角形等最基本的幾何圖形,再復(fù)雜的圖形也離不開這些基本圖形的組成。另外,圖元裝配階段還會(huì)將超出屏幕的頂點(diǎn)坐標(biāo)進(jìn)行裁剪,裁剪之后,頂點(diǎn)坐標(biāo)被轉(zhuǎn)化為屏幕坐標(biāo),之后將圖元數(shù)據(jù)傳遞給管線的下一個(gè)階段進(jìn)行光柵化(幾何著色器為非必須階段,這里就暫時(shí)不講了)。
下圖是 OpenGL 支持的圖元類型:

OpenGL 圖元類型
3)光柵化
拿到圖元裝配傳遞過來的圖元數(shù)據(jù),光柵化要做的就是將一個(gè)圖元轉(zhuǎn)化為一張二維的圖片。而這張圖片由若干個(gè)片段(fragment) 組成(可以當(dāng)做將這張圖拆解為一個(gè)個(gè)類似屏幕上像素的小片段),片段可以近似看成像素,但是又略有不同,一個(gè)片段包含渲染該片段所需要的位置、顏色和深度的全部信息。光柵化完成之后,就把每個(gè)片段傳給片段著色器。
4)片段著色器(Fragment Shader)
接下來的階段是片段著色器,這是另外一個(gè)必須有的重要著色器,也是最后一個(gè)可以通過編程來控制屏幕是上顯示顏色的階段(后面的混合測試階段還可以改變片段的顏色),在這個(gè)階段主要是計(jì)算片段的顏色。這里每個(gè)片段著色器接收一個(gè)片段數(shù)據(jù)的輸入,所以有幾個(gè)片段就會(huì)執(zhí)行所少次,根據(jù)具體需要靈活設(shè)置該片段的顏色。然后片段數(shù)據(jù)就被傳遞到下一個(gè)階段:測試與混合。
5)測試和混合
這個(gè)階段的測試是專門用來丟棄一些不需要顯示的片段,其中測試主要包含深度測試和模板測試。
深度測試是在顯示 3D 圖形的時(shí)候,根據(jù)片段的深度來防止被阻擋的面渲染到其它面的前面。這里是 OpenGL 內(nèi)部維護(hù)一個(gè)深度緩沖,保存這一幀中深度最小的片段的深度,然后對(duì)屏幕同一個(gè)位置的其他片段的深度再進(jìn)行比較,深度比緩沖中大的片段則丟棄,直到找到深度最小的片段,就將其顯示出來。

上圖中每個(gè)方格表示一個(gè)片段,片段上的數(shù)值表示當(dāng)前片段的深度,R 則表示深度無限,加號(hào)表示 2 個(gè)圖形疊加一起,則由下面部分的圖可知,當(dāng) 2 個(gè)圖形疊加在一起的時(shí)候,同一個(gè)位置的片段總是顯示深度較小的那一個(gè)。
模板緩沖區(qū)是用于控制屏幕需要顯示的內(nèi)容,屏幕大小決定了模板緩沖區(qū)大??;模板測試基于模板緩沖區(qū),從而讓我們完成想要的效果。模板測試類似于與運(yùn)算:

上圖可以看出,模板就是每個(gè)片段位置有 0 也有 1,然后和緩沖中的圖像數(shù)據(jù)對(duì)應(yīng)片段進(jìn)行類似與運(yùn)算,也類似與拿一個(gè)遮罩罩住,只留下 1 的對(duì)應(yīng)片段顯示出來。
混合則是計(jì)算帶有透明度的片段的最終顏色,在這個(gè)階段會(huì)與顯示在它背后的片段的顏色按照透明度進(jìn)行疊加行成新的顏色,通俗講就是形成透明物體的效果。

由圖可以看出,通過混合,右邊的窗戶既有部分自己的顏色,又有窗戶里面物體的部分顏色,就是兩者透明度按照比例疊加的結(jié)果。
于是走完整個(gè)渲染管線流程,我們的渲染工作就算是告一段落了。
我們再來回顧一下這條渲染管線做了哪些事情:
首先我們傳入了圖形的頂點(diǎn)數(shù)據(jù),然后 OpenGL 內(nèi)部會(huì)按照指定的圖元類型自動(dòng)將頂點(diǎn)連成圖形,然后再將圖形內(nèi)的區(qū)域切成一個(gè)個(gè)小片段,然后給每個(gè)小片段自由上色,最后把被擋住的或者我們不想顯示的區(qū)域的下片段丟棄,并且對(duì)有透明度的片段進(jìn)行前后片段顏色的混合。
參考:圖形渲染管線的那些事[4]
到此,我們基本上就對(duì) OpenGL 有個(gè)初步的認(rèn)識(shí)了,至于更細(xì)節(jié)的知識(shí)則需要在實(shí)踐中去學(xué)習(xí)和領(lǐng)悟了。
參考資料
[1]
了解 OpenGL 渲染架構(gòu): http://www.itdecent.cn/p/51be4551d36f
[2]
Learn OpenGL: https://learnopengl-cn.github.io/01%20Getting%20started/01%20OpenGL/
[3]
OpenGL 工作機(jī)制: https://juejin.cn/post/7121525553491869703
[4]
圖形渲染管線的那些事: https://juejin.cn/post/7119135465302654984