[Tips] on How to improve 2D lights performance

Hi everybody, this is going to be a short post with some tips on how to drastically (in some cases) reduce the usage of the GPU when using 2D lights and shadow casters in your scenes. I have read many people who complain about the bad performance of the 2D lighting system, without knowing why or what are they doing wrong. I have also read other well-meaning devs that spread some tips that are not true at all.
After a lot of time digging and analyzing the code of the 2D Renderer, I came to some conclusions that I will summarize in the following paragraph. In the near future, if I have enough time, I will explain how the 2D Renderer works internally in detail (until the version 2020.3.4).

Define sorting layers in a contiguous way

Contrary to what some people say, you can have as many sorting layers as you want, and that will not affect (in a noticeable way) the performance of your game. HOWEVER, those sorting layers that are affected by the same lights must be consecutive in the list where you define them, in the editor.

Let’s suppose you have 5 sorting layers:

  • FarBackground

  • NearBackground

  • Props

  • Characters

  • Overlay

Now let’s suppose all your “normal” lights affect in the same way the sprites in the sorting layers NearBackground, Props and Characters. The lights texture will be generated only once, despite they are 3 layers. You could have 500 layers instead of 3 and there would not be any significant difference. The system identifies blocks of lights that affect consecutive layers, so there would be only 1 block in the example.
Now imagine your list looked like this (new layers would not be affected by lights):

  • FarBackgorund

  • NearBackground

  • UnlitNearBackgorund

  • Props

  • UnlitProps

  • Characters

  • UnlitCharacters

  • Overlay

With this setup, the system would check the target sorting layers of the lights, one by one, from the first (furthest/deepest) to the last. It would find that NearBackground is affected by all the lights, but then it would see that UnlitNearBackground is not. All the lights that affect NearBackground would be rendered to the light texture (including the calculation of the shadow casters), the sprites in that sorting layer would be rendered using that texture, and the system would continue checking next layers. The same would happen to the Props layer, the same lights would be rendered (and their shadows), and the same would happen again to the Characters layer. All lights and shadow casters are calculated 3 times.

Combine shadow casters

For each light that is to be rendered (and whose Shadow Intensity > 0) all the shadow casters that match the same target sorting layers will be calculated. So it is better for you to place as less ShadowCaster2Ds in your scene as possible. This does not mean that you must resign yourself to not using them, instead this means that you should find a way to collect all the meshes generated by most of the shadow casters and combine them into one, so you are only calculating one shadow per light in the best case, no matter how many polygons it consists of. This is a big performance booster.

Set Shadow Intensity to zero, when possible

If a light is not supposed to cast shadows because it is far from any shadow caster or the shadow it casts is barely visible, you should set its inspector property “Shadow Intensity” to zero. This way, when rendering the light, no shadow caster will be calculated.

Some last comments, based on my experience

  • It does not matter how many sprites are affected by lights, they will be rendered in the same way, being lit or not.
  • I recommend that you define a small set of “light interactions”, I mean, which contiguous-sorting-layer-blocks are all your lights going to use, and never change it. You could define one for characters and props (with N contiguous layers), and other for the background lights (with another N contiguous layers), for example.
  • I recommend that ShadowCaster2Ds use exactly the same contiguous-sorting-layer-blocks as lights, do not make exceptions or different combinations. What happens if the target sorting layers of the shadow casters do not match exactly the lights’? Well… the lighting system have some bugs, and it is very probable the shadow caster will not be used in the process.
  • I recommend using a kind of " Target sorting layers " asset that stores a contiguous-sorting-layer-block, so every light and every shadow caster uses that value when the scene is loaded. This way this standard value will be centralized and you can change it later in the entire project without having to change every object manually or running any automated tool.

Jailbroken uses 2D lights and shadows intensively. Following these rules I have achieved HUGE performance improvements which allowed me not to cancel the project or not to change its aesthetics drastically.

I hope this information it is useful for you:), I would have loved to know this before I started (Unity documentation, I’m looking at you).

