OpenGL4.0教程 計算着色器簡介

reference:https://antongerdelan.net/opengl/compute.html

       本篇文章給出了OpenGL計算着色器的實用介紹,並且我們將要開始製作一個玩具光線追蹤渲染器。在閱讀本教程前,你需要具備一定的OpenGL基礎,並且知道如何將紋理渲染到全屏四邊形上。

       我之所以推遲編寫OpenGL計算着色器的教程,是因爲我希望先總結足夠多的坑,以便可以幫助人們避免常見錯誤,並有足夠多的經驗來給出一些有用的建議。我想到我之前從未寫過關於光線追蹤或路徑追蹤的demo。光線追蹤渲染是非常有趣的,也並不算特別難,是圖形學中一個非常值得研究的領域。每個圖形學程序都應該有自己的玩具光線追蹤器。當然,你可以完全使用C語言來編寫一個光線追蹤器,或者使用片元着色器,但這看起來是一個很好的機會去同時嘗試這兩個主題,讓我們都試一試。

背景

        計算着色器是一個通用着色器——意味着它使用GPU做一些非渲染三角形的任務,即GPGPU編程。有一些獨立的工具和庫使用GPU來完成通用計算。我們常常使用Nvidia的CUDA和OpenGL來處理需要用到GPU並行浮點計算能力的任務。計算着色器常用於物理模擬、圖像處理,以及其它一些較小的可並行的任務或批處理。我們期望能同時訪問通用3d渲染着色器和GPGPU着色器——因爲這樣他們能夠共享一些信息。這正是OpenGL中的計算着色器所能做到的。微軟的Direct3D 11 在2009年引入了計算着色器。在2012年中,OpenGL4.3版本將計算着色器納入標準。

概述

        由於計算着色器不適用於標準渲染管線,我們需要設置不同類型的輸入和輸出。我們仍然可以使用uniform變量,大多數的任務也是相似的:

        1.定義固定大小的工作數量來完成一系列功任務——工作組計數。OpenGL將其定義爲全局工作組。它們可以是類似於需要寫入的2d圖像的總維度這樣的數據。之後,我們需要再次將這些工作細分成更小的工作組——即局部工作組

        2.編寫一個計算着色器來處理工作組內的任一任務。例如,每個像素的顏色。在着色器中,你也需要定義全局工作組將如何細分成更小的局部工作組(這樣做有些奇怪,因爲它並不影響我們編寫着色器代碼的方式)

        3.設置計算着色器讀或寫的存儲塊或圖像。同樣也可以像往常一樣使用uniforms,例如,創建一個2D的紋理。

        4.調用glDispatchCompute(),OpenGL將會根據設置的工作組數量多次調用計算着色器。這些調用將盡可能並行,並且沒有固定的順序。

計劃

光線追蹤渲染

        光線追蹤和我們的光柵化圖形管線工作方式不一樣。我們無需空間變換、給由三角形組成的幾何體填色,而是使用更接近於真實光線物理性質的方式(光學)。光線將被建模爲數學上的射線,並根據數學方法來測試不同表面上的反射)。這意味着我們可以將場景中的每個物體用數學公式來表達,而不是將其細分成多個三角形,這樣我們就能得到更加真實的曲線和球體。

       光線追蹤比光柵化渲染計算上更加昂貴,因此我們之前不將其用於實時渲染。這通常是動畫電影中使用的渲染方法,因爲它能產出高質量的結果。全分辨率的光線追蹤動畫通常需要數日完成渲染,工作室通常使用集羣計算。

        我們將從一個非常簡單的例子開始,以後在此基礎上可以非常容易地根據需要逐步添加功能。

        ● 我們並不直接從光源發出光線,相反,我們直接從眼睛發出光線(輸出圖像上的每個像素)。

        ● 如果假設爲正交投影,那麼所有光線都只會沿着視線方向直線前進。

        ● 對於每條光線,我們將在簡單場景中對其與所有物體進行相交測試。

        ● 如果不考慮反射,當光線與第一個物體相交後,我們取其顏色作爲像素顏色。

