第五个例子,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。谢谢