Free Script - Particle Systems in UI Screen Space Overlay

I haven’t been too active in the Unity forums, but I am a professional dev who has been working with Unity for several years. I’ve recently been working with the superb new team at Section Studios in LA, to create a stellar new mobile action-RPG.

We’ve been confronting and tackling many interesting issues, trying to create cutting edge effects and gameplay for the mobile platform. My goal is to eventually publish a blog with some of my results, but for now I thought I’d start by posting one particular solution that I’ve been proud of recently.

Currently, the only way to get Particle Systems to show up in UI is to create Camera-Space canvases, which can lead to undesirable effects or complications in design. I decided to scrap together a solution that would allow these particles to be rendered as true UI elements (in the UI render order pipeline). The following script is the result of this experiment. It may not be perfect yet (there are still some Unity bugs preventing 100% support for PS features), but it does seem to handle most cases, and is fairly performant.

INSTRUCTIONS: Simply create a ParticleSystem on an empty RectTransform object in your UI hierarchy, and set it to your UI layer. Then add this script and it should initialize itself with that system and begin reflecting modifications accordingly. NOTE - that it does attempt to hide the actual ParticleSystem particles by using applying a “UI/Particles/Hidden” shader to them. This isn’t really necessary, but will prevent them from showing up (in duplicate) in the Scene View. You can either create this shader which should just “discard” and not render anything, or you can comment out those couple lines which apply that shader.

I’d love to hear any feedback on how to improve this script’s support or performance. Again, I hope to start conversations about many of the issues we’ve been tackling in our endeavors, so I look forward to beginning this dialog. Please comment with any thoughts.
Thanks!

using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;

[ExecuteInEditMode]
[RequireComponent(typeof(CanvasRenderer))]
[RequireComponent(typeof(ParticleSystem))]
public class UIParticleSystem : MaskableGraphic {

    public Texture particleTexture;
    public Sprite particleSprite;

    private Transform _transform;
    private ParticleSystem _particleSystem;
    private ParticleSystem.Particle[] _particles;
    private UIVertex[] _quad = new UIVertex[4];
    private Vector4 _uv = Vector4.zero;
    private ParticleSystem.TextureSheetAnimationModule _textureSheetAnimation;
    private int _textureSheetAnimationFrames;
    private Vector2 _textureSheedAnimationFrameSize;

    public override Texture mainTexture {
        get {
            if (particleTexture) {
                return particleTexture;
            }

            if (particleSprite) {
                return particleSprite.texture;
            }
               
            return null;
        }
    }

    protected bool Initialize() {
        // initialize members
        if (_transform == null) {
            _transform = transform;
        }
        if (_particleSystem == null) {
            _particleSystem = GetComponent<ParticleSystem>();

            if (_particleSystem == null) {
                return false;
            }

            // automatically set material to UI/Particles/Hidden shader, and get previous texture
            ParticleSystemRenderer renderer = _particleSystem.GetComponent<ParticleSystemRenderer>();
            if (renderer == null) {
                renderer = _particleSystem.gameObject.AddComponent<ParticleSystemRenderer>();
            }
            Material currentMaterial = renderer.sharedMaterial;
            if (currentMaterial && currentMaterial.HasProperty("_MainTex")) {
                particleTexture = currentMaterial.mainTexture;
            }
            Material material = new Material(Shader.Find("UI/Particles/Hidden")); // TODO - You should create this discard shader
            if (Application.isPlaying) {
                renderer.material = material;
            }
            #if UNITY_EDITOR
            else {
                material.hideFlags = HideFlags.DontSave;
                renderer.sharedMaterial = material;
            }
            #endif

            // automatically set scaling
            _particleSystem.scalingMode = ParticleSystemScalingMode.Hierarchy;

            _particles = null;
        }
        if (_particles == null) {
            _particles = new ParticleSystem.Particle[_particleSystem.maxParticles];
        }

        // prepare uvs
        if (particleTexture) {
            _uv = new Vector4(0, 0, 1, 1);
        } else if (particleSprite) {
            _uv = UnityEngine.Sprites.DataUtility.GetOuterUV(particleSprite);
        }

        // prepare texture sheet animation
        _textureSheetAnimation = _particleSystem.textureSheetAnimation;
        _textureSheetAnimationFrames = 0;
        _textureSheedAnimationFrameSize = Vector2.zero;
        if (_textureSheetAnimation.enabled) {
            _textureSheetAnimationFrames = _textureSheetAnimation.numTilesX * _textureSheetAnimation.numTilesY;
            _textureSheedAnimationFrameSize = new Vector2(1f / _textureSheetAnimation.numTilesX, 1f / _textureSheetAnimation.numTilesY);
        }

        return true;
    }

