超级加速!利用WebGL2实现GPU粒子系统~!

2,819 阅读12分钟

「这是我参与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对象了。

image.png

另外,我们还需要一个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,
    };

渲染完毕时,我们需要将 currentnext 进行交换。

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);

到此为止,计算的流程已经结束。由于之前我们已经往渲染的程序中绑定了 position1Bufferposition2Buffer ,那么我们可以通过直接绑定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);

其余的流程与正常渲染流程相同。渲染完毕后记得清空绑定关系,并且交换 currentnext

[current, next] = [next, current];
t++;

最后我们可以看一下运行效果:

Jan-24-2022 17-02-34.gif

GPU vs CPU 性能对比

我们可以看到要实现GPU粒子的效果非常的费劲,为什么我们要大费周章实现一个GPU的粒子系统呢?那当然还不是为了抠性能!现在我们就来对比一下CPU粒子系统与CPU粒子系统的性能差距到底几何???

测试用例

我们分别进行500个、5000个、50000、500000个粒子的测试。

image.png

我们可以看到在 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实现提供了可能性。具体的方法步骤此处就不再重复了,如果你已经遗忘怎么做,请回顾上面的文章。

今天的内容相对来说是比较复杂的,希望读者能够仔细阅读一番,如果你觉得本文对你有用,别忘了给作者点个赞~!