reckfellENGINE

Particle: state, motion and sub-emission

Complete shaders and C# setup, birth flags, USERDATA and RD limitations.

On this page

A particle shader simulates GPU-particle state before drawing. Assign it to GpuParticles2D/GpuParticles3D.ProcessMaterial. A mesh or sprite material then draws the result through spatial/canvas_item. CpuParticles does not run this simulation shader, although its image is also GPU-rendered.

Simulation and drawing use different materials

ProcessMaterial contains a particles-type ShaderMaterial or the built-in ParticleProcessMaterial. GpuParticles3D.DrawPass1 contains a mesh with an ordinary surface material. Assigning a particle shader instead of a spatial material does not produce the image.

This complete shader initializes only fields absent from emission data, stores age in USERDATA1 and retires the particle after LIFETIME:

Shader
shader_type particles;
void start() {
    if (RESTART_POSITION) TRANSFORM[3] = EMISSION_TRANSFORM[3];
    if (RESTART_ROT_SCALE) {
        TRANSFORM[0] = EMISSION_TRANSFORM[0];
        TRANSFORM[1] = EMISSION_TRANSFORM[1];
        TRANSFORM[2] = EMISSION_TRANSFORM[2];
    }
    if (RESTART_VELOCITY) VELOCITY = vec3(1.0, 0.0, 0.0);
    if (RESTART_COLOR) COLOR = vec4(1.0, 0.0, 0.0, 1.0);
    if (RESTART_CUSTOM) CUSTOM = vec4(0.0);
    USERDATA1 = vec4(0.0);
}
void process() {
    USERDATA1.x += DELTA;
    CUSTOM.x = USERDATA1.x;
    if (CUSTOM.x >= LIFETIME) ACTIVE = false;
}

VELOCITY automatically moves TRANSFORM here; additionally applying TRANSFORM[3].xyz += VELOCITY * DELTA would double movement. RESTART_* guards preserve manual/sub-emission data: assigning COLOR unconditionally in start() would replace the supplied color.

The complete draw material reads particle color and receives CUSTOM through vertex INSTANCE_CUSTOM and a varying. display=0 shows COLOR, 1 shows CUSTOM.yzw and 2 displays a diagnostic age color:

Shader
shader_type spatial;
render_mode unshaded, cull_disabled;
uniform int display = 0;
varying vec4 particle_custom;
void vertex() {
    particle_custom = INSTANCE_CUSTOM;
}
void fragment() {
    if (display == 1) {
        ALBEDO = particle_custom.yzw;
    } else if (display == 2) {
        ALBEDO = particle_custom.x > 0.25 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
    } else {
        ALBEDO = COLOR.rgb;
    }
}

Enable StandardMaterial3D.VertexColorUseAsAlbedo to see particle COLOR when using that material. CUSTOM does not automatically become color. Your custom shader defines its CUSTOM layout; do not mix it with ParticleProcessMaterial conventions for angle, age and animation frames.

Complete C# setup

Save ParticleSetup.cs and both shaders above. The helper creates a stopped emitter with a QuadMesh, separate simulation/draw materials and a manual-emission method:

C#
using Reckfell;

public static class ParticleSetup
{
    public static GpuParticles3D Create(Shader simulation, Shader surface)
    {
        return new GpuParticles3D
        {
            Emitting = false,
            Amount = 1,
            Lifetime = 10,
            FixedFps = 60,
            LocalCoords = false,
            UseFixedSeed = true,
            Seed = 42,
            ProcessMaterial = new ShaderMaterial { Shader = simulation },
            DrawPass1 = new QuadMesh
            {
                Size = new Vector2(0.2f, 0.2f),
                Material = new ShaderMaterial { Shader = surface },
            },
            VisibilityAabb = new Aabb(new Vector3(-4, -4, -4), new Vector3(8, 8, 8)),
        };
    }

    public static void Emit(GpuParticles3D target, Transform3D transform,
        Vector3 velocity, Color color, Color custom)
    {
        uint flags = (uint)(GpuParticles3D.EmitFlags.Position |
            GpuParticles3D.EmitFlags.RotationScale | GpuParticles3D.EmitFlags.Velocity |
            GpuParticles3D.EmitFlags.Color | GpuParticles3D.EmitFlags.Custom);
        target.EmitParticle(transform, velocity, color, custom, flags);
    }
}

Load the shaders with ResourceLoader.Load<Shader>("asset://MotionParticle.reckfellshader") and the equivalent ParticleSurface call. Create the emitter, add it to the tree, then call ParticleSetup.Emit with Transform3D.Identity, Vector3.Right, Colors.Red and CUSTOM data. For display=1, custom new Color(0, 0, 1, 0) appears green because the draw shader reads yzw. Color is a four-component data carrier here, not a requirement to interpret CUSTOM as color.

