GraphicsLab Project之HDR渲染

作者:i_dovelemon

日期:2016 / 07 / 24

主題:HDR, Bloom, Tone mapping, Post-process, Blur



引言



        HDR渲染技術,從我學3D圖形學開始的時候,打開D3D自帶的例子,就嚮往已久,很想能夠自己實現一個這樣的效果。這麼多年過去了,終於真真的實現了一個基本的HDR渲染流程。今天,我就來向大家介紹下,如何在OpenGL中,實現HDR的渲染。


HDR



        在講解,如何實現HDR之前,先來看下HDR是什麼,以及它的作用。首先來看下本次實例將要給大家實現的效果,如下圖所示:



        上圖是一個典型的HDR場景。所謂的HDR,全稱是High Dynamic Range,高動態光照範圍。我們知道,人類的眼睛能夠接受的光照範圍非常的大,而當今的顯示器,大部分都之能夠顯示0-255這256個光照明度範圍。所以,很多時候,我們通過計算機模擬或者創造出來的圖像,都遠遠低於人類眼睛能夠感知到的光照範圍。而更大的範圍也就意味着,我們能夠看到更多的細節,畫面細節越多,自然帶來的效果就會越好。關於這方面的解釋,大家可以參考這篇文章,它詳細的講解了HDR的概念。

        單討論渲染方面,在使用HDR之前,所有的光照計算都在0-255這個範圍內進行計算。也就是說,一個場景中所有的明度變化只有這麼多。雖然這麼多的明度變化,以及能夠創建出十分驚人的作品出來了,但是我們沒有辦法在這樣的一個場景中去區分一個燈泡的亮度和太陽的亮度。這兩者在現實生活中,太陽的亮度要遠遠的高於燈泡的亮度。如果不使用HDR的話,他們就只能夠是最大的亮度值了,也就是說他們之間沒有什麼區別。有的人會說,我可以將太陽光定義成最高的,然後將燈泡的定義的比較低啊。可是這樣做的話,你就減少的場景中暗的地方的變化範圍了,也就是說暗的地方可能都是一樣的,一篇漆黑。所以,HDR真真的作用,“讓亮的地方更亮,讓暗的地方更暗,使畫面呈現一種有規律性的明暗變化,使得暗部和亮部的細節都能夠展現出來”。


HDR實現原理



        在瞭解了前面一節所說的事實之後,我們很自然的就想到,那就擴大光照計算時使用的範圍唄,幹嘛要侷限在0-255這樣的範圍裏面了。的確,這就是實現HDR最基礎的一個操作。我們可以不考慮顯示器只能夠顯示0-255這個範圍數據的限制,在所有的定義中,我們在更大的範圍裏面定義光源的亮度,計算光照,這樣,我們就能夠得到和現實基本一致的高動態光照範圍了。

        但是,顯示器之能夠顯示0-255這個範圍的限制還是擺在那裏,就算我們能夠計算出HDR的光照場景出來,最終還是需要靠顯示器來顯示不是嗎?所以,前輩們,就發明了一種稱之爲Tone mapping的技術。這個技術的主要目的就是將HDR的數據映射到顯示器能夠顯示的LDR(相對HDR)範圍裏面,並且能夠保持HDR數據的明暗過渡(至少在視覺上是這樣的)關係,達到我們上面所說的亮的更亮,暗的更暗的效果出來。

        所以,通過上面的描述,很自然的就明白HDR的原理分爲兩個基本的步驟:

        1.在HDR範圍裏面進行場景的渲染,如光源的定義,光照計算等等

        2.將渲染好了的HDR場景,進行Tone mapping,最終顯示在顯示器上。


具體實現(OpenGL)



離屏渲染



        很明顯,由於顯示器只能夠顯示LDR的數據,所以,我們不能直接把渲染好的HDR場景保存在顯示器的緩存中。爲此,我們需要一種離屏渲染的計算來完成HDR數據的計算。而這種離屏渲染的方式在OpenGL中,稱之爲Render to target。關於在OpenGL中,如何實現離屏渲染,我的博客裏面《OpenGL&CG技術之Render to target》講述瞭如何利用Frame buffer object來實現。

        除了前面提到的文章之外,我還要另外補充一些知識給大家,便於更加了解如何實現基於HDR數據的RTT。

        首先,由於我們計算出來的場景,最終的結果是HDR數據,也就是說,我們不能夠使用傳統的GL_UNSIGNED_BYTE的格式來保存我們的圖像數據,因爲他們之能夠保存0-255範圍內的數據。所以,我們需要使用OpenGL給我們提供的【浮點紋理】,來保存我們的HDR場景。創建浮點紋理很簡單,只要下面這樣就可以了:

