计算着色器(Compute Shader)在OpenGL 4.3版本提出,可以帮组应用程序使用GPU来实现通用计算的工作。不仅如此,可以访问图形着色器的资源,同时能够对应用程序流和执行方式加以控制。不同于其他着色器,比如顶点着色器处理顶点,几何着色器处理图元,片元着色处理片元,计算着色器处理与计算相关的单元。而且计算着色器只有一级管线,没有固定输入输出,所有默认的输入通过一组内置变量,额外输入是通过固定的输入输出来控制对纹理和缓冲的访问。

工作组

图形处理器通过并行获得能力,计算着色器的并行性更加直接,任务以工作组(Work Group)为单位进行。工作组可以组合成更大的全局工作组(Global Group),工作组的每个单元为工作项(Work Item),其会被计算着色器调用一次。

全局工作组2D 图中右下角的黑色方框为工作组,每个工作组有4*3个工作项。所以总共有20*12个Work Item需要执行。 计算着色器中设定本地工作组大小

1
2
3
4
5
6
7
8
#version 430 compute-shader

layout (local_size_x = 32, local_size_y = 32) in;

void main()
{
	//...
}

通过glDispatchCompute(Gluint num_groups_x, Gluint num_groups_y, Gluint num_groups_z)把工作组发送到计算管线上。需要注意的是每维上的数据不能小于1。 全局工作组3D

类型 内置变量 作用
const uvec3 gl_WorkGroupSize 本地工作组大小
uvec3 gl_NumWorkGroups glDispatchCompute参数对应
uvec3 gl_LocalInvocationID 当前执行单元在本地工作的位置
uvec3 gl_WorkGroupID 本地工作组在全局工作组的位置
uvec3 gl_GlobalInvocationID 当前执行单元在全局工作的位置
uint gl_LocalInvocationIndex gl_GlobalInvocationID的扁平化形式

下面是计算着色器的简单实用内置变量保存值到输出图像中。

1
2
3
4
5
6
7
8
9
#version 430 core

layout (local_size_x = 32, local_size_y = 32) in;
layout (binding = 0 ,rg32f) uniform image2D uOutputImage;

void main()
{
	imageStore(uOutputImage, ivec2(gl_GlobalInvocationID.xy), vec4(vec2(gl_LocalInvocationID.xy) / vec2(gl_WorkGroupSize.xy), 0.0, 0.0));
}

通信和同步

计算着色器在执行任务的过程中,难免会进行通信或者变量共享。但是OpenGL的图形管线是不规定先后的执行顺序,就像windows 多线程编程一样,使用sleep函数或者其他方式等待其他线程执行完毕任务。

通信使用shared关键字声明共享变量,会保存到特定的位置,从而对同一个本地工作组的所有计算着色器请求可见。在某个计算着色器请求修改了值后,等待其他请求也执行完毕后,计算着色器会通知其他本地工作项的执行单元当前变量被修改了。

所有这里等待其他计算单元执行完毕的过程就是同步。两种方法可以实现同步命令。

  1. 运行屏障(exection barrier): 如果计算着色器的一个请求遇到barrier(),它会停止运行,并等待同一个本地工作组的所有请求到达为止。但是,必须在统一的流控制过程中调用barrier()。本地工作组通信,一个请求写入变量,然后两个请求同时执行barrier()函数。当目标请求从barrier()返回的时候,源请求必然执行了同一函数,执行了共享变量的写入,后者可以安全读取变量值。

  2. 内存屏障(memory barrier): 保证着色器请求内存的写入操作一定是提交到内存端,而不是通过缓冲区和调度队列的方式。所有发生在memoryBarrier之后的操作在读取同一处内存的时候,都可以使用这些内存写入的结果。

粒子模拟

上面的例子过于简单,读者不能体会Compute Shader的好处。下面实现粒子1000000个粒子的模拟。一般来说,计算着色器的做法分为两个步骤:

  1. Compute Shader修改缓存里的数据
  2. OpenGL 渲染更新后的缓存数据

Compute Shader框架

这里假设读者对粒子系统有一定的了解,算法实现步骤是分配两个较大的缓存,分别存储每个粒子的速度和位置。根据上面的框架从缓存读取值并且更新,然后再把新的值写入缓存中。这里使用缓存纹理,然后使用图像读取和存取的函数进行操作。

粒子模拟使用到的缓冲区的初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
glGenBuffers(2, buffers);
glBindBuffer(GL_ARRAY_BUFFER, position_buffer);
glBufferData(GL_ARRAY_BUFFER, PARTICLE_COUNT * sizeof(glm::vec4), NULL, GL_DYNAMIC_COPY); //动态模式:缓冲区对象的数据不仅常常需要进行更新,而且使用频率也非常高

//选择绑定的缓冲区对象,然后根据需要来写入新值,可以指定范围
//缓存对象数据的全部或者一部分映射到应用程序地址空间,对映射数据写操; GL_MAP_INVALIDATE_BUFFER_BIT,缓存整个内容可以被抛弃和无效化,不再受到区域范围影响,范围数据没有被随后重新写入,也是未定义
glm::vec4* pPositions = (glm::vec4*)glMapBufferRange(GL_ARRAY_BUFFER, 0, PARTICLE_COUNT * sizeof(glm::vec4), GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT);
for (unsigned int i=0; i<PARTICLE_COUNT; i++)
{
	pPositions[i] = glm::vec4(__randomVector(-10.0f, 10.0f), __randomFloat());//位置和生命周期
}
glUnmapBuffer(GL_ARRAY_BUFFER); //表示对当前绑定缓冲区对象的更新已经完成,并且这个缓冲区可以释放。

glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, NULL);//设定位置的传递索引和大小
glEnableVertexAttribArray(0);

glBindBuffer(GL_ARRAY_BUFFER, velocity_buffer);
glBufferData(GL_ARRAY_BUFFER, PARTICLE_COUNT * sizeof(glm::vec4), NULL, GL_DYNAMIC_COPY);
glm::vec4 * pVelocities = (glm::vec4 *)glMapBufferRange(GL_ARRAY_BUFFER, 0, PARTICLE_COUNT * sizeof(glm::vec4), GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT);
for (unsigned int i = 0; i < PARTICLE_COUNT; i++)
{
	pVelocities[i] = glm::vec4(__randomVector(-0.1f, 0.1f), 0.0);
}
glUnmapBuffer(GL_ARRAY_BUFFER);
glBindVertexArray(0);

glGenTextures(2, tbos);
for (unsigned int i = 0; i < 2; i++)
{
	glBindTexture(GL_TEXTURE_BUFFER, tbos[i]);		//GL_TEXTURE_BUFFER直接绑定到纹理对象的缓存,缓存关联到纹理,着色器才可用
	glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, buffers[i]);//绑定缓存初始化数据格式
}
glBindTexture(GL_TEXTURE_BUFFER, 0);

glGenBuffers(1, &attractor_buffer);
glBindBuffer(GL_UNIFORM_BUFFER, attractor_buffer);	//uniform 缓存对象
glBufferData(GL_UNIFORM_BUFFER, 32 * sizeof(glm::vec4), NULL, GL_STATIC_DRAW);	//静态模式:缓冲区对象的数据只指定1次,但是这些数据被使用的频率很高

for (unsigned int i = 0; i < MAX_ATTRACTORS; i++)
{
	attractor_masses[i] = 0.5f + __randomFloat() * 0.5f;
}

glBindBufferBase(GL_UNIFORM_BUFFER, 0, attractor_buffer);	//UBO缓冲对象绑定到shader的指定点上

粒子模拟Compute Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#version 430 core

//uniform 块中包含引力器的位置和质量
layout (std140, binding = 0) uniform attractor_block//标准内存布局,不然UBO缓存对象在外部需要计算每个对象的偏移量
{
	vec4 attractor[64];	//xyz = position, w = mass
};

//每块中粒子的数量为128
layout (local_size_x = 128) in;

//使用两个缓存来包含粒子的位置和速度信息
layout (rgba32f, binding = 0) uniform imageBuffer velocity_buffer;
layout (rgba32f, binding = 1) uniform imageBuffer position_buffer;

//时间间隔
uniform float dt;

void main(void)
{
	//从缓存中读取当前的位置和速度
	vec4 vel = imageLoad(velocity_buffer, int(gl_GlobalInvocationID.x));
	vec4 pos = imageLoad(position_buffer, int(gl_GlobalInvocationID.x));

	int i;

	//使用当前的速度 * 时间来更新位置
	pos.xyz += vel.xyz * dt;
	pos.w -= 0.0001 * dt;

	for (i=0; i<4; i++)
	{
		//计算受力情况并更新速度
		vec3 dist = (attractor[i].xyz - pos.xyz);
		vel.xyz += dt * dt * attractor[i].w * normalize(dist) / (dot(dist, dist) + 10.0);
	}

	//如果粒子过期,那么重置它
	if (pos.w <= 0.0)
	{
		pos.xyz = -pos.xyz * 0.01;
		vel.xyz *= 0.01;
		pos.w += 1.0f;
	}

	//将新的位置和速度信息重新保存到缓存中
	imageStore(position_buffer, int(gl_GlobalInvocationID.x), pos);
	imageStore(velocity_buffer, int(gl_GlobalInvocationID.x), vel);
}

特别需要注意的粒子的循环渲染中,引力器的位置和质量都是在执行计算着色器(处理粒子的位置和速度)之前更新的。所以必须使用内存屏障来确保计算着色器的写入操作已经完成。下面是Compute PassRender Pass的调用代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Activate the compute program and bind the position and velocity buffers
m_pEffect->openRenderPass(0);
glBindImageTexture(0, velocity_tbo, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F);
glBindImageTexture(1, position_tbo, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F);
// Set delta time
m_pEffect->updateStandShaderUniform("dt", delta_time);
// Dispatch
glDispatchCompute(PARTICLE_GROUP_COUNT, 1, 1);
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);	//确保计算着色器的写入操作已经完成
m_pEffect->closeRenderPass(0);

glm::mat4 MVPMatrix = glm::perspective(glm::radians(45.0f), 1024.0f/768.0f, 0.1f, 1000.0f) *
	glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, -60.0f)) *
	glm::rotate(glm::mat4(1.0f), time * 1000.0f, glm::vec3(0.0f, 1.0f, 0.0f));

// Clear, select the rendering program and draw a full screen quad
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glDisable(GL_DEPTH_TEST);

m_pEffect->openRenderPass(1);
m_pEffect->updateStandShaderUniform("uMVP", MVPMatrix);
glBindVertexArray(m_RenderVAO);
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE);
glDrawArrays(GL_POINTS, 0, PARTICLE_COUNT);
glBindVertexArray(0);
m_pEffect->closeRenderPass(1);