計算着色器GLSL變量

       計算着色器有一些新的內建變量,我們可以利用它們來判斷當前着色器正在處理哪個工作組。如果我們需要寫入圖像,並定義了一個2d的工作組,那麼我們能夠比較容易地知道寫入的是哪個像素。

       uvec3 gl_NumWorkGroups 全局工作組大小(在glDispatchCompute()中設置)

       uvec3 gl_WorkGroupSize 局部工作組大小(在layout中設置)

       uvec3 gl_WorkGroupID 全局工作組的當前調用位置

       uvec3 gl_LocalInvocationID 局部工作組的當前調用位置

       uvec3 gl_GlobalInvocationID 全局工作組的當前調用唯一下標

       uint gl_LocalInvocationIndex gl_LocalInvocationID的一維下標表示

       在確定像素寫入圖像或1維數組的哪個位置時,這些變量非常有用。

       我們也可以在計算着色器之間使用shared關鍵字設置共享內存實現,但我們不會在這篇教程中討論這一內容。

實現

        首先創建一個簡單的OpenGL程序,使用4.3或者更新的版本,然後渲染一個全屏的四邊形。具體的細節將不再闡述。

創建紋理/圖像

        我們設置一個標準的OpenGL紋理,並在我們的計算着色器中寫入。記住你傳遞給glTexImage2D()的內部參數格式,因爲我們需要在着色器代碼中設置相同的格式。我們同樣需要記住紋理的維度。

// dimensions of the image
int tex_w = 512, tex_h = 512;
Gluint tex_output;

glGenTextures(1, &tex_output);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex_output);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WARP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, tex_w, tex_h, 0, GL_RGBA, GL_FLOAT, NULL);
glBindImageTexture(0, tex_output, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA32F);

        爲了寫入紋理,我們需要在着色器代碼中使用圖像存儲函數。對於OpenGL而言,圖像單位和紋理是不同的概念,所以我們需要調用glBindImageTexture()函數來創建鏈接。現在我們可以將其設置爲“只讀”模式了。

設置工作組大小

       如何在計算器着色器調用間定義、劃分工作組大小是由我們自己決定的。首先,我們應該檢查glDispatchCompute()的總工作組最大可以爲多少。我們得到x,y和z的範圍:

int work_grp_cnt[3];

glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 0, &work_grp_cnt[0]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 1, &work_grp_cnt[1]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 2, &work_grp_cnt[2]);

printf("%max global(total) work group counts x:%i y:%i z:%i\n",
    work_grp_cnt[0], work_grp_cnt[1], work_grp_cnt[2]);

        我們同樣也檢查局部工作組的最大大小(總工作組數的細分),這是在計算着色器中用layout定義的。這兩個限制將協助我們劃分我們的工作組:

int work_grp_size[3];

glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 0, &work_group_size[0]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 1, &work_group_size[1]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 2, &work_group_size[2]);

printf("max local (in one shader) work group sizes x:%i y:%i z:%i\n",
     work_grp_size[0], work_grp_size[1], work_grp_size[2]);

         我們還可以確定允許在計算着色器使用的局部工作組的最大工作組單位數,這意味着,如果我們在一個局部工作組中處理32x32規模的任務,那麼它們的乘積(1024)不應超過這一值:

glGetIntegerv(GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, &work_grp_inv);
printf("max local work group invocations %i\n", work_grp_inv);

        局部工作組大小的最好平衡取決於你的設置。最好讓用戶調整局部工作組的大小,以獲得更好的性能。

        我們可以從如下設置開始,之後可以進行適當調整:

        ● 全局工作組大小和紋理維度相同——512x512

        ● 局部工作組大小爲1像素——1x1

        ● 我們不需要z軸,所以將其定義爲1

編寫基本的計算着色器

        計算着色器看起來很像其他GLSL着色器,但有一些重要的差異。首先,需要記得在着色器的最頂部定義GLSL 4.3或者更高的版本!

#version 430
layout(local_size_x = 1, local_size_y = 1) in;
layout(rgba32f, binding = 0) uniform image2D img_output;

        第一個layout限定符指定了局部工作組的大小——注意如果我們希望局部工作組更大,我們不需要調整我們的着色器。我們決定從像素1開始(1x1)。如果你的工作組有不同的結構,也可以將其設置爲1維或3維。

        第二個layout限定符指定了我們設置的圖像的內部格式。注意到我們使用了uniform image2D,而不是紋理採樣器。這使得我們可以任意寫入像素。

void main() {
    // base pixel color for image
    vec4 pixel = vec4(0, 0, 0, 1);
    // get index in global work group, ie: x,y position
    ivec2 pixel_coords = ivec2(gl_GlobalInvocationID.xy);

    // interesting stuff happens here later
    // output to a specific pixel in the image
    imageStore(img_output, pixel_coords, pixel);
}

        我們爲圖像設置基本顏色(黑色),之後可以進行寫入。可以通過內置變量gl_GlobalInvocationID來查找調用在工作組空間的哪個位置,然後確定我們需要修改哪個像素,並將最終顏色寫入圖像的這個位置。

