Skip to content

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::World is the default and preserves normal scene-camera movement, zoom, and shake.
  • RendererModule::RenderSpace::Screen treats 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(...) reports Enter, Inside, Outside, and Exit states 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:

#if SNOWPULSE_HAS_EFFEKSEER
#include <runtime/components/effekseer_renderer.h>
#endif

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