    protected override void Awake() {
        base.Awake();

        if (!Initialize()) {
            enabled = false;
        }
    }

    protected override void OnPopulateMesh(VertexHelper vh) {
        #if UNITY_EDITOR
        if (!Application.isPlaying) {
            if (!Initialize()) {
                return;
            }
        }
        #endif

        // prepare vertices
        vh.Clear();

        if (!gameObject.activeInHierarchy) {
            return;
        }

        // iterate through current particles
        int count = _particleSystem.GetParticles(_particles);

        for (int i = 0; i < count; ++i) {
            ParticleSystem.Particle particle = _particles[i];

            // get particle properties
            Vector2 position = (_particleSystem.simulationSpace == ParticleSystemSimulationSpace.Local ? particle.position : _transform.InverseTransformPoint(particle.position));
            float rotation = -particle.rotation * Mathf.Deg2Rad;
            float rotation90 = rotation + Mathf.PI / 2;
            Color32 color = particle.GetCurrentColor(_particleSystem);
            float size = particle.GetCurrentSize(_particleSystem) * 0.5f;

            // apply scale
            if (_particleSystem.scalingMode == ParticleSystemScalingMode.Shape) {
                position /= canvas.scaleFactor;
            }

            // apply texture sheet animation
            Vector4 particleUV = _uv;
            if (_textureSheetAnimation.enabled) {
                float frameProgress = 1 - (particle.lifetime / particle.startLifetime);
//                float frameProgress = textureSheetAnimation.frameOverTime.curveMin.Evaluate(1 - (particle.lifetime / particle.startLifetime)); // TODO - once Unity allows MinMaxCurve reading
                frameProgress = Mathf.Repeat(frameProgress * _textureSheetAnimation.cycleCount, 1);
                int frame = 0;

                switch (_textureSheetAnimation.animation) {

                case ParticleSystemAnimationType.WholeSheet:
                    frame = Mathf.FloorToInt(frameProgress * _textureSheetAnimationFrames);
                    break;

                case ParticleSystemAnimationType.SingleRow:
                    frame = Mathf.FloorToInt(frameProgress * _textureSheetAnimation.numTilesX);

                    int row = _textureSheetAnimation.rowIndex;
//                    if (textureSheetAnimation.useRandomRow) { // FIXME - is this handled internally by rowIndex?
//                        row = Random.Range(0, textureSheetAnimation.numTilesY, using: particle.randomSeed);
//                    }
                    frame += row * _textureSheetAnimation.numTilesX;
                    break;

                }

                frame %= _textureSheetAnimationFrames;

                particleUV.x = (frame % _textureSheetAnimation.numTilesX) * _textureSheedAnimationFrameSize.x;
                particleUV.y = Mathf.FloorToInt(frame / _textureSheetAnimation.numTilesX) * _textureSheedAnimationFrameSize.y;
                particleUV.z = particleUV.x + _textureSheedAnimationFrameSize.x;
                particleUV.w = particleUV.y + _textureSheedAnimationFrameSize.y;
            }

            _quad[0] = UIVertex.simpleVert;
            _quad[0].color = color;
            _quad[0].uv0 = new Vector2(particleUV.x, particleUV.y);

            _quad[1] = UIVertex.simpleVert;
            _quad[1].color = color;
            _quad[1].uv0 = new Vector2(particleUV.x, particleUV.w);

            _quad[2] = UIVertex.simpleVert;
            _quad[2].color = color;
            _quad[2].uv0 = new Vector2(particleUV.z, particleUV.w);

            _quad[3] = UIVertex.simpleVert;
            _quad[3].color = color;
            _quad[3].uv0 = new Vector2(particleUV.z, particleUV.y);

            if (rotation == 0) {
                // no rotation
                Vector2 corner1 = new Vector2(position.x - size, position.y - size);
                Vector2 corner2 = new Vector2(position.x + size, position.y + size);

                _quad[0].position = new Vector2(corner1.x, corner1.y);
                _quad[1].position = new Vector2(corner1.x, corner2.y);
                _quad[2].position = new Vector2(corner2.x, corner2.y);
                _quad[3].position = new Vector2(corner2.x, corner1.y);
            } else {
                // apply rotation
                Vector2 right = new Vector2(Mathf.Cos(rotation), Mathf.Sin(rotation)) * size;
                Vector2 up = new Vector2(Mathf.Cos(rotation90), Mathf.Sin(rotation90)) * size;

                _quad[0].position = position - right - up;
                _quad[1].position = position - right + up;
                _quad[2].position = position + right + up;
                _quad[3].position = position + right - up;
            }

            vh.AddUIVertexQuad(_quad);
        }
    }

