當前位置:
首頁 > 最新 > 如何快速成長為圖形學工程師

如何快速成長為圖形學工程師

「文末高能」

編輯 | 哈比

目前 IT 市場出現了各路諸侯爭霸局面,從大的方向說分為三類:PC 端、移動端、VR/AR,從細分領域來說有 MMO 端游、單機端游、MMO 移動手游、單機手游、VR/AR、PC 端頁游、移動端頁游等等。

隨著硬體的提升,玩家對產品的品質要求越來越高,想提升品質就需要 GPU 渲染,換句話說就是離不開圖形學渲染,涉及到的圖形渲染庫有 DX、OpenGL、OpenGLES、WebGL。

當然實現渲染技術就需要對 GPU 編程,也就是我們通常說的 Shader 編程。

另外,IT 市場需求也越來越大,水漲船高,薪資也比普通的程序員高很多,通過招聘網站就可以看出,目前這方面的技術人員太少了,很多客戶端程序員或者獨立遊戲開發者也想從事圖形學渲染,但是又感覺自己無處下手,不知道從何入手。

很多人都感覺圖形學高深莫測,有種恐懼感。

本篇課程就為了幫助你快速入手,快速掌握圖形學編程的一些知識點和一些編程技巧,這樣可以在比較短的時間內成長為圖形學工程師,這也需要開發者自己的努力。

市場上成熟的引擎主要是 Unity、UE4,二者都提供了強大的渲染能力,作為新手如何才能快速的掌握圖形學編程呢,下面我們從幾個方面來分享:


Shader 編程其實就是對圖像進行編程,常見的圖像格式有:jpg、png、tga、tga、bmp 等等。

作為圖形學開發者首要的事情是搞清楚它們的存儲格式,每種圖像格式它包括很多信息,當然主要還是顏色的存儲:rgb 或者說 rgba,另外圖像的存儲是按照矩陣的方式,如下圖所示:

如果有 A 通道就表明這個圖像可以有透明效果,R、G、B 每個分量一般是用一個位元組(8 位)來表示,所以圖(1)中每個像素大小就是點陣圖,而圖(2)中每個像素大小是位。

圖像是二維數據,數據在內存中只能一維存儲,二維轉一維有不同的對應方式,比較常見的只有兩種方式:按像素 「行排列」 從上往下或者從下往上。

不同圖形庫中每個像素點中 RGBA 的排序順序可能不一樣,上面說過像素一般會有 RGB 或 RGBA 四個分量,那麼在內存中 RGB 的排列就多種情況,跟排列組合類似,不過一般只會有 RGB、BGR、RGBA、RGBA、BGRA 這幾種排列據, 絕大多數圖形庫或環境是 BGR/BGRA 排列。

如果將圖像原始格式直接存儲到文件中將會非常大,比如一個 24 點陣圖,所佔文件大小為位元組 =71.5MB, 其大小非常可觀。

如果用 zip 或 rar 之類的通用演算法來壓縮像素數據,得到的壓縮比例通常不會太高,因為這些壓縮演算法沒有針對圖像數據結構進行特殊處理。

於是就有了 jpeg、png 等格式,同樣是圖像壓縮演算法 jpeg 和 png 也有不同的適用場景,給讀者看看圖像在內存中的存儲,如下圖所示:

jpeg、png 文件之於圖像,就相當於 zip、rar 格式之於普通文件(用 zip、rar 格式對普通文件進行壓縮)。

這個跟我們的 Unity 打包 Assetbundle 與 zip 類似,二者採用相同的壓縮方式。另外 bmp 是無壓縮的圖像格式,在這裡以 Bmp 為例,介紹一下 Bmp 格式的圖片存儲格式。

bmp 格式沒有壓縮像素格式,存儲在文件中時先有文件頭、再圖像頭、後面就都是像素數據了,上下顛倒存儲。

用 windows 自帶的 mspaint 工具保存 bmp 格式時,可以發現有四種 bmp 可供選擇:

單色: 一個像素只佔一位,要麼是 0,要麼是 1,所以只能存儲黑白信息;

16 色點陣圖: 一個像素 4 位,有 16 種顏色可選;

256 色點陣圖: 一個像素 8 位,有 256 種顏色可選;

24 位點陣圖: 就是圖 (1) 所示的點陣圖,顏色可有 2^24 種可選,對於人眼來說完全足夠了。

這裡為了簡單起見,只詳細討論最常見的 24 點陣圖的 bmp 格式。

現在來看其文件頭和圖片格式頭的結構:

在這裡為了能讓讀者更好的理解圖像的讀取方式,在此把圖像處理的文件頭和圖片頭代碼展示一下,網上資源很多:

//bmp 文件頭 typedef struct tagBITMAPFILEHEADER { unsigned short bfType; // 19778,必須是 BM 字元串,對應的十六進位為 0x4d42, 十進位為 19778 unsigned int bfSize; // 文件大小 unsigned short bfReserved1; // 0 unsigned short bfReserved2; // 0 unsigned int bfOffBits; // 從文件頭到像素數據的偏移,也就是這兩個結構體的大小之和 } BITMAPFILEHEADER;//bmp 圖像頭typedef struct tagBITMAPINFOHEADER { unsigned int biSize; // 此結構體的大小 int biWidth; // 圖像的寬 int biHeight; // 圖像的高 unsigned short biPlanes; // 1 unsigned short biBitCount; // 24 unsigned int biCompression; // 0 unsigned int biSizeImage; // 像素數據所佔大小 , 這個值應該等於上面文件頭結構中 bfSize-bfOffBits int biXPelsPerMeter; // 0 int biYPelsPerMeter; // 0 unsigned int biClrUsed; // 0 unsigned int biClrImportant;// 0} BITMAPINFOHEADER;