int32_t tex_id = 0;
glGenTextures(1, reinterpret_cast<GLuint*>(&tex_id));
glBindTexture(GL_TEXTURE_2D, tex_id);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0, GL_RGBA, GL_FLOAT, NULL);
glGenerateMipmap(GL_TEXTURE_2D);

        你可以看到,和傳統的紋理創建基本一致,只是在調用glTexImage2D的時候,需要傳遞不一樣的參數,告知顯卡,我們要保存16位的浮點數據了。

        除了這個之外,你還需要知道,FBO可以綁定多個COLOR BUFFER,並且通過一些設置,能夠指定它向哪一個COLOR_BUFFER繪製數據。

        比如說,你有一個FBO對象g_FBO,你可以爲這個g_FBO綁定3個COLOR_BUFFER,分別綁定在GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2上面,當你想要向某個指定的COLOR BUFFER繪製的時候,你只要調用如下的函數就可以了:

glDrawBuffer(GL_COLOR_ATTACHMENTX);

Tone mapping



        本篇文章假設你已經會了基本的場景繪製,並且能夠在shader中實現光照計算。通過上面的的內容,你能夠實現將HDR場景繪製並且保存到紋理貼圖中去。那麼在執行完畢了這個內容之後,我們需要進行的就是tone mapping了。Tone mapping的計算相對來說比較簡單,但是也能夠很容易的理解。本篇文章將不探究Tone mapping的實現原理,而僅僅給出實現的方式(主要是我太菜,看不懂他是怎麼想到這個鬼方法的!)。

        首先,我們來定義一些東西。在前面的原理部分,我們講述了HDR實際上是對光照亮度的一種描述,那麼我們怎麼從場景的像素顏色,得到這個亮度信息了。下面的公式是一個經驗公式,根據人眼對不同波長的感知能力,分別提取,從而構造一個亮度出來,如下所示:


Lum = R × 0.27 + G × 0.67 + B × 0.06 (1)

        上面公式中的Lum表示的就是對應像素(R,G,B)的亮度。通過這個公式,我們能夠評價一個像素的亮度。

        我們現在知道了如何獲取一個像素的亮度,接下來,我們需要計算場景的平均亮度值,因爲最終的Tone mapping需要藉助這個平均亮度值來實現。計算平均亮度值的數學步驟如下所示:



        上面公式的意思是對每一個像素的亮度做ln對數運算,其中爲了保證對數運算中的值不爲0,給Lum加上一個很小的值0.0001f。既然是平均,那麼就要對所有的像素進行這樣的對數計算,並且將結果求和。得到這個和之後除以像素的個數,並且再進行一次自然指數的運算。公式爲什麼是這樣的了?我也不知道,感興趣的話,可以看看文章後面給出的鏈接,有關於Tone mapping的原始論文。

        在有了整個場景的平均亮度之後,我們就要進行實際的Tone mapping運算了,公式如下所示:

Lresult = key * Lum(x,y) / Laverage (2)

        是不是很簡單,其中的key是一個控制參數,可以讓你控制整個場景的整體亮度,這個是個經驗參數,根據你對場景的需求,進行調節即可。而最終的Lresult就是經過Tone mapping之後的結果了。

        通過上面的計算,我們並不能保證,Tone mapping的計算結果一定在0-255(在shader中歸一化到0.0-1.0)的範圍裏面,爲此我們需要對它進行一次歸一化操作,這個操作很簡單,直接這樣就可以了:

Lfinal = Lresult / (1 + Lresult) (3)

        Lfinal就是最終我們計算出來的值,這個值也將最終保存在場景的貼圖上,用來進行後續的處理。從上面的計算可以看到,只要獲取到了整個場景的平均亮度之後,剩下的Tone mapping操作就是對一張場景的HDR貼圖進行post-process處理,所以一個Tone mapping就能夠簡單的通過一個post-process shader來完成。