A Camera3D at (0,0,5) looking along −Z can view the result. The particle moves along +X and may leave the frame; fit VisibilityAabb to its whole trajectory. Camera/bounds invisibility does not mean ACTIVE=false. The small diagnostic quad is not a finished visual effect.

start(), process() and time

start() runs when an active particle begins/restarts. process() handles active state for a simulation step, including the first step with RESTART=true. DELTA is simulation-step time, not a promise of one invocation per displayed frame. FixedFps>0 selects fixed simulation steps; Interpolate smooths drawing between them. FractDelta refines a birth step fraction. InterpToEnd is a separate INTERPOLATE_TO_END input; a custom algorithm must use it explicitly.

LIFETIME is the duration setting, not current age. The USERDATA1.x counter explicitly accumulates DELTA and controls ACTIVE. SpeedScale=0 freezes the tested emitter state/position; positive speed resumes simulation. TIME is shared renderer time following Engine.TimeScale and rolling over at rendering/limits/time/time_rollover_secs (normally 3600 seconds), not an individual particle age. Do not substitute it for a local counter.

TRANSFORM, VELOCITY, COLOR, CUSTOM and used USERDATA1 through USERDATA6 persist in a buffer across steps. Initialize needed fields at birth. Current RD MASS is a step-local value initialized to 1, not persistent USERDATA: the test sets 2 in start(), then observes 1 on later steps.

Choose one movement path

Use automatic integration when changing only VELOCITY. For complete TRANSFORM control, enable disable_velocity and integrate position yourself:

Shader
shader_type particles;
render_mode disable_velocity;
void start() {
    if (RESTART_POSITION) TRANSFORM[3] = EMISSION_TRANSFORM[3];
    if (RESTART_ROT_SCALE) {
        TRANSFORM[0] = EMISSION_TRANSFORM[0];
        TRANSFORM[1] = EMISSION_TRANSFORM[1];
        TRANSFORM[2] = EMISSION_TRANSFORM[2];
    }
    if (RESTART_VELOCITY) VELOCITY = vec3(1.0, 0.0, 0.0);
    if (RESTART_COLOR) COLOR = vec4(1.0, 0.0, 0.0, 1.0);
    if (RESTART_CUSTOM) CUSTOM = vec4(0.0);
    USERDATA1 = vec4(0.0);
}
void process() {
    USERDATA1.x += DELTA;
    TRANSFORM[3].xyz += VELOCITY * DELTA;
    CUSTOM.x = USERDATA1.x;
    if (CUSTOM.x >= LIFETIME) ACTIVE = false;
}

Current RD applies automatic movement to an active particle before user process(). A velocity changed in process() affects the next automatic step. A manual integrator can use another order; do not assume numerically identical acceleration/collision behavior.

EMISSION_TRANSFORM helps initialize world position with LocalCoords=false. Do not apply it again to an already supplied world TRANSFORM. LocalCoords=true uses emitter-local coordinates; switching modes requires corresponding position/velocity mapping.

Render modes: checked current RD behavior

ModeBehavior and limitation
disable_velocityDisables automatic movement using VELOCITY. The vector remains available to user code.
collision_use_scaleIncludes TRANSFORM scale in particle collision size; this does not create PhysicsBody3D/CollisionShape3D objects.
keep_dataSyntax is accepted, but checked RD Restart still clears COLOR. Do not rely on this flag retaining state in this version.
disable_forceSyntax is accepted, but checked RD still supplies nonzero ATTRACTOR_FORCE. Omit applying it in your process() when unwanted.

The last two limitations are confirmed by source and separate runtime checks. They describe the installed version rather than behavior inferred from names. Explicit initialization and formulas avoid relying on a flag changing the algorithm.

Built-ins

in is read-only; inout provides initial data that can be changed. Shared values are available in start()/process(); USERDATA values are writable vec4, not read-only. TIME and PI/TAU/E are global.

Shared valuePurpose
in float LIFETIME / DELTADuration / current simulation-step time.
in uint NUMBER / INDEXEmission number / slot index within the system; not an Entity ID.
in mat4 EMISSION_TRANSFORMEmitter transform for the selected coordinate mode.
in vec3 EMITTER_VELOCITYEmitter velocity; your algorithm determines inheritance.
in uint RANDOM_SEEDBase seed for your own deterministic random generator.
in float AMOUNT_RATIOAmountRatio fraction; this is float. Built-in material uses it to select emissions; a custom shader must account for it.
in float INTERPOLATE_TO_ENDInterpToEnd value from 0 to 1; not an independent lifetime clock.
inout bool ACTIVEParticle activity; false retires it from processing/drawing.
inout vec4 COLORColor supplied to the draw material.
inout vec3 VELOCITYVelocity for automatic or custom integration.
inout mat4 TRANSFORMTransform; position is TRANSFORM[3].xyz.
inout vec4 CUSTOMFour components for the next step and draw-material INSTANCE_CUSTOM.
inout vec4 USERDATA1…USERDATA6Additional persistent simulation data; not automatically exposed as separate mesh-material attributes.
inout float MASSMass for current-step calculations, initially 1; not retained between current RD steps.
in uint FLAG_EMIT_POSITION / FLAG_EMIT_ROT_SCALE / FLAG_EMIT_VELOCITY / FLAG_EMIT_COLOR / FLAG_EMIT_CUSTOMData-presence bit flags for emit_subparticle().