Bmp 結構體,作為讀者了解一下即可,知道是咋回事?另外 png 是一種無損壓縮格式, 壓縮大概是用行程編碼演算法,png 可以有透明效果。

png 比較適合適量圖,幾何圖。 比如本文中出現的這些圖都是用 png 保存。

通過對圖像格式的了解,可以幫助大家揭開一個謎團就是說,無論哪種壓縮格式的圖片,載入到內存中後,它們都會被解壓展開,這也是為什麼我們的圖片大小几十 K,載入到內存後是幾 M 的原因。

總結

之所以給讀者介紹關於圖像的結構和載入方式,是因為我們對 GPU 編程核心就是對圖像的處理,只有掌握了它們,我們才可以根據策劃的需求或者是美術的需求做出各種渲染效果,比如在材質中剔除黑色,進行反射,折射,以及高光、法線等的渲染。

即使是後處理渲染又稱為濾鏡的渲染也是對圖片像素的處理,與材質渲染不同的是它是對整個場景的渲染,因為遊戲運行也是通過一幀一幀渲染的圖片播放的,後處理就是對這些圖片進行再渲染。

常用的後出比如:Bloom,Blur,HDR,PSSM 等等。所以關於圖片的存儲結構大家一定要掌握,這樣你在學習 Shader 編程時理解的就更深入了。


不論是 Unity 引擎還是 UE4 引擎,他們都有自己的渲染流程,它們都是從固定流水線的基礎上發展起來的可編程流水線。

我們就先從固定流水線講起,作為圖形學開發是必須要掌握的,因為固定流水線是圖形學渲染的基礎,它們的核心是各個空間之間的矩陣變換。

這個我們在 Shader 編程時經常遇到,比如我們經常使用的。其實,它就是將固定流水線中的矩陣運算轉移到了 GPU 中進行了。

遊戲開發早期,在 3D 遊戲開發的初級階段,顯卡功能還沒有現在這麼強大,3D 遊戲開發都是採用的固定流水線,也可以說固定流水線是 3D 遊戲引擎開發的最基本的底層核心。

現在引擎開發採用的可編程流水線也是在固定流水線的基礎上發展起來的,有人可能會問,3D 固定流水線的作用是什麼?

通俗的講,固定流水線的原理就是將 3D 圖形轉換成屏幕上的 2D 圖像顯示的過程,在此過程中都是通過 CPU 處理的,以前那些比較老的 3D 遊戲都是按照這個原理製作的。

實現固定流水線有幾個步驟?

技術來源於生活,這句話是真理,我就用現實生活中的案例給大家介紹一下固定流水線。平時我們經常用攝像機拍照片,比如我們要拍一個人物木偶,人物木偶首先要做出來,它是在工廠通過工人的機器製做出來的。

木偶在出廠前對於外界是看不到的,工廠製作完成後,將其拿到商場裡面擺出來才能看到,然後我們用攝像機拍照木偶,因為攝像機有視角和遠近距離,視角外的物體拍不到的,距離遠的會做模糊處理。

對準人物木偶讓其在攝像機的正中位置,可以看到在攝像機的鏡頭上一個人物木偶就出現了,人物木偶的顏色也在鏡頭上顯示,這整個過程簡單一句話概括就是一個 3D 圖形在 2D 屏幕上的成像。

說這些的主要目的是為了讓大家能夠用自己的語言表述固定流水線。

以前在公司招聘 3D 程序員時,固定流水線是必須要問的問題,面試的大部分人都回答不上或者回答的不完整,靠的是死記硬背,沒有真正領會其精髓。

流水線前加了兩個字 「固定」 就是告訴大家它是按照固定的流程實現的。將上面所說的流程轉化成程序語言就是固定流水線。固定流水線的流程如下圖所示:

物理空間就是我們說的模型自身的空間,比如美術製作的序列幀動畫或者是 3D 模型,因為這些製作的序列幀動畫或者模型也有自己的朝向,大小,這些都是它們自己擁有的與外界無關,這就是物理空間也稱為局部空間。

將這些美術製作好的對象放置到遊戲編輯器中比如 Unity 編輯器,編輯器所在的空間就是世界空間,比如,我們可以在編輯器中對模型進行 Transfrom 組件中的 position、scale、Rotation 進行設置,這些設置就是在世界空間中完成的。

它是相對於世界中的物體進行設置的,比如我們經常使用的 Transform.position 這個就是設置的世界空間的位置,而如果使用 Transform.localposition 就是設置的物體局部空間的位置,這種一般應用到物體的子孩子進行設置。

比如如果我們要在 Game 視圖中看到場景,我們就需要設置 Camera 相機,這樣我們才能看到我們在場景中擺放的物體,這個就是可視空間,如下圖所示:

當然並不是我們所有擺放的物體都能看到,有些是看不到的。

看不到的物體就被相機裁剪掉了,這會涉及到對物體進行矩陣投影裁剪變換,因為我們要裁剪掉不再視口中的物體,我們需要將其投影變換,以及做遮擋剔除就是設置物體的前後關係。

如下圖所示:

最後將其相機中的物體在屏幕上顯示出來,效果如下圖所示:

各個空間之間的變換是通過矩陣變換也就是物體與矩陣相乘得到的,3D 中的物體是由很多點組成的。

這些點是三維的,在場景中做變換就是對 3D 物體中的點做矩陣運算,但是開發者在操作時為什麼沒有用到矩陣變換,這是因為引擎底層已經為我們封裝好了,我們不需要再進行矩陣變換而只需要對其進行傳值操作。