    void Update() {
        if (Application.isPlaying) {
            // unscaled animation within UI
            _particleSystem.Simulate(Time.unscaledDeltaTime, false, false);

            SetAllDirty();
        }
    }

    #if UNITY_EDITOR
    void LateUpdate() {
        if (!Application.isPlaying) {
            SetAllDirty();
        }
    }
    #endif

}
41 Likes

Looks awesome @glennpow Would you be OK with me adding this to UI Extensions project? (link below)

3 Likes

@SimonDarksideJ , I was undoubtedly inspired by some of your posts and your work on the UI Extensions. One of my goals was actually to reach out to you personally, for feedback and to see if you might find it useful. You can certainly integrate this script into your library. Please let me know if you devise any improvements.
And thank you for all the support you’ve provided over the years!

4 Likes

I’ve been running into issues with particle systems + UGUI lately so I’m definitely gonna check this out!!! Thanks a lot!

Worth noting, that this is only supported in Unity 5.3 and upwards (thankfully there’s a new UNITY_5_3_OR_NEWER preprocessor directive to make that easier)
So the UI Extensions project got it’s first ever #IF :smile:

Added @glennpow for the support, consider it added. Testing tonight for the updated release.

P.S. you can always just fork the project and add these yourself through a Pull Request :smile: But I’m just as happy to put them in as well (no barriers to contributing)

@glennpow can you upload the “ui/particles/hidden” shader you used please?

OK, hitting problems. Added it to the UI and it shows up in the scene view (with the shader commented out) but it doesn’t show up in the Game view.

Another issue is that it always renders behind other UI elements. E.G. I placed the particle script object as a child to an image and no matter what I did / changed it would always render behind.

Thoughts?

Well, it’s likely only showing up in the scene view because it’s showing the default particle system rendering (same as it would without the UIParticleSystem script attached).
When you attach the UIParticleSystem script it will force the particle system to switch to “Hierarchy” Scaling Mode. This is necessary to render correctly in the UI, but will likely mean that the particles are very very small in the UI view. You will need to scale them up rather large, so that they are at Screen-Pixel Size. The speeds will also have to be scaled up accordingly.

It still isn’t necessary to fix your issue, but the hidden particle shader is below.
Please let me know if you still have problems.
Cheers