Bloom



        一個完整的HDR渲染器,除了進行HDR的光照計算,進行Tone mapping,往往還需要爲場景中的高亮部分添加Bloom效果。這樣能夠營造出一種相機過曝時的高亮感覺。而Bloom效果,也就是Glare效果,這個效果在本系列的文章《GraphicsLab Project之輝光(Glare, Glow)效果》中詳細的進行討論了。這裏不再講解如何進行Bloom操作。

        唯一需要值得注意的是,我們需要提取場景的中的高亮部分。這個所謂的場景中的高亮部分,需要自行定義。一般來收,你需要在進行Tone mapping操作之前,在HDR貼圖數據中提取出高亮的部分,形成一張新的高亮貼圖,然後對這個高亮貼圖進行Bloom操作。並且在最後的Tone mapping中,將Bloom了之後的貼圖混合進去,從而營造出高亮曝光的感覺。


實現步驟及結果



        1.繪製HDR場景到一張浮點紋理中去,就本例來說,得到的結果如下所示:



        上面的貼圖是通過gDebugger得到的,紋理爲經過處理的HDR貼圖。由於顯示器之能夠顯示0.0-1.0的顏色值,所以自動的進行截取,從而形成了上面一圈一圈的波紋狀。但是可以看出,每一個波紋裏面都是從白色到黑色的漸變,也就是亮度在逐漸的遞減。

        2.計算場景的平均亮度。這個地方就很有講究了。不同的HDR實現也主要集中在如何快速高效的求出場景的平均亮度上面。就本次實例來說,爲了闡述最原始的概念,將簡單粗暴的使用最原始的方法來進行。一方面,這樣能夠減少很多優化的手段從而造成初學者對HDR的理解困難,另一方面,你只有實際試驗過後,才能夠知道這樣的性能瓶頸在哪裏,而不是人云亦云。好了,在這個階段,我將這個貼圖download到CPU這端,主要通過如下的代碼完成:

glReadBuffer(GL_COLOR_ATTACHMENT0);
GLvoid* pixel = new GLfloat[4 * g_WindowWidth * g_WindowHeight];
glReadPixels(0, 0, g_WindowWidth, g_WindowHeight, GL_RGBA, GL_FLOAT, pixel);

        指定將要讀取的COLOR_BUFFER,然後調用glReadPixel來獲取紋理數據。獲取了整個紋理數據之後,我們就可以通過使用前面的公式來計算整個場景的平均亮度了。只有在實驗之後,我才知道調用ln和exp這樣的函數,是多麼的慢。對整張貼圖進行這樣的對數,指數運算,的確非常的卡,所以你看,明白了性能的損失了。所以,我就換了另外一種方法來計算平均亮度,也就是簡單的將所有亮度值相加,然後除以像素數,這樣也能夠得到一個平均亮度值,而且這樣的計算,似乎速度還可以,至少能夠在本實例的情況下,滿幀運行。下面就是計算這個平均亮度的完整代碼:

    glReadBuffer(GL_COLOR_ATTACHMENT0);
    GLvoid* pixel = new GLfloat[4 * g_WindowWidth * g_WindowHeight];
    glReadPixels(0, 0, g_WindowWidth, g_WindowHeight, GL_RGBA, GL_FLOAT, pixel);
    GLenum error = glGetError();

    GLfloat* buffer = reinterpret_cast<GLfloat*>(pixel);
    float lum = 0.0f;
    for (int32_t i = 0; i < g_WindowHeight; i++) {
        for (int32_t j = 0; j < g_WindowWidth; j++) {
            float r = buffer[i * g_WindowWidth * 4 + j * 4 + 0];
            float g = buffer[i * g_WindowWidth * 4 + j * 4 + 1];
            float b = buffer[i * g_WindowWidth * 4 + j * 4 + 2];
            float cur_lum = r * 0.27f + g * 0.67f + b * 0.06f;
            lum += cur_lum;
        }
    }
    g_RenderTarget->DisableRenderTarget();

    g_AverageLum = lum / (g_WindowWidth * g_WindowHeight);
    delete[] pixel;
    pixel = NULL;

        是不是很簡單粗暴易於理解了???

        3.接下來,我們提取場景中的高亮部分。這個操作也十分的簡單,我們只要將在步驟1中獲取的HDR原始紋理作爲一張貼圖,然後繪製一個和屏幕一樣大小的矩形。在Shader中,我們計算每一個像素的亮度,如果亮度大於我們指定的高亮閥值,那麼就將這個像素繪製出來,如果不是,那麼就不繪製,從而就實現了對一個場景高亮部分的提取。這個部分的shader如下所示:

