Files
QuaternionEngine/docs/ParticleSystem.md

12 KiB
Raw Permalink Blame History

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 simulation
    • shaders/particles_sort_blocks.comp — Block-level depth sorting for alpha blending
    • shaders/particles_build_indices.comp — Build draw indices from sorted blocks
    • shaders/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:

  1. Floating-origin correction: p.pos_age.xyz -= origin_delta keeps particles stable when the world origin shifts
  2. Respawn check: Dead particles (age <= 0) or reset flag respawns particles with randomized properties
  3. Physics integration:
    • Apply gravity: vel += vec3(0, -gravity, 0) * dt
    • Apply drag: vel *= exp(-drag * dt)
    • Integrate position: pos += vel * dt
  4. 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 = 0 emits in a single direction
  • coneAngleDegrees < 0 emits 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:

  1. Block sorting (particles_sort_blocks.comp): Divides particles into 256-particle blocks, computes average depth per block, sorts blocks back-to-front
  2. Index building (particles_build_indices.comp): Writes sorted particle indices into _draw_indices buffer
  3. 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:

  1. Compute frame index: frameIndex = int(time_sec * flipbookFps) % (flipbookCols * flipbookRows)
  2. Map frame to UV rect: (col, row) = (frameIndex % cols, frameIndex / cols)
  3. 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_ranges
  • destroy_system(id): Returns range to free list and merges adjacent ranges
  • resize_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 = true and particleCount > 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 = false after first frame (reset respawns all particles immediately)
  • Increase minLife/maxLife to 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 details
  • docs/RenderPasses.md — Pass execution and pipeline management
  • docs/GameAPI.md — High-level game API
  • docs/TextureLoading.md — Asset loading and streaming