Shader "UI/Particles/Hidden"
{
    Properties
    {
    }
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }
        Cull Off Lighting Off ZWrite Off Fog { Mode Off }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            v2f vert ()
            {
                v2f o;
                o.vertex = fixed4(0, 0, 0, 0);
                return o;
            }
           
            fixed4 frag (v2f i) : SV_Target
            {
                discard;
                return fixed4(0, 0, 0, 0);
            }
            ENDCG
        }
    }
}
5 Likes

OK, got that working now. For some reason without that shader it doesn’t work in runtime, very odd. will have to test more on that.

Another issue I seem to run in to is that no matter the change to size/ duration or anything to do with the default particles, it doesn’t change what is rendered on the ui layer, very odd. Changing the shape seems to work but over all, no changes to the actual particle system take effect. Any ideas?

Hm, that’s strange. I definitely see changes reflected immediately in the UI in my projects.
Is there anything showing up in the UI at all? Are the particles animating? The SetAllDirty call in the LateUpdate method should be triggering the UI to rebuild the mesh each frame (even when not playing, since there is the ExecuteInEditMode attribute on the script).

You could send me a sample project, and I could try it out on my side.

Very Good!2655093--187151--1111111111111111.png

@ , Nice!
Here are a few more shaders that you can create materials from and then set them as the property on the UIParticleSystem component. Then the particles can be drawn additively, etc.

2655105–187153–UI-Shaders.zip (4.79 KB)

1 Like

Added those plus some simple scripts to use them for the 1.1 UI Extensions update @glennpow :smile:
Finishing touches to ship it now.

1 Like

Looks cool in theory, thanks for sharing!
But wouldn’t it be vastly inferior in performance to native particles due to canvas batch sorting?

@ortin , Yes it’s absolutely not as performant as native particles, but Unity as of yet offers no clean solution for this particular situation. Note, that if your project can take advantage of using Screen Space Camera or World Space UI, then you should definitely go that route. This UIParticleSystem script is intended solely for situations where you only want a single Screen Space Overlay UI, and want the particles to be rendered in the sorted UI pipeline.
One other option that I’d considered was to render particles using an extra camera and render texture, and just place that single texture into the UI, but it obviously isn’t quite as dynamic.

1 Like

Yeah, I now insert particles using sorting layers and additional canvases which is PITA to manage. :slight_smile:
But I’ll give it a try when I have time, may be performance is not that bad if you put it in its own Canvas.

As the script simply converts the particle stream in to vertex quads, there isn’t too much impact. Just the normal overhead to convert it.

@SimonDarksideJ and all,
I’ve updated the script so that particle movement works properly despite the screen resolutions, and to handle the hidden shader application a little cleaner. Note that the scaleMode is automatically set to Local instead of Hierarchy. I learned this is actually the right choice. Any existing UIParticleSystems will need to have their velocities modified to account for the change though.

Sorry, I haven’t branched the UI repo, in order to issue pull requests. I’m somewhat short on time at the moment. I’ve included the file here for now.
Hopefully it helps. Cheers!

using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;

[ExecuteInEditMode]
[RequireComponent(typeof(CanvasRenderer))]
[RequireComponent(typeof(ParticleSystem))]
public class UIParticleSystem : MaskableGraphic {

    public Texture particleTexture;
    public Sprite particleSprite;

    private Transform _transform;
    private ParticleSystem _particleSystem;
    private ParticleSystem.Particle[] _particles;
    private UIVertex[] _quad = new UIVertex[4];
    private Vector4 _uv = Vector4.zero;
    private ParticleSystem.TextureSheetAnimationModule _textureSheetAnimation;
    private int _textureSheetAnimationFrames;
    private Vector2 _textureSheedAnimationFrameSize;

    public override Texture mainTexture {
        get {
            if (particleTexture) {
                return particleTexture;
            }

            if (particleSprite) {
                return particleSprite.texture;
            }
               
            return null;
        }
    }

