DESTINY 2

Effects and particles

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.

routecarried bytarget
particle nodesthe placed entity’s own component configsparticle systems
lens flaresa placement resource, class 0x80806CBF, flare tag at +0x10a 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 offsetfield
+0x158flow nodes, 24-byte rows pointing at node wrappers
+0x168work nodes, the same row shape
+0x188channels, 40-byte records, name hash at +0x20
+0x1A0the location table; see “Particle locations”
+0x2D0the root: kind (0 flow, 1 work, 0xFF none) and index
+0x2D8the sequence’s end condition
+0x2E0flags: bit 0 starts on spawn, bit 1 destroys the object when the root ends, bit 2 detaches it from networking
+0x2E1an 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 offsetfield
+0x00name hash, FNV-1, for example flames or sparks
+0x04, +0x06parent kind (0xFF for the root) and parent index
+0x08flags; & 0x18 means no timer
+0x0C, +0x10start delay, shortest and longest
+0x14, +0x18duration, shortest and longest
+0x20type byte
+0x21, +0x22repeat count and loop flag, flow nodes only
+0x28flow nodes: children as {u16 kind, u16 index}
+0x38, +0x40flow nodes: start and end conditions
+0x48 to +0x50type-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 offsetfield
+0x30rows of 24 bytes: a byte array of location indices at +0, a particle-system tag at +0x10
+0x40, +0x88, +0xD0three 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.

typenodesbehavior
04,791serial: each child starts when the one before ends
152,572parallel
21,046random: starts one child, picked by weight
31,002starts its children and ends at once
48,677parallel
52,742parallel
610,382channel 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}:

kindtrue when
0the channel is in [min, max)
1a bit is set in a per-object bitset; unverified
2never
3a component filter passes

Every installed flow start condition is kind 0. It is sampled when the node starts or restarts.

What starts a sequence

starthow
spawnflag bit 0, unless a per-spawn override turns autoplay off
gameplaya play call (42 callers) or a restart call (13 callers)
effect spawnautoplay 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 offsetelement
+0x10a location, 6 bytes: first frame slot, count, first source, source count, flags
+0x20a location source, 16 bytes: kind, cache slot, count, flags, name hash at +8, sub-object at +12
+0x30one FNV-1 name per location; no known reader
source kindframe
0the object’s world transform, or a sub-object’s
1identity, so the frame is the world; named world_space
2, 4the attachment points with the source’s name hash on this object
3the 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 8an attachment point on a linked object
10back: the object frame turned 180 degrees about its Z
11up: the object position with an upright frame
12down
13 to 25runtime 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.

kindflags
00x3F
10x5F
2, 60x3C
3, 70x22
4, 8, 170x36
50x23
9 to 120x3F
130x3E or 0x3C
14, 150x3E
250x26

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 offsetfield
+0x00enable bit index
+0x04node (bone) index
+0x10rotation quaternion
+0x20translation, then uniform scale at +0x2C
+0x30FNV-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.

offsetfield
+0x00emitter, class 0x80806E2C
+0x04, +0x08, +0x10compute materials, when GPU-simulated
+0x14raster material
+0x18a list of entity models
+0x1Ca material
+0x20a 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.

offsetfield
+0x08channel constants, vec4 array
+0x18the slot list, one row-byte index per entry
+0x28{first, count} groups over the slot list
+0x38 to +0x3Fthe spawn group, the simulation group and two more
+0x40, +0x41transform slots: the spawn frame and the simulation frame
+0x44u16 flags; 0x200 and 0x400 interpolate the spawn frame, 0x2000 picks grid cells at random
+0x47total transform slots, at most 7 in the install
+0x50the emitter program, bytecode
+0x60the program’s constants, vec4 array
+0x70eight stage lengths; they sum to the program length
+0x8055 channel records {group, slot}
+0x100small indices, 0 to 0x14; not name hashes
+0x115spawn shape
+0x116spawn direction
+0x141, +0x142vec4s per particle in streams 0 and 1
+0x149, +0x14Anonzero 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.

groupholds
0, 1the particle’s own two streams; position and velocity use group 1
2spawn scratch for the system; initial_particle_position always uses it
4, 5the system instance; particle_system_age uses group 5
6the 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:

stageruns
7, then 0once, when the system is created
1once per system frame, before emission
6, then 2before each spawn
3for each new particle, after the spawn shape is sampled
4for each CPU particle, each frame
5for distance emission

The emitter’s own opcodes include:

byteoperation
0x07one in lanes equal to zero, else zero
0x08, 0x09min, max
0x11lerp, then clamp to 0 to 1
0x14smooth step
0x15, 0x16absolute value; sign, keeping zero
0x18, 0x19ceiling; round to nearest even
0x1B, 0x1Cnormalize over four lanes or over XYZ
0x1Esine wave: the 0x1F wave without its quarter-turn offset
0x2Crotate a vector about an axis by an angle in turns
0x2F to 0x33the curves 0x37 to 0x3B, with the table on the stack
0x360x35, then clamp to 0 to 1
0x3Dpush a random value in 0 to 1
0x3E g s c, 0x3F g s cload or store stream group g vec4 s in lane form c
0x40, 0x41, 0x42write a transform slot, its position, or its rotation
0x43 to 0x45push extern floats or vectors
0x46, 0x47push an object channel, or a hash-named one; zero when unbound
0x48push zero
0x49 to 0x4Bread a transform slot
0x4C to 0x52move, 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:

  1. Set the particle’s age to 0 and write its emit time and spawn id.
  2. Run stages 6 and 2.
  3. Pick a point by the spawn shape and a direction by the spawn direction, scaled by initial_speed. Write them to initial_particle_position and initial_particle_velocity.
  4. Run stage 3. Spawn programs usually copy the initial values into particle_position and particle_velocity.
  5. Move position and velocity into the simulation space by the spawn frame. Add the parent’s velocity through inherit_parent_velocity.
  6. Set the camera distances, a zero angular_position, and angular_velocity from initial_rotation_rate.
spawn shapepoint, scaled by emitter_shape_scale
0the origin
1a ring of radius emitter_radius in the YZ plane
2a sphere surface of radius emitter_radius
3a segment along Y of length emitter_length
4a cube of edge emitter_length
5grid cells, emitter_grid_count per axis
spawn directiondirection before initial_speed
0random in a cone of half-angle emitter_angle degrees about +X
1tilted emitter_angle degrees from +X toward the spawn point
2random over the sphere
3, 4from 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_strength times 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_friction times 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_velocity gains rotational_acceleration and loses its rotational_friction share. angular_position gains 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

mapplacements with particle nodesparticle nodesGPU-simulated systemslens flares
Leviathan5025511821,610
EDZ1,2512,9141,1892,202

Open questions

  • The helpers of opcodes 0x2B, 0x2D and 0x50, the gravity scale, and the writer of particle_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 0x60FECDD4 live at run time.