[OpenGL] 使用計算着色器進行預烘焙

reference: https://www.khronos.org/opengl/wiki/Compute_Shader

        一年前此時我做了一個體積雲的效果,當時我留了一個坑——我的噪聲是實時計算的,因此會帶來一定卡頓;正確的做法應該是將噪聲烘焙成一張3D紋理。今天趁着稍微有點時間,把這個坑填一填。

        其實我很久之前也做過類似的烘焙的工作,比如在IBL效果中,需要烘焙一張環境貼圖,但由於所需的結果正好是一張2D紋理,所以可以使用頂點+片元的傳統方式通過寫入紋理的方式實現,可以僥倖繞過計算着色器。但3D紋理就沒有辦法套用類似的方案了。

       當然,這確實可以通過CPU來完成計算,但考慮到數據大量、離散的特點,使用GPU完成計算是非常合適的。實際上CUDA也提供了類似的功能,不過對於調用圖形API的渲染項目而言,既然圖形API本身已經整合了計算着色器這一解決方案,我們往往也就更傾向於用計算着色器來實現了。

基本概念

        計算着色器不屬於渲染管線的一部分,而是一個較爲獨立的過程。不同於頂點着色器對每頂點執行一次,片段着色器對光柵化的每片元執行一次,而計算着色器的空間是抽象的,它的執行次數由調用計算的函數定義。

        整體而言,實現計算着色器大致需要以下幾個過程:

        1.分配紋理/緩衝區作爲輸入或輸出

        2.綁定當前着色器

        3.綁定當前輸入或輸出的紋理/緩衝區

        4.請求計算的渲染指令

        5.並行運行多次計算着色器,並寫入結果

        ……

        輸入/輸出

        最重要的是,計算着色器沒有用戶定義的輸入,也沒有輸出(注:這裏所謂的輸入輸出對應於glsl代碼中in/out)。但是我們可以通過輸入輸出緩衝區/紋理數據進行讀寫。

        以下爲我歸納的幾個常見的可用的例子,請注意不同情況下參數的細微差異:

        (1) 用計算着色器綁定輸出的2D紋理

        ● 生成2D紋理

glGenTextures(1, &texId);
glBindTexture(GL_TEXTURE_2D, texId);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
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, width, height, 0, GL_RGBA, GL_FLOAT, NULL);

        ● 綁定2D紋理(C++部分)

glBindImageTexture(0, texId, 0, GL_FALSE,
                       0, GL_WRITE_ONLY, GL_RGBA32F);

        ● 綁定2D紋理(着色器部分)

layout(binding = 0, rgba32f) uniform image2D texOut;

void main()
{
    // texcoord : ivec2 
    // data     : vec4
    imageStore(texOut, texcoord, data); // 寫入圖像的方式
}

        (2) 用計算着色器綁定輸入的2D紋理

        ● 生成2D紋理

glGenTextures(1, &texId);
glBindTexture(GL_TEXTURE_2D, texId);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
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, width, height, 0, GL_RGBA, GL_FLOAT, NULL);

        ● 綁定2D紋理(C++部分)

glBindImageTexture(0, texId, 0, GL_FALSE,
                       0, GL_READ_ONLY, GL_RGBA32F);

        ● 綁定2D紋理(着色器部分)

layout (binding = 0, rgba32f) uniform image2D texIn;

void main()
{
    vec4 data = imageLoad(texIn, gl_GlobalInvocationID.xyz); // 讀取圖像
}

        (3) 用計算着色器綁定輸出的3D紋理

        ● 生成3D紋理

glGenTextures(1, &texId);
glBindTexture(GL_TEXTURE_3D, texId);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_REPEAT);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexImage3D(GL_TEXTURE_3D, 0, GL_RGBA32F, width, height, depth, 0, GL_RGBA, GL_FLOAT, NULL);

        ● 綁定3D紋理(C++部分)

glBindImageTexture(0, m_cloudTexId, 0, GL_TRUE,
                       0, GL_WRITE_ONLY, GL_RGBA32F);

          ● 綁定3D紋理(着色器部分)

layout(binding = 0, rgba32f) uniform image3D texOut;

void main()
{
    // texcoord : ivec3 
    // data     : vec4
    imageStore(texOut, texcoord, data); // 寫入圖像的方式
}

 

       幾個差異主要在於:

        (1) 圖像的分層屬性。1D/2D爲GL_FALSE, 3D爲GL_TRUE

        (2) 圖像的讀寫性質。GL_READ_ONLY、GL_WRITE_ONLY、GL_READ_WRITE

        (3) binding指定圖像插槽。用於CPU和GPU中圖像的對應。

        執行計算指令

        通過調用以下兩個函數之一可以執行計算,作用於當前活躍程序。儘管這些命令不是繪製指令,但它們也屬於渲染指令,可以有條件地執行(參照條件渲染)。

         第一個函數可以執行計算,同時指定工作組的三個維度。這些數字不能爲零,且可分配的工作組數量是有限制的:

void glDispatchCompute(GLuint num_groups_x​, GLuint num_groups_y​, GLuint num_groups_z​);

        第二個函數的工作組計數存儲在Buffer Object中,其中Indirect參數指定了當前綁定到GL_DISPATCH_INDIRECT_BUFFER對象上的偏移字節。間接分配繞過了OpenGL常規錯誤檢查,嘗試以超過範圍的工作組大小進行調用可能會導致崩潰或GPU hard-lock。這一方式一般應該適用於需要從別的地方輸出的數據作爲計數。