    protected bool Initialize() {
        // initialize members
        if (_transform == null) {
            _transform = transform;
        }

        // prepare particle system
        ParticleSystemRenderer renderer = GetComponent<ParticleSystemRenderer>();
        bool setParticleSystemMaterial = false;

        if (_particleSystem == null) {
            _particleSystem = GetComponent<ParticleSystem>();

            if (_particleSystem == null) {
                return false;
            }

            // get current particle texture
            if (renderer == null) {
                renderer = _particleSystem.gameObject.AddComponent<ParticleSystemRenderer>();
            }
            Material currentMaterial = renderer.sharedMaterial;
            if (currentMaterial && currentMaterial.HasProperty("_MainTex")) {
                particleTexture = currentMaterial.mainTexture;
            }

            // automatically set scaling
            _particleSystem.scalingMode = ParticleSystemScalingMode.Local;

            _particles = null;
            setParticleSystemMaterial = true;
        } else {
            if (Application.isPlaying) {
                setParticleSystemMaterial = (renderer.material == null);
            }
            #if UNITY_EDITOR
            else {
                setParticleSystemMaterial = (renderer.sharedMaterial == null);
            }
            #endif
        }

        // automatically set material to UI/Particles/Hidden shader, and get previous texture
        if (setParticleSystemMaterial) {
            Material material = new Material(Shader.Find("UI/Particles/Hidden"));
            if (Application.isPlaying) {
                renderer.material = material;
            }
            #if UNITY_EDITOR
            else {
                material.hideFlags = HideFlags.DontSave;
                renderer.sharedMaterial = material;
            }
            #endif
        }

        // prepare particles array
        if (_particles == null) {
            _particles = new ParticleSystem.Particle[_particleSystem.maxParticles];
        }

        // prepare uvs
        if (particleTexture) {
            _uv = new Vector4(0, 0, 1, 1);
        } else if (particleSprite) {
            _uv = UnityEngine.Sprites.DataUtility.GetOuterUV(particleSprite);
        }

        // prepare texture sheet animation
        _textureSheetAnimation = _particleSystem.textureSheetAnimation;
        _textureSheetAnimationFrames = 0;
        _textureSheedAnimationFrameSize = Vector2.zero;
        if (_textureSheetAnimation.enabled) {
            _textureSheetAnimationFrames = _textureSheetAnimation.numTilesX * _textureSheetAnimation.numTilesY;
            _textureSheedAnimationFrameSize = new Vector2(1f / _textureSheetAnimation.numTilesX, 1f / _textureSheetAnimation.numTilesY);
        }

        return true;
    }

    protected override void Awake() {
        base.Awake();

        if (!Initialize()) {
            enabled = false;
        }
    }

