WebGPU-8

第五个例子,compute_boids
在这里插入图片描述
boids指的是群落,一群鸟,一群人什么的。特指一大群什么东西相互作用运动的case,这个例子是2D的,一堆三角形在哪动来动去,相互作用,在快要碰到的时候或减速,或掉头,或换方向,看起来就像一群没头苍蝇一样在扭动。

先来看看大致的流程,个人理解流程分了三步,
第一步通过compute shader,我们计算出1000份数据,每一份数据包含速度和角度之类的数据,用来决定这个三角形最终出现的位置。
第二步,vertex shader绘制一个三角形的三个点,
通过instance drawing,绘制1000个,此时我们有1000个三角形了。将上一步的1000份数据应用到1000个三角形上,决定最终出现的位置
第三步,通过fragment shader将1000个三角形的每一份的三个点连成三角形,绘制到屏幕上。
在这里插入图片描述
以上就是最最笼统的流程。和之前的例子不一样的是,我们终于看到了compute shader的使用,也是这个sample最重要的部分。

先看vertex shader:

const vertexShaderGLSL = `#version 450
                              layout(location = 0) in vec2 a_particlePos;
                              layout(location = 1) in vec2 a_particleVel;
                              layout(location = 2) in vec2 a_pos;
                              void main() {
                                  float angle = -atan(a_particleVel.x, a_particleVel.y);
                                  vec2 pos = vec2(a_pos.x * cos(angle) - a_pos.y * sin(angle),
                                                  a_pos.x * sin(angle) + a_pos.y * cos(angle));
                                  gl_Position = vec4(pos + a_particlePos, 0, 1);
                              }
                             `

我们看到shader里有速度和位置信息,根据角度进行旋转,根据速度决定移动范围,最终决定点的位置。

再来看看fragment shader

    const fragmentShaderGLSL = `#version 450
                                layout(location = 0) out vec4 fragColor;
                                void main() {
                                    fragColor = vec4(1.0);
                                }
                               `

啥都没干,就是简简单单的画了白色(fragColor被设置成了白色)

如果只看这两个shader,基本流程和最开始hello triangle的例子是一毛一样的不是么。vertex设置了位置,fragment将点连成了三角形,然后绘制出来。仅此而已。

问题是,这些位置数据是如何算出来的。
compute shader我们来仔细研究下看看:

const computeShaderGLSL = `#version 450
								// 定义粒子的结构,粒子即一个三角形,包含位置和速度两个变量
                                struct Particle {
                                    vec2 pos;
                                    vec2 vel;
                                };
								
								// 参数列表,决定最终某一个粒子该怎么走,具体包括的参数比较多,这里先不一一解释
                                layout(std140, set = 0, binding = 0) uniform SimParams {
                                    float deltaT;
                                    float rule1Distance;
                                    float rule2Distance;
                                    float rule3Distance;
                                    float rule1Scale;
                                    float rule2Scale;
                                    float rule3Scale;
                                } params;
								// buffer A,个人理解这边可以理解为src,及上一帧,每个粒子的状态
                                layout(std140, set = 0, binding = 1) buffer ParticlesA {
                                    Particle particles[${numParticles}];
                                } particlesA;
								// buffer B,理解成dst,即结果,根据A计算出来的更新过的状态
                                layout(std140, set = 0, binding = 2) buffer ParticlesB {
                                    Particle particles[${numParticles}];
                                } particlesB;

                                void main() {
                                    // https://github.com/austinEng/Project6-Vulkan-Flocking/blob/master/data/shaders/computeparticles/particle.comp

                                    uint index = gl_GlobalInvocationID.x;
                                    if (index >= ${numParticles}) { return; }

                                    vec2 vPos = particlesA.particles[index].pos;
                                    vec2 vVel = particlesA.particles[index].vel;

                                    vec2 cMass = vec2(0.0, 0.0);
                                    vec2 cVel = vec2(0.0, 0.0);
                                    vec2 colVel = vec2(0.0, 0.0);
                                    int cMassCount = 0;
                                    int cVelCount = 0;

                                    vec2 pos;
                                    vec2 vel;
                                    for (int i = 0; i < ${numParticles}; ++i) {
                                        if (i == index) { continue; }
                                        pos = particlesA.particles[i].pos.xy;
                                        vel = particlesA.particles[i].vel.xy;

                                        if (distance(pos, vPos) < params.rule1Distance) {
                                            cMass += pos;
                                            cMassCount++;
                                        }
                                        if (distance(pos, vPos) < params.rule2Distance) {
                                            colVel -= (pos - vPos);
                                        }
                                        if (distance(pos, vPos) < params.rule3Distance) {
                                            cVel += vel;
                                            cVelCount++;
                                        }
                                    }
                                    if (cMassCount > 0) {
                                        cMass = cMass / cMassCount - vPos;
                                    }
                                    if (cVelCount > 0) {
                                        cVel = cVel / cVelCount;
                                    }

                                    vVel += cMass * params.rule1Scale + colVel * params.rule2Scale + cVel * params.rule3Scale;

                                    // clamp velocity for a more pleasing simulation.
                                    vVel = normalize(vVel) * clamp(length(vVel), 0.0, 0.1);

                                    // kinematic update
                                    vPos += vVel * params.deltaT;

                                    // Wrap around boundary
                                    if (vPos.x < -1.0) vPos.x = 1.0;
                                    if (vPos.x > 1.0) vPos.x = -1.0;
                                    if (vPos.y < -1.0) vPos.y = 1.0;
                                    if (vPos.y > 1.0) vPos.y = -1.0;

                                    particlesB.particles[index].pos = vPos;

                                    // Write back
                                    particlesB.particles[index].vel = vVel;
                                }
                              `

计算过程可以参考代码里的link,或者其他相关资料,我们只要抓住重点即可,我们有一份buffer 的 ParticalA的数据,经过相互之间的计算,限制,加成,最终得出了三角形下一帧的速度和位置数据。

这里最最要紧的,是两个地方:

			uint index = gl_GlobalInvocationID.x;
	        if (index >= ${numParticles}) { return; }
	        ...

compute shader是针对一个instance而言的,获取当前instance是通过gl_GlobalInvocationID来获取的。

第二个地方是:

		for (int i = 0; i < ${numParticles}; ++i) {
		   if (i == index) { continue; }
		    pos = particlesA.particles[i].pos.xy;
		    vel = particlesA.particles[i].vel.xy;
		    ...
		}

每一个instance都要遍历1000遍的其他数据。
我们想一下,如果这一步要用CPU来做,那计算量就是1000 * 1000。
而通过GPU的compute shader,通过并行计算,计算量是相同的,但是同时分1000个instance来做,效率也大大提升了。

接下来我们看看外部API调用的情况:

const swapChain = context.configureSwapChain({
        device,
        format: "bgra8unorm"
      });

swapChain恢复到了最开始的情况,而不像上一个例子那样需要作为src再次使用

const computeBindGroupLayout = device.createBindGroupLayout({
        bindings: [
          { binding: 0, visibility: GPUShaderStageBit.COMPUTE, type: "uniform-buffer" },
          { binding: 1, visibility: GPUShaderStageBit.COMPUTE, type: "storage-buffer" },
          { binding: 2, visibility: GPUShaderStageBit.COMPUTE, type: "storage-buffer" },
        ],
      });

新增了computeBindGroupLayout 变量,专门供compute pass使用。其中的三个数据binding也和compute shader中的
SimParams, ParticlesA, ParticlesB一一对应。这里要解释一下:

Storage Buffer Object不同于uniform buffer在着色器不可修改,storage buffer是可读可写的。修改的内容给其他着色器调用或者应用程序本身。
我们需要通过各种遍历和判断,将最终数据写入ParticlesB中,且在帧与帧之间,ParticlesA和ParticlesB的角色是相互切换的。
直白的说,上一帧通过SRC写入DST,下一帧就反过来了。DST变成了SRC,所以不论是ParticlesA还是ParticlesB,都定义成storage-buffer。

