12 KiB
Particle System
The particle system provides GPU-accelerated particle simulation and rendering with support for flipbook animation, soft particles, and alpha/additive blending.
Architecture Overview
The system is implemented across multiple components:
- ParticlePass (
src/render/passes/particles.h/.cpp) — Render pass managing particle pools, compute pipelines, and graphics pipelines - GameAPI (
src/core/game_api.h) — High-level API for creating and controlling particle systems - Shaders — Compute and graphics shaders for simulation and rendering
shaders/particles_update.comp— Per-particle physics simulationshaders/particles_sort_blocks.comp— Block-level depth sorting for alpha blendingshaders/particles_build_indices.comp— Build draw indices from sorted blocksshaders/particles.vert/.frag— Vertex/fragment shaders for rendering
Key Features
- Global particle pool: Up to 128K particles (
k_max_particles = 128 * 1024) shared across all systems - GPU simulation: Fully GPU-driven via compute shaders (no CPU readback)
- Flipbook animation: Supports sprite sheet animation with configurable atlas layout and FPS
- Soft particles: Depth-aware fading near opaque geometry
- Blend modes: Additive (fire, sparks) and Alpha (smoke, debris) with automatic depth sorting
- Noise distortion: Optional UV distortion for organic motion
- Floating-origin stable: Automatically adjusts particle positions when world origin shifts
Particle Data Layout
Each particle is represented as 64 bytes (4 × vec4) on the GPU:
struct Particle
{
vec4 pos_age; // xyz = local position, w = remaining life (seconds)
vec4 vel_life; // xyz = local velocity, w = total lifetime (seconds)
vec4 color; // rgba
vec4 misc; // x=size, y=random seed, z/w=unused
};
Creating Particle Systems
Via GameAPI
#include "core/game_api.h"
GameAPI::Engine api(&engine);
// Create a particle system with 1024 particles
uint32_t systemId = api.create_particle_system(1024);
// Configure parameters
GameAPI::ParticleSystem sys = api.get_particle_system(systemId);
sys.enabled = true;
sys.reset = true; // Respawn all particles immediately
sys.blendMode = GameAPI::ParticleBlendMode::Additive;
// Emitter settings
sys.params.emitterPosLocal = glm::vec3(0.0f, 0.0f, 0.0f);
sys.params.spawnRadius = 0.1f;
sys.params.emitterDirLocal = glm::vec3(0.0f, 1.0f, 0.0f); // Upward
sys.params.coneAngleDegrees = 20.0f;
// Particle properties
sys.params.minSpeed = 2.0f;
sys.params.maxSpeed = 8.0f;
sys.params.minLife = 0.5f;
sys.params.maxLife = 1.5f;
sys.params.minSize = 0.05f;
sys.params.maxSize = 0.15f;
// Physics
sys.params.drag = 1.0f;
sys.params.gravity = 0.0f; // Positive pulls down -Y in local space
// Appearance
sys.params.color = glm::vec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange
// Flipbook animation (16×4 atlas, 30 FPS)
sys.flipbookTexture = "vfx/flame.ktx2";
sys.params.flipbookCols = 16;
sys.params.flipbookRows = 4;
sys.params.flipbookFps = 30.0f;
sys.params.flipbookIntensity = 1.0f;
// Noise distortion
sys.noiseTexture = "vfx/simplex.ktx2";
sys.params.noiseScale = 6.0f;
sys.params.noiseStrength = 0.05f;
sys.params.noiseScroll = glm::vec2(0.0f, 0.0f);
// Soft particles
sys.params.softDepthDistance = 0.15f; // Fade particles within 0.15 units of geometry
api.set_particle_system(systemId, sys);
Direct API
ParticlePass* particlePass = /* obtain from RenderPassManager */;
// Create system
uint32_t systemId = particlePass->create_system(1024);
// Access and modify
auto& systems = particlePass->systems();
for (auto& sys : systems)
{
if (sys.id == systemId)
{
sys.enabled = true;
sys.params.color = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f);
break;
}
}
Simulation Details
Update Pipeline (Compute)
The particles_update.comp shader runs once per frame for each active system:
- Floating-origin correction:
p.pos_age.xyz -= origin_deltakeeps particles stable when the world origin shifts - Respawn check: Dead particles (
age <= 0) or reset flag respawns particles with randomized properties - Physics integration:
- Apply gravity:
vel += vec3(0, -gravity, 0) * dt - Apply drag:
vel *= exp(-drag * dt) - Integrate position:
pos += vel * dt
- Apply gravity:
- Age decrement:
age -= dt
Random number generation uses a per-particle seed (misc.y) combined with system time to ensure deterministic but varied behavior.
Cone Emission
When coneAngleDegrees > 0, particles are emitted within a cone:
- Cone axis is
emitterDirLocal - Particles are randomly distributed within the cone solid angle
coneAngleDegrees = 0emits in a single directionconeAngleDegrees < 0emits in all directions (sphere)
Spawn Radius
Particles spawn at emitterPosLocal ± random_in_sphere(spawnRadius).
Rendering Pipeline
Blend Modes
Additive (BlendMode::Additive):
- Source:
VK_BLEND_FACTOR_SRC_ALPHA - Dest:
VK_BLEND_FACTOR_ONE - No depth sorting required
- Ideal for fire, sparks, energy effects
Alpha (BlendMode::Alpha):
- Source:
VK_BLEND_FACTOR_SRC_ALPHA - Dest:
VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA - Block-level depth sorting (256 particles per block)
- Better for smoke, debris, leaves
Alpha Sorting
For alpha-blended systems:
- Block sorting (
particles_sort_blocks.comp): Divides particles into 256-particle blocks, computes average depth per block, sorts blocks back-to-front - Index building (
particles_build_indices.comp): Writes sorted particle indices into_draw_indicesbuffer - Rendering: Vertex shader reads particles via indirection:
Particle p = pool.particles[indices[gl_InstanceIndex]]
This provides coarse-grained sorting with minimal compute overhead (512 blocks max).
Soft Particles
Fragment shader samples G-buffer depth (gbufferPosition.w) and fades particle alpha near intersections:
float sceneDepth = texture(posTex, screenUV).w;
float particleDepth = /* compute from world pos */;
float depthDiff = sceneDepth - particleDepth;
float softFactor = smoothstep(0.0, softDepthDistance, depthDiff);
outColor.a *= softFactor;
Set softDepthDistance = 0 to disable.
Flipbook Animation
The fragment shader samples an animated sprite sheet:
- Compute frame index:
frameIndex = int(time_sec * flipbookFps) % (flipbookCols * flipbookRows) - Map frame to UV rect:
(col, row) = (frameIndex % cols, frameIndex / cols) - Sample texture:
color = texture(flipbookTex, baseUV * cellSize + cellOffset)
Noise Distortion
Optional UV distortion using a noise texture:
vec2 noiseUV = uv * noiseScale + noiseScroll * time_sec;
vec2 distortion = (texture(noiseTex, noiseUV).rg - 0.5) * 2.0 * noiseStrength;
vec2 finalUV = uv + distortion;
Memory Management
Particle Pool Allocation
The global pool is pre-allocated (128K particles × 64 bytes = 8 MB) and subdivided into ranges:
create_system(count): Allocates a contiguous range from_free_rangesdestroy_system(id): Returns range to free list and merges adjacent rangesresize_system(id, new_count): Reallocates (may move particles)
Allocation uses a simple first-fit strategy with automatic coalescing.
Texture Caching
VFX textures (flipbook/noise) are loaded on-demand and cached in _vfx_textures:
preload_vfx_texture(assetName): Explicitly load texture (safe to call from UI)preload_needed_textures(): Load all textures referenced by active systems (call before ResourceUploads pass)- Fallback 1×1 textures (
_fallback_flipbook,_fallback_noise) are used when load fails
Render Graph Integration
The particle pass registers into the render graph after lighting and SSR:
void ParticlePass::register_graph(RenderGraph* graph,
RGImageHandle hdrTarget,
RGImageHandle depthHandle,
RGImageHandle gbufferPosition)
{
graph->add_pass("Particles", RGPassType::Graphics,
[=](RGPassBuilder& b, EngineContext*) {
b.write_color(hdrTarget); // Composite onto HDR
b.read_depth(depthHandle); // Depth test
b.sample_image(gbufferPosition); // Soft particles
},
[this](VkCommandBuffer cmd, const RGPassResources& res, EngineContext* ctx) {
// 1. Run compute update for each system
// 2. For alpha systems: sort blocks + build indices
// 3. Render all systems (additive first, then alpha)
}
);
}
Performance Considerations
- Particle count: 128K global limit; budget carefully across systems
- Overdraw: Additive blending is fill-rate intensive; keep particle size and count moderate
- Sorting cost: Alpha systems incur compute overhead for block sorting (~512 blocks × 256 particles)
- Texture bandwidth: Flipbook textures should be compressed (KTX2) and atlased (16×4 common)
- Soft particles: G-buffer read adds bandwidth; disable if depth fading isn't visible
Common Presets
Fire
sys.blendMode = Additive;
sys.params.color = glm::vec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange
sys.params.gravity = 0.0f;
sys.params.minSpeed = 1.0f; sys.params.maxSpeed = 3.0f;
sys.params.drag = 0.5f;
sys.flipbookTexture = "vfx/flame.ktx2";
Smoke
sys.blendMode = Alpha;
sys.params.color = glm::vec4(0.3f, 0.3f, 0.3f, 0.5f); // Gray, semi-transparent
sys.params.gravity = -2.0f; // Rise upward (negative gravity)
sys.params.drag = 1.5f; // Slow down quickly
sys.params.minSpeed = 0.5f; sys.params.maxSpeed = 2.0f;
sys.noiseTexture = "vfx/simplex.ktx2";
sys.params.noiseStrength = 0.2f; // Strong distortion
Sparks
sys.blendMode = Additive;
sys.params.color = glm::vec4(1.0f, 0.8f, 0.2f, 1.0f); // Bright yellow
sys.params.gravity = 9.8f; // Fall downward
sys.params.drag = 0.1f;
sys.params.minSpeed = 5.0f; sys.params.maxSpeed = 15.0f;
sys.params.minSize = 0.01f; sys.params.maxSize = 0.03f; // Small
sys.flipbookTexture = ""; // Disable flipbook (procedural sprite)
Troubleshooting
Particles not visible:
- Ensure
enabled = trueandparticleCount > 0 - Check
color.a > 0(fully transparent particles are invisible) - Verify system is allocated:
api.get_particle_systems()should list the ID
Particles flickering or popping:
- Set
reset = falseafter first frame (reset respawns all particles immediately) - Increase
minLife/maxLifeto prevent frequent respawning
Performance issues:
- Reduce total particle count (check
allocated_particles()) - Use additive blend for most systems (cheaper than alpha)
- Reduce flipbook texture resolution or mip levels
Textures missing:
- Call
preload_vfx_texture("vfx/texture.ktx2")before first frame - Or call
preload_needed_textures()in engine setup - Check AssetManager can resolve path:
assetPath("vfx/texture.ktx2")
API Reference
ParticlePass
class ParticlePass : public IRenderPass
{
// System management
uint32_t create_system(uint32_t count);
bool destroy_system(uint32_t id);
bool resize_system(uint32_t id, uint32_t new_count);
std::vector<System>& systems();
const std::vector<System>& systems() const;
// Pool stats
uint32_t allocated_particles() const;
uint32_t free_particles() const;
// Texture preloading
void preload_vfx_texture(const std::string& assetName);
void preload_needed_textures();
};
GameAPI::Engine Particle Methods
// System creation/destruction
uint32_t create_particle_system(uint32_t particle_count);
bool destroy_particle_system(uint32_t system_id);
// System control
void set_particle_system(uint32_t system_id, const ParticleSystem& sys);
ParticleSystem get_particle_system(uint32_t system_id) const;
std::vector<ParticleSystem> get_particle_systems() const;
// Pool stats
uint32_t get_particle_pool_allocated() const;
uint32_t get_particle_pool_free() const;
// Texture preloading
void preload_particle_texture(const std::string& asset_path);
See Also
docs/RenderGraph.md— Render graph integration detailsdocs/RenderPasses.md— Pass execution and pipeline managementdocs/GameAPI.md— High-level game APIdocs/TextureLoading.md— Asset loading and streaming