[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中) 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值