start() only

RESTART_POSITION, RESTART_ROT_SCALE, RESTART_VELOCITY, RESTART_COLOR and RESTART_CUSTOM are bool. true means the corresponding field needs initialization; when its manual/sub-emission flag is supplied, that field already contains data. Check each independently: supplied position does not imply supplied velocity or color.

process() only

ValuePurpose
in bool RESTARTFirst step after birth/restart.
in bool COLLIDEDContact with a GPU-particle collider.
in vec3 COLLISION_NORMALContact normal; zero without contact.
in float COLLISION_DEPTHPenetration depth, not normalized-normal length.
in vec3 ATTRACTOR_FORCEAccumulated attractor force; explicitly apply it to velocity when wanted.

These contacts do not create ordinary PhysicsBody3D signals for every particle. A custom particle shader must define the response: COLLIDED alone is not a complete bounce algorithm.

Sub-emission flags and return value

The additional built-in bool emit_subparticle(mat4 xform, vec3 velocity, vec4 color, vec4 custom, uint flags) queues a particle on the linked sub-emitter. It is available in start()/process(). Ordinary user-defined shader functions are also allowed; this is not a restriction against writing your own functions.

Create both GpuParticles3D objects in the tree, set the first SubEmitter = GetPathTo(secondary), and give the target its own ProcessMaterial and mesh. The example target does not emit automatically. This complete source shader attempts emission once on RESTART: green means accepted, red means rejected.

Shader
shader_type particles;
render_mode disable_velocity;
void start() {
    TRANSFORM = EMISSION_TRANSFORM;
    VELOCITY = vec3(0.0);
    COLOR = vec4(1.0, 0.0, 0.0, 1.0);
}
void process() {
    if (RESTART) {
        mat4 child = TRANSFORM;
        child[3].x += 1.0;
        uint flags = FLAG_EMIT_POSITION | FLAG_EMIT_ROT_SCALE |
            FLAG_EMIT_VELOCITY | FLAG_EMIT_COLOR | FLAG_EMIT_CUSTOM;
        bool accepted = emit_subparticle(child, vec3(0.0),
            vec4(0.0, 0.0, 1.0, 1.0), vec4(0.0), flags);
        COLOR = accepted ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
    }
}

Flags determine which target fields are supplied. Without FLAG_EMIT_COLOR, the target sees RESTART_COLOR=true and its start() must initialize color. A true return means queue acceptance, not a guarantee of a visible pixel: check target material, position, bounds and capacity. The function does not create a child Entity per particle. See sub-emitter setup.

Troubleshooting

SymptomCheck
Nothing drawnSeparate ProcessMaterial/DrawPass, activity, mesh, camera and VisibilityAabb.
Simulation color is invisibleCOLOR in a custom draw shader, or StandardMaterial3D.VertexColorUseAsAlbedo.
Manual color/velocity disappearsUnconditional start() assignments and matching RESTART_*/emission flags.
Double-speed motionAutomatic VELOCITY integration plus manual TRANSFORM movement.
Age resets every stepCounter initialization in process() rather than start().
MASS does not persistUse USERDATA/CUSTOM for persistent state.
keep_data/disable_force behaves unexpectedlyCurrent RD mode limitations in the table; define the algorithm explicitly.
No visible sub-emissionRelative path, existing target, capacity, bool result, flags and target material.

Four complete shaders and the C# helper are checked on Windows Forward+/Vulkan: motion, COLOR/CUSTOM, persistent USERDATA, speed freezing, ACTIVE, manual/sub-emission and current keep_data/disable_force/MASS behavior. Step settings are checked as configuration values, not a complete integration-accuracy analysis. All collisions/scales, trails, 2D, other platforms/renderers and performance are not fully runtime-accepted here. Small diagnostic pixel readbacks are not published as beauty illustrations.

Reckfell Engine documentationBack to top ↑
QUICK LINKSGetting startedEntity hierarchyC# & .NET 10Scenes and prefabs
Reckfell guides and full API K
reckfellENGINE
RECKFELL ACCOUNT

Sign in

Sign in to your Reckfell account.

One account for the entire Reckfell platform.