再来看看pipleline:

	  const computePipelineLayout = device.createPipelineLayout({
        bindGroupLayouts: [computeBindGroupLayout],
      });


      const computePipeline = device.createComputePipeline({
        layout: computePipelineLayout,
        computeStage: {
          module: device.createShaderModule({
            code: Utils.compile("c", computeShaderGLSL)
          }),
          entryPoint: "main",
        }
      });

就两句话,仅仅指明layout的设置和用哪个shader就行了。

interface GPUDevice {
    readonly attribute GPUExtensions extensions;
    readonly attribute GPULimits limits;
    readonly attribute GPUAdapter adapter;

    GPUBuffer createBuffer(GPUBufferDescriptor descriptor);
    (GPUBuffer, ArrayBuffer) createBufferMapped(GPUBufferDescriptor descriptor);
    Promise<(GPUBuffer, ArrayBuffer)> createBufferMappedAsync(GPUBufferDescriptor descriptor);
    GPUTexture createTexture(GPUTextureDescriptor descriptor);
    GPUSampler createSampler(GPUSamplerDescriptor descriptor);

    GPUBindGroupLayout createBindGroupLayout(GPUBindGroupLayoutDescriptor descriptor);
    GPUPipelineLayout createPipelineLayout(GPUPipelineLayoutDescriptor descriptor);
    GPUBindGroup createBindGroup(GPUBindGroupDescriptor descriptor);

    GPUShaderModule createShaderModule(GPUShaderModuleDescriptor descriptor);
    GPUComputePipeline createComputePipeline(GPUComputePipelineDescriptor descriptor);
    GPURenderPipeline createRenderPipeline(GPURenderPipelineDescriptor descriptor);

    GPUCommandEncoder createCommandEncoder(GPUCommandEncoderDescriptor descriptor);

    GPUQueue getQueue();
};

回过头来再看看Spec上的API定义,我们可以看到对于pipleline,只有两种 createComputePipeline和createRenderPipeline,其中createComputePipeline负责接收compute shader,renderPipeline负责接收vertex和fragment shader

接下来的renderPipeline这里就不讲了,流程和之前的都一样。没有新东西

      const simParamData = new Float32Array([0.04, 0.1, 0.025, 0.025, 0.02, 0.05, 0.005]);
      const simParamBuffer = device.createBuffer({
        size: simParamData.byteLength,
        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.TRANSFER_DST,
      });
      simParamBuffer.setSubData(0, simParamData);

首先对boid算法中需要用到的模拟参数值进行设置,由于这7个值都是固定的值,所以设置成了UNIFORM↑↑↑↑↑↑↑

      const initialParticleData = new Float32Array(numParticles * 4);
      for (let i = 0; i < numParticles; ++i) {
        initialParticleData[4 * i + 0] = 2 * (Math.random() - 0.5);
        initialParticleData[4 * i + 1] = 2 * (Math.random() - 0.5);
        initialParticleData[4 * i + 2] = 2 * (Math.random() - 0.5) * 0.1;
        initialParticleData[4 * i + 3] = 2 * (Math.random() - 0.5) * 0.1;
      }

接下来对buffer值设定了一个随机的数组↑↑↑↑↑↑↑

      const particleBuffers = new Array(2);
      for (let i = 0; i < 2; ++i) {
        particleBuffers[i] = device.createBuffer({
          size: initialParticleData.byteLength,
          usage: GPUBufferUsage.TRANSFER_DST | GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE
        });
        particleBuffers[i].setSubData(0, initialParticleData);
      }

