# Profiling Mass Representation: Native ISM vs Bulk ISM Updates vs NDC

**URL:** <https://forums.unrealengine.com/t/profiling-mass-representation-native-ism-vs-bulk-ism-updates-vs-ndc/2844794>\
**Category:** Programming & Scripting\
**Tags:** unreal-engine, CPP\
**Created:** [October 25, 2026, 1:45am UTC](https://forums.unrealengine.com/t/profiling-mass-representation-native-ism-vs-bulk-ism-updates-vs-ndc/2844794 "2026-10-25T01:45:22Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![pezzott1](https://d1ap1mz92jnks1.cloudfront.net/user_avatar/forums.unrealengine.com/pezzott1/32/1215068_2.png) [@pezzott1](https://forums.unrealengine.com/u/pezzott1)\
**Post date:** [October 25, 2026, 1:45am UTC](https://forums.unrealengine.com/t/profiling-mass-representation-native-ism-vs-bulk-ism-updates-vs-ndc/2844794/1 "2026-10-25T01:45:23Z")

</div>

I’m working on a Mass-based projectile framework, with the goal of simulating and displaying large numbers of moving projectiles while keeping the CPU cost low. While profiling it, I noticed that native Mass ISM representation was taking a substantial amount of frame time, particularly in UMassVisualizationComponent::EndVisualChanges(). This led me to experiment with Niagara Mesh Renderer and Niagara Data Channels as another way to display the same Mass entities.

The initial Niagara implementation also had significant CPU overhead. Switching to a persistent native writer with cached typed field access reduced that cost considerably. That raised another question: how much of the ISM cost came from the way transforms were submitted?

To investigate, I created a separate benchmark project comparing native Mass ISM, custom bulk ISM updates, and Niagara Data Channels. The goal is to understand where the representation cost occurs and how much the submission path affects it, while keeping the underlying Mass movement simulation consistent.

> **Made a separate project for this benchmark with the following setup:**
>
> - Engine: Unreal Engine 5.8.4
> - Build: Packaged Development
> - Platform: W11, Dx12 (SM6)
> - CPU: AMD Ryzen 9 3900X
> - GPU: NVIDIA GeForce RTX 3080
> - RAM: 32GB
> - Entity count: 20,000
> - Profiling: Unreal Insights CPU timing and stack sampling

The entities are created once in a grid. A shared Mass movement processor updates their FTransformFragment with deterministic vertical wave motion. Switching representation keeps the existing entities and movement simulation running.

The available modes are:

- 0 - None: movement simulation without visual representation.
- 1 - Native ISM: Unreal’s Mass Representation update path.
- 2 - Niagara: persistent GPU mesh particles reading current and previous transforms from a Niagara Data Channel.
- 3 - Custom ISM: a custom Mass processor collecting transforms by stable instance index and submitting one bulk ISM update per frame.

The ISM paths use the same benchmark mesh and material configuration, with shadows, collision, and navigation disabled. Native Mass representation significance is fixed to avoid distance-based representation changes during the comparison.

All three paths read transforms produced by the same Mass movement processor. Their main difference is how those transforms reach the visual representation:

- **Native Mass ISM:** `UMassUpdateISMProcessor` collects entity transforms into Mass Representation’s buffers. `UMassVisualizationComponent::EndVisualChanges()` then resolves entity-to-instance IDs, checks for duplicates and removals, and applies current and previous transforms through individual ISM update calls.
- **Custom bulk ISM:** Each entity stores a stable ISM instance index. A custom processor gathers transforms into arrays indexed by that value, then calls `BatchUpdateInstancesTransforms()` once per frame on a standard `UInstancedStaticMeshComponent`. This bypasses Mass Representation’s lookup and reconciliation work and reduces repeated component-level operations. It retains previous transforms for motion vectors. The implementation assumes a fixed population and does not handle individual instance removal or representation LOD transitions.
- **Niagara Data Channels:** Each entity receives a reusable visual slot. A custom processor writes its current and previous position, rotation, and scale, together with an active flag into the corresponding NDC record. Persistent GPU particles store their slot index at spawn and read that record every update. Publication uses a persistent typed writer derived from FNDCWriterBase, which caches variable offsets. One write session covers the full pool each frame, including inactive slots. Collection and writing remain sequential on the game thread.

Niagara receives the same current and previous transform data supplied to the ISM paths, while each representation retains its own update and rendering pipeline.

These are inclusive CPU Frame timer statistics from steady sections of each mode, approximately 10 seconds per selection.

 ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/7/9/f/79f365d85d9410882ee87b0bcd75fdcfec98c70f.png)

Difference in average CPU Frame duration relative to baseline:

- Mass ISM: +6.719 ms
- NDC: +1.337 ms
- Custom ISM: +2.994 ms

> **Unreal Insights captures**
>
> - **Native Mass ISM**
> 
> ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/3/9/c/39cdd8f68f2e75335985e7e644977fd39d7810f3.png)
> 
> ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/2/a/d/2ad61bd04680ddcf4598c18b5832e5d43bcfb14a.png)
> 
> - **NDC**
> 
> ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/2/f/3/2f3ec352948bfb6ea87748ce0a5cff4f59a2b46a.png)
> 
> - **Custom ISM**
> 
> ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/5/7/3/57339a4ce76f7c4a70db3aa70e8ed866c6841efa.png)