void glDispatchComputeIndirect(GLintptr indirect​);

        工作組大小和局部大小

        (1) 工作組大小

        我們注意到在調用cs時,我們會執行glDispatchCompute,它包含三個參數,我們將其稱爲工作組大小。

        我們使用工作組來描述計算着色器的空間,工作組是用戶可以執行的最小數量的計算操作。

        工作組是三維的,用戶可以定義工作組的數量,這些中任一都可以爲1,因此我們也可以進行1D或2D的運算,而不僅是3D運算。這樣,我們就可以更方便地處理二維的圖像數據或線性陣列的粒子系統。

        當系統計算工作組時,它可以按任意順序進行,因此計算着色器的計算應該是離散、獨立的,不應依賴於各個組的執行順序。

       (2) 局部大小

       在着色器中,我們還需要指定計算着色器的調用次數,我們將其稱爲局部大小:

layout(local_size_x = X​, local_size_y = Y​, local_size_z = Z​) in;

        默認尺寸爲1,當只想要一維或二維的工作組空間時,可以僅指定x或x和y,它們必須是大於0的整數常量表達式。

        着色器大小可以將本地大小作爲編譯時常量(compile-time constant variable)使用,因此無需自己定義它:

 

        單個工作組不等價於單個計算着色器調用,在一個工作組中,可能有多次計算着色器調用。

        工作組計數的值不一定等於工作組的局部大小,計算着色器對應的函數的調用次數是這兩者的積。每個調用將具有一組唯一標識輸入。

        這樣的設計對於執行各種形式的圖像壓縮或解壓縮很有用;本地大小可以設定爲圖像數據塊的大小,而組計數可以設定爲圖像大小除以塊大小。每個塊將作爲一個工作組處理。

        工作組中的各個調用將“並行”執行。區分工作組計數和局部大小的主要目的是:不同的計算着色器調用內一個工作組可以通過共享變量通信。而不同工作組(同一計算着色器調用中)則無法有效地進行通信,不排除還有造成系統死鎖的可能性。

       內置輸入變量

        計算着色器具有以下內置輸入變量:

in uvec3 gl_NumWorkGroups;           // 當前調度的工作組數
in uvec3 gl_WorkGroupID;             // 當前調度的工作組id
in uvec3 gl_LocalInvocationID;       // 當前調度的本地調用id
in uvec3 gl_GlobalInvocationID;      // 所有調度的全局調用id 
                                     // gl_WorkGroupID * gl_WorkGroupSize + int gl_LocalInvocationID
in uint  gl_LocalInvocationIndex;    // gl_LocalInvocationID的1D版本

        共享變量

        共享變量在工作組內所有調用共享。不能將sampler聲明爲共享變量,但可將數組、結構體聲明爲共享變量。

shared uint foo = 0; // No initializers for shared variables.

        如果需要將共享變量初始化爲特定值,應該在調用之一將變量顯示設置爲該值。而只有一個調用必須這樣做。

        限制

        單個調用最大的工作組數由GL_MAX_COMPUTE_WORK_GROUP_COUNT定義。可用glGetIntegeri_v查詢。

        工作組的本地大小限制由GL_MAX_COMPUTE_WORK_GROUP_SIZE定義。

        所有共享變量的總存儲大小由GL_MAX_COMPUTE_SHARED_MEMORY_SIZE定義。

實例:體積雲紋理烘焙

        以下爲我的一個實現例子,體積雲的紋理烘焙,僅在開始渲染前執行一次,分配一個包含兩個通道的3D紋理,其中x通道存儲value噪聲,y通道存儲worley噪聲:

        此例中,我們不需要處理比較複雜的邏輯,所以無需在着色器中額外進行分組,因此直接在CPU中指定總分組,着色器中設爲1,1,1即可,直接使用gl_GlobalInvocationID索引。

        注意此處寫入圖像數據時,下標爲整數,具體下標對應於工作組大小。

        C++代碼:

void RenderCommon::CreateCloudTexture()
{
    int size = 500;
    QOpenGLFunctions* gl = QOpenGLContext::currentContext()->functions();
    QOpenGLExtraFunctions* extraGL = QOpenGLContext::currentContext()->extraFunctions();

    gl->glGenTextures(1, &m_cloudTexId);
    gl->glBindTexture(GL_TEXTURE_3D, m_cloudTexId);
    gl->glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    gl->glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    gl->glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_REPEAT);
    gl->glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    gl->glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    extraGL->glTexImage3D(GL_TEXTURE_3D, 0, GL_RG32F, size, size, size, 0, GL_RGBA, GL_FLOAT, NULL);

    QOpenGLShaderProgram* program = CResourceInfo::Inst()->CreateCSProgram("genCloudTex.csh");
    program->bind();
    extraGL->glBindImageTexture(0, m_cloudTexId, 0, GL_TRUE,
                       0, GL_WRITE_ONLY, GL_RG32F);

    extraGL->glDispatchCompute(size, size, size);
}

        着色器代碼(不包含噪聲計算函數):

#version 430 core
layout(binding = 0, rg32f) uniform image3D cloudTex;
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;

void main()
{
    vec3 pos;
    pos.x = float(gl_GlobalInvocationID.x) / 100;
    pos.y = float(gl_GlobalInvocationID.y) / 100;
    pos.z = float(gl_GlobalInvocationID.z) / 100;
    float x = value_fractal(pos);
    float y = worley(pos);
    imageStore(cloudTex, ivec3(gl_GlobalInvocationID.xyz),vec4(x,y,0,0));
}

        寫入後,圖像在顯存中,一般而言我們較少需要回讀到CPU中,我們可以像使用普通紋理一樣(綁定對應的紋理id),將其傳遞到接下來的着色器中,此時就將使用(0~1)之間的下標索引這個生成的紋理了。

       (同理,靜態陰影/光照貼圖烘焙 & 一些粒子/物理/布料計算也可以放在cs中) 

 

 

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