367 lines
12 KiB
Markdown
367 lines
12 KiB
Markdown
# 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:
|
||
|
||
```glsl
|
||
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
|
||
|
||
```cpp
|
||
#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
|
||
|
||
```cpp
|
||
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:
|
||
|
||
```glsl
|
||
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:
|
||
|
||
```glsl
|
||
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:
|
||
|
||
```cpp
|
||
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
|
||
|
||
```cpp
|
||
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
|
||
|
||
```cpp
|
||
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
|
||
|
||
```cpp
|
||
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
|
||
|
||
```cpp
|
||
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
|
||
|
||
```cpp
|
||
// 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
|