Learn Simulant
Everything you need to know to build games with Simulant
Particle Systems
Particle systems in Simulant let you create dynamic visual effects such as fire, smoke, explosions, magic spells, weather, and engine trails. They are driven by data-defined scripts (.kglp files) and rendered as camera-facing billboards.
Related documentation: Stage Nodes, Materials, Particle Script Format.
1. Overview
A particle system in Simulant consists of three conceptual layers:
- Particle Script (
.kglp) -- A JSON file that defines emitters, manipulators, particle quotas, dimensions, and the material used for rendering. - ParticleSystem (StageNode) -- A scene node that loads a particle script, simulates particles each frame, and submits billboards to the render queue.
- Particle (struct) -- An individual particle with position, velocity, color, dimensions, and a time-to-live (TTL).
The simulation loop each frame works like this:
- Dead particles (TTL <= 0) are swapped to the end of the active list.
- Manipulators run over all living particles, modifying their color, size, velocity, or position.
- Emitters spawn new particles up to the remaining quota.
- Living particles are rebuilt into vertex data as camera-facing billboards and submitted for rendering.
Stage (root)
|-- Actor "Player"
| |-- ParticleSystem "ExhaustTrail"
| `-- ParticleSystem "FootstepDust"
|-- Stage "Environment"
| |-- ParticleSystem "Campfire"
| `-- ParticleSystem "Rain"
`-- ParticleSystem "Explosion"
Key Characteristics
- Particles are rendered as billboarded quads (two triangles forming a quad that always faces the camera).
- Each particle system has a quota -- the maximum number of simultaneous live particles.
- Emitters can be point or box shaped.
- Particles can exist in world space (positions are absolute) or local space (positions are relative to the ParticleSystem node's transform).
- A particle system can have up to 8 emitters (
ParticleScript::MAX_EMITTER_COUNT).
2. Particle Scripts (.kglp / .spart)
Particle scripts are JSON files with the .kglp (or .spart) extension. They describe the complete configuration of a particle effect. The loader recognizes both extensions:
// From LoaderType::supports()
return filename.ext() == ".spart" || filename.ext() == ".kglp";
Root-Level Properties
| Property | Type | Description |
|---|---|---|
name |
string | Human-readable name for the effect |
quota |
integer | Maximum number of simultaneous live particles |
particle_width |
float | Width of each particle billboard in world units |
particle_height |
float | Height of each particle billboard in world units |
cull_each |
boolean | If true, each particle is individually culled (not yet implemented) |
material |
string | Path to a material file, or a built-in material name (e.g. "TEXTURED_PARTICLE", "TEXTURE_ONLY", "DIFFUSE_ONLY") |
material.<property> |
varies | Override individual material properties (see below) |
emitters |
array | List of emitter definitions |
manipulators |
array | List of manipulator (affector) definitions |
Material Properties in Scripts
You can set material properties directly from the particle script using the material. prefix. This is convenient because particle effects almost always need specific blending and texture settings.
{
"name": "fire",
"quota": 50,
"particle_width": 2.0,
"particle_height": 2.0,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "flare.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "add"
}
Supported material property types that can be set from JSON:
- bool --
trueorfalse - float -- numeric value
- int -- integer value (including blend function enums like
"add") - Texture -- path to a texture file (resolved relative to the particle script's directory)
Built-in Particle Effects
Simulant ships with a built-in fire effect you can reference directly:
// ParticleScript::BuiltIns::FIRE = "particles/fire.kglp"
auto fire_script = scene->assets->load_particle_script(
ParticleScript::BuiltIns::FIRE
);
3. Loading Particle Scripts
Particle scripts are loaded through the AssetManager:
// Load by path (relative to asset search paths)
ParticleScriptPtr script = scene->assets->load_particle_script("particles/fire.kglp");
// Load the built-in fire effect
ParticleScriptPtr fire = scene->assets->load_particle_script(
ParticleScript::BuiltIns::FIRE
);
The loader parses the JSON, creates Emitter and Manipulator objects, configures the material, and returns a shared pointer to the ParticleScript asset.
4. Creating a ParticleSystem Node
The ParticleSystem is a StageNode subclass. Create it as a child of any node in your scene graph:
// Create using a loaded script
auto fire = scene->assets->load_particle_script("particles/fire.kglp");
auto fire_system = create_child<ParticleSystem>(fire);
// The script is passed as a "script" parameter internally.
// You can also use Params explicitly:
Params params;
params.set<ParticleScriptRef>("script", fire);
auto fire_system2 = create_child<ParticleSystem>(params);
Because ParticleSystem is a StageNode, it inherits all standard node behavior:
// Position it in the world
fire_system->transform->set_position(Vec3(0, 0, 0));
// Attach it to a moving object (e.g., a spaceship exhaust)
fire_system->set_parent(spaceship_actor);
// Hide/show it
fire_system->set_visible(false);
// Give it a name for lookup
auto trail = create_child<ParticleSystem>(trail_script)->set_name_and_get("EngineTrail");
Update When Hidden
By default, hidden particle systems stop simulating. You can override this so particles continue to simulate even when the node is invisible:
fire_system->set_update_when_hidden(true);
// Particles will keep simulating even if set_visible(false) is called
Destroy on Completion
You can configure the system to automatically destroy itself when all emitters have finished and all particles have died:
explosion_system->set_destroy_on_completion(true);
// The node will call destroy() on itself when no particles remain
// and no emitters are active
5. Emitters
Emitters are responsible for spawning particles. Each particle script can have up to 8 emitters.
Emitter Types
| Type | Description |
|---|---|
point |
All particles originate from a single point |
box |
Particles spawn randomly within a 3D box defined by width, height, depth |
Emitter Properties
| Property | Type | Description |
|---|---|---|
type |
string | "point" or "box" (default: "point") |
direction |
string | Space-separated "X Y Z" direction vector (default: "0 1 0") |
velocity |
float | Fixed emission speed |
velocity_min |
float | Minimum emission speed (overrides velocity if both present) |
velocity_max |
float | Maximum emission speed |
ttl |
float | Fixed lifetime in seconds |
ttl_min |
float | Minimum particle lifetime |
ttl_max |
float | Maximum particle lifetime |
angle |
float | Cone angle in degrees from direction (0 = straight, 360 = sphere) |
color |
string | Single color as "R G B A" |
colors |
array | List of colors; one is chosen randomly per particle |
emission_rate |
float | Particles per second |
duration |
float | How long the emitter runs (0 = forever) |
repeat_delay |
float | Delay before the emitter restarts after its duration ends |
width |
float | Box emitter width (X dimension) |
height |
float | Box emitter height (Y dimension) |
depth |
float | Box emitter depth (Z dimension) |
Example: Point Emitter (Flame)
{
"emitters": [{
"type": "point",
"direction": "0 1 0",
"velocity_min": 2.0,
"velocity_max": 5.0,
"ttl_min": 0.5,
"ttl_max": 1.5,
"angle": 15,
"color": "1.0 0.5 0.0 1.0",
"emission_rate": 30
}]
}
Example: Box Emitter (Snow)
{
"emitters": [{
"type": "box",
"width": 100,
"height": 5,
"depth": 100,
"direction": "0 -1 0",
"velocity_min": 1.0,
"velocity_max": 3.0,
"ttl_min": 3.0,
"ttl_max": 5.0,
"angle": 10,
"color": "1 1 1 1",
"emission_rate": 100
}]
}
Example: Burst Emitter (Explosion)
An emitter with a short duration and no repeat_delay fires once then stops. Combine two opposing emitters for a bi-directional burst:
{
"emitters": [
{
"type": "point",
"direction": "0 1 0",
"velocity": 6,
"ttl_min": 0.1,
"ttl_max": 1.0,
"angle": 180,
"color": "1 1 1 1",
"emission_rate": 500,
"duration": 0.1
},
{
"type": "point",
"direction": "0 -1 0",
"velocity": 6,
"ttl_min": 0.1,
"ttl_max": 1.0,
"angle": 180,
"color": "1 1 1 1",
"emission_rate": 500,
"duration": 0.1
}
]
}
Emission Angle
The angle property controls particle spread:
angle: 0-- All particles travel exactly along the direction vector.angle: 15-- Particles deviate up to 15 degrees from the direction (narrow cone).angle: 90-- Particles spread in a wide hemisphere.angle: 180-- Particles spread in a full sphere around the emitter.angle: 360-- Special case: each particle gets a completely random direction.
Repeat Delay
When duration is set and repeat_delay is greater than zero, the emitter cycles between active and inactive periods:
{
"duration": 0.5,
"repeat_delay": 2.0
}
This creates a burst pattern: the emitter fires for 0.5 seconds, pauses for 2 seconds, then fires again.
Activating and Deactivating Emitters at Runtime
You can toggle all emitters on a particle system:
particle_system->set_emitters_active(false); // Stop spawning
particle_system->set_emitters_active(true); // Resume spawning
Check if any emitter is currently active:
if (particle_system->has_active_emitters()) {
// Emitters are still spawning particles
}
6. Particle Parameters
Each individual Particle struct holds the following data:
| Field | Type | Description |
|---|---|---|
position |
Vec3 |
Current position (relative to emitter origin) |
velocity |
Vec3 |
Current velocity vector |
dimensions |
Vec2 |
Current width and height of the billboard |
initial_dimensions |
Vec2 |
Original size at spawn (used by size manipulators) |
ttl |
float | Time remaining until the particle dies |
lifetime |
float | Total lifetime (set at spawn, does not change) |
color |
Color |
Current RGBA color |
emitter_index |
uint8_t | Index of the emitter that spawned this particle |
How Particles Move
Each frame during on_update():
particle.position += particle.velocity * dt;
particle.ttl -= dt;
if (particle.ttl <= 0) {
// Particle dies -- swapped to end of array
}
Manipulators can then modify position, velocity, color, and dimensions before the next frame.
Color Selection at Spawn
When an emitter has multiple colors, one is chosen randomly for each spawned particle:
{
"colors": [
"1.0 0.8 0.0 1.0",
"1.0 0.4 0.0 1.0",
"0.8 0.1 0.0 1.0"
]
}
Size at Spawn
Particle dimensions are initialized from the script's particle_width and particle_height, scaled by the ParticleSystem node's transform scale:
p.initial_dimensions = p.dimensions = Vec2(
script->particle_width() * scale.x,
script->particle_height() * scale.y
);
7. Manipulators
Manipulators (also called affectors in the file format) modify all living particles each frame. They run after particles move and before new particles are emitted.
Available manipulator types:
Size Manipulator
Changes particle dimensions over their lifetime. Supports linear and bell curves.
Linear rate -- shrinks or grows particles at a constant rate:
{
"type": "size",
"rate": -0.9
}
A negative rate shrinks particles; a positive rate grows them.
Bell curve -- particles grow to a peak size then shrink:
{
"type": "size",
"curve": "bell(0.5, 0.2)"
}
The parameters are (peak, deviation) where peak is the normalized lifetime at which the particle reaches maximum size.
Linear curve -- explicit linear specification:
{
"type": "size",
"curve": "linear(-0.5)"
}
Color Fader
Transitions particle colors over their lifetime through a sequence of colors:
{
"type": "color_fader",
"colors": ["1 1 1 1", "1 0.5 0 1", "0.5 0 0 1"],
"interpolate": true
}
With interpolate: true, colors blend smoothly between keyframes. With false, colors snap abruptly.
The color progression is based on each particle's normalized lifetime (0 = just born, 1 = about to die).
Alpha Fader
Transitions only the alpha channel over lifetime. Useful for fade-in/fade-out effects:
{
"type": "alpha_fader",
"alphas": [0.0, 1.0, 1.0, 0.0],
"interpolate": true
}
This makes particles fade in, stay visible, then fade out before dying.
Direction Manipulator
Applies a constant force to every particle's position each frame:
{
"type": "direction",
"force": "0 -9.8 0"
}
This is commonly used for gravity (downward force) or wind (horizontal force). The force is added to the particle's position scaled by dt, effectively acting as a velocity offset.
Direction Noise Random
Applies a force plus random noise to each particle's position. Creates chaotic, organic movement:
{
"type": "direction_noise_random",
"force": "0 -1 0",
"noise_amount": "0.5 0.5 0.5"
}
force-- base directional force applied uniformlynoise_amount-- per-axis random noise multiplier
This is ideal for effects like swirling smoke, turbulent flames, or scattered debris.
Adding Manipulators Programmatically
You can add manipulators to a script at runtime:
auto script = scene->assets->load_particle_script("particles/base.kglp");
// Add gravity
script->add_manipulator(std::make_shared<DirectionManipulator>(
script.get(), Vec3(0, -9.8f, 0)
));
// Add a color fader
std::vector<Color> fade_colors = {
Color(1, 1, 1, 1),
Color(1, 0.5f, 0, 1),
Color(0.3f, 0, 0, 1)
};
script->add_manipulator(std::make_shared<ColorFader>(
script.get(), fade_colors, true
));
Clear all manipulators:
script->clear_manipulators();
8. World Space vs Local Space
Particle systems can operate in two coordinate spaces, controlled by set_space():
World Space (Default)
particle_system->set_space(PARTICLE_SYSTEM_SPACE_WORLD);
In world space:
- Particle positions are absolute in world coordinates.
- When the ParticleSystem node moves, existing particles stay where they are.
- New particles spawn at the emitter's current world position.
- Ideal for effects like rain, snow, or area effects that should not move with their parent.
// World space: move the system up
system->transform->set_position(Vec3(0, 100, 0));
// Existing particles stay at their original world positions.
// New particles spawn 100 units higher.
Local Space
particle_system->set_space(PARTICLE_SYSTEM_SPACE_LOCAL);
In local space:
- Particle positions are relative to the ParticleSystem node's transform.
- When the node moves, all particles move with it.
- Ideal for effects attached to moving objects (engine trails, dust behind a character).
// Local space: move the system up
system->set_space(PARTICLE_SYSTEM_SPACE_LOCAL);
system->transform->set_position(Vec3(0, 100, 0));
// All particles move up with the system.
// Their relative positions to the emitter stay the same.
Attaching to Moving Objects
For effects that follow a moving object (like a spaceship exhaust), use local space:
auto trail_script = scene->assets->load_particle_script("particles/trail.kglp");
auto trail = spaceship->create_child<ParticleSystem>(trail_script);
trail->set_space(PARTICLE_SYSTEM_SPACE_LOCAL);
// Now when the spaceship moves and rotates, the trail follows correctly
// and the emission velocity stays aligned with the spaceship's orientation.
9. GPU Particle Rendering
Simulant's particle system uses a CPU-simulated, GPU-rendered approach:
How It Works
- CPU simulation -- Particle positions, velocities, and lifetimes are updated on the CPU each frame.
- Billboard reconstruction -- Each frame, vertex data is rebuilt. Each particle becomes a quad (4 vertices) that faces the camera using the camera's up and right vectors.
- GPU rendering -- The quads are submitted to the render queue as a
TRIANGLE_STRIParrangement with the script's material.
Vertex Layout
Each particle billboard uses these vertex attributes:
| Attribute | Format | Description |
|---|---|---|
| Position | 3F | World-space corner position of the quad |
| TexCoord0 | 2F | UV coordinates (0,0 to 1,1) |
| Color | 4F (or 4UB_BGRA on Dreamcast) | Per-particle RGBA color |
Rendering Pipeline
ParticleSystem::do_generate_renderables()
|
+-> rebuild_vertex_data(camera_up, camera_right)
| For each particle:
| - Calculate world position (respecting space mode)
| - Build 4 corners of billboard facing camera
| - Write position, color, UV data
|
+-> Create Renderable with TRIANGLE_STRIP arrangement
+-> Submit to render queue with the script's material
Material Considerations
Particle effects almost always need:
- Additive or alpha blending -- so particles composite nicely over the scene.
- Depth write disabled -- so particles do not occlude each other incorrectly.
- A texture -- a soft circular sprite, spark, or flare texture.
{
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "flare.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "add"
}
Common blend functions for particles:
"add"-- Additive blending (bright, glowing effects like fire and magic)."alpha"-- Alpha blending (smoke, fog, transparent effects).
10. Creating Particles Programmatically
While the primary workflow uses .kglp scripts, you can also construct particle systems entirely in code by manipulating the ParticleScript object directly.
Creating a Script from Scratch
// Create a new material for the particles
auto material = stage->assets->new_material();
material->set_diffuse_map(flare_texture);
material->set_blend_func(BLEND_FUNC_ADD);
material->set_depth_write_enabled(false);
// Create the particle script
auto script = std::make_shared<ParticleScript>(asset_id, asset_manager);
script->set_name("CustomEffect");
script->set_quota(200);
script->set_particle_width(1.5f);
script->set_particle_height(1.5f);
script->set_material(material);
// Create an emitter
Emitter emitter;
emitter.type = PARTICLE_EMITTER_POINT;
emitter.direction = Vec3(0, 1, 0);
emitter.velocity_range = {3.0f, 6.0f};
emitter.ttl_range = {0.5f, 2.0f};
emitter.angle = Degrees(20);
emitter.colors = {Color::white()};
emitter.emission_rate = 50;
script->push_emitter(emitter);
// Add a size manipulator
auto size_manip = std::make_shared<SizeManipulator>(script.get());
size_manip->set_linear_curve(-0.5f);
script->add_manipulator(size_manip);
// Create the ParticleSystem node
auto system = create_child<ParticleSystem>(script);
Modifying an Existing Script
auto script = scene->assets->load_particle_script("particles/fire.kglp");
// Change the emission rate
auto* emitter = script->mutable_emitter(0);
emitter->emission_rate = 100;
// Change the color palette
emitter->colors = {
Color(1, 0.2f, 0, 1),
Color(1, 0.6f, 0, 1),
Color(1, 1, 0.5f, 1)
};
// Clear and replace manipulators
script->clear_manipulators();
script->add_manipulator(std::make_shared<AlphaFader>(
script.get(), std::vector<float>{0, 1, 0.8f, 0}, true
));
Querying Particle State at Runtime
// How many particles are currently alive?
std::size_t count = particle_system->particle_count();
// Access individual particles
for (std::size_t i = 0; i < particle_system->particle_count(); ++i) {
const Particle& p = particle_system->particle(i);
// p.position, p.velocity, p.color, p.ttl, etc.
}
11. Performance Considerations
Particle systems can become expensive quickly. Here is how to keep them performant:
Quota Management
The quota is the single most important performance setting. It caps the maximum number of simultaneous particles:
{
"quota": 50
}
- Low quota (10-100): Small effects like muzzle flashes, sparks.
- Medium quota (100-500): Campfires, fountains, dust clouds.
- High quota (500-2000): Explosions, heavy rain, dense smoke.
Each particle adds 4 vertices and a vertex range to the vertex buffer each frame.
Emission Rate vs Quota
If emission_rate is too high relative to quota, particles will die quickly and the effect looks sparse. If it is too low, the effect looks thin. Balance them:
quota / ttl_average >= emission_rate * duration
For a continuously running emitter:
quota >= emission_rate * ttl_average
Vertex Rebuild
Vertex data is rebuilt every frame for every particle system. This means:
- Each particle = 4 vertices written to the vertex buffer.
- 200 particles = 800 vertices per frame per system.
- Multiple systems multiply this cost.
Keep quotas as low as possible for the desired effect.
Update When Hidden
By default, hidden particle systems skip all simulation. Only enable update_when_hidden if you truly need particles to keep simulating while offscreen:
particle_system->set_update_when_hidden(false); // Default, most efficient
Destroy on Completion
For one-shot effects (explosions, impacts), enable destroy_on_completion so the system cleans itself up:
particle_system->set_destroy_on_completion(true);
Material Sharing
All particles in a system share a single material. This is efficient because the renderer can batch all particles together into one draw call.
Per-Particle Culling
The cull_each property would enable individual particle frustum culling, but this is not yet implemented. Currently, the entire system is culled as one unit based on its AABB.
12. Common Effects
Fire
A narrow upward cone with shrinking particles and warm colors:
{
"name": "fire",
"quota": 50,
"particle_width": 2.0,
"particle_height": 2.0,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "flare.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "add",
"emitters": [{
"type": "point",
"angle": 15,
"emission_rate": 25,
"ttl_min": 1.0,
"ttl_max": 1.0,
"direction": "0 1 0",
"velocity_min": 2.0,
"velocity_max": 5.0,
"color": "0.3 0.05 0 1.0"
}],
"manipulators": [{
"type": "size",
"rate": -0.9
}]
}
Smoke
Wide spread, slow rising, with alpha fade-out:
{
"name": "smoke",
"quota": 80,
"particle_width": 3.0,
"particle_height": 3.0,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "smoke.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "alpha",
"emitters": [{
"type": "point",
"angle": 45,
"emission_rate": 10,
"ttl_min": 2.0,
"ttl_max": 4.0,
"direction": "0 1 0",
"velocity_min": 0.5,
"velocity_max": 1.5,
"color": "0.4 0.4 0.4 0.5"
}],
"manipulators": [
{
"type": "size",
"rate": 0.5
},
{
"type": "alpha_fader",
"alphas": [0.0, 0.6, 0.4, 0.0],
"interpolate": true
}
]
}
Explosion
High-velocity burst in all directions, short-lived:
{
"name": "explosion",
"quota": 200,
"particle_width": 1.5,
"particle_height": 1.5,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "flare.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "add",
"emitters": [
{
"type": "point",
"direction": "0 1 0",
"velocity_min": 5.0,
"velocity_max": 15.0,
"ttl_min": 0.2,
"ttl_max": 0.8,
"angle": 360,
"color": "1 1 0.8 1",
"emission_rate": 1000,
"duration": 0.15
}
],
"manipulators": [
{
"type": "size",
"curve": "bell(0.3, 0.15)"
},
{
"type": "color_fader",
"colors": ["1 1 1 1", "1 0.5 0 1", "0.5 0 0 1", "0 0 0 0"],
"interpolate": true
}
]
}
Magic / Spell Effect
Swirling particles with noise and color cycling:
{
"name": "magic_orb",
"quota": 100,
"particle_width": 0.8,
"particle_height": 0.8,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "spark.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "add",
"emitters": [{
"type": "point",
"direction": "0 1 0",
"velocity_min": 1.0,
"velocity_max": 3.0,
"ttl_min": 1.0,
"ttl_max": 2.0,
"angle": 90,
"colors": ["0 0.5 1 1", "0.5 0 1 1", "1 0 0.5 1"],
"emission_rate": 40
}],
"manipulators": [
{
"type": "direction_noise_random",
"force": "0 0.5 0",
"noise_amount": "1.0 1.0 1.0"
},
{
"type": "color_fader",
"colors": ["1 1 1 1", "0 0.3 1 0.5"],
"interpolate": true
}
]
}
Weather: Rain
A large box emitter shooting downward at high speed:
{
"name": "rain",
"quota": 500,
"particle_width": 0.3,
"particle_height": 1.5,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "rain_drop.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "alpha",
"emitters": [{
"type": "box",
"width": 200,
"height": 5,
"depth": 200,
"direction": "0 -1 0",
"velocity_min": 15.0,
"velocity_max": 25.0,
"ttl_min": 0.5,
"ttl_max": 1.0,
"angle": 5,
"color": "0.7 0.8 1 0.6",
"emission_rate": 500
}],
"manipulators": [{
"type": "direction",
"force": "0.5 0 0"
}]
}
Weather: Snow
Wide box, slow falling, with random drift:
{
"name": "snow",
"quota": 300,
"particle_width": 0.5,
"particle_height": 0.5,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "snowflake.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "alpha",
"emitters": [{
"type": "box",
"width": 150,
"height": 3,
"depth": 150,
"direction": "0 -1 0",
"velocity_min": 1.0,
"velocity_max": 3.0,
"ttl_min": 3.0,
"ttl_max": 6.0,
"angle": 10,
"color": "1 1 1 0.9",
"emission_rate": 100
}],
"manipulators": [{
"type": "direction_noise_random",
"force": "0 -0.5 0",
"noise_amount": "0.3 0.1 0.3"
}]
}
13. Best Practices
Keep quotas low. The quota is a hard cap, but every slot costs CPU (simulation) and GPU (vertex buffer writes). Start with the smallest quota that still looks good.
Use additive blending for glow effects. Fire, magic, and explosions look best with
"add"blending. Smoke and fog need"alpha"blending.Disable depth write on particles. Set
material.s_depth_write_enabled: falseso particles do not create harsh depth boundaries between overlapping particles.Prefer fewer, larger particles over many tiny ones. A fire effect with 50 well-sized particles often looks better than 200 microscopic ones.
Use local space for attached effects. Engine trails, spell effects on characters, and dust behind vehicles should use
PARTICLE_SYSTEM_SPACE_LOCALso they follow their parent naturally.Use world space for environmental effects. Rain, snow, area fog, and stationary campfires should stay in world space.
Enable
destroy_on_completionfor one-shot effects. Explosions, impacts, and death effects should self-clean to avoid leaking nodes.Disable
update_when_hiddenby default. There is no reason to simulate particles the player cannot see. Only enable it for effects that must persist through visibility changes.Use color faders instead of many colors. Rather than listing 10 colors on the emitter, use a color fader manipulator with 3-4 keyframes and
interpolate: truefor smooth transitions.Preload particle scripts. Loading a
.kglpfile involves JSON parsing. Load scripts during level loading, not during gameplay.Scale particle size through the node transform. The particle system respects its node's
scale_factor, so you can uniformly resize effects by scaling the parent node rather than editing the script.Combine effects with multiple emitters. A campfire can have one emitter for flames (upward, narrow cone) and another for sparks (wider angle, longer lifetime) in the same script.
14. Complete Particle Script Example
Here is a full, production-quality particle script for a campfire effect with flames, embers, and smoke all in one file:
{
"name": "Campfire",
"quota": 200,
"particle_width": 2.0,
"particle_height": 2.0,
"cull_each": false,
"material": "TEXTURE_ONLY",
"material.s_base_color_map": "flare.tga",
"material.s_depth_write_enabled": false,
"material.s_blend_func": "add",
"emitters": [
{
"type": "point",
"direction": "0 1 0",
"velocity_min": 2.0,
"velocity_max": 4.0,
"ttl_min": 0.8,
"ttl_max": 1.5,
"angle": 15,
"color": "0.8 0.3 0.0 1.0",
"emission_rate": 20
},
{
"type": "point",
"direction": "0 1 0",
"velocity_min": 3.0,
"velocity_max": 7.0,
"ttl_min": 1.0,
"ttl_max": 2.0,
"angle": 30,
"colors": [
"1 0.9 0.3 1",
"1 0.5 0 1",
"0.6 0.1 0 1"
],
"emission_rate": 8
},
{
"type": "point",
"direction": "0 1 0",
"velocity_min": 0.5,
"velocity_max": 1.5,
"ttl_min": 2.0,
"ttl_max": 4.0,
"angle": 40,
"color": "0.3 0.3 0.3 0.4",
"emission_rate": 5
}
],
"manipulators": [
{
"type": "size",
"rate": -0.8
},
{
"type": "color_fader",
"colors": ["1 1 1 1", "1 0.4 0 1", "0.3 0 0 0.5", "0 0 0 0"],
"interpolate": true
},
{
"type": "direction_noise_random",
"force": "0 0.5 0",
"noise_amount": "0.2 0.3 0.2"
}
]
}
This script defines:
- Emitter 0 -- Main flame column. Narrow cone, moderate speed, warm orange color.
- Emitter 1 -- Sparks. Wider cone, faster, multiple warm colors for variety.
- Emitter 2 -- Smoke. Slow, wide spread, grey with partial transparency.
- Size manipulator -- Shrinks all particles over time at a rate of -0.8 per second.
- Color fader -- Transitions particles from white-hot through orange to dark fade-out.
- Direction noise random -- Adds gentle upward drift with slight random turbulence for organic movement.
Using It In Code
// Load the script (ideally during level loading)
auto campfire_script = scene->assets->load_particle_script("effects/campfire.kglp");
// Create the particle system at the fire pit location
auto campfire = create_child<ParticleSystem>(campfire_script);
campfire->transform->set_position(Vec3(10, 0, 5));
// Optionally scale the entire effect
campfire->transform->set_scale(Vec3(1.5f, 1.5f, 1.5f));
Summary
| Concept | Key Methods / Properties |
|---|---|
| Loading scripts | scene->assets->load_particle_script(path) |
| Creating system | create_child<ParticleSystem>(script) |
| World vs local space | set_space(PARTICLE_SYSTEM_SPACE_WORLD) / set_space(PARTICLE_SYSTEM_SPACE_LOCAL) |
| Toggle emitters | set_emitters_active(true/false) |
| Check active | has_active_emitters() |
| Update when hidden | set_update_when_hidden(true/false) |
| Auto-destroy | set_destroy_on_completion(true/false) |
| Particle count | particle_count() |
| Access particle | particle(index) |
| Script reference | script() returns ParticleScript* |
| Quota | script()->set_quota(n) |
| Add manipulator | script()->add_manipulator(...) |
| Clear manipulators | script()->clear_manipulators() |
| Emitter access | script()->emitter(i) / script()->mutable_emitter(i) |
| Built-in effect | ParticleScript::BuiltIns::FIRE |