You can find other of my posts here:

You can follow me (and my game) on Twitter: @JailbrokenGame

10 Likes

Hey @ThundThund these are really great tips thank you! :smile:

Interestingly, we are working on a tool to help users visualise batching inefficiencies and to show how light batching (with shadows) work in order to improve performance (speed and memory). We are really excited as this would significantly streamline users’ optimization workflow and we’ll be sharing more on our 2D Public Roadmap soon.

Also Quick Q:

Is this somehow related to this ShadowCaster2D bug ? Where if there are multiple 2D Lights in the Scene (with ShadowCaster2D set up correctly), only one of the 2D Lights would cast the shadow. The other shadows from the other 2D Lights wouldn’t show.

The reason for asking is that we want to make sure we capture all the bugs related to our lighting system so that our Light2Ds and ShadowCaster2Ds work well as intended.

That’s good news. Something that helped me a lot was RenderDoc. I also modified the code of RendererLighting.cs to get more useful information in that tool (see CUSTOM CODE):

        public static void RenderLights(this IRenderPass2D pass, RenderingData renderingData, CommandBuffer cmd, int layerToRender, uint blendStylesUsed)
        {
            var blendStyles = pass.rendererData.lightBlendStyles;

            for (var i = 0; i < blendStyles.Length; ++i)
            {
                if ((blendStylesUsed & (uint)(1 << i)) == 0)
                    continue;

                // CUSTOM CODE
#if UNITY_EDITOR

                SortingLayer[] layers = Light2DManager.GetCachedSortingLayer();
                string layerName = layerToRender.ToString();

                for(int l = 0; l < layers.Length; ++l)
                {
                    if(layers[l].id == layerToRender)
                    {
                        layerName = layers[l].name;
                    }
                }

                string sampleName = "BlendStyle:" + blendStyles[i].name + " Layer:" + layerName;
#else
                string sampleName = "BlendStyle:" + blendStyles[i].name + " Layer:" + layerToRender;
#endif

                cmd.BeginSample(sampleName);
                //

                var rtID = pass.rendererData.lightBlendStyles[i].renderTargetHandle.Identifier();
                cmd.SetRenderTarget(rtID);

                var rtDirty = false;
                if (!Light2DManager.GetGlobalColor(layerToRender, i, out var clearColor))
                    clearColor = Color.black;
                else
                    rtDirty = true;

                rtDirty |= RenderLightSet(
                    pass, renderingData,
                    i,
                    cmd,
                    layerToRender,
                    rtID,
                    (pass.rendererData.lightBlendStyles[i].isDirty || rtDirty),
                    clearColor,
                    pass.rendererData.lightCullResult.visibleLights
                );

                pass.rendererData.lightBlendStyles[i].isDirty = rtDirty;

                // CUSTOM CODE
                cmd.EndSample(sampleName);
                //
            }
        }

        public static void RenderLightVolumes(this IRenderPass2D pass, RenderingData renderingData, CommandBuffer cmd, int layerToRender, RenderTargetIdentifier renderTarget, RenderTargetIdentifier depthTarget, uint blendStylesUsed)
        {
            var blendStyles = pass.rendererData.lightBlendStyles;

            for (var i = 0; i < blendStyles.Length; ++i)
            {
                if ((blendStylesUsed & (uint)(1 << i)) == 0)
                    continue;

                // CUSTOM CODE
#if UNITY_EDITOR

                SortingLayer[] layers = Light2DManager.GetCachedSortingLayer();
                string layerName = layerToRender.ToString();

                for (int l = 0; l < layers.Length; ++l)
                {
                    if (layers[l].id == layerToRender)
                    {
                        layerName = layers[l].name;
                    }
                }

                string sampleName = "VOLUMES=BlendStyle:" + blendStyles[i].name + " Layer:" + layerName;
#else
                string sampleName = "VOLUMES=BlendStyle:" + blendStyles[i].name + " Layer:" + layerToRender;
#endif

                cmd.BeginSample(sampleName);
                //

                RenderLightVolumeSet(
                    pass, renderingData,
                    i,
                    cmd,
                    layerToRender,
                    renderTarget,
                    depthTarget,
                    pass.rendererData.lightCullResult.visibleLights
                );

                // CUSTOM CODE
                cmd.EndSample(sampleName);
                //
            }
        }

