Visuals and Niagara
Mass never spawns a Niagara component per bullet. The Niagara visualizer keeps a few persistent systems alive and rewrites their User.* arrays every frame:
Mass entities (position / velocity / type)
│ gathered on the game thread each frame (closest N within the tracer budget)
├─ Projectile System (NS_MassProjectile) one component per projectile type
│ User.Positions / Velocities / Count + per-type look (colour, size, mesh)
├─ Smoke Trail System (NS_ProjectileSmokeTrail) one component (+1 per unique smoke material)
│ User.TrailSmoke* spawn-and-forget GPU puffs
└─ Impact FX System (NS_ProjectileImpactFX) one component for the whole world
User.Dust* / Spark* / Debris* / Splash* spawn-and-forget bursts
The systems are assigned in Project Settings → Plugins → Mass Projectile Niagara (details). A projectile type has no Niagara slot: it styles these shared systems through its Visual and Surface FX traits. That's why a new ammo type costs no new Niagara asset.
Dedicated servers create none of this. With Enable Niagara Visualizer off, everything still simulates, hits, decals and plays audio.
Styling per type (no Niagara editing)
Visual trait → tracer component
Pushed once when the type's component is created:
| Trait field | Niagara parameter | Notes |
|---|---|---|
| Color | User.Color |
HDR; values above 1 bloom with an emissive material |
| Size | User.SpriteSize |
cm; also scales the fallback impact puff |
| Trail Segments / Time Span / Length / Taper | same names | Analytic streak, not stored history |
| Bullet Mesh | User.BulletMesh |
GPU mesh renderer (run proj.CreateNiagaraMeshRenderer once) |
| Mesh Scale | User.MeshScale (vector) |
|
| Bullet Mesh Material | User.BulletMeshMaterial |
Needs Used with Niagara Mesh Particles |
User.GravityZ = −980 × Gravity Scale. User.HeroCount is always 0. User.MeshesEnabled follows proj.NiagaraMeshViz.
Surface FX trait → impact bursts
Effect Color and Effect Scale on each surface row tint and size that type's bursts in the Impact FX system. Decals and sounds on the row are handled by the core, not Niagara.
Recipe: an RPG round without a new Niagara asset
- Duplicate
EC_Projectile_Shell(slow, heavy). - Visual: a rocket Bullet Mesh + material, Mesh Scale, a larger Size, Emit Smoke on with thicker Smoke Start/End Size. Only set a unique Smoke Material if the puff texture must change.
- Surface FX: larger Effect Scale, hotter Effect Color.
- Make sure
proj.CreateNiagaraMeshRendererhas been run andproj.NiagaraMeshVizis 1.
Rifle, Shell and Plasma already differ exactly this way: same NS_MassProjectile, different Visual and Surface FX rows.
Authoring rules for all three systems
- GPU sim, world space, looping. The component lives for the whole session, so the emitter must loop (not Once).
- Spawn Burst Instantaneous every loop. Spawn Count is an expression on a
User.*Count; burst Age =Engine.Emitter.LoopedAge, so it re-fires every loop. - No Spawn Rate as well. Rate plus burst piles particles at the origin.
- Array index per particle:
(Particles.UniqueID - Engine.Emitter.TotalSpawnedParticles) / N, whereNis particles per event. Spawn Count must beUser.Count × Nwith the sameN. - Read arrays at spawn only, via
SelectPositionFromArray,SelectVectorFromArray,SelectLinearColorFromArray,SelectFloatFromArray,SelectIntFromArraywith Array Sampling Mode = Direct Set. - Capture into
Particles.*anything needed after the spawn frame. TheUser.*arrays are rewritten next frame. - Materials: sprites need Used with Niagara Sprites; meshes need Used with Niagara Mesh Particles.
- Finite lifetimes. Particle lifetime does the cleanup.
C++ writes every array every frame, including empty ones, so a stale non-zero count can never replay.
User parameter contracts
Names as they appear in Niagara (User. prefix).
Projectile System — live tracers
One component per type; arrays hold this frame's live projectiles of that type.
| Parameter | Type | Content |
|---|---|---|
User.Positions |
Position array | Projectile positions |
User.Velocities |
Vector array | Velocities (cm/s) |
User.Count |
int | Number of entries |
User.SmokePositions / User.SmokeCount |
Position array / int | Fallback puffs for hits without a batched impact event |
User.Color, User.SpriteSize, User.TrailLength, User.TrailSegments, User.TrailTimeSpan, User.TrailTaper, User.GravityZ |
scalars | From the type (set once) |
User.BulletMesh, User.BulletMeshMaterial, User.MeshScale |
mesh / material / vector | From the type (set once) |
User.MeshesEnabled |
bool | proj.NiagaraMeshViz |
User.HeroCount |
int | Always 0 |
Follow model: spawn Count particles every loop with a lifetime of about one frame. Long-lived particles here accumulate as ghosts; put trails in TrailSegments or the smoke system.
Smoke Trail System — lingering puffs
| Parameter | Type | Content |
|---|---|---|
User.TrailSmokePositions |
Position array | Path samples this frame |
User.TrailSmokeVelocities |
Vector array | Flight direction × puff stretch (cm). Copy its length into sprite Y at spawn. |
User.TrailSmokeColors |
Color array | Visual Smoke Color |
User.TrailSmokeScales |
float array | Smoke Start Size |
User.TrailSmokeStretch |
float array | Slice length × overlap |
User.TrailSmokeLifetimes |
float array | Smoke Lifetime |
User.TrailSmokeCount |
int | Budgeted sample count |
User.SmokeStartSize, SmokeEndSize, SmokeStretchLength, SmokeLifetime, SmokeColor, SmokeRiseSpeed, SmokeWindScale, SmokeTurbulence |
scalars | Defaults / per-sample capture |
User.SmokeMaterial |
material | Type's Smoke Material override |
User.Wind |
vector | Global wind (cm/s) |
Legacy aliases SmokePositions/SmokeCount and DustPositions/DustColors/DustScales/DustCount receive the same samples for older emitters.
After changing smoke authoring, run proj.CreateSmokeTrailSystem, then proj.ReloadAssets, and save NS_ProjectileSmokeTrail.
Impact FX System — batched bursts
Solid hits fill Dust + Sparks + Debris (index-aligned). Water (SurfaceType6) fills Splash only.
| Parameter | Type | Content |
|---|---|---|
User.DustCount, User.SparkCount, User.DebrisCount |
int | Solid impacts this frame |
User.SplashCount |
int | Water impacts |
User.DustPositions / SparkPositions / DebrisPositions / SplashPositions |
Position arrays | Hit point pushed out along the normal (6 / 2 / 3 / 2 cm) |
User.DustColors / DebrisColors / SplashColors |
Color arrays | Row Effect Color |
User.SparkColors |
Color array | Effect Color × 1.4 |
User.DustScales / SparkScales / DebrisScales / SplashScales |
float arrays | 30 / 7 / 0.6 / 26 × Effect Scale |
User.SparkDirs / User.DebrisDirs |
Vector arrays | Reflected velocity |
User.DebrisMeshIndices |
int array | Wood 3–4, others 0–2 |
User.ImpactEventTypes |
int array | 0 Stop, 1 Ricochet, 2 Penetration Enter, 3 Penetration Exit |
Caps: Max Impact Events Per Frame (512), Impact Effect Cull Distance (120 m). Toggle: proj.ImpactEffects. Add extra emitters inside this system; never spawn a system per hit.
Converting a marketplace Niagara system
proj.MakeNiagaraCompatible (editor console) duplicates a Niagara system and wires it to the contract above. The source asset is never modified unless you pass inplace.
proj.MakeNiagaraCompatible (uses the Content Browser selection)
proj.MakeNiagaraCompatible /Game/VFX/NS_RocketTrail role=tracer dest=/Game/FX/NS_RPG_Mass
proj.MakeNiagaraCompatible role=impact assign=impact force
proj.MakeNiagaraCompatible info
| Argument | Meaning |
|---|---|
role=tracer |
(default) Live follow: Positions / Velocities / Count, short lifetime |
role=impact |
Spawn-and-forget hits: wires Dust / Spark / Debris / Splash by emitter name, otherwise Dust |
role=smoke |
Spawn-and-forget path puffs: TrailSmoke* |
dest=/Game/Folder/Asset |
Output asset. Default <source>_MassCompat next to the source |
inplace |
Patch the selected asset instead of duplicating |
force |
Replace an existing destination |
gpu |
Switch emitters to GPU simulation (may break CPU-only modules) |
keepparticles |
Do not shorten tracer lifetime |
emitter=Name |
Only convert emitters whose name contains this text |
assign=projectile / impact / smoke |
Also write the result into Project Settings |
info |
Print emitters, sim targets, renderers and inputs; writes nothing |
The command adds the role's User.* parameters, forces world space, zeroes Spawn Rate, drives Spawn Burst from the role's count, prefers looping, wires Position (and Velocity, Color, Size when present) through Select*FromArray, binds mesh renderers for tracers, then compiles and saves, logging anything it could not wire.
Then: assign the result in Project Settings (or use assign=) and run proj.ReloadAssets.
Heads-up: the Projectile System is shared, so assigning a converted rocket there makes every type draw with it. Types still differ by colour, size and mesh.
What does not convert cleanly
| Source feature | Result / fix |
|---|---|
| One-shot "play at location" systems | Ghosts at the origin unless Position is array-wired and the emitter loops |
| CPU ribbons attached to sockets | No socket exists; drive ribbons from User.Velocities |
| CPU collision, events, skeletal mesh DI | GPU compile fails. Keep CPU or remove those modules |
| Spawn Rate as the only spawner | Add Spawn Burst Instantaneous and re-run |
Manual conversion checklist
- Duplicate the system. Set emitters to looping, world space, GPU if possible.
- Add
User.Count(int) andUser.Positions(Position array). - Spawn Burst count =
User.Count; Spawn Rate = 0. - Initialize Particle → Position =
SelectPositionFromArray(User.Positions)with the index formula above; same for Velocity. - Tracers: lifetime ≈
Engine.DeltaTime × 2. - Assign in Project Settings and run
proj.ReloadAssets.
Replacing Niagara entirely (C++)
The core module has no Niagara dependency. Implement IProjectileVisualizer (ProjectileVisualizer.h) and register it from a world subsystem's Initialize with UProjectileSubsystem::SetVisualizer. Disable Enable Niagara Visualizer so both do not run. See API Reference.
Debugging visuals
| Tool | Use |
|---|---|
proj.MakeNiagaraCompatible info |
Dump the selected system's stack |
proj.CreateNiagaraMeshRenderer info |
Renderers and sim target on the Projectile System |
fx.Niagara.Debug.Hud 1 |
Live Niagara instance and particle counts |
proj.ReloadAssets |
Recreate the runtime components after editing assets |
proj.ImpactEffects 0 |
Fall back to the cheap per-type puff |
Log category LogMassProjectiles |
Missing assets, inactive components, type registration |
Particles at the world origin: Position is not array-wired. No particles: Spawn Count not linked, emitter set to Once, or GPU compile failed.