接下来设置了两个buffer,这里有2个点需要注意。首先是buffer的生成,是2份,不是1份,只是这两份数据都用了CPU中的同一份initialParticleData数据初始化了,第二点是两个buffer设置的usage包含了STORAGEHE VERTEX两个,storage很好理解,因为这部分数据是可改写的,而vertex是因为这块buffer还要用来传给vertex shader作为绘制前决定三角形位置的数据。↑↑↑↑↑↑↑

      const particleBindGroups = new Array(2);
      for (let i = 0; i < 2; ++i) {
        particleBindGroups[i] = device.createBindGroup({
          layout: computeBindGroupLayout,
          bindings: [{
            binding: 0,
            resource: {
              buffer: simParamBuffer,
              offset: 0,
              size: simParamData.byteLength
            },
          }, {
            binding: 1,
            resource: {
              buffer: particleBuffers[i],
              offset: 0,
              size: initialParticleData.byteLength,
            },
          }, {
            binding: 2,
            resource: {
              buffer: particleBuffers[(i + 1) % 2],
              offset: 0,
              size: initialParticleData.byteLength,
            },
          }],
        });
      }

这一段比较有意思,设置了2个bindGroup,而且这两个group的设置是相互反一反的,bindGroup[0]中一个是src,一个是dst,而bindGroup[1]则反过来了。这是为了打一个ping-pong的操作。↑↑↑↑↑↑↑

      function frame() {
        renderPassDescriptor.colorAttachments[0].attachment = swapChain.getCurrentTexture().createDefaultView();

        const commandEncoder = device.createCommandEncoder({});
        {
          const passEncoder = commandEncoder.beginComputePass();
          passEncoder.setPipeline(computePipeline);
          passEncoder.setBindGroup(0, particleBindGroups[t % 2]);
          passEncoder.dispatch(numParticles);
          passEncoder.endPass();
        }
        {
          const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
          passEncoder.setPipeline(renderPipeline);
          passEncoder.setVertexBuffers(0, [particleBuffers[(t + 1) % 2], verticesBuffer], [0, 0]);
          passEncoder.draw(3, numParticles, 0, 0);
          passEncoder.endPass();
        }
        device.getQueue().submit([commandEncoder.finish()]);

        ++t;
        requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }

最后看一下frame函数,这里有三个地方需要在意:
首先是compute的中

passEncoder.setBindGroup(0, particleBindGroups[t % 2]);

然后是render中

passEncoder.setVertexBuffers(0, [particleBuffers[(t + 1) % 2], verticesBuffer], [0, 0]);

这里能看到 compute用了A,render就用B;反过来compute用B,render就用A,这就是我说的ping-pong的一个流程。
一块buffer作为src,另一块做dst。

最后一个要注意的是

passEncoder.draw(3, numParticles, 0, 0);

numParticles赋值了1000,就是1个三角形,绘制1000个instance,这和最开始gl_GlobalInvocationID中对应上。

最后再补充一点,之前说renderPipeline没有什么新鲜东西,说错了。

		vertexInput: {
          indexFormat: "uint32",
          vertexBuffers: [{
            // instanced particles buffer
            stride: 4 * 4,
            stepMode: "instance",
            attributes: [{
              // instance position
              shaderLocation: 0,
              offset: 0,
              format: "float2"
            }, {
              // instance velocity
              shaderLocation: 1,
              offset: 2 * 4,
              format: "float2"
            }],
          }, {
            // vertex buffer
            stride: 2 * 4,
            stepMode: "vertex",
            attributes: [{
              // vertex positions
              shaderLocation: 2,
              offset: 0,
              format: "float2"
            }],
          }],
        }

看一下vertexInput的设置,其中有个mode被设置成了instance,然后设置了位置,步长,格式等。而shaderLocation是2的时候是普通的vertex。

结合刚才我说的 draw函数中的1000个instance来理解。
即:
绘制一个三角形,绘制1000个instance,在shader中,location是0和1的情况,是instance模式,是将buffer按照offset,type拆分之后的一一对应的送到shader里的。而location是2的时候,则是普通的vertex数据,是统一的,就那么一份数据。

cool~以上就是这个sample的解析。
下一个sample将是官方提供的sample中最后的一个了。

统一结尾:以上均为个人理解和一家之言,有任何错漏之处欢迎留言讨论,共同进步,一经发现错漏,必立刻更新,且会在修改处指明reporter。谢谢

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值