How I learned particles: simple CPU-based emitter
In this post I'm going to share a quick and effective way I learned in school how to generate a simple particle system. The code is simple and easily integrated, since most of the work is done CPU-side. As a quick note, this experiment was done on an earlier version of my graphics engine (downloaded from Github), as to make it easier to isolate and debug particle-related code.
A high-level overview of how setup works:
1. Simulation: particle lifecycle and simulation is handled CPU-side in an Emitter class
2. Copying: Copy updated particles into a dynamic buffer that resides on the GPU
3. Draw: Draw the particles using the appropriate shaders (in this case they have separate shaders) and render states
So, for step 1, what data is being updated? A particle needs position (XMFLOAT3), color (XMFLOAT4), velocity (XMFLOAT3), size (float), and age (float).
There is also the need to keep track of each vertex of a particle, those properties will be important for rendering. For each particle vertex, we need to know position (XMFLOAT3), uv (XMFLOAT2), and color (XMFLOAT4).
These will be emitted by, well....an emitter. So we make an Emitter class containing a particle struct, and a "Particle Vertex" struct.
We need to keep track of living and dead particles, and recycle the dead particles into living ones. The list of particles the emitter has is treated as a circular buffer, so it's constantly checking age, lifetime, the index of the first alive particle, and the index of the first dead particle.
But, a buffer needs to be made in the constructor before doing all the work. It will ideally look something like this:
D3D11_BUFFER_DESC vbDesc = {}; vbDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER; vbDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; vbDesc.Usage = D3D11_USAGE_DYNAMIC; vbDesc.ByteWidth = sizeof(ParticleVertex) * 4 * maxParticles; device->CreateBuffer(&vbDesc, 0, &vertexBuffer);
When the time comes, we will need to unlock the buffer to write to it, and lock it when we are done. It can be combined with a static index buffer, because only the data of a vertex is changing, not the order. So, the index data and buffer being used would look like this:
// Index buffer data unsigned int* indices = new unsigned int[maxParticles * 6]; int indexCount = 0; for (int i = 0; i < maxParticles * 4; i += 4) //four vertices per particle { indices[indexCount++] = i; indices[indexCount++] = i + 1; indices[indexCount++] = i + 2; indices[indexCount++] = i; indices[indexCount++] = i + 2; indices[indexCount++] = i + 3; } D3D11_SUBRESOURCE_DATA indexData = {}; indexData.pSysMem = indices;
// Regular index buffer D3D11_BUFFER_DESC ibDesc = {}; ibDesc.BindFlags = D3D11_BIND_INDEX_BUFFER; ibDesc.CPUAccessFlags = 0; ibDesc.Usage = D3D11_USAGE_DEFAULT; ibDesc.ByteWidth = sizeof(unsigned int) * maxParticles * 6; device->CreateBuffer(&ibDesc, &indexData, &indexBuffer);
delete[] indices;
Actually getting the particles to update is simple. In an Update method (which should take in a float value, later being set to deltaTime), we first check the cyclical buffer. Here it is important to know firstAlive is before firstDead, so the living particles are contiguous. So, if firstAlive < firstDead, then for all particles between firstAlive and firstDead, update them one at a time.
Else, firstAlive must be after firstDead, so in that case, the living particles wrap around. We'd have to update one half at a time:
// 0 -------- FIRST DEAD ----------- FIRST ALIVE -------- MAX // | alive | dead | alive |
// Update first half (from firstAlive to max particles) for (int i = firstAliveIndex; i < maxParticles; i++) UpdateSingleParticle(dt, i);
// Update second half (from 0 to first dead) for (int i = 0; i < firstDeadIndex; i++) UpdateSingleParticle(dt, i);
Then we just add to the time, then while timeSinceEmit > secondsPerParticle, call Spawn(), and decrement timeSinceEmit by secondsPerParticle.
Now, what happens in the previously mentioned UpdateSingleParticle() method? We simply add to it's lifetime, and do the appropriate checks for when it exceeds said lifetime. If it died, retire by moving the "alive" count. We would also adjust the position and move it using constant acceleration.
They still have to spawn, which is the last part of "Simulation" phase. If there is any left to spawn, reset all the properties of the first dead particle, increment firstDeadIndex, wrap around with "firstDeadIndex %= maxParticles", and increment the living count.
The most complicated step is out of the way....now, we need to copy the particles. This method takes in an ID3D11DeviceContext, and has similar logic to the Update process:
// Check cyclic buffer status if (firstAliveIndex < firstDeadIndex) { for (int i = firstAliveIndex; i < firstDeadIndex; i++) CopyOneParticle(i); } else { // Update first half (from firstAlive to max particles) for (int i = firstAliveIndex; i < maxParticles; i++) CopyOneParticle(i);
// Update second half (from 0 to first dead) for (int i = 0; i < firstDeadIndex; i++) CopyOneParticle(i); }
// All particles copied locally - send whole buffer to GPU D3D11_MAPPED_SUBRESOURCE mapped = {}; context->Map(vertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
memcpy(mapped.pData, localParticleVertices, sizeof(ParticleVertex) * 4 * maxParticles);
context->Unmap(vertexBuffer, 0);
CopyOneParticle() goes through a single particle. It copies the color, size, and position for each local particle vertex (4 per particle) into each particle in our main array.
Step 3 is to draw. After calling CopyParticlesToGPU, you would draw as you would any object in DirectX: by setting up the index and vertex buffers, as well as the shader variables. And actually draw each particle using the very same logic for updating.
Now, what do the shaders look like? The vertex shader needs to perform the process of billboarding, so that the particles appear in front of the camera no matter where it goes. This is done very simply in the main method:
// Use UV to offset position (billboarding) float2 offset = input.uv * 2 - 1; offset *= input.size; offset.y *= -1; output.position.xy += offset;
As for the pixel shader, we need even less work. We need to do the correct sampling is all:
struct VertexToPixel { float4 position : SV_POSITION; float2 uv : TEXCOORD0; float4 color : TEXCOORD1; };
// Textures and such Texture2D particle : register(t0); SamplerState trilinear : register(s0);
// Entry point for this pixel shader float4 main(VertexToPixel input) : SV_TARGET { return particle.Sample(trilinear, input.uv) * input.color * input.color.a; }
Tired yet? We're almost done! Make sure in the main Game class, you have a Shader Resource View that holds the desired particle texture, as well as a depth stencil state and blend state for the particles. Depth writing should be turned off; we don't want additive particles to write to the depth buffer, and occlude some others. This can be done by changing the depth state's DepthWriteMask.
The blend mode can be used as you please: alpha clipping for solid particles (dirt), aplha for cloudy (smoke), or additive for glowing (fire). I chose the last option, resulting in this (keep in mind sky is temporarily turned off):
And we made fire! Now, keep in mind, this is the "quick" way to get a particle effect going. A more advance method would involve using Geometry Shaders. This allows quads to be made procedurally on the GPU, and changes logic so that only one vertex per particle is needed. But, that's another article for another day.