gethighlight.vs
//-----------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 07 / 24
// Brief: Get high light pass through shader
//-----------------------------------------------------------
#version 330

in vec2 vertex;
in vec2 texcoord;
out vec2 vs_texcoord;

void main() {
	gl_Position = vec4(vertex, 0.0, 1.0);
	vs_texcoord = texcoord;
}

gethgihlight.ps
//-----------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 07 / 24
// Brief: Get high light
//-----------------------------------------------------------
#version 330

in vec2 vs_texcoord;
out vec4 color;

uniform float lum_average;
uniform sampler2D hdr_tex;

const float kKey = 0.5;
const float kHighLight = 0.80;

void main() {
	vec3 hdr_color = texture2D(hdr_tex, vs_texcoord).xyz;
	float lum = hdr_color.x * 0.27 + hdr_color.y * 0.67 + hdr_color.z * 0.06;
	float lum_after_tonemapping = (kKey * lum) / lum_average;
	lum_after_tonemapping = lum_after_tonemapping / (1.0 + lum_after_tonemapping);
	if (lum_after_tonemapping > kHighLight) {
		color = vec4(hdr_color, 0.5);
	} else {
		color = vec4(0.0, 0.0, 0.0, 0.0);
	}
}

        這裏要解釋下,我在提取高亮部分的時候,實際上是檢測進行Tone mapping之後的像素數據的亮度。這樣做是爲了我們能夠通過0.0-1.0這個範圍裏面,來設定高亮閥值,否則的話,我可不知道該設置成什麼樣的值,才能夠比較準確的提取出來高亮部分。同時你還可能注意到,我這裏手動的爲每一個高亮的顏色值設置了0.5的alpha值,而爲所有的非高亮像素設置了0.0的alpha值。這麼做是爲了在後面進行blend的時候,能夠準確的只和高亮的部分進行blend,而不需要和非高亮的像素進行混合。

        這個階段得到的貼圖如下所示:



        和前面同樣的原因,這裏的貼圖依然還是HDR的,所以顏色還是從白到黑的變化。整個場景也之後中間的螺環是高亮的部分,因爲我給它加上了好高的emission。

        4.對高亮的貼圖進行bloom操作,從而得到一個bloom之後的高亮貼圖,這個操作的shader就是glare的shader,這裏再次列出來,以保持文章的整體性:

blur.vs
//--------------------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 06 / 29
// Brief: Gauss blur pass through vertex shader
//--------------------------------------------------------------------
#version 330

in vec3 vertex;
in vec2 texcoord;

out vec2 vs_texcoord;

void main() {
    gl_Position = vec4(vertex, 1.0);
    vs_texcoord = texcoord;
}

blurh.ps
//--------------------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 06 / 29
// Brief: Gauss blur horizontal pass shader
//--------------------------------------------------------------------
#version 330

in vec2 vs_texcoord;
out vec4 color;

uniform sampler2D tex;
uniform float tex_width;

uniform float gauss_num[21];

void main() {
    color = texture2D(tex, vs_texcoord) * gauss_num[0];
    float step = 1.0 / tex_width;

    for (int i = 1; i < 21; i++) {
        if (vs_texcoord.x - i * step >= 0.0) {
            color += texture2D(tex, vec2(vs_texcoord.x - i * step, vs_texcoord.y)) * gauss_num[i];
        }

        if (vs_texcoord.x + i * step <= 1.0) {
            color += texture2D(tex, vec2(vs_texcoord.x + i * step, vs_texcoord.y)) * gauss_num[i];
        }
    }
}

blurv.ps
//--------------------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 06 / 29
// Brief: Gauss blur vertical pass shader
//--------------------------------------------------------------------
#version 330

in vec2 vs_texcoord;
out vec4 color;

