Particle: state, motion and sub-emission
Complete shaders and C# setup, birth flags, USERDATA and RD limitations.
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_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_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:
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_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
| Mode | Behavior and limitation |
|---|---|
| disable_velocity | Disables automatic movement using VELOCITY. The vector remains available to user code. |
| collision_use_scale | Includes TRANSFORM scale in particle collision size; this does not create PhysicsBody3D/CollisionShape3D objects. |
| keep_data | Syntax is accepted, but checked RD Restart still clears COLOR. Do not rely on this flag retaining state in this version. |
| disable_force | Syntax 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 value | Purpose |
|---|---|
in float LIFETIME / DELTA | Duration / current simulation-step time. |
in uint NUMBER / INDEX | Emission number / slot index within the system; not an Entity ID. |
in mat4 EMISSION_TRANSFORM | Emitter transform for the selected coordinate mode. |
in vec3 EMITTER_VELOCITY | Emitter velocity; your algorithm determines inheritance. |
in uint RANDOM_SEED | Base seed for your own deterministic random generator. |
in float AMOUNT_RATIO | AmountRatio fraction; this is float. Built-in material uses it to select emissions; a custom shader must account for it. |
in float INTERPOLATE_TO_END | InterpToEnd value from 0 to 1; not an independent lifetime clock. |
inout bool ACTIVE | Particle activity; false retires it from processing/drawing. |
inout vec4 COLOR | Color supplied to the draw material. |
inout vec3 VELOCITY | Velocity for automatic or custom integration. |
inout mat4 TRANSFORM | Transform; position is TRANSFORM[3].xyz. |
inout vec4 CUSTOM | Four components for the next step and draw-material INSTANCE_CUSTOM. |
inout vec4 USERDATA1…USERDATA6 | Additional persistent simulation data; not automatically exposed as separate mesh-material attributes. |
inout float MASS | Mass 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_CUSTOM | Data-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
| Value | Purpose |
|---|---|
in bool RESTART | First step after birth/restart. |
in bool COLLIDED | Contact with a GPU-particle collider. |
in vec3 COLLISION_NORMAL | Contact normal; zero without contact. |
in float COLLISION_DEPTH | Penetration depth, not normalized-normal length. |
in vec3 ATTRACTOR_FORCE | Accumulated 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_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
| Symptom | Check |
|---|---|
| Nothing drawn | Separate ProcessMaterial/DrawPass, activity, mesh, camera and VisibilityAabb. |
| Simulation color is invisible | COLOR in a custom draw shader, or StandardMaterial3D.VertexColorUseAsAlbedo. |
| Manual color/velocity disappears | Unconditional start() assignments and matching RESTART_*/emission flags. |
| Double-speed motion | Automatic VELOCITY integration plus manual TRANSFORM movement. |
| Age resets every step | Counter initialization in process() rather than start(). |
| MASS does not persist | Use USERDATA/CUSTOM for persistent state. |
| keep_data/disable_force behaves unexpectedly | Current RD mode limitations in the table; define the algorithm explicitly. |
| No visible sub-emission | Relative 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.