What are the best practices for optimizing Niagara vertex shader performance?

Attached is a sokatoa profile capture (vertexshaderperformance.zip), along with some screenshots of some of the problems we’ve identified. On our low end android mobile devices (Mali-G52 MC2), we’re spending a lot of time in the vertex shader. Some of this is for characters, and part of that is definitely that they are not all decimated yet. But a large portion is stemming from niagara. With some of our simpler cosmetics, we’ll hover around 25ms per frame even with characters on screen. But that will jump up to 60ms once combat starts and the VFX start playing.

I’ve attached a Sokatoa capture (https://github.com/sarc\-acl/sokatoa), as well as some screenshots from the capture showing the vertex bubble, high register pressure, low usage of 16-bit interpolators. I’ve also attached a zip that contains the Mali offline compiler output for both vertex and pixel shaders on both OpenGL and Vulkan, plus the glsl, all gathered from RenderDoc for ARM. malioc appears to point to Load/Store, not arithmetic, as the biggest cost, plus we are at 50% occupancy with our register usage.

We have many assets to optimize, and they can’t all be done by hand, so we’d like to get some best practices/guidance on how to optimize these systems for better vertex shader performance. We know that we’ll have to apply scalability as well to cut some things out, but we’d like to get our baseline performance of our shaders down as much as possible too.

[Attachment Removed]

Steps to Reproduce
Our niagara vfx have poor GPU performance on low end mobile, and are particularly vertex shader bound. We can’t easily share a repro, but I’ve attached performance profiling information.

[Attachment Removed]

Hi Tommy,

Can you additionally provide a .utrace with GPU channel captured on this device?

Thanks!

[Attachment Removed]

Is there anything in particular we need to do to enable GPU timings on mobile for Insights? I use gpu in my -trace command, and in Insights I check the boxes next to GPU* under ‘All Tracks’ and ‘CPU/GPU’ in Insights, but I have never gotten anything to show up in the GPU track across any of my devices.

[Attachment Removed]

Hi Tommy,

If running Vulkan, you would need to add:

r.Android.SupportsTimestampQueries=1Due to the heavyweight nature of VK timestamps, these are disabled by default.

Best regards.

[Attachment Removed]

Here’s a trace with that set. It gets some numbers showing up on the GPU0-Graphics0 track, but nothing more granular, and nothing on the GPU track. Presumably due to limited support from the device (which has a Mali-G52 MP2)?

Just to clarify the “if running vulkan” part of your advice, is OpenGL expected to provide something without r.Android.SupportsTimestampQueries, or is it not expected to show anything at all? I don’t even see overall GPU frame times in Insights (even though we see overall gpu frametime in stat unitgraph).

On a related note, have you experienced devices where the GPU frametime on OpenGL is inconsistent with what shows up in other tools? On this device, we’re finding that during heavy combat, while vulkan GPU frametime is jumping up around 60ms, OpenGL is around 30-35ms according to stat unitgraph. However, if I look at an arm streamline capture and zoom in on a single frame, the OpenGL gpu time appears to be closer to what we’re seeing with Vulkan. So I don’t know which tool to trust, though for now I’ve been working under the assumption that streamline is correct and stat unitgraph is under-reporting OpenGL gpu frametime.

[Attachment Removed]

Hi Tommy,

Thank you for the utrace, much appreciated.

The same CVar should apply to GLES devices, however, the functionality would additionally be gated on the device implementing the GL_EXT_disjoint_timer_query. I do not have a G52 device handy to verify if it meets the conditions, however these queries on Mali devices have a history of being unreliable and innaccurate. You’re very likely getting better data with ARM Streamline.

Best regards.

[Attachment Removed]

One more specific question we had was what is the impact of having extra Niagara variables bound in the bindings section that are ultimately unused?

We have lots of examples like the attached image where 8 or so bindings are either un-set by the niagara system itself, set but using a constant default value, or set by the system but having no effect on the final result because there’s nothing in the material/shader that does anything with that value (like normalized age binding).

Does that add overhead in the vertex shader, or are those ultimately stripped out? According to the Epic Developer Assistant, it adds overhead. But when I made a local test where I set those to ‘None’ manually and took another renderdoc capture, the glsl and malioc stats appear identical to the ones I attached in my initial post, which were made with all those unused bindings set. (though that could have been a flaw in my testing)

[Attachment Removed]

Hi Tommy,

I’ll look into and discuss this particular issue with the Niagara Folks. At first glance though, unused bindings shouldn’t have significant impact on VS performance. As for fetching constant vertex attributes, this generally yields similar performance uniform buffer access.

One thing that was brought up in our current discussions is the possibility to enable GPU Scene for Niagara meshes. If you are using these, it would be interesting to see if that offers any improved performance. To do so, you can change the FNiagaraMeshVertexFactory::IsGPUSceneEnabled function to return true. That would likely help a lot as all the transformation are done up front rather than per vertex, however would affect the sort order of translucents.

As far as overall performance, the team does see the Niagara VS itself as the likely biggest performance bottleneck. Likely can be optimized and simplified but there isn’t a clear timeline as to when that will happen yet.

Best regards.

[Attachment Removed]

I will try using GPUScene for the niagara meshes, however I’m interested in why it’s disabled for mobile in the first place with if (FeatureLevel == ERHIFeatureLevel::ES3_1).

Additionally, we’ve been running into sporadic issues where a shader fails to compile with

[2026.06.19-05.52.09:710][389]LogRHI: Error: Failed to compile shader. Compile log:

Compile failed.

ERROR: 0:33: shader storage block binding gets value 9, out of range [0 - 7]

ERROR: 0:38: shader storage block binding gets value 8, out of range [0 - 7]

2 compilation errors. No code generated.

One of the solutions we’re considering is to disable GPUScene entirely on mobile, at least on devices that encounter this crash, which appears to work around the shader compilation issue. But in that case, enabling GPUScene for niagara meshes would not be an option.

[Attachment Removed]

Hi Tommy,

I would guess that that shader compilation failure is occurring on devices running the GLES RHI correct? It is indicative of a shader related to GPU scene exceeding the device’s GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS which could be used to conditionally disable GPU scene when this is limited to 8 (the minimum guaranteed under ES 3.2).

Best regards.

[Attachment Removed]