uniform sampler2D tex;
uniform float tex_height;

uniform float gauss_num[21];

void main() {
    color = texture2D(tex, vs_texcoord) * gauss_num[0];
    float step = 1.0 / tex_height;

    for (int i = 0; i <21; i++) {
        if (vs_texcoord.y - i * step >= 0.0) {
            color += texture2D(tex, vec2(vs_texcoord.x, vs_texcoord.y - i * step)) * gauss_num[i];
        }

        if (vs_texcoord.y + i * step <= 1.0) {
            color += texture2D(tex, vec2(vs_texcoord.x, vs_texcoord.y + i * step)) * gauss_num[i];
        }
    }
}

        這個階段得到的是如下的一張貼圖:



        5.得到了Bloom高亮貼圖之後,拿這個Bloom高亮貼圖與進行Tone mapping之後HDR場景貼圖進行混合操作,從而得到最終的結果。

tonemap.vs
//-----------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 07 / 24
// Brief: Tone map pass through shader
//-----------------------------------------------------------
#version 330

in vec2 vertex;
in vec2 texcoord;
out vec2 vs_texcoord;

void main() {
	gl_Position = vec4(vertex, 0.0, 1.0);
	vs_texcoord = texcoord;
}

//--------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[[email protected]]
// Date: 2016 / 07 / 24
// Brief: Tone mapping the HDR scene
//--------------------------------------------------------
#version 330

in vec2 vs_texcoord;
out vec4 color;

uniform float lum_average;
uniform sampler2D hdr_tex;
uniform sampler2D bloom_tex;

const float key = 0.5;

void main() {
	vec4 hdr_color = texture2D(hdr_tex, vs_texcoord);
	vec4 bloom_color = texture2D(bloom_tex, vs_texcoord);

	float lum = hdr_color.x * 0.27 + hdr_color.y * 0.67 + hdr_color.z * 0.06;
	float lum_after_tonemapping = (key * lum) / lum_average;
	vec4 blend_color = hdr_color * (1.0 - bloom_color.w) + bloom_color * bloom_color.w;
	color = blend_color * lum_after_tonemapping;
	color /= vec4(1.0 + color.x, 1.0 + color.y, 1.0 + color.z, 1.0);
}

        最終的結果如下所示:




一個有趣的效果



        爲了讓這個實例的逼格更高一點,我添加了DX SDK中關於HDR的人眼適應過程。這個實現方法很簡單,由於我每一幀都會計算下場景的平均亮度,所以,我們可以定義另外一個平均亮度值,這個值用來傳遞到shader中,構造虛假的場景平均亮度,並且這個亮度值慢慢的逼近場景的平均亮度,這樣就能夠模擬出先是很亮,然後人眼慢慢適應了之後,慢慢看輕場景的效果。

        下面是模擬的代碼:

    static float time = 0.0f;
    float ratio = std::sin(3.1415f * 0.5f * (time / 120.0f));
    g_UsedLum = 1.0f + (g_AverageLum - 1.0f) * ratio;
    time = time + 1.0f;
    if (time > 120.0f) {
        time = 120.0f;
    }

    if (GetKeyState('F') & 0x8000) {
        time = 0;
    }

        是不是很簡單?


整體流程代碼一覽



void glb_display() {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    glb_draw_scene_to_texture();

    glb_calc_average_lum();

    glb_draw_to_get_highlight_texture();

    glb_draw_hpass_bloom_highlight_to_texture();

    glb_draw_vpass_bloom_highlight_to_texture();

    glb_draw_hdr_scene();

    glutSwapBuffers();
}


總結



        HDR已經是當今引擎的標配了,作爲遊戲開發人員,有必要了解它。對那些立志於進行引擎相關工作的人來說,更有必要自己實現一個HDR渲染器。



參考文獻



[1] http://dev.gameres.com/Program/Visual/3D/HDRTutorial/HDRTutorial.htm HDR渲染器的實現(基於OpenGL)
[2] http://www.nutty.ca/?page_id=352&link=hdr High Dynamic Range(HDR)
[3] http://www.cs.utah.edu/~reinhard/cdrom/tonemap.pdf Photographic Tone Reproduction for Digital Images

發佈了92 篇原創文章 · 獲贊 95 · 訪問量 34萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章