為了方便讀者理解,現把 Unity 引擎底層關於矩陣計算的部分代碼給讀者展示如下,希望幫助讀者理解關於矩陣的換算:

TransformType Transform::CalculateTransformMatrix (Matrix4x4f& transform) const{ //@TODO: Does this give any performance gain?? Prefetch(m_CachedTransformMatrix.GetPtr()); if (m_HasCachedTransformMatrix) { CopyMatrix(m_CachedTransformMatrix.GetPtr(), transform.GetPtr()); return (TransformType)m_CachedTransformType; } const Transform* transforms[32]; int transformCount = 1; TransformType type = (TransformType)0; Matrix4x4f temp; { // collect all transform that need CachedTransformMatrix update transforms[0] = this; Transform* parent = NULL; for (parent = GetParent(); parent != NULL && !parent->m_HasCachedTransformMatrix; parent = parent->GetParent()) { transforms[transformCount++] = parent; // reached maximum of transforms that we can calculate - fallback to old method if (transformCount == 31) { parent = parent->GetParent(); if (parent) { type = parent->**CalculateTransformMatrixIterative**(temp); Assert(parent->m_HasCachedTransformMatrix); } break; } } // storing parent of last transform (can be null), the transform itself won"t be updated transforms[transformCount] = parent; Assert(transformCount = 0; --i) { const Transform* t = transforms[i]; const Transform* parent = transforms[i + 1]; if (parent) { Assert(parent->m_HasCachedTransformMatrix); // Build the local transform into temp type |= t->CalculateLocalTransformMatrix(temp); type |= (TransformType)parent->m_CachedTransformType; **MultiplyMatrices4x4**(&parent->m_CachedTransformMatrix, &temp, &t->m_CachedTransformMatrix); } else { // Build the local transform into m_CachedTransformMatrix type |= t->CalculateLocalTransformMatrix(t->m_CachedTransformMatrix); } // store cached transform t->m_CachedTransformType = UpdateTransformType(type, t); t->m_HasCachedTransformMatrix = true; } Assert(m_HasCachedTransformMatrix); CopyMatrix(m_CachedTransformMatrix.GetPtr(), transform.GetPtr()); return (TransformType)m_CachedTransformType;}

以上是引擎底層關於矩陣的運算實現,下面再給讀者介紹關於視口變換的案例。

我們在自己實現項目時遇到的問題就是使用下面的處理方式把問題解決了,我們的需求是將相機獲取到圖片在屏幕上某個位置顯示出來。

這就要實現從局部坐標到世界坐標,再到屏幕坐標的換算,實質上就是對圖片的像素進行具體轉換操作:

再解釋一下,上圖中 clipSpace 是相機裁剪完成後,讀者如果使用過 Unity3D 引擎知道,它的視口大小是 0,0,1,1,這個是被標準化處理過了,也就是圖中的 Normalized Device Space。

但是屏幕上的坐標數值不是用 0,1 表示的,也就是圖中的 Window Space,我們知道屏幕的大小尺寸都是用像素表示的,比如: 640480,720640,1280 * 720 等,這些像素與 0,1 之間關係是一一對應的,對應公式如下所示:

公式中的參數 (xw,yw) 是屏幕坐標,(x, y, width, height) 是傳入的參數,(xnd, ynd) 是投影之後經歸一化之後的點,這樣我們就可以計算出屏幕上的點坐標。

如果讀者對矩陣變換不理解可以查看《線性代數》和《3D 數學基礎:圖形與遊戲開發》這兩本書。

費這麼多筆墨給讀者介紹固定流水線以及矩陣變換,主要是為我們下面介紹的可編程流水線做鋪墊。

可編程流水線主要是針對 GPU 編程的,換句話說就是將固定流水線的矩陣變換放到 GPU 中進行計算,這樣可以徹底解放 CPU 用於處理其他事情,提升效率。

這就涉及到 GPU 編程了,GPU 編程語言目前有 3 種主流語言:

–基於 OpenGL 的 GLSL(OpenGLShading Language,也稱為 GLslang)

–基於 Direct3D 的 HLSL(High Level ShadingLanguage)語言,

–NVIDIA 公司的 Cg (C for Graphic)語言。

跨平台的 Shader 編程語言是 GLSL 和 CG,二者的語法跟 C 語言很類似,可編程流水線的執行流程圖如下:

從上圖可以看出在 GPU 中——圖中黃色的部分,主要負責頂點坐標變換、光照、裁剪、投影以及屏幕映射,該階段基於 GPU 進行運算。

在該階段的末端得到了經過變換和投影之後的頂點坐標、顏色、以及紋理坐標。

當然 GPU 並不是只是簡單的執行這些,因為 GPU 是多線程的它可以做很多的工作,GPU 比較擅長做的就是關於矩陣的運算。

說到 GPU 編程,不得不說頂點著色器和片段著色器,這個也是開發者要重點掌握的,再看看頂點著色器和片段著色器在 GPU 中的執行流程,如下圖所示:

上圖主要是實現了頂點著色程序從 GPU 前端模塊(寄存器)中提取圖元信息(頂點位置、法向量、紋理坐標等),並完成頂點坐標空間轉換、法向量空間轉換、光照計算等操作,最後將計算好的數據傳送到指定寄存器中。

這就是說 GPU 也有自己的寄存器,我們在 Shader 中聲明的變數它是存儲在 GPU 的寄存器中。

片斷著色程序從寄存器中獲取需要的數據,通常為 「紋理坐標、光照信息等」,並根據這些信息以及從應用程序傳遞的紋理信息(如果有的話)進行每個片斷的顏色計算。

這個就涉及到圖片的像素計算了,也是開發者要掌握的。最後將處理後的數據送光柵操作模塊完成。

另外,我們自己所寫的 Shader,程序是如何使用的?換句話說,我們的 Shader 屬於一種特殊的腳本,程序載入它並對它進行解釋,最後通過介面將其輸送到 GPU 中處理。

這個處理過程是引擎底層實現的,為了讓讀者清楚,在此通過一部分核心代碼給讀者展示引擎是通過載入讀取 Shader 並將其傳輸給 GPU 中處理。

現在比較流行的 H5 遊戲,它使用的渲染庫是 WebGL,WebGL 提供了相應的介面,用於載入已有的 Shader,當然 OpenGL,DX 都提供了相應的介面。

下面我們先定義頂點著色器和片段著色器腳本,簡單的舉個例子:

attribute vec3 position; uniform mat4 mvpMatrix; void main(void){ gl_Position = mvpMatrix * vec4(position, 1.0); }

這裡用到了一個 attribute 變數和一個 uniform 變數,用於在 Shader 中聲明變數,這個是原生態的 Shader 腳本與 Unity 的是不一樣的。

變數 position 的類型是 vec3,表示的是一個 3 維向量,裡面是頂點的位置,向量的三個元素分別是 X,Y,Z 坐標,另一個 uniform 聲明的變數 mvpMatrix,類型是 mat4,它表示的是一個 4x4 的方陣。

它是模型,視圖,投影的各個變換矩陣結合後的矩陣。這次的頂點著色器,只是利用坐標變換矩陣來變換頂點的坐標位置,使用乘法運算,頂點著色器得到的結果將傳遞給片段著色器。

為了讓 position 和矩陣相乘,使用 vec4 先將其變成一個 4 維的向量,然後相乘,最後將計算結果代入到 gl_Position,頂點著色器的處理結束。

接著說片段著色器,這次,繪製的模型是一個簡單的三角形,先不進行著色,只是使用白色來填充。

所以,片段著色器中的處理,就只是將白色信息傳給 gl_FragColor 中。下面是片段著色器的代碼。

void main(void){ gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); }

