「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。
前言
粒子系统是采用许多单一的粒子组成的系统,我们赋予各个粒子不同的属性,可以模拟各种各样的场景。比如模拟风、雨、雪等自然环境,模拟流体等,还有各位大神利用写的各位特效等等,比如”新春烟花“等等。
但是大部分的粒子系统是在CPU中进行计算的,一旦粒子数量过多,难免会产生卡顿,今天我们就来为大家介绍一种加速粒子系统的方法:利用GPU为粒子系统加速!
技术背景
使用GPU加速粒子系统的本质其实就是GPGPU的一种应用,那么什么是GPGPU?
GPGPU(General Purpose GPU)
GPGPU 是指利用GPU的算力进行通用计算。GPU不仅拥有强大的渲染能力,由于GPU核心数众多,所以GPU也特别适合做一些重复性高,逻辑简单,但是运算量特别大的任务。利用GPU的通用计算能力加速粒子系统仅仅只是其中的一种应用。发挥你的创意,利用GPU帮助你加速运算,这就是GPGPU被设计出来的本意。
如何在WebGL中使用GPGPU
在WebGL中实现GPGPU的方法有很多,本文介绍其中一种方式,我们采用 Transform Feedback 来实现GPGPU。
Transform Feedback
首先,我不得不承认,Transform Feedback 是一个花里胡哨的名字,它的作用是将顶点着色器中的varying变量从GPU中反向写入到 WebGLBuffer 中,然后我们可以通过相关的API 读取 buffer 中的数据。使用Transform Feedback 还有一个好处就是,我们读取出来的数据是一维的,这与JavaScript中的 map 函数类似。
我们先简单看看如何使用 Transform Feedback
使用
首先,以这样的一个例子来进行说明:现有两个数组
const a = [1, 2, 3, 4, 5, 6];
const b = [3, 6, 9, 12, 15, 18];
我们利用GPGPU来计算这两个数组每一项相加的和、差、积。
第一步:编写shader程序
首先,我们编写用于计算的顶点着色器
#version 300 es
in float a;
in float b;
out float sum;
out float difference;
out float product;
void main() {
sum = a + b;
difference = a - b;
product = a * b;
}
对于片元着色器,由于我们采用 Transform Feedback 不需要进行渲染,那么我们则不许要片元着色器进行着色,所以写一个空的片元着色器即可。
#version 300 es
precision highp float;
void main() {
}
然后,是对Shader程序进行初始化,这里初始化稍稍与我们之前进行图形渲染时进行初始化有所不同。 这里多了一行代码: gl.transformFeedbackVaryings(prog, varyingAttribute, gl.SEPARATE_ATTRIBS); 这里我们需要将需要反向输出的变量名传入 varyingAttribute 数组中。比如,这里我们需要反向输出 sum, difference, product 那么这里 varyingAttribute = ['sum', 'difference', 'product']
export function initTransformWebGLProgram(
gl: WebGL2RenderingContext,
varyingAttribute: string[],
vsSrc: string,
fsSrc: string
): WebGLProgram {
const vs = compileShader(gl, vsSrc, gl.VERTEX_SHADER);
const fs = compileShader(gl, fsSrc, gl.FRAGMENT_SHADER);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
console.log(varyingAttribute);
gl.transformFeedbackVaryings(prog, varyingAttribute, gl.SEPARATE_ATTRIBS);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error(gl.getProgramParameter(prog, gl.LINK_STATUS));
}
return prog;
}
第二步:创建&绑定buffer,并设置输入值
输入值的Buffer,与使用渲染流程的WebGL一样。
export function makeBuffer(
gl: WebGL2RenderingContext | WebGLRenderingContext,
sizeOrData: any
) {
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);
return buf;
}
export function makeBufferAndSetAttribute(
gl: WebGL2RenderingContext | WebGLRenderingContext,
data: any,
loc: number
) {
const buf = makeBuffer(gl, data);
// setup our attributes to tell WebGL how to pull
// the data from the buffer above to the attribute
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(
loc,
1, // size (num components)
gl.FLOAT, // type of data in buffer
false, // normalize
0, // stride (0 = auto)
0 // offset
);
return gl;
}
const aBuffer = makeBufferAndSetAttribute(gl, new Float32Array(a), aLoc);
const bBuffer = makeBufferAndSetAttribute(gl, new Float32Array(b), bLoc);
再创建用于接受输出结果的Buffer,这只指定了Buffer的大小,其中并没有数据
const sumBuffer = makeBuffer(gl, a.length * 4);
const differenceBuffer = makeBuffer(gl, a.length * 4);
const productBuffer = makeBuffer(gl, a.length * 4);
第三步:创建Transform Feedback对象,并与Shader中的变量进行绑定
const tf = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, sumBuffer);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, differenceBuffer);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 2, productBuffer);
第四步:进行渲染流程
首先,先将我们之前设置过的状态重置一下。
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
接下来与直接进行渲染有一点点不同,由于这里我么不需要使用片元着色器,我们可以禁用掉片元着色器
gl.enable(gl.RASTERIZER_DISCARD);。再然后我们在调用 drawArrays之前,我们需要使用 Transform Feedback,具体的使用方式如下:
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, a.length);
gl.endTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
gl.disable(gl.RASTERIZER_DISCARD);
这样,我们的值就应该写入到了我们之前创建的Buffer中,剩下的只是读取其中的内容了。
第五步:读取Buffer中的值
const results = new Float32Array(a.length);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.getBufferSubData(
gl.ARRAY_BUFFER,
0, // byte offset into GPU buffer,
results
);
console.log(results);
以上,就是我们利用 Transform Feedback 进行GPGPU计算的原理,现在我们可以开始着手于设计GPU版本的粒子系统了。
实现GPU粒子系统
我们先从简单的设计入手,我们的粒子只具有以下属性:
- position: 当前位置
- oldPosition: 前一帧的位置
- velocity: 粒子的速度
其中: position = oldPosition + velocity * deltaTime;
那么我们可以写出以下的Shader 计算程序
用于计算的Shader程序
顶点着色器
#version 300 es
in vec2 oldPosition;
in vec2 velocity;
uniform float deltaTime;
out vec2 newPosition;
void main() {
newPosition = oldPosition + velocity * deltaTime;
}
片元着色器
#version 300 es
precision highp float;
void main() {
}
用于渲染的Shader程序
顶点着色器
#version 300 es
in vec4 position;
in vec2 uv;
out vec2 vuv;
uniform mat4 matrix;
void main () {
vec4 pos = matrix * position;
gl_Position = pos;
vuv = uv;
}
片元着色器
#version 300 es
precision highp float;
uniform vec2 size;
in vec2 vuv;
out vec4 out_color;
void main () {
float d = distance(vuv, vec2(0.5));
d = 1.0 - smoothstep(0.0, 0.5, d);
vec4 color = vec4(1.0, 1.0, 1.0, 1.0) * d;
out_color = color;
}
然后,我们还需要编写一些帮助我们减少代码的辅助函数:
function compileShader(
gl: WebGL2RenderingContext | WebGLRenderingContext,
shaderSource: string,
shaderType: number
): WebGLShader {
var shader = gl.createShader(shaderType);
gl.shaderSource(shader, shaderSource);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(shaderSource);
console.error('shader compiler error:\n' + gl.getShaderInfoLog(shader));
}
return shader;
}
function linkShader(
gl: WebGL2RenderingContext | WebGLRenderingContext,
vs: WebGLShader,
fs: WebGLShader
): WebGLProgram {
var prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('shader linker error:\n' + gl.getProgramInfoLog(prog));
}
return prog;
}
export function initWebGLProgram(
gl: WebGL2RenderingContext | WebGLRenderingContext,
vsSrc: string,
fsSrc: string
): WebGLProgram {
const vs = compileShader(gl, vsSrc, gl.VERTEX_SHADER);
const fs = compileShader(gl, fsSrc, gl.FRAGMENT_SHADER);
const program = linkShader(gl, vs, fs);
return program;
}
export function initTransformWebGLProgram(
gl: WebGL2RenderingContext,
varyingAttribute: string[],
vsSrc: string,
fsSrc: string
): WebGLProgram {
const vs = compileShader(gl, vsSrc, gl.VERTEX_SHADER);
const fs = compileShader(gl, fsSrc, gl.FRAGMENT_SHADER);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
console.log(varyingAttribute);
gl.transformFeedbackVaryings(prog, varyingAttribute, gl.SEPARATE_ATTRIBS);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error(gl.getProgramParameter(prog, gl.LINK_STATUS));
}
return prog;
}
export function makeBuffer(
gl: WebGL2RenderingContext | WebGLRenderingContext,
sizeOrData: any,
usage: number = gl.STATIC_DRAW
) {
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, gl.STATIC_DRAW);
return buf;
}
export const rand = (min: number, max?: number) => {
if (max === undefined) {
max = min;
min = 0;
}
return Math.random() * (max - min) + min;
};
export const createPoints = (num: number, ranges: [number, number][]) => {
return new Array(num)
.fill(0)
.map((_) => ranges.map((range) => rand(...range)))
.flat();
};
type VertexArrayParam = {
size: number;
stride: number;
offset: number;
};
export function makeVertexArray(
gl: WebGL2RenderingContext,
bufLocPairs: [WebGLBuffer, number, ('vertex' | 'instance')?][],
params: VertexArrayParam[]
) {
const va = gl.createVertexArray();
gl.bindVertexArray(va);
let index = 0;
for (const [buffer, loc, mode] of bufLocPairs) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(
loc,
params[index].size,
gl.FLOAT,
false,
params[index].stride,
params[index].offset
);
if (mode === 'instance') {
gl.vertexAttribDivisor(loc, 1);
}
++index;
}
return va;
}
export function makeTransformFeedback(gl: WebGL2RenderingContext, buffer: WebGLBuffer) {
const tf = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buffer);
return tf;
}
export function getBufferResult(
gl: WebGL2RenderingContext,
buffer: WebGLBuffer,
size: number
) {
const results = new Float32Array(size);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.getBufferSubData(gl.ARRAY_BUFFER, 0, results);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
return results;
}
export function generateQuadByPoint(x: number, y: number, size: number) {
const halfSize = size / 2;
// prettier-ignore
return [
x - halfSize, y - halfSize, 0.0, 0.0,
x + halfSize, y - halfSize, 1.0, 0.0,
x - halfSize, y + halfSize, 0.0, 1.0,
x - halfSize, y + halfSize, 0.0, 1.0,
x + halfSize, y - halfSize, 1.0, 0.0,
x + halfSize, y + halfSize, 1.0, 1.0
];
}
export function generateQuadByPoints(points: ArrayLike<number>, size: number) {
const result = [];
for (let i = 0; i < points.length; i += 2) {
const quad = generateQuadByPoint(points[i], points[i + 1], size);
result.push(...quad);
}
return result;
}
其中一些函数的说明如何:
| 函数名 | 说明 |
|---|---|
| initWebGLProgram | 根据shader程序字符串创建一个WebGLProgram |
| initTransformWebGLProgram | 根据shader程序字符串创建一个用于计算的 WebGLProgram |
| initWebGLProgram | 根据shader程序字符串创建一个WebGLProgram |
| makeBuffer | 创建Buffer对象,并填充数据 |
| getBufferResult | 读取WebGLBuffer中的数据 |
| rand | 产生随机数 |
| makeVertexArray | 创建一个VAO对象,并对如何读取Buffer中的数据进行配置 |
| makeTransformFeedback | 创建一个TransformFeedback对象,并绑定相应的WebGLBuffer |
| generateQuadByPoint | 给定一个点,生成一个矩形的顶点数据(位置数据和UV数据) |
数据准备
现在我们首先要准备好在渲染流程中使用的相关数据和对象。首先初始化两个WebGLProgram,其中一个用于进行粒子数据的计算,另一个用于渲染粒子
初始化Shader
const computeProgram = initTransformWebGLProgram(gl, ['newPosition'], vs, fs);
const renderProgram = initWebGLProgram(gl, renderVs, renderFs);
获取Shader中的变量位置
const updatePositionProgLocs = {
oldPosition: gl.getAttribLocation(computeProgram, 'oldPosition'),
velocity: gl.getAttribLocation(computeProgram, 'velocity'),
deltaTime: gl.getUniformLocation(computeProgram, 'deltaTime'),
};
const drawParticlesProgLocs = {
position: gl.getAttribLocation(renderProgram, 'position'),
uv: gl.getAttribLocation(renderProgram, 'uv'),
matrix: gl.getUniformLocation(renderProgram, 'matrix'),
size: gl.getUniformLocation(renderProgram, 'size'),
};
初始化粒子数据
这里我们假设一共产生500个粒子,我们先来产生一些位置随机和初识速度随机的粒子。
const positions = new Float32Array(
createPoints(numParticles, [
[-1, 1],
[-1, 1],
])
);
const velocities = new Float32Array(
createPoints(numParticles, [
[-1, 1],
[-1, 1],
])
);
// 用于渲染单个粒子的顶点数据。
const positionAndUV = new Float32Array([
-0.02, -0.02, 0, 0,
0.02, -0.02, 1, 0,
-0.02, 0.02, 0, 1,
-0.02, 0.02, 0, 1,
0.02, -0.02, 1, 0,
0.02, 0.02, 1, 1
]);
创建Buffer并往其中填充数据
这里我们需要两个Buffer来保存计算前的位置信息和计算后的位置信息,当计算完毕后我们需要将初识值得Buffer与保存结果的Buffer进行交换,这样样我们就可以不断的复用这两个Buffer对象了。
另外,我们还需要一个Buffer来保存速度信息。最后还需要一个保存真正需要渲染的粒子的顶点数据。
const position1Buffer = makeBuffer(gl, positions, gl.DYNAMIC_DRAW);
const position2Buffer = makeBuffer(gl, positions, gl.DYNAMIC_DRAW);
const velocityBuffer = makeBuffer(gl, velocities, gl.STATIC_DRAW);
const drawQuadBuffer = makeBuffer(gl, positions.length * 6, gl.DYNAMIC_DRAW);
创建VAO(VertexArray Object)对象
VAO对象的作用就是告诉GPU应该怎样从Buffer中读取数据。VAO一旦创建好了过后,我们后续使用就可以通过bindVertexArray的方式直接使用,而不需要重复的去做一些冗余繁复的操作了。这里同样需要3个VAO对象:
- updatePositionVA1: 用于奇数次计算位置Buffer的VAO
- updatePositionVA2: 用于偶数次计算位置Buffer的VAO
- drawVA1: 用于奇数次渲染程序的顶点Buffer的VAO
- drawVA1: 用于偶数次渲染程序的顶点Buffer的VAO
const updatePositionVA1 = makeVertexArray(
gl,
[
[position1Buffer, updatePositionProgLocs.oldPosition],
[velocityBuffer, updatePositionProgLocs.velocity],
],
[
{ stride: 0, offset: 0, size: 2 },
{ stride: 0, offset: 0, size: 2 },
]
);
const updatePositionVA2 = makeVertexArray(
gl,
[
[position2Buffer, updatePositionProgLocs.oldPosition],
[velocityBuffer, updatePositionProgLocs.velocity],
],
[
{ stride: 0, offset: 0, size: 2 },
{ stride: 0, offset: 0, size: 2 },
]
);
const drawVA1 = makeVertexArray(
gl,
[
[drawQuadBuffer, drawParticlesProgLocs.position],
[drawQuadBuffer, drawParticlesProgLocs.uv],
[position2Buffer, drawParticlesProgLocs.particlePos, 'instance'],
],
[
{ stride: 4 * 4, offset: 0, size: 2 },
{ stride: 4 * 4, offset: 4 * 2, size: 2 },
{ stride: 2 * 4, offset: 0, size: 2 },
]
);
const drawVA2 = makeVertexArray(
gl,
[
[drawQuadBuffer, drawParticlesProgLocs.position],
[drawQuadBuffer, drawParticlesProgLocs.uv],
[position1Buffer, drawParticlesProgLocs.particlePos, 'instance'],
],
[
{ stride: 4 * 4, offset: 0, size: 2 },
{ stride: 4 * 4, offset: 4 * 2, size: 2 },
{ stride: 2 * 4, offset: 0, size: 2 },
]
);
由于我们后续会采取多实例绘制的策略,所以这里我们告诉GPU这个Buffer数据是 instance 的,GPU在Buffer中寻址数据的方式是根据 stride 不断的往前步进的,如果我们没有采用 instance 的方式的话,在每个实例之间,它的地址会重置。而采用了 instance 的策略则不会重置。
创建TransformFeedback 对象并绑定Buffer
const tf1 = makeTransformFeedback(gl, position1Buffer);
const tf2 = makeTransformFeedback(gl, position2Buffer);
清空绑定关系
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.TRANSFORM_FEEDBACK_BUFFER, null);
gl.bindVertexArray(null);
进行渲染
现在,我们可以进入渲染流程了,刚刚我们创建Buffer的时候已经提到了我们需要复用创建好的两个保存位置信息的Buffer,一个是用于保存当前位置信息,另一个保存更新后的位置信息,然后交换他们。这里为了将 Buffer、VAO、TransformFeedback对应起来,我们将其封装在一起
let current: ComputeBuffer = {
updateVA: updatePositionVA1,
buffer: position2Buffer,
tf: tf2,
drawVA: null,
};
let next: ComputeBuffer = {
updateVA: updatePositionVA2,
buffer: position1Buffer,
tf: tf1,
drawVA: null,
};
渲染完毕时,我们需要将 current 和 next 进行交换。
GPU计算流程
首先,我们先进行粒子的位置信息计算,我们会使用到计算Shader程序。与上面提到的如何使用 Transform Feedback一样,采用下面的固定流程,此处不再赘述。
gl.useProgram(computeProgram);
gl.bindVertexArray(current.updateVA);
gl.uniform1f(updatePositionProgLocs.deltaTime, deltaTime);
gl.enable(gl.RASTERIZER_DISCARD);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, current.tf);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, numParticles);
gl.endTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
gl.disable(gl.RASTERIZER_DISCARD);
到此为止,计算的流程已经结束。由于之前我们已经往渲染的程序中绑定了 position1Buffer 和 position2Buffer ,那么我们可以通过直接绑定VAO对象即可。
gl.useProgram(renderProgram);
t % 2 === 0 ? gl.bindVertexArray(drawVA1) : gl.bindVertexArray(drawVA2);
gl.bindBuffer(gl.ARRAY_BUFFER, drawQuadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(quadPoints), gl.DYNAMIC_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, numParticles * 6);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
其余的流程与正常渲染流程相同。渲染完毕后记得清空绑定关系,并且交换 current 与 next
[current, next] = [next, current];
t++;
最后我们可以看一下运行效果:
GPU vs CPU 性能对比
我们可以看到要实现GPU粒子的效果非常的费劲,为什么我们要大费周章实现一个GPU的粒子系统呢?那当然还不是为了抠性能!现在我们就来对比一下CPU粒子系统与CPU粒子系统的性能差距到底几何???
测试用例
我们分别进行500个、5000个、50000、500000个粒子的测试。
我们可以看到在 5000个粒子这个数量级的时候,CPU和GPU还旗鼓相当,当粒子数量上到50000个时,CPU粒子系统一下就不太行了,而GPU粒子系统还保持着42FPS的数据,经测试,当粒子数量来到500000个时,GPU粒子系统的帧率还可以保持在30帧以上。
2022.1.29作者更新:
由于之前的代码错误,不应该从GPU中去二次读取数据到CPU,再从CPU中传入数据到GPU中,导致性能急剧下降。上述的测试结果CPU粒子系统不变,对于GPU系统,在500000个粒子时,帧率依旧可以平稳在60帧。
可以说在这种场景下,GPU是占据了巨大的性能优势。
总结
今天我们介绍了在WebGL中使用GPGPU的方法,我们利用了WebGL2中提供的 TransformFeedback 新特性,该特性允许我们从 WebGLBuffer 读取经过GPU计算后的值,所以这就为我们的GPGPU实现提供了可能性。具体的方法步骤此处就不再重复了,如果你已经遗忘怎么做,请回顾上面的文章。
今天的内容相对来说是比较复杂的,希望读者能够仔细阅读一番,如果你觉得本文对你有用,别忘了给作者点个赞~!