Instagram,Snapchat,Photoshop。
所有這些應(yīng)用都是用來做圖像處理的。圖像處理可以簡單到把一張照片轉(zhuǎn)換為灰度圖,也可以復(fù)雜到是分析一個視頻,并在人群中找到某個特定的人。盡管這些應(yīng)用非常的不同,但這些例子遵從同樣的流程,都是從創(chuàng)造到渲染。
在電腦或者手機(jī)上做圖像處理有很多方式,但是目前為止最高效的方法是有效地使用圖形處理單元,或者叫 GPU。你的手機(jī)包含兩個不同的處理單元,CPU 和 GPU。CPU 是個多面手,并且不得不處理所有的事情,而 GPU 則可以集中來處理好一件事情,就是并行地做浮點運算。事實上,圖像處理和渲染就是在將要渲染到窗口上的像素上做許許多多的浮點運算。
通過有效的利用 GPU,可以成百倍甚至上千倍地提高手機(jī)上的圖像渲染能力。如果不是基于 GPU 的處理,手機(jī)上實時高清視頻濾鏡是不現(xiàn)實,甚至不可能的。
著色器 (shader) 是我們利用這種能力的工具。著色器是用著色語言寫的小的,基于 C 語言的程序?,F(xiàn)在有很許多種著色語言,但你如果做 OS X 或者 iOS 開發(fā)的話,你應(yīng)該專注于 OpenGL 著色語言,或者叫 GLSL。你可以將 GLSL 的理念應(yīng)用到其他的更專用的語言 (比如 Metal) 上去。這里我們即將介紹的概念與和 Core Image 中的自定義核矩陣有著很好的對應(yīng),盡管它們在語法上有一些不同。
這個過程可能會很讓人恐懼,尤其是對新手。這篇文章的目的是讓你接觸一些寫圖像處理著色器的必要的基礎(chǔ)信息,并將你帶上書寫你自己的圖像處理著色器的道路。
我們將乘坐時光機(jī)回顧一下過去,來了解什么是著色器,以及它是怎樣被集成到我們的工作流當(dāng)中的。
如果你在 iOS 5 或者之前就開始做 iOS 開發(fā),你或許會知道在 iPhone 上 OpenGL 編程有一個轉(zhuǎn)變,從 OpenGL ES 1.1 變成了 OpenGL ES 2.0。
OpenGL ES 1.1 沒有使用著色器。作為替代,OpenGL ES 1.1 使用被稱為固定功能管線 (fixed-function pipeline) 的方式。有一系列固定的函數(shù)用來在屏幕上渲染對象,而不是創(chuàng)建一個單獨的程序來指導(dǎo) GPU 的行為。這樣有很大的局限性,你不能做出任何特殊的效果。如果你想知道著色器在工程中可以造成怎樣的不同,看看這篇 Brad Larson 寫的他用著色器替代固定函數(shù)重構(gòu) Molecules 應(yīng)用的博客
OpenGL ES 2.0 引入了可編程管線。可編程管線允許你創(chuàng)建自己的著色器,給了你更強大的能力和靈活性。
在 OpenGL ES 中你必須創(chuàng)建兩種著色器:頂點著色器 (vertex shaders) 和片段著色器 (fragment shaders)。這兩種著色器是一個完整程序的兩半,你不能僅僅創(chuàng)建其中任何一個;想創(chuàng)建一個完整的著色程序,兩個都是必須存在。
頂點著色器定義了在 2D 或者 3D 場景中幾何圖形是如何處理的。一個頂點指的是 2D 或者 3D 空間中的一個點。在圖像處理中,有 4 個頂點:每一個頂點代表圖像的一個角。頂點著色器設(shè)置頂點的位置,并且把位置和紋理坐標(biāo)這樣的參數(shù)發(fā)送到片段著色器。
然后 GPU 使用片段著色器在對象或者圖片的每一個像素上進(jìn)行計算,最終計算出每個像素的最終顏色。圖片,歸根結(jié)底,實際上僅僅是數(shù)據(jù)的集合。圖片的文檔包含每一個像素的各個顏色分量和像素透明度的值。因為對每一個像素,算式是相同的,GPU 可以流水線作業(yè)這個過程,從而更加有效的進(jìn)行處理。使用正確優(yōu)化過的著色器,在 GPU 上進(jìn)行處理,將使你獲得百倍于在 CPU 上用同樣的過程進(jìn)行圖像處理的效率。
把東西渲染到屏幕上從一開始就是一個困擾 OpenGL 開發(fā)者的問題。僅僅讓屏幕呈現(xiàn)出非黑色就要寫很多樣板代碼和設(shè)置。開發(fā)者必須跳過很多坑 ,而這些坑所帶來的沮喪感以及著色器測試方法的匱乏,讓很多人放棄了哪怕是嘗試著寫著色器。
幸運的是,過去幾年,一些工具和框架減少了開發(fā)者在嘗試著色器方面的焦慮。
下面我將要寫的每一個著色器的例子都是從開源框架 GPUImage 中來的。如果你對 OpenGL/OpenGL ES 場景如何配置,從而使其可以使用著色器渲染感到好奇的話,可以 clone 這個倉儲。我們不會深入到怎樣設(shè)置 OpenGL/OpenGL ES 來使用著色器渲染,這超出了這篇文章的范圍。
好吧,關(guān)于著色器我們說的足夠多了。我們來看一個實踐中真實的著色器程序。這里是一個 GPUImage 中一個基礎(chǔ)的頂點著色器:
attribute vec4 position;
attribute vec4 inputTextureCoordinate;
varying vec2 textureCoordinate;
void main()
{
gl_position = position;
textureCoordinate = inputTextureCoordinate.xy;
}
我們一句一句的來看:
attribute vec4 position;
像所有的語言一樣,著色器語言的設(shè)計者也為常用的類型創(chuàng)造了特殊的數(shù)據(jù)類型,例如 2D 和 3D 坐標(biāo)。這些類型是向量,稍后我們會深入更多?;氐轿覀兊膽?yīng)用程序的代碼,我們創(chuàng)建了一系列頂點,我們?yōu)槊總€頂點提供的參數(shù)里的其中一個是頂點在畫布中的位置。然后我們必須告訴我們的頂點著色器它需要接收這個參數(shù),我們稍后會將它用在某些事情上。因為這是一個 C 程序,我們需要記住要在每一行代碼的結(jié)束使用一個分號,所以如果你正使用 Swift 的話,你需要把在末尾加分號的習(xí)慣撿回來。
attribute vec4 inputTextureCoordinate;
現(xiàn)在你或許很奇怪,為什么我們需要一個紋理坐標(biāo)。我們不是剛剛得到了我們的頂點位置了嗎?難道它們不是同樣的東西嗎?
其實它們并非一定是同樣的東西。紋理坐標(biāo)是紋理映射的一部分。這意味著你想要對你的紋理進(jìn)行某種濾鏡操作的時候會用到它。左上角坐標(biāo)是 (0,0)。右上角的坐標(biāo)是 (1,0)。如果我們需要在圖片內(nèi)部而不是邊緣選擇一個紋理坐標(biāo),我們需要在我們的應(yīng)用中設(shè)定的紋理坐標(biāo)就會與此不同,像是 (.25, .25) 是在圖片左上角向右向下各圖片高寬 1/4 的位置。在我們當(dāng)前的圖像處理應(yīng)用里,我們希望紋理坐標(biāo)和頂點位置一致,因為我們想覆蓋到圖片的整個長度和寬度。有時候你或許會希望這些坐標(biāo)是不同的,所以需要記住它們未必是相同的坐標(biāo)。在這個例子中,頂點坐標(biāo)空間從 -1.0 延展到 1.0,而紋理坐標(biāo)是從 0.0 到 1.0。
varying vec2 textureCoordinate;
因為頂點著色器負(fù)責(zé)和片段著色器交流,所以我們需要創(chuàng)建一個變量和它共享相關(guān)的信息。在圖像處理中,片段著色器需要的唯一相關(guān)信息就是頂點著色器現(xiàn)在正在處理哪個像素。
gl_Position = position;
gl_Position
是一個內(nèi)建的變量。GLSL 有一些內(nèi)建的變量,在片段著色器的例子中我們將看到其中的一個。這些特殊的變量是可編程管道的一部分,API 會去尋找它們,并且知道如何和它們關(guān)聯(lián)上。在這個例子中,我們指定了頂點的位置,并且把它從我們的程序中反饋給渲染管線。
textureCoordinate = inputTextureCoordinate.xy;
最后,我們?nèi)〕鲞@個頂點中紋理坐標(biāo)的 X 和 Y 的位置。我們只關(guān)心 inputTextureCoordinate
中的前兩個參數(shù),X 和 Y。這個坐標(biāo)最開始是通過 4 個屬性存在頂點著色器里的,但我們只需要其中的兩個。我們拿出需要的屬性,然后賦值給一個將要和片段著色器通信的變量,而不是把更多的屬性反饋給片段著色器。
在大多數(shù)圖像處理程序中,頂點著色器都差不多,所以,這篇文章接下來的部分,我們將集中討論片段著色器。
看過了我們簡單的頂點著色器后,我們再來看一個可以實現(xiàn)的最簡單的片段著色器:一個直通濾鏡:
varying highp vec2 textureCoordinate;
uniform sampler2D inputImageTexture;
void main()
{
gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
}
這個著色器實際上不會改變圖像中的任何東西。它是一個直通著色器,意味著我們輸入每一個像素,然后輸出完全相同的像素。我們來一句句的看:
varying highp vec2 textureCoordinate;
因為片段著色器作用在每一個像素上,我們需要一個方法來確定我們當(dāng)前在分析哪一個像素/片段。它需要存儲像素的 X 和 Y 坐標(biāo)。我們接收到的是當(dāng)前在頂點著色器被設(shè)置好的紋理坐標(biāo)。
uniform sampler2D inputImageTexture;
為了處理圖像,我們從應(yīng)用中接收一個圖片的引用,我們把它當(dāng)做一個 2D 的紋理。這個數(shù)據(jù)類型被叫做 sampler2D
,這是因為我們要從這個 2D 紋理中采樣出一個點來進(jìn)行處理。
gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
這是我們碰到的第一個 GLSL 特有的方法:texture2D
,顧名思義,創(chuàng)建一個 2D 的紋理。它采用我們之前聲明過的屬性作為參數(shù)來決定被處理的像素的顏色。這個顏色然后被設(shè)置給另外一個內(nèi)建變量,gl_FragColor
。因為片段著色器的唯一目的就是確定一個像素的顏色,gl_FragColor
本質(zhì)上就是我們片段著色器的返回語句。一旦這個片段的顏色被設(shè)置,接下來片段著色器就不需要再做其他任何事情了,所以你在這之后寫任何的語句,都不會被執(zhí)行。
就像你看到的那樣,寫著色器很大一部分就是了解著色語言。即使著色語言是基于 C 語言的,依然有很多怪異和細(xì)微的差別讓它和普通的 C 語言有不同。
各式著色器都是用 OpenGL 著色語言 (GLSL) 寫的。GLSL 是一種從 C 語言導(dǎo)出的簡單語言。它缺少 C 語言的高級功能,比如動態(tài)內(nèi)存管理。但是,它也包含一些在著色過程中常用的數(shù)學(xué)運算函數(shù)。
在負(fù)責(zé) OpenGL 和 OpenGL ES 實現(xiàn)的 Khronos 小組的網(wǎng)站上有一些有用的參考資料。在你開始之前,一件你可以做的最有價值的事情就是獲取 OpenGL 和 OpenGL ES 的快速入門指導(dǎo):
通過查看這些參考卡片,你可以快速簡單地了解在寫 OpenGL 應(yīng)用時需要的著色語言函數(shù)和數(shù)據(jù)類型。
盡早用,經(jīng)常用。
即使在這么簡單的著色器的例子里,也有一些地方看起來很怪異,不是嗎?看過了基礎(chǔ)的著色器之后,是時候開始解釋其中一些內(nèi)容,以及它們?yōu)槭裁创嬖谟?GLSL 中。
看一看我們的直通著色器,你會注意到有一個屬性被標(biāo)記為 “varying”,另一個屬性被標(biāo)記為 “uniform”。
這些變量是 GLSL 中的輸入和輸出。它允許從我們應(yīng)用的輸入,以及在頂點著色器和片段著色器之間進(jìn)行交流。
在 GLSL 中,實際有三種標(biāo)簽可以賦值給我們的變量:
Uniforms 是一種外界和你的著色器交流的方式。Uniforms 是為在一個渲染循環(huán)里不變的輸入值設(shè)計的。如果你正在應(yīng)用茶色濾鏡,并且你已經(jīng)指定了濾鏡的強度,那么這些就是在渲染過程中不需要改變的事情,你可以把它作為 Uniform 輸入。 Uniform 在頂點著色器和片段著色器里都可以被訪問到。
Attributes 僅僅可以在頂點著色器中被訪問。Attribute 是在隨著每一個頂點不同而會發(fā)生變動的輸入值,例如頂點的位置和紋理坐標(biāo)等。頂點著色器利用這些變量來計算位置,以它們?yōu)榛A(chǔ)計算一些值,然后把這些值以 varyings 的方式傳到片段著色器。
最后,但同樣重要的,是 varyings 標(biāo)簽。Varying 在頂點著色器和片段著色器都會出現(xiàn)。Varying 是用來在頂點著色器和片段著色器傳遞信息的,并且在頂點著色器和片段著色器中必須有匹配的名字。數(shù)值在頂點著色器被寫入到 varying ,然后在片段著色器被讀出。被寫入 varying 中的值,在片段著色器中會被以插值的形式插入到兩個頂點直接的各個像素中去。
回看我們之前寫的簡單的著色器的例子,在頂點著色器和片段著色器中都用 varying 聲明了 textureCoordinate
。我們在頂點著色器中寫入 varying 的值。然后我們把它傳入片段著色器,并在片段著色器中讀取和處理。
在我們繼續(xù)之前,最后一件要注意的事??纯磩?chuàng)建的這些變量。你會注意到紋理坐標(biāo)有一個叫做 highp 的屬性。這個屬性負(fù)責(zé)設(shè)置你需要的變量精度。因為 OpenGL ES 被設(shè)計為在處理能力有限的系統(tǒng)中使用,精度限制被加入進(jìn)來可以提高效率。
如果不需要非常高的精度,你可以進(jìn)行設(shè)定,這或許會允許在一個時鐘循環(huán)內(nèi)處理更多的值。相反的,在紋理坐標(biāo)中,我們需要盡可能的確保精確,所以我們具體說明確實需要額外的精度。
精度修飾存在于 OpenGL ES 中,因為它是被設(shè)計用在移動設(shè)備中的。但是,在老版本的桌面版的 OpenGL 中則沒有。因為 OpenGL ES 實際上是 OpenGL 的子集,你幾乎總是可以直接把 OpenGL ES 的項目移植到 OpenGL。如果你這樣做,記住一定要在你的桌面版著色器中去掉精度修飾。這是很重要的一件事,尤其是當(dāng)你計劃在 iOS 和 OS X 之間移植項目時。
在 GLSL 中,你會用到很多向量和向量類型。向量是一個很棘手的話題,它們表面上看起來很直觀,但是因為它們有很多用途,這使我們在使用它們時常常會感到迷惑。
在 GLSL 環(huán)境中,向量是一個類似數(shù)組的特殊的數(shù)據(jù)類型。每一種類型都有固定的可以保存的元素。深入研究一下,你甚至可以獲得數(shù)組可以存儲的數(shù)值的精確的類型。但是在大多數(shù)情況下,只要使用通用的向量類型就足夠了。
有三種向量類型你會經(jīng)??吹剑?/p>
vec2
vec3
vec4
這些向量類型包含特定數(shù)量的浮點數(shù):vec2
包含兩個浮點數(shù),vec3
包含三個浮點數(shù),vec4
包含四個浮點數(shù)。
這些類型可以被用在著色器中可能被改變或者持有的多種數(shù)據(jù)類型中。在片段著色器中,很明顯 X 和 Y 坐標(biāo)是的你想保存的信息。 (X,Y) 存儲在 vec2
中就很合適。
在圖像處理過程中,另一個你可能想持續(xù)追蹤的事情就是每個像素的 R,G,B,A 值。這些可以被存儲在 vec4
中。
現(xiàn)在我們已經(jīng)了解了向量,接下來繼續(xù)了解矩陣。矩陣和向量很相似,但是它們添加了額外一層的復(fù)雜度。矩陣是一個浮點數(shù)數(shù)組的數(shù)組,而不是單個的簡單浮點數(shù)數(shù)組。
類似于向量,你將會經(jīng)常處理的矩陣對象是:
mat2
mat3
mat4
vec2
保存兩個浮點數(shù),mat
保存相當(dāng)于兩個 vec2
對象的值。將向量對象傳遞到矩陣對象并不是必須的,只需要有足夠填充矩陣的浮點數(shù)即可。在 mat2
中,你需要傳入兩個 vec2
或者四個浮點數(shù)。因為你可以給向量命名,而且相比于直接傳浮點數(shù),你只需要負(fù)責(zé)兩個對象,而不是四個,所以非常推薦使用封裝好的值來存儲你的數(shù)字,這樣更利于追蹤。對于 mat4
會更復(fù)雜一些,因為你要負(fù)責(zé) 16 個數(shù)字,而不是 4 個。
在我們 mat2
的例子中,我們有兩個 vec2
對象。每個 vec2
對象代表一行。每個 vec2
對象的第一個元素代表一列。構(gòu)建你的矩陣對象的時候,確保每個值都放在了正確的行和列上是很重要的,否則使用它們進(jìn)行運算肯定得不到正確的結(jié)果。
既然我們有了矩陣也有了填充矩陣的向量,問題來了:“我們要用它們做什么呢?“ 我們可以存儲點和顏色或者其他的一些的信息,但是要如果通過修改它們來做一些很酷的事情呢?
我找到的最好的關(guān)于線性代數(shù)和矩陣是如何工作的資源是這個網(wǎng)站的更好的解釋。我從這個網(wǎng)站偷來借鑒的一句引述就是:
線性代數(shù)課程的幸存者都成為了物理學(xué)家,圖形程序員或者其他的受虐狂。
矩陣操作總體來說并不“難”;只不過它們沒有被任何上下文解釋,所以很難概念化地理解究竟為什么會有人想要和它們打交道。我希望能在給出一些它們在圖形編程中的應(yīng)用背景后,我們可以了解它們怎樣幫助我們實現(xiàn)不可思議的東西。
線性代數(shù)允許你一次在很多值上進(jìn)行操作。假想你有一組數(shù),你想要每一個數(shù)乘以 2。你一般會一個個地順次計算數(shù)值。但是因為對每一個數(shù)都進(jìn)行的是同樣的操作,所以你完全可以并行地實現(xiàn)這個操作。
我們舉一個看起來可怕的例子,CGAffineTransforms
。仿射轉(zhuǎn)化是很簡單的操作,它可以改變具有平行邊的形狀 (比如正方形或者矩形) 的大小,位置,或者旋轉(zhuǎn)角度。
在這種時候你當(dāng)然可以坐下來拿出筆和紙,自己去計算這些轉(zhuǎn)化,但這么做其實沒什么意義。GLSL 有很多內(nèi)建的函數(shù)來進(jìn)行這些龐雜的用來計算轉(zhuǎn)換的函數(shù)。了解這些函數(shù)背后的思想才是最重要的。
這篇文章中,我們不會把所有的 GLSL 內(nèi)建的函數(shù)都過一遍,不過你可以在 Shaderific 上找到很好的相關(guān)資源。很多 GLSL 函數(shù)都是從 C 語言數(shù)學(xué)庫中的基本的數(shù)學(xué)運算導(dǎo)出的,所以解釋 sin 函數(shù)是做什么的真的是浪費時間。我們將集中闡釋一些更深奧的函數(shù),從而達(dá)到這篇文章的目的,解釋怎樣才能充分利用 GPU 的性能的一些細(xì)節(jié)。
step()
: GPU 有一個局限性,它并不能很好的處理條件邏輯。GPU 喜歡做的事情是接受一系列的操作,并將它們作用在所有的東西上。分支會在片段著色器上導(dǎo)致明顯的性能下降,在移動設(shè)備上尤其明顯。step()
通過允許在不產(chǎn)生分支的前提下實現(xiàn)條件邏輯,從而在某種程度上可以緩解這種局限性。如果傳進(jìn) step()
函數(shù)的值小于閾值,step()
會返回 0.0。如果大于或等于閾值,則會返回 1.0。通過把這個結(jié)果和你的著色器的值相乘,著色器的值就可以被使用或者忽略,而不用使用 if()
語句。
mix()
: mix 函數(shù)將兩個值 (例如顏色值) 混合為一個變量。如果我們有紅和綠兩個顏色,我們可以用 mix()
函數(shù)線性插值。這在圖像處理中很常用,比如在應(yīng)用程序中通過一組獨特的設(shè)定來控制效果的強度等。
*clamp()
: GLSL 中一個比較一致的方面就是它喜歡使用歸一化的坐標(biāo)。它希望收到的顏色分量或者紋理坐標(biāo)的值在 0.0 和 1.0 之間。為了保證我們的值不會超出這個非常窄的區(qū)域,我們可以使用 clamp()
函數(shù)。 clamp()
會檢查并確保你的值在 0.0 和 1.0 之間。如果你的值小于 0.0,它會把值設(shè)為 0.0。這樣做是為了防止一些常見的錯誤,例如當(dāng)你進(jìn)行計算時意外的傳入了一個負(fù)數(shù),或者其他的完全超出了算式范圍的值。
我知道數(shù)學(xué)的洪水一定讓你快被淹沒了。如果你還能跟上我,我想舉幾個優(yōu)美的著色器的例子,這會更有意義,這樣你又有機(jī)會淹沒在 GLSL 的潮水中。
http://wiki.jikexueyuan.com/project/objc/images/21-25.png" alt="" />
這是一個做飽和度調(diào)節(jié)的片段著色器。這個著色器出自 《圖形著色器:理論和實踐》一書,我強烈推薦整本書給所有對著色器感興趣的人。
飽和度是用來表示顏色的亮度和強度的術(shù)語。一件亮紅色的毛衣的飽和度要遠(yuǎn)比北京霧霾時灰色的天空的飽和度高得多。
在這個著色器上,參照人類對顏色和亮度的感知過程,我們有一些優(yōu)化可以使用。一般而言,人類對亮度要比對顏色敏感的多。這么多年來,壓縮軟件體積的一個優(yōu)化方式就是減少存儲顏色所用的內(nèi)存。
人類不僅對亮度比顏色要敏感,同樣亮度下,我們對某些特定的顏色反應(yīng)也更加靈敏,尤其是綠色。這意味著,當(dāng)你尋找壓縮圖片的方式,或者以某種方式改變它們的亮度和顏色的時候,多放一些注意力在綠色光譜上是很重要的,因為我們對它最為敏感。
varying highp vec2 textureCoordinate;
uniform sampler2D inputImageTexture;
uniform lowp float saturation;
const mediump vec3 luminanceWeighting = vec3(0.2125, 0.7154, 0.0721);
void main()
{
lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
lowp float luminance = dot(textureColor.rgb, luminanceWeighting);
lowp vec3 greyScaleColor = vec3(luminance);
gl_FragColor = vec4(mix(greyScaleColor, textureColor.rgb, saturation), textureColor.w);
}
我們一行行的看這個片段著色器的代碼:
varying highp vec2 textureCoordinate;
uniform sampler2D inputImageTexture;
uniform lowp float saturation;
再一次,因為這是一個要和基礎(chǔ)的頂點著色器通信的片段著色器,我們需要為輸入紋理坐標(biāo)和輸入圖片紋理聲明一個 varyings 變量,這樣才能接收到我們需要的信息,并進(jìn)行過濾處理。這個例子中我們有一個新的 uniform 的變量需要處理,那就是飽和度。飽和度的數(shù)值是一個我們從用戶界面設(shè)置的參數(shù)。我們需要知道用戶需要多少飽和度,從而展示正確的顏色數(shù)量。
const mediump vec3 luminanceWeighting = vec3(0.2125, 0.7154, 0.0721);
這就是我們設(shè)置三個元素的向量,為我們的亮度來保存顏色比重的地方。這三個值加起來要為 1,這樣我們才能把亮度計算為 0.0 - 1.0 之間的值。注意中間的值,就是表示綠色的值,用了 70% 的顏色比重,而藍(lán)色只用了它的 10%。藍(lán)色對我們的展示不是很好,把更多權(quán)重放在綠色上是很有意義的。
lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
我們需要取樣特定像素在我們圖片/紋理中的具體坐標(biāo)來獲取顏色信息。我們將會改變它一點點,而不是想直通濾鏡那樣直接返回。
lowp float luminance = dot(textureColor.rgb, luminanceWeighting);
這行代碼會讓那些沒有學(xué)過線性代數(shù)或者很早以前在學(xué)校學(xué)過但是很少用過的人看起來不那么熟悉。我們是在使用 GLSL 中的點乘運算。如果你記得在學(xué)校里曾用過點運算符來相乘兩個數(shù)字的話,那么你就能明白是什么回事兒了。點乘計算以包含紋理顏色信息的 vec4
為參數(shù),舍棄 vec4
的最后一個不需要的元素,將它和相對應(yīng)的亮度權(quán)重相乘。然后取出所有的三個值把它們加在一起,計算出這個像素綜合的亮度值。
lowp vec3 greyScaleColor = vec3(luminance);
我們創(chuàng)建一個三個值都是亮度信息的 vec3
。如果你只指定一個值,編譯器會幫你把該將向量中的每個分量都設(shè)成這個值。
gl_FragColor = vec4(mix(greyScaleColor, textureColor.rgb, saturation), textureColor.w);
最后,我們把所有的片段組合起來。為了確定每個新的顏色是什么,我們使用剛剛學(xué)過的很好用的 mix 函數(shù)。mix 函數(shù)會把我們剛剛計算的灰度值和初始的紋理顏色以及我們得到的飽和度的信息相結(jié)合。
這就是一個很棒的,好用的著色器,它讓你用主函數(shù)里的四行代碼就可以把圖片從彩色變到灰色,或者從灰色變到彩色。還不錯,不是嗎?
最后,我們來看一個很漂亮的濾鏡,你可以用來向你的朋友炫耀,或者嚇唬你的敵人。這個濾鏡看起來像是有一個玻璃球在你的圖片上。這會比之前的看起來更復(fù)雜。但我相信我們可以完成它。
http://wiki.jikexueyuan.com/project/objc/images/21-26.png" alt="" />
varying highp vec2 textureCoordinate;
uniform sampler2D inputImageTexture;
uniform highp vec2 center;
uniform highp float radius;
uniform highp float aspectRatio;
uniform highp float refractiveIndex;
void main()
{
highp vec2 textureCoordinateToUse = vec2(textureCoordinate.x, (textureCoordinate.y * aspectRatio + 0.5 - 0.5 * aspectRatio));
highp float distanceFromCenter = distance(center, textureCoordinateToUse);
lowp float checkForPresenceWithinSphere = step(distanceFromCenter, radius);
distanceFromCenter = distanceFromCenter / radius;
highp float normalizedDepth = radius * sqrt(1.0 - distanceFromCenter * distanceFromCenter);
highp vec3 sphereNormal = normalize(vec3(textureCoordinateToUse - center, normalizedDepth));
highp vec3 refractedVector = refract(vec3(0.0, 0.0, -1.0), sphereNormal, refractiveIndex);
gl_FragColor = texture2D(inputImageTexture, (refractedVector.xy + 1.0) * 0.5) * checkForPresenceWithinSphere;
}
再一次,看起來很熟悉...
uniform highp vec2 center;
uniform highp float radius;
uniform highp float aspectRatio;
uniform highp float refractiveIndex;
我們引入了一些參數(shù),用來計算出圖片中多大的區(qū)域要通過濾鏡。因為這是一個球形,我們需要一個中心點和半徑來計算球形的邊界。寬高比是由你使用的設(shè)備的屏幕尺寸決定的,所以不能被硬編碼,因為 iPhone 和 iPad 的比例是不相同的。我們的用戶或者程序員會決定折射率,從而確定折射看起來是什么樣子的。GPUImage 中折射率被設(shè)置為 0.71.
highp vec2 textureCoordinateToUse = vec2(textureCoordinate.x, (textureCoordinate.y * aspectRatio + 0.5 - 0.5 * aspectRatio));
圖像的紋理坐標(biāo)是在歸一化的 0.0-1.0 的坐標(biāo)空間內(nèi)。歸一化的坐標(biāo)空間意味著考慮屏幕是一個單位寬和一個單位長,而不是 320 像素寬,480 像素高。因為手機(jī)的高度比寬度要長,我們需要為球形計算一個偏移率,這樣球就是圓的而不是橢圓的。
http://wiki.jikexueyuan.com/project/objc/images/21-27.png" alt="" />
highp float distanceFromCenter = distance(center, textureCoordinateToUse);
我們需要計算特定的像素點距離球形的中心有多遠(yuǎn)。我們使用 GLSL 內(nèi)建的 distance()
函數(shù),它會使用勾股定律計算出中心坐標(biāo)和長寬比矯正過的紋理坐標(biāo)的距離。
lowp float checkForPresenceWithinSphere = step(distanceFromCenter, radius);
這里我們計算了片段是否在球體內(nèi)。我們計算當(dāng)前點距離球形中心有多遠(yuǎn)以及球的半徑是多少。如果當(dāng)前距離小于半徑,這個片段就在球體內(nèi),這個變量被設(shè)置為 1.0。否則,如果距離大于半徑,這個片段就不在球內(nèi),這個變量被設(shè)置為 0.0 。
http://wiki.jikexueyuan.com/project/objc/images/21-28.png" alt="" />
distanceFromCenter = distanceFromCenter / radius;
By dividing it by the radius, we are making our math calculations easier in the next few lines of code.
既然我們已經(jīng)計算出哪些像素是在球內(nèi)的,我們接著要對這些球內(nèi)的像素進(jìn)行計算并做些事情。再一次,我們需要標(biāo)準(zhǔn)化到球心的距離。我們直接重新設(shè)置 distanceFromCenter
的值,而不是新建一個變量,因為那會增加我們的開銷。 通過將它與半徑相除,我們可以讓之后幾行計算代碼變得簡單一些。
highp float normalizedDepth = radius * sqrt(1.0 - distanceFromCenter * distanceFromCenter);
因為我們試圖模擬一個玻璃球,我們需要計算球的“深度”是多少。這個虛擬的球,不論怎樣,在 Z 軸上,將會延伸圖片表面到觀察者的距離。這將幫助計算機(jī)確定如何表示球內(nèi)的像素。還有,因為球是圓的,距離球心不同的距離,會有不同的深度。由于球表面方向的不同,球心處和邊緣處對光的折射會不相同:
http://wiki.jikexueyuan.com/project/objc/images/21-29.png" alt="" />
highp vec3 sphereNormal = normalize(vec3(textureCoordinateToUse - center, normalizedDepth));
這里我們又進(jìn)行了一次歸一化。為了計算球面某個點的方向,我們用 X ,Y 坐標(biāo)的方式,表示當(dāng)前像素到球心的距離,然后把這些和計算出的球的深度結(jié)合。然后把結(jié)果向量進(jìn)行歸一化。
想想當(dāng)你正在使用 Adobe Illustrator 這樣的軟件時,你在 Illustrator 中創(chuàng)建一個三角形,但是它太小了。你按住 option 鍵,放大三角形,但是它現(xiàn)在太大了。你然后把它縮小到你想要的尺寸:
http://wiki.jikexueyuan.com/project/objc/images/21-30.png" alt="" />
highp vec3 refractedVector = refract(vec3(0.0, 0.0, -1.0), sphereNormal, refractiveIndex);
refract()
是一個很有趣的 GLSL 函數(shù)。refract()
以我們剛才創(chuàng)建的球法線和折射率來計算當(dāng)光線通過這樣的球時,從任意一個點看起來是怎樣的。
gl_FragColor = texture2D(inputImageTexture, (refractedVector.xy + 1.0) * 0.5) * checkForPresenceWithinSphere;
最后,通過所有這些障礙后,我們終于湊齊了計算片段使用的顏色所需要的所有信息。折射光向量用來查找讀取的輸入位于圖片哪個位置的,但是因為在那個向量中,坐標(biāo)是從 -1.0 到 1.0 的,我們需要把它調(diào)整到 0.0-1.0 的紋理坐標(biāo)空間內(nèi)。
我們?nèi)缓蟀盐覀兊慕Y(jié)果和球邊界檢查的值相乘。如果我們的片段沒有在球內(nèi),一個透明的像素 (0.0, 0.0, 0.0, 0.0) 將被寫入。如果片段在球形內(nèi),這個結(jié)果被使用,然后返回計算好的顏色值。這樣我們在著色器中可以就避免昂貴的條件邏輯。
著色器調(diào)試不是一件直觀的工作。普通的程序中,如果程序崩潰了,你可以設(shè)置一個斷點。這在每秒會被并行調(diào)用幾百萬次的運算中是不可能的。在著色器中使用 printf()
語句來調(diào)試哪里出錯了也是不可能的,因為輸出到哪里呢?考慮你的著色器運行在黑盒中,你怎么才能打開它然后看看為什么它們不工作呢?
你有一個可以使用的輸出:我們的老朋友 gl_FragColor
。gl_FragColor
會給你一個輸出,換一種思路想一想,你可以用它來調(diào)試你的代碼。
所有你在屏幕上看到的顏色都是由一系列的數(shù)字表示的,這些數(shù)字是每一個像素的紅綠藍(lán)和透明度的百分比。你可以用這些知識來測試著色器的每一部分是不是像你構(gòu)建的那樣工作,從而確定它是不是按照你想的那樣在運行。和一般調(diào)試不同,你不會得到一個可以打印的值,而是拿到一個顏色以及和它相關(guān)的某個指定值,依靠這些你可以進(jìn)行逆向反推。
如果想知道你的一個在 0 和 1 之間的值,你可以把它設(shè)置給一個將要傳入 gl_FragColor
的 vec4
中。假設(shè)你把它設(shè)置進(jìn)第一部分,就是紅色值。這個值會被轉(zhuǎn)換然后渲染到屏幕上,這時候你就可以檢查它來確定原始的傳進(jìn)去的值是什么。
你會有幾種方法來捕捉到這些值。從著色器輸出的圖片可以被捕獲到然后作為圖片寫進(jìn)磁盤里 (最好用戶沒有壓縮過的格式)。這張圖片之后就可以放進(jìn)像 Photoshop 這樣的應(yīng)用,然后檢查像素的顏色。
為了更快一些,你可以將圖片用 OS X 的程序或者 iOS 的模擬器顯示到屏幕上。在你的應(yīng)用程序文件夾下的實用工具里有一個“數(shù)碼測色計”的工具可以用來分析這些渲染過的視圖。把鼠標(biāo)放在桌面的任何一個像素點上,它都會精確的展示這個像素點 RGB 的值。因為 RGB 值在數(shù)碼測色計和 Photoshop 中是從 0 到 255 而不是 從 0 到 1,你需要把你想要的值除以 255 來獲得一個近似的輸入值。
回顧下我們的球形折射著色器。簡直無法想象沒有任何測試就可以寫下整個程序。我們有很大一塊代碼來確定當(dāng)前處理的像素是不是在這個圓形當(dāng)中。那段代碼的結(jié)尾用 step()
函數(shù)來設(shè)置像素的這個值為 0.0 或者 1.0 。
把一個 vec4
的紅色分量設(shè)為 step()
的輸出,其他兩個顏色值設(shè)為 0,然后傳入gl_FragColor
中去。如果你的程序正確的運行,你將看到在黑色的屏幕上一個紅色的圈。如果整個屏幕都是黑色,或者都是紅色,那么肯定是有什么東西出錯了。
性能測試和調(diào)優(yōu)是非常重要的事情。尤其是你想讓你的應(yīng)用在舊的 iOS 設(shè)備上也能流暢運行時。
測試著色器性能很重要,因為你總是不能確定一個東西的性能會怎樣。著色器性能變化的很不直觀。你會發(fā)現(xiàn) Stack Overflow 上一個非常好的優(yōu)化方案并不會加速你的著色器,因為你沒有優(yōu)化代碼的真正瓶頸。即使僅只是調(diào)換你工程里的幾行代碼都有可能非常大的減少或增加渲染的時間。
分析的時候,我建議測算幀渲染的時間,而不是每秒鐘渲染多少幀。幀渲染時間隨著著色器的性能線性的增加或減少,這會讓你觀察你的影響更簡單。FPS 是幀時間的倒數(shù),在調(diào)優(yōu)的時候可能會難于理解。最后,如果你使用 iPhone 的相機(jī)捕捉圖像,它會根據(jù)場景的光亮來調(diào)整 FPS ,如果你依賴于此,會導(dǎo)致不準(zhǔn)確的測量。
幀渲染時間是幀從開始處理到完全結(jié)束并且渲染到屏幕或者一張圖片所花費的時間。許多移動 GPU 用一種叫做 “延遲渲染” 的技術(shù),它會把渲染指令批量處理,并且只會在需要的時候才會處理。所以,需要計算整個渲染過程,而不是中間的操作過程,因為它們或許會以一種與你想象不同的順序運行。
不同的設(shè)備上,桌面設(shè)備和移動設(shè)備上,優(yōu)化也會很不相同。你或許需要在不同類型的設(shè)備上進(jìn)行分析。例如,GPU 的性能在移動 iOS 設(shè)備上有了很大的提升。iPhone 5S 的 CPU 比 iPhone 4 快了接近十倍,而 GPU 則快上了好幾百倍。
如果你在有著 A7 芯片或者更高的設(shè)備上測試你的應(yīng)用,相比于 iPhone 5 或者更低版本的設(shè)備,你會獲得非常不同的結(jié)果。Brad Larson 測試了高斯模糊在不同的設(shè)備上花費的時間,并且非常清晰的展示了在新設(shè)備上性能有著令人驚奇的提升:
iPhone 版本 | 幀渲染時間 (毫秒) |
---|---|
iPhone 4 | 873 |
iPhone 4S | 145 |
iPhone 5 | 55 |
iPhone 5S | 3 |
你可以下載一個工具,Imagination Technologies PowerVR SDK,它會幫助你分析你的著色器,并且讓你知道著色器渲染性能的最好的和最壞的情況 。為了保持高幀速率,使渲染著色器所需的周期數(shù)盡可能的低是很重要的。如果你想達(dá)成 60 幀每秒,你只有 16.67 毫秒來完成所有的處理。
這里有一些簡單的方式來幫助你達(dá)成目標(biāo):
消除條件邏輯: 有時候條件邏輯是必須得,但盡量最小化它。在著色器中使用像 step()
函數(shù)這樣的變通方法可以幫助你避免一些昂貴的條件邏輯。
減少依賴紋理的讀取: 在片段著色器中取樣時,如果紋理坐標(biāo)不是直接以 varying 的方式傳遞進(jìn)來,而是在片段著色器中進(jìn)行計算時,就會發(fā)生依賴紋理的讀取。依賴紋理的讀取不能使用普通的紋理讀取的緩存優(yōu)化,會導(dǎo)致讀取更慢。例如,如果你想從附近的像素取樣,而不是計算和片段著色器中相鄰像素的偏差,最好在頂點著色器中進(jìn)行計算,然后把結(jié)果以 varying 的方式傳入片段著色器。在 Brad Larson的文章中關(guān)于索貝爾邊緣檢測的部分有一個這方面的例子。
讓你的計算盡量簡單: 如果你在避免一個昂貴的操作情況下可以獲得一個近似的足夠精度的值,你應(yīng)該這樣做。昂貴的計算包括調(diào)用三角函數(shù) (像sin()
, cos()
, 和 tan()
)。
如果可以的話,把工作轉(zhuǎn)移到頂點著色器: 之前講的關(guān)于依賴紋理的讀取就是把紋理坐標(biāo)計算轉(zhuǎn)移到頂點著色器的很有意義的一種情況。如果一個計算在圖片上會有相同的結(jié)果,或者線性的變化,看看能不能把計算移到頂點著色器進(jìn)行。頂點著色器對每個頂點運行一次,片段著色器在每個像素上運行一次,所以在前者上的計算會比后者少很多。
lowp vec4
相加的操作可以在一個時鐘周期內(nèi)完成,而兩個 highp vec4
相加則需要四個時鐘周期。但是在桌面 GPU 和最近的移動 GPU 上,這變得不再那么重要,因為它們對低精度值的優(yōu)化不同。著色器剛開始看起來很嚇人,但它們也僅僅是改裝的 C 程序而已。創(chuàng)建著色器相關(guān)的所有事情,我們大多數(shù)都在某些情況下處理過,只不過在不同的上下文中罷了。
對于想深入了解著色器的人,我非常推薦的一件事就是回顧下三角學(xué)和線性代數(shù)。做相關(guān)工作的時候,我遇到的最大的阻力就是忘了很多大學(xué)學(xué)過的數(shù)學(xué),因為我已經(jīng)很長時間沒有實際使用過它們了。
如果你的數(shù)學(xué)有些生疏了,我有一些書可以推薦給你:
也有數(shù)不清的關(guān)于GLSL書和特殊著色器被我們行業(yè)突出的人士創(chuàng)造出來:
還有,再一次強調(diào),GPUImage是一個開源的資源,里面有一些非??岬闹鳌R粋€非常好的學(xué)習(xí)著色器的方式,就是拿一個你覺得很有意思的著色器,然后一行一行看下去,搜尋任何你不理解的部分。GPUImage 還有一個著色器設(shè)計的 Mac 端應(yīng)用,可以讓你測試著色器而不用準(zhǔn)備 OpenGL 的代碼。
學(xué)習(xí)有效的在代碼中實現(xiàn)著色器可以給你帶來很大的性能提升。不僅如此,著色器也使你可以做以前不可能做出來的東西。
學(xué)習(xí)著色器需要一些堅持和好奇心,但是并不是不可能的。如果一個 33 歲的還在康復(fù)中的新聞專業(yè)的人都能夠克服她對數(shù)學(xué)的恐懼來處理著色器的話,那么你肯定也可以。