Particles and Trails¶
Snowpulse includes three related visual-effects components: the configurable
ParticleEffects emitter, the node-following Trail, and the Effekseer-backed
EffekseerRenderer.
ParticleEffects¶
ParticleEffects is a configurable particle emitter. It can be configured in code or loaded from a JSON file exported by the particle editor in tools/particleeditor.
The public configuration is split into modules. The main groups are:
| Group | Controls |
|---|---|
main |
Duration, looping, lifetime, initial speed/size/rotation/color, gravity, simulation space/time, seed, culling, ring-buffer behavior, and particle limit |
emission, shape |
Time/distance emission, bursts, and circle/cone/box/line/texture emitters |
| Motion | Velocity, force, noise, speed limiting, inherited velocity, external forces, and lifetime by emitter speed |
| Lifetime appearance | Color, size, and rotation over lifetime or speed |
| Interaction | Physics collisions and trigger volumes |
| Composition | Sub-emitters, texture-sheet animation, trails, and custom data |
renderer |
Sprite, billboard mode/alignment, world/screen render space, flip/pivot, particle-size clamps, sorting, texture filtering, and blending |
The curve-backed properties use MinMaxCurve; color properties use
MinMaxGradient. Both support fixed values, randomized ranges, and authored
curves/gradients.
Angle spread is clamped to 0-360 degrees, so full-circle emitters are supported.
Configure an Emitter in Code¶
Every module is public, so an emitter can be configured without an editor or JSON asset. This example creates a finite cone burst with randomized lifetime, speed, and size:
using Particles = snowpulse::ParticleEffects;
auto* emitter = node->addComponent<Particles>();
emitter->main.looping = false;
emitter->main.duration = 1.25f;
emitter->main.maxParticles = 128;
emitter->main.playOnAwake = false;
emitter->main.startLifetime = Particles::MinMaxCurve::range(0.6f, 1.1f);
emitter->main.startSpeed = Particles::MinMaxCurve::range(80.0f, 140.0f);
emitter->main.startSizeX = Particles::MinMaxCurve::range(6.0f, 12.0f);
emitter->main.startSizeY = Particles::MinMaxCurve::range(6.0f, 12.0f);
emitter->emission.rateOverTime = Particles::MinMaxCurve::constantValue(0.0f);
Particles::EmissionModule::Burst burst;
burst.time = 0.0f;
burst.count = Particles::MinMaxCurve::range(24.0f, 32.0f);
burst.cycles = 1;
burst.probability = 1.0f;
emitter->emission.bursts = { burst };
emitter->shape.shape = Particles::ShapeModule::ShapeType::Cone;
emitter->shape.radius = 8.0f;
emitter->shape.angle = 30.0f;
emitter->renderer.blendMode = snowpulse::BlendMode::Additive;
emitter->renderer.textureFiltering = snowpulse::TextureFiltering::Linear;
emitter->sprite(assetManager()->loadSprite("effects/particle.png"), false);
emitter->play(true);
Passing false to sprite() keeps module sizes in logical units rather than
scaling them by the source image size. Use effects("effects/emitter.json")
instead when loading an exported particle configuration.
Curves and Gradients¶
Curve and Gradient keys use normalized time from 0 to 1. The following
configuration grows particles quickly, then shrinks and fades them:
using Particles = snowpulse::ParticleEffects;
Particles::Curve sizeCurve;
sizeCurve.keys = {
{ 0.0f, 0.0f },
{ 0.15f, 1.0f },
{ 1.0f, 0.0f },
};
emitter->sizeOverLifetime.enabled = true;
emitter->sizeOverLifetime.size = Particles::MinMaxCurve::fromCurve(sizeCurve);
Particles::Gradient colorGradient;
colorGradient.keys = {
{ 0.0f, { 0.3f, 0.8f, 1.0f, 1.0f } },
{ 0.65f, { 0.7f, 0.3f, 1.0f, 0.8f } },
{ 1.0f, { 0.1f, 0.1f, 0.2f, 0.0f } },
};
emitter->colorOverLifetime.enabled = true;
emitter->colorOverLifetime.color =
Particles::MinMaxGradient::fromGradient(colorGradient);
World and Screen Render Space¶
renderer.renderSpace controls whether particle positions pass through the
scene camera:
RendererModule::RenderSpace::Worldis the default and preserves normal scene-camera movement, zoom, and shake.RendererModule::RenderSpace::Screentreats positions as logical screen coordinates with a bottom-left origin. Screen particles remain fixed during camera movement, zoom, and shake, making them suitable for HUD celebrations and full-screen feedback.
Screen render space still uses the normal render queue. To place an effect
relative to UI content, derive its sort order from UIRoot::baseSortOrder().
For example, a value below the UI base renders over ordinary UI layers:
auto* particles = node->addComponent<snowpulse::ParticleEffects>();
particles->renderer.renderSpace =
snowpulse::ParticleEffects::RendererModule::RenderSpace::Screen;
particles->sortOrder = scene->rootUI()->baseSortOrder() - 20;
Particle JSON version 2 represents this as modules.renderer.renderSpace with a
value of "world" or "screen". Omitting the field keeps the backward-compatible
world-space default. The particle editor exposes the same choice as Render
Space in the Renderer module.
Runtime Control and Events¶
Use play(restart), pause(), stop(clearParticles), clear(), reset(), or
emit(count) to control an emitter. aliveCount(), isPlaying(), and
isPaused() expose its current state. Seed control is available through
setSeed() and setAutoSeed().
emitter->setSeed(2026u); // Repeatable simulation; also disables automatic seed selection.
emitter->play(true); // Restart the timeline and clear existing particles.
emitter->pause();
emitter->play(); // Resume without restarting.
emitter->emit(8); // Emit immediately, independently of the emission rate.
emitter->stop(false); // Stop emission but let live particles expire.
if (emitter->aliveCount() == 0) {
emitter->reset();
}
Callbacks cover the events produced by the interaction and composition modules:
onStop(...)reports emitter completion after emission has stopped and all live particles have expired.onCollision(...)reports the particle, collider, collision normal, and speed.onTrigger(...)reportsEnter,Inside,Outside, andExitstates for trigger colliders.onSubEmitter(...)reports birth, collision, death, trigger, or manual sub-emission.
Callbacks run synchronously during particle simulation on the engine update thread.
emitter->onStop([](const snowpulse::ParticleEffects::StopEvent& event) {
if (event.completedCycle) {
// Update persistent application state; the callback runs during update().
}
});
emitter->onCollision(
[](const snowpulse::ParticleEffects::CollisionEvent& event) {
const auto particleId = event.particleId;
const auto impactSpeed = event.speed;
(void)particleId;
(void)impactSpeed;
});
emitter->onTrigger([](const snowpulse::ParticleEffects::TriggerEvent& event) {
const auto triggerState = event.type;
(void)triggerState;
});
emitter->onSubEmitter(
[](const snowpulse::ParticleEffects::SubEmitterEvent& event) {
const int emittedCount = event.emitted;
(void)emittedCount;
});
Collision callbacks require collision.enabled and
collision.sendCollisionMessages. Trigger callbacks require the trigger module
and collider queries; sub-emitter callbacks require enabled sub-emitter entries.
Trail¶
Trail renders a ribbon or triangle mesh following a moving node. It supports
sprite textures, sampling distance and resolution, width, lifetime, fade,
filtering, and blending.
auto* trail = node->addComponent<snowpulse::Trail>();
trail->shape = snowpulse::TrailShape::Ribbon;
trail->thickness(14.0f);
trail->minDistance = 3.0f;
trail->resolution(48);
trail->lifetime = 0.7f;
trail->fadeStart = 0.55f;
trail->color = { 0.35f, 0.75f, 1.0f, 0.9f };
trail->blendMode = snowpulse::BlendMode::Additive;
trail->textureFiltering = snowpulse::TextureFiltering::Linear;
trail->sortOrder = 20;
trail->sprite(assetManager()->loadSprite("effects/trail.png"));
// Clear sampled points after teleporting the owner node.
trail->reset();
EffekseerRenderer¶
EffekseerRenderer plays effects authored with Effekseer runtime assets
(.efkefc). It supports desktop OpenGL and full-web/iOS/Android OpenGL ES 3
paths, and renders through Snowpulse sort layers. The size-focused playable-ad
web profile omits Effekseer. On Android, effect files are loaded through the
packaged-asset provider and device objects are restored after an EGL context
loss.
The umbrella header intentionally does not expose this optional component. Guard both its include and its usage:
Current limits:
- Effekseer sound modules are disabled.
- Distortion/background capture is disabled.
playPrimary() returns a handle and, by default, keeps that primary instance
synchronized with its owner node. play(position) creates an independently
positioned instance. Store handles only while their effects are alive:
#if SNOWPULSE_HAS_EFFEKSEER
auto* effect = node->addComponent<snowpulse::EffekseerRenderer>();
effect->sortOrder = 10;
if (effect->load("effects/effect.efkefc", 1.0f)) {
const auto handle = effect->play({ 120.0f, 80.0f, 0.0f });
if (handle != snowpulse::EffekseerRenderer::kInvalidHandle) {
effect->setColor(handle, { 0.4f, 0.8f, 1.0f, 1.0f });
effect->setSpeed(handle, 0.8f);
effect->setScale(handle, { 1.25f, 1.25f, 1.0f });
if (effect->isPlaying(handle)) {
effect->setLocation(handle, { 140.0f, 90.0f, 0.0f });
}
effect->stop(handle);
}
}
#endif
Handle controls also include rotation (in radians), target location,
visibility, pause, layer, group mask, dynamic inputs, and per-handle time
scale. stopPrimary(), stopAll(), and unload() provide broader cleanup.
Sample Usage¶
auto* emitter = node->addComponent<snowpulse::ParticleEffects>();
if (emitter->effects("effects/emitter.json")) {
emitter->play(true);
}
#if SNOWPULSE_HAS_EFFEKSEER
auto* effect = node->addComponent<snowpulse::EffekseerRenderer>();
if (effect->load("effects/effect.efkefc")) {
effect->playPrimary();
}
#endif