GLuint ray_shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(ray_shader, 1, &the_ray_shader_string, NULL);
glCompileShader(ray_shader);

// check for compilation errors as per normal here

GLuint ray_program = glCreateProgram();
glAttachShader(ray_program, ray_shader);
glLinkProgram(ray_program);

// check for linking errors and validate program as per normal here

       我們只需要一個着色器就可以編譯程序。當然我們還需要另外一個着色器用於將最終的紋理渲染到四邊形,並且可以從新紋理中讀取它。

執行着色器

      我的繪製循環如下所示。注意到計算着色器的調度看起來就像是另外一個繪製pass。我設置和我的紋理維度匹配的工作組大小,並把z軸設爲1:

// drawing loop
while(!glfwWindowShouldClose(window)) {
    {
         // launch compute shaders !
         glUseProgram(ray_program);
         glDispatchCompute((GLuint)tex_w, (GLuint)tex_h, 1);
    }

    // make sure writing to image has finished before read
    glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);

    // normal drawing pass
    {
         glClear(GL_COLOR_BUFFER_BIT);
         glUseProgram(quad_program);
         glBindVertexArray(quad_vao);
         glActiveTexture(GL_TEXTURE0);
         
         glBindTexture(GL_TEXTGURE_2D, tex_output);
         glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    }

    glfwPollEvents();
    if(GLFW_PRESS == glfwGetKey(window, GLFW_KEY_ESCAPE)) {
        glfwSetWindowShouldClose(window, 1);
    }
    glfwSwapBuffers(window);
}

       爲了確保在我們開始採樣之前,計算着色器已經完成圖像的寫入,我們需要通過調用glMemoryBarrier加上圖像訪問位,來設置一個內存barrier。你可以使用GL_ALL_BARRIER_BITS來確保所有寫入都是安全的。而在較大的項目中,應該選擇更加匹配的採樣紋理的barrier,以免引入任何不必要的等待。我綁定了我的新紋理,並將其繪製到全屏四邊形。現在,如果編譯通過,你可以修改基本顏色來測試它工作是否正常。如果正常,那麼你已經掌握了計算着色器的基本工作原理。

光線追蹤場景入門

        

A ray expressed as origin O, direction D, and points as distances t along the ray.

       我們可以使用計算着色器硬編碼我們的場景。首先,我們計算出光線的當前像素。一條光線由3d原點和3d方向定義。我們想要從原點延展得到所有的像素,並且可以將其標準化爲x和y上-5.0到5.0的任意視野大小。我們知道在平視投影中,所有的光線都指向前方,所以我們可以認爲光線爲-z方向。

float max_x = 5.0;
float max_y = 5.0;
ivec2 dims = imageSize(img_output); // fetch image dimensions
float x = (float(pixel_coords.x * 2 - dims.x) / dims.x);
float y = (float(pixel_coords.y * 2 - dims.y) / dims.y);

vec3 ray_o = vec3(x * max_x, y * max_y, 0.0);
vec3 ray_d = vec3(0.0, 0.0, -1.0); // ortho

       我希望在場景中有一個球體,它由3d中心點和半徑定義:

vec3 sphere_c = vec3(0.0, 0.0, -10.0);
float sphere_r = 1.0;

        我們可以在視線中心看到它,只佔據部分場景,並在我們的眼前。

The equation for determining and intersection between a ray and a sphere.

        我們可以進行射線-球體的相交測試來判斷每個像素點處能否“看見”這個球體。在更復雜的場景中,你需要測試更多可能的情況——球體可能在相機後面。

vec3 omc = ray_o - sphere_c;
float b = dot(ray_d, omc);
float c = dot(omc, omc) - sphere_r * sphere_r;
float bsqmc = b * b - c;

// hit one or both sides
if(bsqmc >= 0.0) {
    pixel = vec4(0.4, 0.4, 1.0, 1.0);
}

       其中,bsqmc是指“b squared, minus c"(b的平方減去c),下圖是測試的結果:

You should see something like this now, which is a start!

升級思路

       

I added a few more features, a plane, and some animation to my demo.

       你可以考慮加入Phong或者更加真實的光照模型、反射、折射(可能具有最大反彈次數)、燈光、陰影、動畫、透視,或者渲染圖像序列輸出到視頻。

       維護一個可交互式的光線追蹤器(例如,使用用戶控制的相機)將是一項優化挑戰,您能否找到在實時光柵化渲染中添加光線追蹤效果的方法?

       分塊渲染可能會提升你的渲染效率。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章