關於顏色,基本上使用 vec3 或者 vec4 的情況比較多。因為一般就是使用 RGB 或者 RGBA,需要 3~4 個元素。

這一次使用的 vec4 是所有的參數都是 1.0 的向量,顏色是白色[紅,綠,藍,不透明度的各元素都是最大=白色]。

接下來,我們看頂點著色器和片段著色器的運行過程,也就是引擎內部的實現過程,我們編寫 Shader 不僅要知道咋寫?還要清楚引擎內部是咋調用的,做到知其然,知其所以然。

Shader 的編譯也不需要什麼特別的編譯器,只需要調用 WebGL 內部的介面函數就可以進行編譯了。

從著色器的編譯,到實際著色器的生成這一連串的流程,都在一個函數中來完成。下面是這個函數的代碼:

function create_shader(id){ // 用來保存著色器的變數 var shader; // 根據 id 從 HTML 中獲取指定的 script 標籤 var scriptElement = document.getElementById(id); // 如果指定的 script 標籤不存在,則返回 if(!scriptElement) // 判斷 script 標籤的 type 屬性 switch(scriptElement.type){ // 頂點著色器 case "x-vertex": shader = gl.createShader(gl.VERTEX_SHADER); break; // 片段著色器 case "x-fragment": shader = gl.createShader(gl.FRAGMENT_SHADER); break; default : return; } // 將標籤中的代碼分配給生成的著色器 gl.shaderSource(shader, scriptElement.text); // 編譯著色器 gl.compileShader(shader); // 判斷一下著色器是否編譯成功 if(gl.getShaderParameter(shader, gl.COMPILE_STATUS)){ // 編譯成功,則返回著色器 return shader; }else{ // 編譯失敗,彈出錯誤消息 alert(gl.getShaderInfoLog(shader)); } }

簡單的介紹一下上述代碼,重點的函數都加了注釋,將代碼分配給生成的著色器的時候,使用的是 shaderSource 函數,參數有兩個,第一個參數是著色器對象,第二個參數是著色器的代碼。

這時候,只是把著色器的代碼分配給了著色器,Shader 編譯的時候,使用的是 compileShader 函數,將著色器對象作為參數傳給這個函數,這樣就可以將著色器在引擎中進行編譯了。

這個自定義函數,無論是頂點著色器還是片段著色器,都可以進行編譯讀取。實際上,頂點著色器和片段著色器的處理不同的地方就是 createShader 函數,其他地方是完全一樣的。

從頂點著色器向片段著色器中傳遞數據,其實,實現的就是從一個著色器向另一個著色器傳遞數據的,不是別的,就是程序對象。

程序對象是管理頂點著色器和片段著色器,或者 WebGL 程序和各個著色器之間進行數據的互相通信的重要的對象。

那麼,生成程序對象,並把著色器傳給程序對象,然後連接著色器,將這些處理函數化,關於程序對象的實現是通過調用函數介面實現的,代碼如下:

function create_program(vs, fs){ // 程序對象的生成 var program = gl.createProgram(); // 向程序對象里分配著色器 gl.attachShader(program, vs); gl.attachShader(program, fs); // 將著色器連接 gl.linkProgram(program); // 判斷著色器的連接是否成功 if(gl.getProgramParameter(program, gl.LINK_STATUS)){ // 成功的話,將程序對象設置為有效 gl.useProgram(program); // 返回程序對象 return program; }else{ // 如果失敗,彈出錯誤信息 alert(gl.getProgramInfoLog(program)); } }

這個函數接收頂點著色器和片段著色器兩個參數,首先生成程序對象,分配著色器,生成著色器的時候,使用 WebGL 中的函 createProgram。

將著色器分配給程序對象的時候使用函數介面 attachShader,attachShader 函數的第一個參數是程序對象,第二個參數是著色器。

著色器分配結束後,根據程序對象,要連接兩個著色器,這時候使用 linkProgram 函數,參數就是程序對象。再後面就是賦值了,下面語句:

var projMat = getPerspectiveProjection(30, 16 / 9, 1, 100); var viewMat = lookAt(6, 6, 14, 0, 0, 0, 0, 1, 0); var mvpMat = multiMatrix44(projMat, viewMat); var u_MvpMatrix = gl.getUniformLocation(sp, "mvpMatrix"); gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMat);

該語句將世界視口投影矩陣傳遞給 GPU 使用,其他的跟這個類似,以上我們就實現了對 Shader 的載入編譯以及參數傳遞。

這些對 Unity 都是封閉的,開發者是不清楚的,給讀者介紹這些的目的是告訴讀者 Shader 在引擎中的一個執行流程。

我們寫的代碼是通過以上類似的處理方式進行的,掌握了以上兩點後,下面開始 Shader 實用技術編程講解。


在告訴大家編程技巧之前,我們首先要清楚 Shader 編程使用的介面函數的定義,換句話說我們要能看懂已有 Shader 的語句。

以 Unity 為例,Unity 為我們提供了很多現成的 Shader,還有一些 Shader 庫,為我們封裝了很多函數,我們要做的事情就是要了解這些函數作用,在講解語句之前,先給讀者介紹幾個空間概念。

雖然我們已經在上面提過,因為這幾個概念非常重要,所以有必要強調一下:

在 model space 中,坐標是相對於模型網格的原點(0,0,0)定義的,這也是為什麼我們要求美術在導出模型時將其設置成原點。

我們的 vertex function 需要把這些坐標轉換到 clip space 中,為投影做準備。

在 tangent space 中也稱為切線空間,法線紋理的計算就是在這個空間中進行的,坐標是相對於模型的正面定義的——在處理法線紋理時我們使用這個 space,它是放在 UnityCG.cginc 庫中,定義如下:

#define TANGENT_SPACE_ROTATION float3 binormal = cross( v.normal, v.tangent.xyz ) * v.tangent.w; float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )

可以看出 rotation 就是由三個向量構建出了的 3X3 矩陣,而這三個向量分別對應了 Object Space 中的 tangent、binormal 和 normal 的方向。

這三個方向對應了 Tangent Space 中的三個坐標軸的方向,效果如下所示:

其實就是一個坐標變換,如果我們想得到從坐標系 A 轉換到坐標系 B 的一個變換矩陣,我們只需用 A 中 B 的三個坐標軸的方向、按 X、Y、Z 軸的順序構建一個矩陣即可。

在 world space 中,坐標是相對於世界的原點(0,0,0)定義的。

在 view space 中,坐標是相對於攝像機定義的,因此在這個 space 中,攝像機的位置就是(0,0,0)。

在 clip space 中,通常圖元會被裁剪,然後再通過屏幕映射投影到屏幕空間中。

在 Shader 中我們通常會將其放到一個宏裡面,比如 UNITY_MATRIX_MVP 這個就是(在 UnityShaderVariables.cginc 里定義)相乘,從而把頂點位置從 model space 轉換到 clip space。

我們使用了矩陣乘法操作 mul 來執行這個步驟。

下面我們通過頂點著色器和片段著色器給讀者介紹關於使用的函數定義:

v2f vert(appdata_base v) { v2f o; o.pos = mul (UNITY_MATRIX_MVP, v.vertex); o.srcPos = ComputeScreenPos(o.pos); o.w = o.pos.w; return o; }

頂點著色器中 ComputeScreenPos 是在 UnityCG.cginc 中定義的函數,它就作用如名字一樣,計算該頂點轉換到屏幕上的位置。

還有如果我們需要把法線從模型空間變換到世界空間中,可以直接使用內置函數 UnityObjectToWorldNormal 函數,常見的語句如下:

fixed3 worldNormal = UnityObjectToWorldNormal(v.normal)它的實現可以在它的實現可以在 UnityCG.cginc 里找到: inline float3 UnityObjectToWorldNormal( in float3 norm ) { // Multiply by transposed inverse matrix, actually using transpose() generates badly optimized code return normalize(_World2Object[0].xyz * norm.x + _World2Object[1].xyz * norm.y + _World2Object[2].xyz * norm.z); }

還有一種寫法:

o.worldNormal = normalize(mul(v.normal, (float3x3)_World2Object));

總之,讀者一定要清楚重點函數的作用,這個可以查閱 Unity 自帶的庫。

我們在編寫 Shader 時,通常會把引用的庫在 Shader 前面寫出來,比如 Shader 代碼中 Include」UnityCG.cginc」 等等。

它們都是可以在 Unity 提供的庫中找到的,讀者學習 Shader 編程對於向量之間的點乘,叉乘,矩陣相乘都要搞清楚。

掌握了上面的知識點後,我們在項目開發時,怎麼去滿足需求呢?

首先先從 Unity 官方提供的 Shader 中匹配需求開發,Unity 為我們提供了很多現成的 Shader,可以直接拿過來用。如果單個現成的滿足不了需求,看看能否將其中兩個合併成一個使用,或者在已有的基礎上修改,就不要自己重新寫了。

舉個例子,關於透明的材質我們經常會遇到渲染順序問題,比如我們渲染的透明材質按照 Unity 提供的渲染順序,它是在不透明的物體後面渲染,如果我們遇到需求先渲染透明材質,這種問題解決起來比較簡單。

直接在 Tag 標籤中的 Queue 中 減去 1000,這樣它的渲染數值就小於不透明物體了,代碼如下:

Tags {"Queue"="Transparent-1000" "IgnoreProjector"="True" "RenderType"="Transparent"}

還有我們在做實時陰影渲染時,為了防止陰影重疊,我們將原有的 Unity 自帶的 Shader 做了一個改動就是將不需要的顏色才剪掉,只是加了一個判斷。

根據設置的參數去做裁剪,功能就實現出來了:

half4 frag(v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.texcoord); **if(col.a

其實,我們只是加了一個判斷見加黑體部分,它就是根據材質的 Alpha 值與設定的值進行比較裁剪掉而已。這樣實現的效果如下:

原來的代碼是沒有這個判斷,這是我們根據自己的需求加上去的。另外,我們針對角色的渲染也會做一些處理,如下圖所示的效果:

它對應的 Unity 中材質的渲染如下圖所示:

其實我們也是在 Unity 原有 Shader 的基礎上增加了一些變數設置:

Properties { _Color ("Main Color", Color) = (1,1,1,1) _ReflectColor ("Reflection Color", Color) = (1,1,1,0.5) _MainTex ("Base (RGB) RefStrength (A)", 2D) = "white" {} _Cube ("Reflection Cubemap", Cube) = "_Skybox" { TexGen CubeReflect } _BumpMap ("Normalmap", 2D) = "bump" {} _AddColor ("Add Color", Color) = (0,0,0,1) _Shininess ("Shininess", Range (0.05, 1)) = 0.9 _ShininessPath ("ShininessPath", Range (0.1, 1)) = 0.5 _IllColor ("Ill Color", Color) = (0.5, 0.5, 0.5, 1)}

當然要定義這些變數:

sampler2D _MainTex;sampler2D _BumpMap;samplerCUBE _Cube;fixed4 _Color;fixed4 _ReflectColor;fixed4 _AddColor;half _Shininess;half _ShininessPath;fixed4 _IllColor;

注意它們的名字跟 Properties 定義的是一一對應的,最後在 Shader 中進行處理這些變數,無非是對材質進行相乘或者相加運算,最終還是對紋理的像素進行操作:

void surf (Input IN, inout SurfaceOutput o) { fixed4 tex = tex2D(_MainTex, IN.uv_MainTex); fixed4 c = tex * _Color + _AddColor; o.Albedo = c.rgb; o.Gloss = tex.a; o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)); float3 worldRefl = WorldReflectionVector (IN, o.Normal); fixed4 reflcol = texCUBE (_Cube, worldRefl); reflcol *= tex.a; o.Emission = reflcol.rgb * _ReflectColor.rgb + tex.rgb * _IllColor.rgb; o.Alpha = reflcol.a * _ReflectColor.a; o.Specular *= o.Alpha * _Shininess + _ShininessPath;}

顏色的疊加或者對相應的顏色進行加強利用相乘得到,是不是很簡單?

另外,如果 Unity 現有的 Shader 滿足不了需求,我們可以借用 OpenGL 中的 Shader,它們也可以比較方便的修改成 Unity 的 Shader。

換句話說,如果我們用 Unity 自帶的 Shader 搞不定,如果你能找到 OpenGL 中的 Shader 一樣可以修改,代碼如下所示,下面是 OpenGL 中的代碼:

varying vec2 v_texCoord;uniform sampler2D yTexture; uniform sampler2D uvTexture;const mat3 yuv2rgb = mat3( 1, 0, 1.2802, 1, -0.214821, -0.380589, 1, 2.127982, 0 );void main() { vec3 yuv = vec3( 1.1643 * (texture2D(yTexture, v_texCoord).r - 0.0627), texture2D(uvTexture, v_texCoord).a - 0.5, texture2D(uvTexture, v_texCoord).r - 0.5 ); vec3 rgb = yuv * yuv2rgb; gl_FragColor = vec4(rgb, 1.0);}

我們要把以上的 Shader 代碼應用到我們的 Unity 中,這就需要將上面代碼改成 Unity 能識別的 Shader 代碼。

下面就以修改這個為例給讀者分析一下:varying vec2 v_texCoord;可以定義成輸入結構體,也就是我們說的 UV 紋理,定義的結構體如下:

struct Input { float2 uv_MainTex; }

再修改下面的代碼:

uniform sampler2D yTexture; uniform sampler2D uvTexture;

可以修成成如下代碼:

Properties { _XTex ("XTexture(RGB)", 2D) = "white" {} _YTex ("YTexture(RGB)", 2D) = "white" {}}

接下來再將定義的矩陣修成 Unity 中的 Shader,語句如下:

float3x3 = matrix( 1, 0, 1.2802, 1, -0.214821, -0.380589, 1, 2.127982, 0);

接下來再改造:

vec3 rgb = yuv * yuv2rgb; float3= mul(yuv2rgb,yuv);

最後 獲取顏色:float4(rgb,1.0f);

按照這種方式就可以將 OpenGL 中的 Shader 改造成 Unity 自身的 Shader。當然使用這種方式只是為了讓大家快的實現需求,實現不是目的。

我們的主要目的是在此基礎上掌握 Shader 的函數運用以及實現它們的思路。

再此給讀者推薦一個網站 OpenGL 的,裡面的 Shader 我們很多可以使用,比如用 Shader 實現的效果如下:

左邊是實時渲染效果,右邊是代碼和渲染通道。網址:https://www.shadertoy.com/。


Shader 的核心用法就是材質渲染,材質渲染無非涉及到材質高光,法線這些點,還有反射,折射,卡通渲染,描邊等等,以及 Unity 高版本實現的 PBR 物理效果。

在此給讀者介紹一個 Shader 編輯器工具 Shader Forge,如果讀者使用過 UE4 虛幻藍圖,它的操作方式跟 Shader Forge 非常類似。

讀者可以先看看這兩篇博客在此就不重複了,只是說,在使用 Shader Forge 時要注意,我們使用時雖然實現了效果,但是要考慮到在手機端的效率問題,有時我們需要對其做一些效率優化。

比如渲染順序問題,效率問題等等,我們用 Shader Forge 實現的效果如下:

對應的 Unity 中的 Shader 控制面板 Shader 如下所示:

在優化時,比如我們不想讓其受光照,我們一般的做法是直接如下:

// Tags {// "LightMode"="ForwardBase"// }

將光照模式注釋掉,同時可以加一句 Light off,關閉燈光。另外,下面這行代碼也要注意,將其注釋掉:

//#pragma exclude_renderers gles3 metal d3d11_9x xbox360 xboxone ps3 ps4 psp2

因為手機系統的適配,這句可以在 Shader Forge 中設置屏蔽掉,如果不設置可以直接把這行代碼注視掉,Shader Forge 操作起來非常方便,直接用線鏈接就可以,而且可以實時的查看效果。

下面再說說 Shader 優化,這個是比較頭疼的問題,一方面要考慮到優化,一方面要考慮到品質。


Shader 的優化處理,這個是一直困擾著程序的問題,想要好的品質,也要顧及到運行效率,下面就給讀者分析一下。

在編寫 Shader 使如果遇到需求多時,我們會在 Shader 中添加很多變數,在 Shader 如果使用變數比較多,我們通常的優化方案是從其聲明的變數精度開始。

通常的定義如下:

float:表示的是最高精度,通常 32 位;

half:表示的是中等精度,通常 16 位;

fixed:表示的是最低精度,通常 11 位。

同樣還有我們常見的 float2,half2,fixed2 精度依次降低,當然效率是逐步提升的,精度越低效率越高。

我們在使用時也是按照這種去考慮,當然也要考慮到品質,fixed2 精度肯定不如 float2 的精度,當然那品質也就不如 float2 渲染的精度,這個要酌情處理。

另外,對於 Shader 代碼中使用的 if else,while,for,這些用於判斷的語句和循環語句盡量少用,因為 GPU 是多線程執行的,這些容易打斷它的執行。

盡量少用,不是說完全不用,因為一些特殊需求還是要特殊處理的。

對於大批量的物體,比如國戰的遊戲,需要大量的玩家,如果我們使用 CPU 去處理,這樣容易導致產生大量的批處理,嚴重影響效率,也有讀者會考慮到使用網格合成,但是這樣就不能一一操作單體了,所以這種方法需要排除。

如果我們使用 GPU Instancing 去處理就容易的多了,但是使用 GPU Instancing 處理的條件是必須是相同的角色動作和材質,高版本的 Unity 的 Shader 都為我們提供了這個功能,在 Unity2017.2 中的 Shader 截圖如下:

紅線加粗的部分就是實例化,我們要勾選上,當然,我們自己的 Shader 也可以這麼處理,GPU Instancing 實例化角色的 Shader 代碼如下所示:

Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 開啟 gpu instancing #pragma multi_compile_instancing #include "UnityCG.cginc" struct appdata { float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID }; sampler2D _MainTex; float4 _MainTex_ST; sampler2D _AnimMap; float4 _AnimMap_TexelSize;//x == 1/width float _AnimLen; v2f vert (appdata v, uint vid : SV_VertexID) { UNITY_SETUP_INSTANCE_ID(v); float f = _Time.y / _AnimLen; fmod(f, 1.0); float animMap_x = (vid + 0.5) * _AnimMap_TexelSize.x; float animMap_y = f; float4 pos = tex2Dlod(_AnimMap, float4(animMap_x, animMap_y, 0, 0)); v2f o; o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.vertex = UnityObjectToClipPos(pos); return o; }

看以上代碼帶有 INSTANCE_ID 的部分,這需要在 Shader 中事先聲明定義,這樣 Shader 才具有實例化功能。

只要將該 Shader 掛到需要實例化的角色身上即可,網上也有這樣的案例。

以上給讀者介紹了幾個常用的優化方案,策劃會根據不同的項目提出不同的需求,方法掌握了,其他的修改就可以了。


GPU 不僅能用於渲染材質,而且還能渲染場景也就是我們說的後處理,又稱為濾鏡。

因為 Unity 使用的是單線程渲染方式,而且它是通過相機表現的,就是說後處理的腳本要掛接到相機上,這樣如果相機上的後處理腳本過多,嚴重影響運行效率。

而 UE4 虛幻使用的是多線程渲染,這樣它的渲染效率大大提升。

所以 UE4 可以使用大量的後處理效果,當然在 PC 端是完全可以的,手機端就要酌情考慮了。如果要掌握後處理渲染,首要的是要知道其工作原理。

下面以遊戲中比較經典 Bloom 的處理演算法為例給讀者介紹:

Bloom 又稱 「全屏泛光」,在遊戲場景的渲染中使用的非常多。

像 Bloom 這些後處理渲染效果,在遊戲場景中是必備的渲染,它們的實現方式都是在 GPU 中實現的。要實現 Bloom 演算法,首先要做的事情是明白其實現原理,下面我們就來揭秘這個 Bloom 特效的實現流程。

在流程上總共分為 4 步:

提取原場景貼圖中的亮色;

針對提取貼圖進行橫向模糊;

在橫向模糊基礎上進行縱向模糊;

所得貼圖與原場景貼圖疊加得最終效果圖。

首先展示流程的第 1 步代碼如下所示:

BloomExtract.fx // 提取原始場景貼圖中明亮的部分// 這是應用全屏泛光效果的第一步sampler TextureSampler : register(s0);float BloomThreshold;float4 ThePixelShader(float2 texCoord : TEXCOORD0) : COLOR0{ // 提取原有像素顏色 float4 c = tex2D(TextureSampler, texCoord); // 在 BloomThreshold 參數基礎上篩選較明亮的像素 return saturate((c - BloomThreshold) / (1 - BloomThreshold));}technique BloomExtract{ pass Pass1 { PixelShader = compile ps_2_0 ThePixelShader(); }}

接下來是第 2,3 步的實現代碼如下所示:

GaussianBlur.fx // 高斯模糊過濾// 這個特效要應用兩次,一次為橫向模糊,另一次為橫向模糊基礎上的縱向模糊,以減少演算法上的時間複雜度// 這是應用 Bloom 特效的中間步驟sampler TextureSampler : register(s0);#define SAMPLE_COUNT 15// 偏移數組float2 SampleOffsets[SAMPLE_COUNT];// 權重數組float SampleWeights[SAMPLE_COUNT];float4 ThePixelShader(float2 texCoord : TEXCOORD0) : COLOR0{ float4 c = 0; // 按偏移及權重數組疊加周圍顏色值到該像素 // 相對原理,即可理解為該像素顏色按特定權重發散到周圍偏移像素 for (int i = 0; i

第 4 步實現的代碼如下所示:

BloomCombine.fx // 按照特定比例混合原始場景貼圖及高斯模糊貼圖,產生泛光效果// 這是全屏泛光特效的最後一步// 模糊場景紋理採樣器sampler BloomSampler : register(s0);// 原始場景紋理及採樣器定義texture BaseTexture;sampler BaseSampler = sampler_state{ Texture = (BaseTexture); MinFilter = Linear; MagFilter = Linear; MipFilter = Point; AddressU = Clamp; AddressV = Clamp;};float BloomIntensity;float BaseIntensity;float BloomSaturation;float BaseSaturation;// 減緩顏色的飽和度float4 AdjustSaturation(float4 color, float saturation){ // 人眼更喜歡綠光,因此選取 0.3, 0.59, 0.11 三個值 float grey = dot(color, float3(0.3, 0.59, 0.11)); return lerp(grey, color, saturation);}float4 ThePixelShader(float2 texCoord : TEXCOORD0) : COLOR0{ // 提取原始場景貼圖及模糊場景貼圖的像素顏色 float4 bloom = tex2D(BloomSampler, texCoord); float4 base = tex2D(BaseSampler, texCoord); // 柔化原有像素顏色 bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity; base = AdjustSaturation(base, BaseSaturation) * BaseIntensity; // 結合模糊像素值微調原始像素值 base *= (1 - saturate(bloom)); // 疊加原始場景貼圖及模糊場景貼圖,即在原有像素基礎上疊加模糊後的像素,實現發光效果 return base + bloom;}technique BloomCombine{ pass Pass1 { PixelShader = compile ps_2_0 ThePixelShader(); }}

具體實現見下圖所示:

下面,大家結合這個流程圖來分析各個步驟:

第一步:應用 BloomExtract 特效提取原始場景貼圖中較明亮的顏色繪製到貼圖中

第二步:應用 GaussianBlur 特效,在貼圖基礎上進行橫向高斯模糊到貼圖

第三步:再次應用 GaussianBlur 特效,在橫向模糊之後的貼圖基礎上進行縱向高斯模糊,得到最終的模糊貼圖

注意:此時,貼圖即為最終的模糊效果貼圖。

第四步:應用 BloomCombine 特效,疊加原始場景貼圖 m_pResolveTarget 及兩次模糊之後的場景貼圖 m_pTexture1,從而實現發光效果 (m_pResolveTarget+m_pTexture1)。

實現的效果如下所示:

另外,其他的後處理方式比如 Blur,HDR,PSSM 等等,也是基於圖像的演算法實現的。掌握了演算法的原理,不論用什麼引擎,它們的原理都是類似的,只是在一些細節方面做的不同罷了。

Unity 也為用戶提供了大量的後處理 Shader,拿過來使用就可以了,但是也要注意其效率,我們可以在此基礎上進行優化處理,比如適當的把精度降低一些。

有些函數語句在不影響效果的前提下可以簡化,比如一些 exp,exp2 等等,這種類似的函數都可以簡化處理,畢竟它們也是非常耗的。


Shader 因為是一種腳本語言,面臨著非常尷尬的境地是無法調試,以前我們開發端游時使用的是 Render Monkey,它是可以調試的。

Unity 中的 Shader 可以看到其錯誤的行數,如果語句沒有錯誤,我們要查找問題,通常的做法就是逐步的注釋掉語句進行排查,雖然麻煩但是可以解決問題。

也可以使用特殊值的方式進行測試語句。


以上幾點也是作者自己關於 Shader 學習的一點總結,希望對讀者有所幫助。

有一點大家要清楚,我們使用 Shader 渲染都是基於圖像的處理方式,不論是材質渲染還是後處理渲染,其實如果想成為圖形學工程師,以上六點還是必須要掌握的。

在此也是拋磚引玉,裡面用到了一些技巧,只是幫助你快速的入手,要想深入的學習,我們還是要把基礎打好,其實圖形學沒有想像的那麼複雜。


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 GitChat技術雜談 的精彩文章:

境外業務性能優化實踐

TAG:GitChat技術雜談 |