    protected override void OnPopulateMesh(VertexHelper vh) {
        #if UNITY_EDITOR
        if (!Application.isPlaying) {
            if (!Initialize()) {
                return;
            }
        }
        #endif

        // prepare vertices
        vh.Clear();

        if (!gameObject.activeInHierarchy) {
            return;
        }

        // iterate through current particles
        int count = _particleSystem.GetParticles(_particles);

        for (int i = 0; i < count; ++i) {
            ParticleSystem.Particle particle = _particles[i];

            // get particle properties
            Vector2 position = (_particleSystem.simulationSpace == ParticleSystemSimulationSpace.Local ? particle.position : _transform.InverseTransformPoint(particle.position));
            float rotation = -particle.rotation * Mathf.Deg2Rad;
            float rotation90 = rotation + Mathf.PI / 2;
            Color32 color = particle.GetCurrentColor(_particleSystem);
            float size = particle.GetCurrentSize(_particleSystem) * 0.5f;

            // apply scale
            if (_particleSystem.scalingMode == ParticleSystemScalingMode.Shape) {
                position /= canvas.scaleFactor;
            }

            // apply texture sheet animation
            Vector4 particleUV = _uv;
            if (_textureSheetAnimation.enabled) {
                float frameProgress = 1 - (particle.lifetime / particle.startLifetime);
//                float frameProgress = textureSheetAnimation.frameOverTime.curveMin.Evaluate(1 - (particle.lifetime / particle.startLifetime)); // TODO - once Unity allows MinMaxCurve reading
                frameProgress = Mathf.Repeat(frameProgress * _textureSheetAnimation.cycleCount, 1);
                int frame = 0;

                switch (_textureSheetAnimation.animation) {

                case ParticleSystemAnimationType.WholeSheet:
                    frame = Mathf.FloorToInt(frameProgress * _textureSheetAnimationFrames);
                    break;

                case ParticleSystemAnimationType.SingleRow:
                    frame = Mathf.FloorToInt(frameProgress * _textureSheetAnimation.numTilesX);

                    int row = _textureSheetAnimation.rowIndex;
//                    if (textureSheetAnimation.useRandomRow) { // FIXME - is this handled internally by rowIndex?
//                        row = Random.Range(0, textureSheetAnimation.numTilesY, using: particle.randomSeed);
//                    }
                    frame += row * _textureSheetAnimation.numTilesX;
                    break;

                }

                frame %= _textureSheetAnimationFrames;

                particleUV.x = (frame % _textureSheetAnimation.numTilesX) * _textureSheedAnimationFrameSize.x;
                particleUV.y = Mathf.FloorToInt(frame / _textureSheetAnimation.numTilesX) * _textureSheedAnimationFrameSize.y;
                particleUV.z = particleUV.x + _textureSheedAnimationFrameSize.x;
                particleUV.w = particleUV.y + _textureSheedAnimationFrameSize.y;
            }

            _quad[0] = UIVertex.simpleVert;
            _quad[0].color = color;
            _quad[0].uv0 = new Vector2(particleUV.x, particleUV.y);

            _quad[1] = UIVertex.simpleVert;
            _quad[1].color = color;
            _quad[1].uv0 = new Vector2(particleUV.x, particleUV.w);

            _quad[2] = UIVertex.simpleVert;
            _quad[2].color = color;
            _quad[2].uv0 = new Vector2(particleUV.z, particleUV.w);

            _quad[3] = UIVertex.simpleVert;
            _quad[3].color = color;
            _quad[3].uv0 = new Vector2(particleUV.z, particleUV.y);

            if (rotation == 0) {
                // no rotation
                Vector2 corner1 = new Vector2(position.x - size, position.y - size);
                Vector2 corner2 = new Vector2(position.x + size, position.y + size);

                _quad[0].position = new Vector2(corner1.x, corner1.y);
                _quad[1].position = new Vector2(corner1.x, corner2.y);
                _quad[2].position = new Vector2(corner2.x, corner2.y);
                _quad[3].position = new Vector2(corner2.x, corner1.y);
            } else {
                // apply rotation
                Vector2 right = new Vector2(Mathf.Cos(rotation), Mathf.Sin(rotation)) * size;
                Vector2 up = new Vector2(Mathf.Cos(rotation90), Mathf.Sin(rotation90)) * size;

                _quad[0].position = position - right - up;
                _quad[1].position = position - right + up;
                _quad[2].position = position + right + up;
                _quad[3].position = position + right - up;
            }

            vh.AddUIVertexQuad(_quad);
        }
    }

    void Update() {
        if (Application.isPlaying) {
            // unscaled animation within UI
            _particleSystem.Simulate(Time.unscaledDeltaTime, false, false);

            SetAllDirty();
        }
    }

    #if UNITY_EDITOR
    void LateUpdate() {
        if (!Application.isPlaying) {
            SetAllDirty();
        }
    }
    #endif

}
3 Likes

No worries @glennpow I’ll grab this and add it to the latest source (after testing of course :smile:)

Thanks for this man - helped alot!

Updated script included in the 1.1.1 update. Currently in source and will package with the next release @glennpow