**What the investigation suggests**

Bulk submission substantially improved the ISM result without modifying engine classes. This suggests the native representation’s submission and bookkeeping path contributes meaningfully to the cost in this workload.

During separate editor/standalone sampling, I observed entity-to-instance hash lookups, transform conversions, instance change tracking, and UObject::Modify() calls beneath EndVisualChanges(). Those samples identify work being performed, but do not establish which operation dominates the packaged result.

Custom ISM still incurs downstream bounds, instance-buffer, and rendering updates. Niagara uses a different simulation and rendering pipeline, so matching transform data does not make their total workloads identical.

**Limitations and questions**

These are preliminary results from short selections, without repeated-run statistics or a GPU-time comparison yet. The custom implementation also handles a narrower workload than the general native representation system.

In relation to the discussion about specialized versus larger processors, this experiment changed the representation submission path; it does not isolate processor count or organization.

For this fixed-population:

- Is there an intended native Mass Representation path that provides comparable bulk submission?
- Is the per-instance application in `EndVisualChanges()` necessary for its general bookkeeping or could a bulk path preserve that functionality?
- Are there configuration choices I should check before attributing the difference to the implementation?

* * *

[Benchmark project.](https://github.com/pezzott1/Bench_MassRep)

@James.Keeling

---

<div class="post-metadata">

**Author:** ![Tom\_Looman](https://d1ap1mz92jnks1.cloudfront.net/user_avatar/forums.unrealengine.com/tom_looman/32/1215549_2.png) [@Tom\_Looman](https://forums.unrealengine.com/u/Tom_Looman)\
**Post date:** [October 29, 2026, 1:42pm UTC](https://forums.unrealengine.com/t/profiling-mass-representation-native-ism-vs-bulk-ism-updates-vs-ndc/2844794/2 "2026-10-29T13:42:49Z")

</div>

Thanks for sharing! Will you continue to expand on this? I am building my own data-oriented projectiles too - without Mass but simple data structures inside a subsystem. since I have particles as the visual representation, ISMs isn’t really going to work, but still interesting to see the various implementations.

Project: [GitHub - tomlooman/ActionRoguelike: Project Orion - Co-op Action Roguelike in Unreal Engine C++ · GitHub](https://github.com/tomlooman/ActionRoguelike)

---

<div class="post-metadata">

**Author:** ![pezzott1](https://d1ap1mz92jnks1.cloudfront.net/user_avatar/forums.unrealengine.com/pezzott1/32/1215068_2.png) [@pezzott1](https://forums.unrealengine.com/u/pezzott1)\
**Post date:** [October 29, 2026, 3:55pm UTC](https://forums.unrealengine.com/t/profiling-mass-representation-native-ism-vs-bulk-ism-updates-vs-ndc/2844794/3 "2026-10-29T15:55:14Z")

</div>

Thanks, Tom! I do plan to keep expanding the benchmark, especially to test dynamic spawning and destruction.

I had a quick look at your RogueProjectilesSubsystem, and I think our approaches are actually quite similar in principle. The main difference is that using Mass pushed me toward a more composition-based architecture, with fragments holding the data and specialized processors handling different responsibilities. One thing I particularly like about Mass is how it makes it easier to parallelize processors provided their data access and execution dependencies allow it.

Elaborating a bit more about my plugin: I’ve been working on a projectile framework for a while now, with the goal of eventually handling tens of thousands of projectiles simultaneously. Performance was actually the main reason I went with Mass in the first place along with keeping the framework with UE6 in mind.

After experimenting with a few approaches, I settled on using Mass’s native entity management. I currently have a lightweight MassSubsystem that mainly handles framework-specific tasks, such as validating spawn requests, caching archetypes from projectile definitions, and initializing fragments.

The current architecture is split into several processors, with their execution order explicitly defined:

**PrePhysics:**

- **Age (optional):** Tracks elapsed time.
- **Lifetime:** Handles expiration based on age.
- **Ballistic / Homing / Linear:** These are separate, mutually exclusive movement behaviors. They only modify the velocity fragment, changing speed and/or direction without committing changes to the transform fragment.
- **Collision:** This is where movement actually gets resolved. It takes the velocity, performs the necessary collision queries, and writes the final transform depending on what happened. A projectile might move normally, stop at an impact point, or continue along a ricochet trajectory.
- **Event, Trigger, and Damage:** Handle the events and gameplay consequences that follow.

**Physics:**

- Chaos updates any physics-driven objects that projectiles might be attached to.

**PostPhysics:**

- **Sticky (optional):** Updates projectiles attached to moving physics objects.
- **Niagara Publisher:** Sends visual data to Niagara through NDC.
- **Event Cleanup:** Handles and clears events generated by previous processors. The idea is to centralize event handling rather than having multiple processors duplicate the same logic or potentially interfere with each other.
- **Behavior Transition:** Applies behavior changes for the following frame. For example, a ballistic entity could become a homing entity based on certain conditions. Since these structural changes are deferred in Mass, they’re committed at the end of the pipeline and take effect the next frame.
- **Termination:** Handles final entity destruction.

One thing I particularly wanted was to avoid having several processors modifying the same Transform. For example, homing only needs to adjust the velocity direction. It doesn’t need to know anything about collision resolution or actually moving the projectile. That responsibility stays with the Collision Processor.

Initially, I was using Mass’s native ISM representation, but profiling showed `EndVisualChanges()` taking around 10.7–10.9 ms with 20,000 visible entities. That’s ultimately what led me to create this benchmark. After comparing the different approaches, I’ve decided to stick with a shared Niagara system using persistent particles, feeding it transforms through a persistent typed writer. The results so far have been very good.

The second biggest problem I’ve encountered is the cost of performing thousands of collision traces every frame. I’ve been experimenting with lower-level Chaos raycasts, processing them in batches to reduce some of the overhead associated with individual engine trace calls. My current approach is to perform a cheaper, lower-level collision query every frame and only fall back to the regular engine traces when a potential hit is detected.

This makes individual hits a bit more expensive, but my reasoning is that a projectile will typically spend many frames in flight and only hit something once (unless it ricochets). So paying the additional cost only when a hit occurs should be cheaper overall than performing full engine traces every frame. This is something I still need to validate through a dedicated benchmark.

There are some drawbacks, though. Batching doesn’t make the queries asynchronous or reduce the actual number of collision tests. There’s still synchronization involved when accessing the physics scene, and sphere sweeps have been a lot more expensive in my testing. So collision is still an area where I think there’s room for improvement.

I’d eventually like to compare Mass against a straightforward array-based subsystem using equivalent simulation and rendering workloads. I’m curious how much of the performance difference would actually come from Mass itself.

All of this comes after several months of research and refactoring as I learn more. I’m actually still implementing this latest architecture which is a fairly substantial refactor of the original framework. I feel I’ve reached a point where the biggest performance challenges are not coming from processing the projectile data itself, but from the engine systems that consume it.

I’m also relatively new to Mass and ECS in general so it’s been quite a learning experience. A lot of these decisions still need to be tested under real gameplay conditions, but that’s part of what makes this interesting to me.

I’d be happy to share more about the current architecture once I’ve finished implementing the refactor. And feel free to ask me anything in the meantime. I’ll do my best to share what I’ve learned along the way!

---

<div class="post-metadata">

**Author:** ![pezzott1](https://d1ap1mz92jnks1.cloudfront.net/user_avatar/forums.unrealengine.com/pezzott1/32/1215068_2.png) [@pezzott1](https://forums.unrealengine.com/u/pezzott1)\
**Post date:** [November 4, 2026, 5:37am UTC](https://forums.unrealengine.com/t/profiling-mass-representation-native-ism-vs-bulk-ism-updates-vs-ndc/2844794/4 "2026-11-04T05:37:30Z")

</div>

Tested MassEngine component-free ISM path in UE 5.8.4. These are the average Game Thread frame times from a packaged dev build with 20,000 moving entities:

- No representation: 2.93 ms
- Traditional Mass ISM: 9.34 ms
- NDC: 4.10 ms
- Custom bulk ISM: 5.86 ms
- MassEngine ISM: 7.29 ms

For MassEngine ISM the average CPU costs per call were:

- Transform collection and signals: 0.082 ms
- MassDirtyRenderISMInstanceProcessor: 3.409 ms
- MassCreateRenderStateProcessor: 0.900 ms

Most of MassEngine ISMs CPU update cost comes from updating instance data and recreating render state.

> **Example of MassEngine ISM frame from the packaged capture:**
>
> ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/7/9/a/79a06d19f5bd3aa13ed5655cbecee0a674bd2955.png)
> 
> ![image](https://d3kjluh73b9h9o.cloudfront.net/original/4X/a/4/7/a4718a96c9ec6f93f5a9adfd0daad76d366f6764.png)

A quick comparison of the two Mass representation paths:

- Traditional Mass ISM (MISM) vs MassEngine (MEISM)
  - System:
    - MISM: MassRepresentation
    - MEISM: MassEngine

  - Rendering owner:
    - MISM: UMassVisualizationComponent manages ISM components.
    - MEISM: MassEngine manages render state and scene proxies directly, without ISM components.

  - Transform updates:
    - MISM: Mass collects instance changes and applies them through the components.
    - MEISM: the benchmark processor signals changed transforms; native MassEngine processors update instance data.

  - Main measured cost:
    - MISM: MassUpdateISMProcessor and EndVisualChanges.
    - MEISM: Dirty-instance processing and render-state creation.

  - Packaged frame time:
    - MISM: 9.34 ms
    - MEISM: 7.29 ms

Checking ue6-main, if I’m not mistaken there is a new path that keeps render state alive, but I have to compile and ain’t nobody got time for that. I’ll wait for EA. 🫠

* * *

@James.Keeling
