Effects and particles
How maps and entities place effects: particle nodes, the sequence player, particle locations, systems, emitters and their programs, and lens flares.
Effects are package data. An entity carries particle nodes inside a sequence. The sequence decides when each node plays, and each node names particle systems. A system names an emitter. The emitter runs a small bytecode program that spawns and moves particles. Lens flares are a separate, simpler route.
Class ids like 0x80806E28 are package classes. Function and field names are descriptive. Channel names such as particle_age and flow-node class names are the game’s own. Sunrise Studio draws this data. Its particle drawing is an approximation, and visual parity is unverified.
What places an effect
A map places effects in two ways. Both go through the map’s placement rows.
route
carried by
target
particle nodes
the placed entity’s own component configs
particle systems
lens flares
a placement resource, class 0x80806CBF, flare tag at +0x10
a flare, class 0x80806F68
Every particle node in Leviathan and the EDZ sits in an entity component whose primary class is 0x808084D7, the sequence player.
Sequences
A sequence is a tree of two kinds of node: flow nodes, which decide order, and work nodes, which do something. A particle node is a work node of type 4.
The sequence player reads a sequence resource, class 0x808084E9:
resource offset
field
+0x158
flow nodes, 24-byte rows pointing at node wrappers
+0x168
work nodes, the same row shape
+0x188
channels, 40-byte records, name hash at +0x20
+0x1A0
the location table; see “Particle locations”
+0x2D0
the root: kind (0 flow, 1 work, 0xFF none) and index
+0x2D8
the sequence’s end condition
+0x2E0
flags: bit 0 starts on spawn, bit 1 destroys the object when the root ends, bit 2 detaches it from networking
+0x2E1
an importance threshold; the player pauses while the object’s importance is below it
A channel is an object variable. That an unset channel reads 0 is assumed.
The node header
body offset
field
+0x00
name hash, FNV-1, for example flames or sparks
+0x04, +0x06
parent kind (0xFF for the root) and parent index
+0x08
flags; & 0x18 means no timer
+0x0C, +0x10
start delay, shortest and longest
+0x14, +0x18
duration, shortest and longest
+0x20
type byte
+0x21, +0x22
repeat count and loop flag, flow nodes only
+0x28
flow nodes: children as {u16 kind, u16 index}
+0x38, +0x40
flow nodes: start and end conditions
+0x48 to +0x50
type-6 flow nodes: range minimum, maximum and channel index
A work node runs its delay, then its duration, or no timer at all. On timeout it ends and tells its parent. Work nodes have no start condition. There are 72 work node types. Type 4 is the particle node.
Particle nodes
A particle node is an inline struct inside an entity config, class 0x80806CC6, 296 bytes. Its body is the shared node header above. It adds:
body offset
field
+0x30
rows of 24 bytes: a byte array of location indices at +0, a particle-system tag at +0x10
+0x40, +0x88, +0xD0
three expressions, each a byte code array then a vec4 constant array
No node field holds a position, rotation, scale or bone. The rows place the node; see “Particle locations”. A row can hold 0xFFFFFFFF instead of a system tag. Its config then lists several systems as variants, in the shape entity models use for material variants. How the runtime picks a variant is unverified.
Flow nodes
The type byte picks the class. Counts are over all 81,212 installed flow nodes.
type
nodes
behavior
0
4,791
serial: each child starts when the one before ends
1
52,572
parallel
2
1,046
random: starts one child, picked by weight
3
1,002
starts its children and ends at once
4
8,677
parallel
5
2,742
parallel
6
10,382
channel gate: children start when a channel enters [min, max) and stop when it leaves
A channel gate re-arms when its loop flag is set.
A condition is {u8 kind, f32 min, f32 max, i32 channel}:
kind
true when
0
the channel is in [min, max)
1
a bit is set in a per-object bitset; unverified
2
never
3
a component filter passes
Every installed flow start condition is kind 0. It is sampled when the node starts or restarts.
What starts a sequence
start
how
spawn
flag bit 0, unless a per-spawn override turns autoplay off
gameplay
a play call (42 callers) or a restart call (13 callers)
effect spawn
autoplay off, destroy at end, up to six initial channel values, then play
A node plays when its sequence plays and every flow node above it lets it through. A node under a loop flag restarts. A node with no timer runs until stopped. A node with neither plays once.
Of 15,786 installed sequences, 13,419 start on spawn. The channels most often gating a node closed at zero are alive, vehicle_power, device_position, emote_active, power_anim, damage_type, treasure_chest_looted and weapon_firing. An animation clip lists channel names. That a playing clip writes them is assumed.
Particle locations
A particle node places its systems through the sequence’s location table. Each node row holds a byte array of location indices and a particle-system tag. Row byte k gives the frames for emitter transform slot k.
table offset
element
+0x10
a location, 6 bytes: first frame slot, count, first source, source count, flags
+0x20
a location source, 16 bytes: kind, cache slot, count, flags, name hash at +8, sub-object at +12
+0x30
one FNV-1 name per location; no known reader
source kind
frame
0
the object’s world transform, or a sub-object’s
1
identity, so the frame is the world; named world_space
2, 4
the attachment points with the source’s name hash on this object
3
the same lookup in a second pose mode; on a first-person weapon it lands on the first-person gun, and kind 2 on the world weapon
6 to 8
an attachment point on a linked object
10
back: the object frame turned 180 degrees about its Z
11
up: the object position with an upright frame
12
down
13 to 25
runtime frames such as the camera, a projectile target or a beam target
Kinds 5 and 9 are not decoded. The empty-name hash 0x811C9DC5 gives the object transform. Source flag 0x80 rebuilds a frame upright while keeping its position.
Each source kind uses one flags value at source +4, with or without the 0x80 bit. The location’s own flag byte holds the same value. What each bit selects is not decoded.
kind
flags
0
0x3F
1
0x5F
2, 6
0x3C
3, 7
0x22
4, 8, 17
0x36
5
0x23
9 to 12
0x3F
13
0x3E or 0x3C
14, 15
0x3E
25
0x26
Attachment points
An attachment point set belongs to the placed entity, not to the sequence. It holds 64-byte rows sorted by name hash, so several rows can share a name:
row offset
field
+0x00
enable bit index
+0x04
node (bone) index
+0x10
rotation quaternion
+0x20
translation, then uniform scale at +0x2C
+0x30
FNV-1 name hash
A name that repeats gives one frame per row. That is how an effect sits on both sides of an entity: two rows of one name, never a mirrored frame. A Hydra’s two primary_trigger rows put one muzzle effect on each gun.
Frames and instances
A location joins the frames of its sources up to its count. A point source gives one frame per row of its name, and skips a row whose enable bit is clear. A lookup that finds no row gives no valid frame.
A system runs one instance per valid spawn frame. Instance k takes spawn frame k, and simulation frame k while there are enough simulation frames, else the first one. A spawn location whose point is missing therefore emits nothing. Each instance emits at the full rate. That the count is not split is assumed.
CPU particles are stored in the simulation frame. So a system that spawns and simulates in one moving location keeps its particles with it. One that simulates in world_space leaves them where they spawned. This storage rule is unverified. The inherit_parent_velocity channel adds the parent’s velocity at spawn. A burst on a moving weapon point follows the gun in game when that channel is 0.
Particle systems
Class 0x80806E28 is the particle system. All 29,785 installed tags are 52 bytes.
offset
field
+0x00
emitter, class 0x80806E2C
+0x04, +0x08, +0x10
compute materials, when GPU-simulated
+0x14
raster material
+0x18
a list of entity models
+0x1C
a material
+0x20
a hash
About a quarter of systems simulate on the GPU with cs_5_0 compute shaders. The raster vertex shader reads per-particle data from vertex streams whose layout is not decoded.
Emitters
Class 0x80806E2C is the emitter: 26,250 installed tags, struct size 336.
offset
field
+0x08
channel constants, vec4 array
+0x18
the slot list, one row-byte index per entry
+0x28
{first, count} groups over the slot list
+0x38 to +0x3F
the spawn group, the simulation group and two more
+0x40, +0x41
transform slots: the spawn frame and the simulation frame
+0x44
u16 flags; 0x200 and 0x400 interpolate the spawn frame, 0x2000 picks grid cells at random
+0x47
total transform slots, at most 7 in the install
+0x50
the emitter program, bytecode
+0x60
the program’s constants, vec4 array
+0x70
eight stage lengths; they sum to the program length
+0x80
55 channel records {group, slot}
+0x100
small indices, 0 to 0x14; not name hashes
+0x115
spawn shape
+0x116
spawn direction
+0x141, +0x142
vec4s per particle in streams 0 and 1
+0x149, +0x14A
nonzero when acceleration 0 or 1 is authored in the emitter frame
The 55 channels follow a fixed name list in the executable, from particle_age to emitter_grid_count. The first ones are particle_age, particle_random, particle_system_age, particle_system_random, particle_emit_time, particle_lifetime, particle_position, particle_velocity, initial_particle_position, initial_particle_velocity, emission_rate, emitter_shape_scale, emitter_angle, emitter_radius, emitter_length, initial_speed, initial_particle_count and particle_max_count.
A channel record names a stream group and a float index into it. Group 0xFF marks an unused channel.
group
holds
0, 1
the particle’s own two streams; position and velocity use group 1
2
spawn scratch for the system; initial_particle_position always uses it
4, 5
the system instance; particle_system_age uses group 5
6
the emitter’s channel constants at +0x08
A transform slot below the node row’s byte count takes that row byte’s frames. A slot at or above it is a program slot: only the emitter program writes it. Program slots are common, most of all on GPU-simulated systems.
Emitter programs
The emitter program is the same kind of stack machine as the TFX interpreter on Rendering, over 32 vec4s. Opcodes 0x01 to 0x3B run the same math as TFX. The program is split into eight stages:
stage
runs
7, then 0
once, when the system is created
1
once per system frame, before emission
6, then 2
before each spawn
3
for each new particle, after the spawn shape is sampled
4
for each CPU particle, each frame
5
for distance emission
The emitter’s own opcodes include:
byte
operation
0x07
one in lanes equal to zero, else zero
0x08, 0x09
min, max
0x11
lerp, then clamp to 0 to 1
0x14
smooth step
0x15, 0x16
absolute value; sign, keeping zero
0x18, 0x19
ceiling; round to nearest even
0x1B, 0x1C
normalize over four lanes or over XYZ
0x1E
sine wave: the 0x1F wave without its quarter-turn offset
0x2C
rotate a vector about an axis by an angle in turns
0x2F to 0x33
the curves 0x37 to 0x3B, with the table on the stack
0x36
0x35, then clamp to 0 to 1
0x3D
push a random value in 0 to 1
0x3E g s c, 0x3F g s c
load or store stream group g vec4 s in lane form c
0x40, 0x41, 0x42
write a transform slot, its position, or its rotation
0x43 to 0x45
push extern floats or vectors
0x46, 0x47
push an object channel, or a hash-named one; zero when unbound
0x48
push zero
0x49 to 0x4B
read a transform slot
0x4C to 0x52
move, lerp and turn transforms
Byte 0x00, byte 0x3C and bytes above 0x52 end the stage. A transform is two vec4s: a rotation quaternion, then a position with uniform scale in W. On the stack the position sits under the rotation.
Load lane forms are xyzw, xyzz, xyxy, zwzw, xxxx, yyyy, zzzz and wwww. Store lane forms write all lanes, xyz, xy, the source XY into ZW, or the source X into one of X, Y, Z or W.
Random values come from s = 1664525 s + 1013904223, taking the high 16 bits over 65,535. The spawn samplers use the same generator. Extern float 0 is the wrapped simulation time, floats 1 to 4 are the other frame clock values, float 5 is 1/30 and float 6 is 1/60.
Spawn and update
Each frame adds emission_rate times the frame time to a carried fraction and spawns the whole part. initial_particle_count spawns once. particle_max_count caps the live total; zero means no cap. The system frame advances particle_system_age by the frame time over the system’s duration, clamped to 1.
A spawn runs in this order:
- Set the particle’s age to 0 and write its emit time and spawn id.
- Run stages 6 and 2.
- Pick a point by the spawn shape and a direction by the spawn direction, scaled by
initial_speed. Write them toinitial_particle_positionandinitial_particle_velocity. - Run stage 3. Spawn programs usually copy the initial values into
particle_positionandparticle_velocity. - Move position and velocity into the simulation space by the spawn frame. Add the parent’s velocity through
inherit_parent_velocity. - Set the camera distances, a zero
angular_position, andangular_velocityfrominitial_rotation_rate.
spawn shape
point, scaled by emitter_shape_scale
0
the origin
1
a ring of radius emitter_radius in the YZ plane
2
a sphere surface of radius emitter_radius
3
a segment along Y of length emitter_length
4
a cube of edge emitter_length
5
grid cells, emitter_grid_count per axis
spawn direction
direction before initial_speed
0
random in a cone of half-angle emitter_angle degrees about +X
1
tilted emitter_angle degrees from +X toward the spawn point
2
random over the sphere
3, 4
from the origin toward the spawn point
Each update advances the particle’s age by the frame time over particle_lifetime, clamped to 1, and runs stage 4. Then the native code integrates:
gravity_strengthtimes a system scale pulls along world -Z.- Two accelerations add direction times strength times the frame time. A direction is in world space unless the emitter marks it as emitter-frame.
air_frictiontimes the frame time, clamped to 0 to 1, removes that share of the velocity.- The position moves by the new velocity times the frame time.
angular_velocitygainsrotational_accelerationand loses itsrotational_frictionshare.angular_positiongains the velocity times 2 pi times the frame time, so the velocity is in turns.
The simulation space is the frame of slot +0x41. World directions and gravity are turned into it by the inverse of that slot.
Lens flares
A lens flare, class 0x80806F68, holds 12-byte rows: a material, a parameter block and a flag. The flare vertex shader builds a screen-space quad at a fixed clip depth of 0.99. The lens-flare role is assumed from that shader.
Counts
map
placements with particle nodes
particle nodes
GPU-simulated systems
lens flares
Leviathan
502
551
182
1,610
EDZ
1,251
2,914
1,189
2,202
Open questions
- The helpers of opcodes
0x2B,0x2Dand0x50, the gravity scale, and the writer ofparticle_random. - The particle stream layout the raster shader reads, and the GPU path’s constant buffers.
- The runtime choice between particle-system variants.
- Which gameplay code plays the sequences that do not start on spawn.
- Who writes the gate channels, and condition kinds 1 and 3.
- Location kinds 5, 9 and 13 to 25, and the meaning of the location and source flag bits.
- Which skeleton a point’s node index counts in.
- Where missing point hashes such as
0x60FECDD4live at run time.
Related pages
- Rendering – TFX, materials and object channels.
- Weapons and perks – muzzle flashes and clip events.