8069453--1042583--upload_2022-4-22_13-43-52.png

No, it’s not related to that. In the version I work with (2020.3.4), if the shadow casters do not have all the target sorting layers the lights have, they are “discarded” normally. I haven’t researched so much since for me that’s not a problem. Weird things happen also when using volumetric lights (Volume Opacity > 0), I found that many times volumes are not rendered at all unless I enable some specific target sorting layers in their inspector. I haven’t investigated the cause.

PD: Here is a list of other things I modified https://discussions.unity.com/t/844784/12

And here is my fork: https://github.com/QThund/Graphics

1 Like

Hey thank you @ThundThund for all these helpful details! Let me bring these back to the team so that we may look into them.

2 Likes

@suxiangting , I just submitted bug report 1427824, and was wondering if you and your team could take a look. The problem is that Light2D.LateUpdate generates ~136 bytes of garbage per Light2D per LateUpdate. In a small scene that procedurally places lights on multiple Tilemaps this adds up to 168KB of garbage per LateUpdate(!).

I’m actually not sure if the details of the report (including a screenshot of the profiler) made it into the bug report as I can’t see context of the bug report (just the title).

One potential solution would be to cache the variables used in Light2D.UpdateMesh and Light2D.UpdateBoundingSphere, and also to not call the BoundingSphere constructor during Light2D.UpdateBoundingSphere (rather just set the values of the existing Light2D.boundingSphere appropriately).

1 Like

Thank you @kayroice for letting us know. I managed to find your ticket on Fogbugz and have assigned it to our 2D team.

One of the developers manage to add a small optimization so UpdateMesh doesn’t trigger all the time. They will be looking into more ways to optimize it further.

Thanks for addressing this @suxiangting . I took a deeper look into Light2D.cs yesterday, and discovered that the culprit for the original 136bytes of garbage I pointed out was only occurring when in the editor with the call to Light2DManager.UpdateSortingLayers. It’d be really nice to be able to disable this since it has a significant performance impact in the editor, and also causes unnecessary time tracking down garbage when using the profiler (I’m sure I’m not the only one that has done this). A couple other findings are that calls to LightUtility.GenerateParametricMesh result in about 500 bytes of garbage per call, Light2D.get_lightMesh has a constructor that causes an allocation, and caching Light2D.boundingSphere then setting the BoundingSphere’s properties rather than using a constructor will eliminate Light2D.UpdateBoundingSphere’s allocation entirely. I’m sure there’s more, but this is what I was able to dig up yesterday relatively quickly. It doesn’t seem like much, but in a small scene I have >1k Light2D objects, while in a more medium sized scene I have over 4k Light2D objects. On top of that I have Light2D objects on projectiles which get ejected into the scene pretty liberally. So knocking out all those little allocations helps, thanks!

One more quick request, would it be possible to expose Light2D.m_ApplyToSortingLayers? Currently it’s private, so users of the Light2D API can’t get or set the sorting layers on Light2D objects programmatically (unless I’m missing something) without using reflection. There’s a thread about this here . I ran into this issue yesterday when I wanted to remove a sorting layer, and needed to identify which Light2D objects are using it. If I could call get on that field then I could write a method to recursively identify which Light2D objects need updating prior to removing the sorting layer. Thanks!

Thank you @kayroice for these helpful additional insights! I’ve also shared these findings with the developer looking into the optimisation.

Regarding the request to expose Light2D.m_ApplyToSortingLayers, upon further discussion with the team, we realised that we have in fact been seeing similar requests popping up. So we have added this into our 2D Graphics backlog so that we may schedule time to make this possible. Any latest updates will be reflected in our 2D Public Roadmap. Thanks!