聊聊Unity的Gamma校正以及線性工作流
0x00 前言的前言
這篇小文其實是在清明節前後起的頭,不過後來一度擱筆。一直到這周末才又想起來起的這個頭還沒有寫完,所以還是直接用一個月前的開頭,再將過程和結尾補齊。
0x01 前言
結束了在南方一周的出差,清明時節回到了剛好下過雪並且和南方有20多度溫差的北京之後,終於有時間來寫點文字了。這篇小文,我主要想來聊一聊在使用Unity時和gamma校正相關的話題。事實上關於Gamma校正的來源歷史以及理論知識已經有很多相關的文章了,比如龔大的《gamma的傳說》、Nvidia的Gpu Gems的文章等等。所以我在理論知識上只是稍作著墨,主要還是要來聊聊Unity中的Gamma校正的相關內容。
0x02 顯示器和gamma校正
關於gamma校正來源的說法很多,具體可以參考龔大的《gamma的傳說》的顯示器說以及樂樂的《我理解的伽馬校正》中所提到的人眼視覺特點說。兩者都有道理,並且客觀上這兩個說法發生了有趣的巧合,最後達到了一個還不錯的效果。
簡單來說,過去的CRT顯示器存在一個特點,即屏幕上顯示的顏色對於傳遞而來的原始值並不是線性的(非線性) ,在這裡非線性意味著以一個比率增加某個顏色分量,並不會導致顯示器屏幕上的光強度增加相同的比例。舉一個例子,假如我們將一個顏色的紅色分量變成之前的兩倍,顯示器的屏幕所顯示的紅光並不會變成之前的兩倍。
事實上CRT顯示器的輸入和輸出之間的關係近似於一個指數關係,而這個指數便是我們常常聽到的gamma。典型的gamma範圍在2.0到2.4之間,一般該值常常以2.2作為折中。雖然後來的LCD並不存在這個特點,但是為了保證兼容,也選擇了和當年CRT一樣的非線性特性。
上圖中的紅色實心線是在gamma = 2.2的情況下,顯示器實際顯示色彩強度的方式。 這一部分是由顯示器的特性導致的。所以如果圖片不做任何處理,經過pow(2.2)的操作之後顯然會變得更暗,所以gamma校正就顯得很有必要了。而gamma校正要做的事情也十分簡單,即通過pow(1/2.2)將顏色強度提高,也就是上方的紅色虛線,這樣經過顯示器時就會將顯示器的pow(2.2)抵消掉。
同時,人眼對暗部的變化更加敏感,而對亮部變化其實不是很敏感。這可以以攝像作為一個例子,使用攝像機時,攝像機會把進入到鏡頭內的光線亮度編碼成圖像中的像素。
例如人們看到下面這張圖,會自然而然的認為中間的地方即灰度為0.5的地方。
所以在只有8bit的情況下,沒有必要在亮部浪費過多,這樣就可以表現更多暗部的細節變化,所以實際亮度只有0.2經過gamma校正後實際被編碼成了0.5的像素值。
當然,我個人認為顯示器的特性是gamma校正出現的主要原因,而人眼對暗部的敏感而出現的gamma校正則更像是為了適應顯示器這種特性而為的一種編碼策略的「優化」。
0x03 硬體實現
sRGB 顏色空間是一個可以直接用來在顯示器上顯示的非線性顏色空間。
以OpenGL作為圖形庫為例,Unity實現Gamma校正以及Linear workflow藉助了OpenGL的 texture_sRGB 以及 framebuffer_sRGB 相關拓展,事實上是一種硬體層面的實現。
EXT_texture_sRGB會對texture做pow2.2的gamma校正,將輸入從sRGB空間轉換到線性空間;而framebuffer_sRGB如果開啟,並且輸出的目標是sRGB顏色空間,則硬體會將結果再做一次pow0.45(為了方便,下文使用0.45代替1/2.2)的gamma校正,將結果從線性空間轉換到sRGB顏色空間。這樣保證輸入的是正確的數據,並且中間的計算是線性的過程,最後的結果再轉移到sRGB空間來中和顯示器的輸出,這樣就能保證一個正確的光照計算結果。
下面我們可以通過RenderDoc來分別分析一下gamma workflow和linear workflow在安卓手機上的渲染流水線。
不過我在使用RenderDoc的目前正式版本(v1.0 - 6 Mar, 2018)時遇到了一些小問題,即我無法正常的通過RenderDoc啟動我的App,總是會報下圖中的錯誤。
這其實是這個版本的一個bug,相關issue可以參考:
https://github.com/baldurk/renderdoc/issues/903解決方案的話就是不使用這個正式版,而是下載latest nightly build。
ok,回到正題。大家都知道,利用renderdoc我們可以很方便的查看渲染流水線的各種數據以及各種資源的參數等等,所以首先我們來看看在gamma空間下的整個工作流。
首先我在工程中導入兩張一樣的圖片,分別叫做gamma和linear,在gamma空間下一個勾選了導入設置中的sRGB,另一個則不勾選。
可以看到兩張圖片並沒有什麼變化。下面我們來抓一幀來看一看兩者在OpenGL中的紋理格式:
兩者的格式都是GL_COMPRESSED_RGB8_ETC2。有趣吧,可以看到Unity設置了Gamma空間後,圖片導入時無論是否選擇勾選sRGB的結果都是一樣的。之後我們再來看一看FrameBuffer的情況:
沒有什麼意外,同樣是我們常見且習慣的格式——GL_RGBA8。
這樣整個gamma workflow的過程中沒有涉及到所謂的gamma校正,整個過程和上一節中描述的一樣——導入了經過處理的圖片,最後再經過顯示器的處理中和——傳統且充滿了巧合與錯誤。
接下來,我們將整個工程切換到Linear空間。同樣,兩張一樣的圖片一個勾選sRGB,另一個不勾選。
這次就更有趣了,我們可以看到勾選了sRGB的圖片變暗了,而沒有勾選的則仍然保持原樣。並且,勾選sRGB的圖片在下面的信息中顯示是sRGB——它被作為一張sRGB紋理來看待,需要進行gamma校正;而另一張,則顯示的是Linear——它被當作一張Linear紋理來看待,不需要經過gamma校正。所以勾選了sRGB的紋理變的更暗了,這是因為經過了pow(2.2)的gamma校正處理。
下面我們來抓一幀來看一看在linear workflow下兩者在OpenGL中的紋理格式:
不過有意思的還在後面,即framebuffer的格式。下面我們就來看一看framebuffer的抓幀結果:
framebuffer的格式為GL_SRGB8_ALPHA8,即此時保存的結果經過了pow0.45的gamma校正,從線性空間轉換到了sRGB空間——這當然是合理的,因為它要中和最後顯示器的gamma校正——但是,有一件事情這時會變的比較棘手……
0x03 透明混合
是的,這件事情就是透明混合的問題。由於透明混合是一個線性的過程,因此在混合中作為Dst的那一方的framebuffer的數據就要是線性空間的了。
所以此時混合的操作事實上會先將framebuffer的內容從sRGB空間再次pow2.2轉換到線性空間,和src進行混合,再將混合後的結果pow0.45轉換回sRGB空間保存到framebuffer中。
是不是有點亂?
我們來寫一下公式,代入一個數據就明白了:
ret = (srcColor^2.2 * srcAlpha + dstColor^2.2 * (1 - srcAlpha) ) ^(1/2.2)
ok,這時我們假設src的color值的g分量為1,alpha為0.2;dst的color值的g分量為0。則計算結果為:
0.481156505。但是在我們的傳統的認知下,或者是在Gamma workflow的情況下,這次混合的結果是什麼呢?我們來寫一下混合公式:
ret = srcColor * srcAlpha + dstColor * (1 - srcAlpha)
代入同樣的數據,計算的結果為:
0.2。兩個差別頗大的結果,而如果混合的次數越多,則結果的差別也會更大。
事實上這個問題的處理目前也並沒有一個特別十全十美的解決方案,目前常見的幾種做法大概包括以下幾種方案:
- 使用gamma 1.0來製作資源,即在線性空間中製作資源。
- 自己在ui的shader中對alpha進行pow(2.2)的操作,但是這個只是稍微修復問題,並沒有真正的解決問題。
- 仍然使用gamma space的workflow,但是涉及光照的shader自己來做pow 2.2和pow 0.45的校正。這樣的話ui還在gamma space,而光照計算在linear space。
當然這裡只是拋磚引玉,希望有更好解決方案的朋友能夠在此多多分享一下經驗。
Ref
http://www.klayge.org/2011/02/26/gamma的傳說/
https://developer.nvidia.com/gpugems/GPUGems3
https://blog.csdn.net/candycat1992/article/details/46228771
https://renderdoc.org
https://www.khronos.org/registry/OpenGL/extensions/EXT/EXT_texture_sRGB.txt
https://www.khronos.org/registry/OpenGL/extensions/ARB/ARB_framebuffer_sRGB.txt
-EOF-
最後打個廣告,歡迎支持我的書
《Unity 3D腳本編程:使用C#語言開發跨平台遊戲》(陳嘉棟)【摘要 書評 試讀】- 京東圖書歡迎大家關注我的公眾號慕容的遊戲編程:chenjd01
推薦閱讀:
※遊戲資源傳送門
※GMS2官方教程系列5/8——繪製文字
※基礎1——遊戲物體和腳本——造一個鍾
※Unity遊戲開發 UGUI篇
※102_Entity 實體