2 sprite atlases in different depth, same sort order break batching

  • I have some 100 Sprite Renderers on my screen USING THE SAME MATERIAL. Some overlap, some don’t.

  • If all of them have sprites from the same ATLAS (let’s call it ATLAS 1), they’ll all batch nicely and use just 1 DRAW CALL.

  • If some of them have sprites from ATLAS 1 and some from ATLAS 2, the following will happen:

*** If sprites from each ATLAS have a different Sorting Order (i.e. ATLAS 1 sprites in ORDER 0 and ATLAS 2 sprites in ORDER 1), I’ll get 2 DRAW CALLS for the lot of them -one for each Z-ORDER-)

*** If all sprites have the same Sorting Order, batching will break and I’ll get around 30 extra DRAW CALLS!

Is there anything I can do to have all atlases batch properly using the SAME Z-ORDER? I have no choice, since sprites are in a 3D world, so sprites closer to other sprites have to show up in front.

Tested in Unity 4.5.0 and 4.5.3.

ILLUSTRATIVE IMAGES

This is a section of the game screen using DIFFERENT Z-ORDER for each ATLAS

This is a section of the game screen using THE SAME Z-ORDER for ALL ATLASES

Now, doing a bit of experimenting while sprites were all on the SAME Z-ORDER, I tested the following.

When ATLAS 2 sprite is in front of ATLAS 1 sprite, I get 49 DRAW CALLS in this case

When I move ATLAS 2 slightly behind ATLAS 1, I get 2 extra DRAW CALLS (up to 51)

And if I move ATLAS 2 a bit further back behind ATLAS 1 -but still overlapping!!!- I get 49 DRAW CALLS again!?
1767521--112050--49-rear.jpg

Sorry it says “in behind”… I copied from “in front”, forgot the “IN” word, and now it sounds embarrassingly funny.

Ok, I see what’s going on now. I’ve changed the thread title to more accurately reflect it.

After a few tests, what I’ve found out is that when many Sprite Renderers using sprites from 2 or more atlases are placed at different depths from the camera (it doesn’t matter if the sprites overlap or not), but in the same SORTING ORDER, Unity will only batch sprites of the SAME ATLAS until it finds a sprite from ANOTHER ATLAS behind it. Then, it will break batching.

I’d say this is a bug, or at least a “feature” that shouldn’t work that way, since it makes it really hard to work with sprites in a 3D space, virtually limiting work to a single atlas.

To make it clear, here’s an illustration of what’s going on.

hehe, I’m having completely the same problem and I’m now stuck with one giant atlas that’s almost full…
Did you or anybody else find any solutions or workarrounds to this problem?

R.

To begin with, I submitted bug report 632743, but it’s not active for voting yet. So, fingers crossed; and remember to vote on the issue if Unity Technologies ever get around to it.

Meanwhile, I’ve tested 3 methods to deal with the symptoms, so to speak. Keep in mind I’m working for Mobile, so I only tested highly performant options. Each one has its ups and downs and may work on your specific case better than the others.

METHOD 1 WITH SPHERECASTS AND COLLIDERS
Pros: Medium performance
Cons: Overlap test not perfectly accurate. Performance drops exponentially the further distance you check for overlapping. Performance drops with quantity of objects.
Description:
1 - For each Sprite Renderer gameobject, add a collider and rigidbody (check isKinematic if you don’t need the physics).
2 - Throw a SPHERECAST behind it relative to the camera. The diameter of the ray could be the width of your sprite in world units. Limit its distance to the maximum distance you expect your farthest sprite to be at, to save performance.
3 - If the SPHERECAST hits NOTHING, assign the Sprite Renderer a SORT ORDER particular to its ATLAS. (i.e., all ATLAS1 sprites in sort order 1, ATLAS2 sprites in sort order 2, etc.)
4 - If the SPHERECAST hits something, set this sprite’s SORT ORDER to the highest number you expect to use (i.e. 255). That way, only overlapping sprites will generate additional draw calls.
** Sorry, no source code available for this one

METHOD 2 WITH BOXCOLLIDER2D
Pros: Perfectly accurate overlap test to sprite’s size. Tests to infinity distance without affecting performance.
Cons: Low performance, drops with quantity of objects
Description:
1 - Corresponding to each Sprite Renderer object, add by code a new parent GameObject with a BoxCollider2D and a rigidbody (check isKinematic so it does not use physics).
2 - In OnBecameVisible function, modify the BoxCollider2D’s size to fit the Sprite’s size on screen (i.e. the sprite is 50x60 pixels, the BoxCollider2D will be 50x60 world units. Suggest you scale it down later for performance reasons.
** If you’re using a Perspective camera instead of Orthographic, consider doing this in the Update function instead. Do keep in mind it will degrade performance if you do.
3 - On the Update function, move the BoxCollider2D’s position to the sprite’s position on screen. (i.e. the sprite is at position 500x600 pixels on the screen, move it to Vector3(500, 600, 0) in world units)
4 - On the BoxCollider2D gameobject, if there’s a collision, set this sprite’s SORT ORDER to the highest number you expect to use (i.e. 255). That way, only overlapping sprites will generate additional draw calls.
If instead there’s no collision on this frame/cycle, assign the Sprite Renderer a SORT ORDER particular to its ATLAS. (i.e., all ATLAS1 sprites in sort order 1, ATLAS2 sprites in sort order 2, etc.)

Source code (it is missing point 4 actions, since I noticed its performance was no good for my project. May be missing something to compile.):
SpriteBatchFix.cs

/* To be assigned to a GameObject containing a SpriteRenderer
Creates a 2D collider box in a "virtual screen", corresponding to the sprite.

Requires a Layer named "SpriteCollider" to exist

Useful to check collisions between sprites on the screen space (overlapping sprites).
Failed to make the cut since moving Colliders manually is EXTREMELY slow

By Diego Wasser (Razorwings18)*/

using UnityEngine;
using System.Collections;

public class SpriteBatchFix : MonoBehaviour {
    static Hashtable atlasSortOrders = new Hashtable();

    GameObject collider;
    SpriteRenderer referenceSpriteRenderer;
    BoxCollider2D spriteCollider;
    Bounds spriteBounds;
    Camera mainCamera;
    Vector2 spriteSizeInWorldUnits;
    Vector3 spriteSizeOnViewPort;
    Vector3 spriteScale;
    static float colliderMotionScale = 100; // The whole collider system will be scaled down by this much everywhere to improve performance

    public static byte GetAtlasSortOrder(string atlasName){
        if (!atlasSortOrders.ContainsKey(atlasName)){
            byte i = 0;
            while (atlasSortOrders.ContainsValue(i)){
                ++i;
            }
            atlasSortOrders.Add(atlasName, i);
            return i;
        }
        else{
            return (byte)atlasSortOrders[atlasName];
        }
    }

    void Awake(){
        if (GameOptions.drawcallFixEnabled){
            referenceSpriteRenderer = gameObject.GetComponent<SpriteRenderer>();
            mainCamera = Camera.main;

            //Create the collider that checks if this sprite overlaps with something else
            collider = new GameObject ("spriteCollider");
            collider.layer = LayerMask.NameToLayer("SpriteCollider");
            collider.AddComponent<Rigidbody2D>();
            collider.GetComponent<Rigidbody2D>().gravityScale = 0;
            //collider.GetComponent<Rigidbody2D>().gravityScale = 0;
            collider.AddComponent<BoxCollider2D> ();
            spriteCollider = collider.GetComponent<BoxCollider2D> ();
            spriteCollider.isTrigger = true;
            spriteCollider.enabled = false;
            this.enabled = false;
        }
    }

    void OnBecameVisible(){
        spriteCollider.enabled = true;
        this.enabled = true;

        //Resize the collider to fit the sprite bounds (it may have moved further or closer to the camera)
        spriteBounds = referenceSpriteRenderer.sprite.bounds;
        spriteScale = gameObject.transform.lossyScale;
        spriteSizeInWorldUnits = new Vector2(spriteBounds.size.x * spriteScale.x, spriteBounds.size.y * spriteScale.y);
      
        //Transform the sprite size to size on viewport
        Vector3 _spriteBottomLeft = referenceSpriteRenderer.bounds.center - mainCamera.transform.TransformDirection((spriteSizeInWorldUnits.x / 2), (spriteSizeInWorldUnits.y / 2), 0);
        //Vector3 _spriteTopRight = referenceSpriteRenderer.bounds.center + mainCamera.transform.TransformDirection((spriteSizeInWorldUnits.x / 2), (spriteSizeInWorldUnits.y / 2), 0);
        spriteSizeOnViewPort = mainCamera.WorldToScreenPoint(referenceSpriteRenderer.bounds.center) - mainCamera.WorldToScreenPoint(_spriteBottomLeft);
        spriteSizeOnViewPort = spriteSizeOnViewPort * 2; //On the last statement, we only calculated half the size of the sprite (to save performance). Correcting that here.
      
        //Resize the collider to match the sprite's size on the viewport
        spriteCollider.size = new Vector2(spriteSizeOnViewPort.x , spriteSizeOnViewPort.y) / colliderMotionScale;
    }

    void OnBecameInvisible(){
        this.enabled = false;
        spriteCollider.enabled = false;
    }

    void Update () {
        //Move the collider to its position in the world, matching the sprite's position on the viewport
        Vector3 _spritePos = mainCamera.WorldToScreenPoint (gameObject.transform.position);
        collider.transform.position = new Vector3(_spritePos.x, _spritePos.y, 0) / colliderMotionScale;
    }
}

METHOD 3 WITH VIRTUAL COLLISION MATRIX (this is the one I’m using, and am particularly proud of :smile: )
Pros: Ultra-lightning-fast performance. Can check to infinity distance with no additional CPU performance hit.
Cons: Recommended only if sprites are all roughly the same size in pixels on the screen. Overlap test inaccurate / rough on certain parts of the screen, so it may show sprites in the wrong order when overlapping is partial. May generate a little bit more draw calls than it should under certain circumstances.
Description:
Basically this method divides the screen in squares which are roughly the size of your sprites. If more than one sprite falls on the same square, we consider the sprites to be overlapping.
1 - Create an array containing all Sprite Renderer gameobjects to be checked.
2 - Create a matrix of [x,y] elements, where X is
[width of your screen in pixels] / ([width of your expected sprites in pixels] * 1.5)
and Y is
[height of your screen in pixels] / ([height of your expected sprites in pixels] * 1.5)
3 - Reinitialize the matrix every time a frame / cycle starts.
4 - At each frame / cycle, for each sprite in the array, check which position of the matrix it should be at depending on its position on screen.
4.1 - If this matrix position value is 0 (meaning no other object was present at this position), assign the array’s index to this position and assign the Sprite Renderer a SORT ORDER particular to its ATLAS. (i.e., all ATLAS1 sprites in sort order 1, ATLAS2 sprites in sort order 2, etc.)
4.2 - If this matrix position is something other than 0, it means there is overlapping. Set this sprite’s SORT ORDER to the highest number you expect to use (i.e. 255). Also, set the SORT ORDER for the Sprite Renderer in the array that was at this matrix position before this one to that same number.

Source Code (NOTE: You have to change collisionGridSize to the size of your sprite in pixels * 1.5. I recommend that, on the InitializeCollisionGrid method, you set scaledCollisionGridSize = collisionGridSize; and scaledCheckDistance = 2000; until you know what they do and can properly adjust them; otherwise you may see no effect for your particular camera settings)
SpriteBatchFix.cs

/* To be assigned to a GameObject containing a SpriteRenderer
Use in conjunction with overlapCheck.cs

By Diego Wasser (Razorwings18)*/

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

public class SpriteBatchFix : MonoBehaviour {
    public byte atlasSortOrder;
    string atlasName;

    void Start(){
        /*
         *
         * DO WHATEVER YOU HAVE TO DO TO GET YOUR ATLAS NAME HERE
         * AND STORE IT IN VARIABLE atlasName
         *
         * NOTE: YOU HAVE TO CALL OVERLAPCHECK.GETATLASSORTORDER AGAIN EVERY
         * TIME YOUR SPRITERENDERER CHANGES ITS SPRITE TO A DIFFERENT ATLAS
         *
         *
         */

        atlasSortOrder = overlapCheck.GetAtlasSortOrder(atlasName);
    }

    void OnBecameVisible(){
        overlapCheck.AddVisibleCharacter(gameObject);
    }

    void OnBecameInvisible(){
        overlapCheck.RemoveVisibleCharacter(gameObject);
    }

    public void StopFixOnThisObject(){
    // Removes this object from the overlap checking system forever
        overlapCheck.RemoveVisibleCharacter(gameObject);
        Destroy (this);
    }
}

overlapCheck.cs

/* To be assigned to an empty parent GameObject
Divides the screen into a "matrix" where each element should be roughly 1.5 times the size of our sprites,
then checks for more than one sprite occupying the same matrix position.

Useful to check collisions between sprites on the screen space (overlapping sprites).
Ultra fast, but inaccurate overlap checking.

By Diego Wasser (Razorwings18)*/

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

public class overlapCheck : MonoBehaviour {
    public class vcClass{
        public GameObject spriteObject {get; set;} // SpriteRenderer's GameObject
        public bool lastCycleHadOverlap {get; set;} // Did this sprite overlap with another in the last check cycle?
    }

    public static List<vcClass> visibleCharacters = new List<vcClass>();
    static Hashtable atlasSortOrders = new Hashtable();

    static int[,] screenCollisionGrid;
    public static Vector2 collisionGridSize = new Vector2(55,55); //NOMINAL Collision grid size in pixels (width, height) on 1080px height screen
    static float maxCheckDistance = 1100; // Maximum object distance from camera at which overlap checking will occur at NOMINAL grid size
    Vector2 scaledCollisionGridSize;
    float scaledCheckDistance;

    SpriteRenderer thisSpriteRenderer;
    byte maxSortOrder = 0;
    byte overlappingSortOrder = 250; //This is the Sort Order where overlapping objects will be assigned.
    int currentOverlapCheck = 0;
    int overlapChecksPerFrame = 15; //Amount of sprites that will be processed for overlapping per frame
    static byte reservedLowSortOrder = 2; // Sorting orders below this number are never assigned, they are reserved (i.e. dead characters will always appear behind sprites in their layer)

    Camera mainCamera;
    float lastFOV;

    public static byte GetAtlasSortOrder(string atlasName){
        //Returns a unique Sort Order number for each different atlasName

        if (atlasName != null){
            if (!atlasSortOrders.ContainsKey(atlasName)){
                byte i = reservedLowSortOrder;
                while (atlasSortOrders.ContainsValue(i)){
                    ++i;
                }
                atlasSortOrders.Add(atlasName, i);
                return i;
            }
            else{
                return (byte)atlasSortOrders[atlasName];
            }
        }
        else{
            return 0;
        }
    }

    void InitializeCollisionGrid(){
        // Transform the grid size depending on the camera's field of view
        if (mainCamera.fieldOfView != lastFOV){
            lastFOV = mainCamera.fieldOfView;
            scaledCollisionGridSize = (collisionGridSize) / (lastFOV / 4); // This is just wrong... it works for minFOV 4 and maxFOV 10
            scaledCheckDistance = (-50 * lastFOV) + 1200; // Linear function decreases distance as FOV increases... not that good a function either
        }

        //Initialize the collision grid as per the specified collisionGridSize
        screenCollisionGrid = new int[Mathf.CeilToInt(Screen.width / scaledCollisionGridSize.x), Mathf.CeilToInt(Screen.height / scaledCollisionGridSize.y)];
    }

    void Awake(){
        mainCamera = Camera.main;

        // Adjust collisionGridSize for current screen dimensions
        collisionGridSize.x = (Screen.height * collisionGridSize.x) / 1080;
        collisionGridSize.y = (Screen.height * collisionGridSize.y) / 1080;

        if (!GameOptions.drawcallFixEnabled)
            this.enabled = false;
    }

    void Start(){
        InitializeCollisionGrid(); //Run this for the first time so the first Update has something to work with. Next time it will be run at the end of Update.
    }

    public static void AddVisibleCharacter(GameObject sourceObject){
        // Add a character (SpriteRenderer's object) to be tracked for overlapping by this script
        vcClass _thisItem = new vcClass();
        _thisItem.spriteObject = sourceObject;
        _thisItem.lastCycleHadOverlap = false;

        visibleCharacters.Add (_thisItem);
    }

    public static void RemoveVisibleCharacter(GameObject sourceObject){
        // Remove a character (SpriteRenderer's object) from tracking for overlapping by this script
        vcClass _thisItem = new vcClass();
        //Remove for both TRUE or FALSE in lastCycleHadOverlap, since we don't know which state it has when trying to remove
        _thisItem.spriteObject = sourceObject;
        _thisItem.lastCycleHadOverlap = false;

        visibleCharacters.Remove (_thisItem);
        _thisItem.lastCycleHadOverlap = true;
        visibleCharacters.Remove (_thisItem);
    }

    // Update is called once per frame
    void Update () {
        int currentBatchCheck = 0;

        if (visibleCharacters.Count>0){
        //There are sprites being tracked for overlap checking
            while ((currentOverlapCheck < visibleCharacters.Count) && (currentBatchCheck < overlapChecksPerFrame))
            {
                //Get this sprite's position on screen in pixels
                Vector3 _spriteWorldPos = mainCamera.WorldToScreenPoint (visibleCharacters[currentOverlapCheck].spriteObject.transform.position);
                if (_spriteWorldPos.z < scaledCheckDistance){
                //Sprite is closer than the maximum check distance from camera
                    // Transform sprite's world position to collision grid position
                    Vector2 _spriteGridPos = new Vector2(Mathf.FloorToInt(_spriteWorldPos.x / scaledCollisionGridSize.x), Mathf.FloorToInt(_spriteWorldPos.y / scaledCollisionGridSize.y));

                    if (((int)_spriteGridPos.x >= 0) && ((int)_spriteGridPos.y >= 0) && ((int)_spriteGridPos.x < screenCollisionGrid.GetLength(0)) && ((int)_spriteGridPos.y < screenCollisionGrid.GetLength(1))){
                    //If sprite is inside the screen boundaries...
                        thisSpriteRenderer = visibleCharacters[currentOverlapCheck].spriteObject.GetComponent<SpriteRenderer>();

                        //Check sprite's position against the collision grid
                        if (screenCollisionGrid[(int)_spriteGridPos.x, (int)_spriteGridPos.y] == 0){
                            //There is no other object at this grid position
                            screenCollisionGrid[(int)_spriteGridPos.x, (int)_spriteGridPos.y] = currentOverlapCheck + 1; // Add 1 to currentOverlapCheck, since original Collision Grid uses 0 for its no-collision value

                            if (!visibleCharacters[currentOverlapCheck].lastCycleHadOverlap){
                                // There was no overlap for this sprite on the last cycle or this one; return sort order to original state
                                thisSpriteRenderer.sortingOrder = visibleCharacters [currentOverlapCheck].spriteObject.GetComponent<SpriteBatchFix>().atlasSortOrder;
                            }
                            visibleCharacters[currentOverlapCheck].lastCycleHadOverlap = false; // For the time being, this object is not known to be overlapping

/* Enable this to visually identify confirmed overlapping sprites
visibleCharacters[currentOverlapCheck].spriteObject.transform.localScale = new Vector3(5.5F, 5.5F, 5.5F);*/
                        }
                        else{
                            //There's another object a this grid position
                            visibleCharacters[currentOverlapCheck].lastCycleHadOverlap = true; // Store the fact that this object had an overlap in this cycle
                            thisSpriteRenderer.sortingOrder = overlappingSortOrder;

                            //Since the first character to be assigned to the grid assumed it was not colliding, apply colliding status on it
                            int baseCollisionCharacter = screenCollisionGrid[(int)_spriteGridPos.x, (int)_spriteGridPos.y] - 1;
                            visibleCharacters[baseCollisionCharacter].spriteObject.GetComponent<SpriteRenderer>().sortingOrder = overlappingSortOrder;
                            visibleCharacters[baseCollisionCharacter].lastCycleHadOverlap = true; // Store the fact that this object had an overlap in this cycle
/* Enable this to visually identify confirmed overlapping sprites
* visibleCharacters[baseCollisionCharacter].spriteObject.transform.localScale = new Vector3(10, 10, 10);
visibleCharacters[currentOverlapCheck].spriteObject.transform.localScale = new Vector3(10, 10, 10);*/
                        }
                    }
                    else{
                        // Sprite is out of bounds of the Collision Grid; restore original sort order
                        thisSpriteRenderer = visibleCharacters[currentOverlapCheck].spriteObject.GetComponent<SpriteRenderer>();
                        thisSpriteRenderer.sortingOrder = visibleCharacters [currentOverlapCheck].spriteObject.GetComponent<SpriteBatchFix>().atlasSortOrder;
                    }
                }
                else{
                    // Sprite is too far to collide; restore original sort order
                    thisSpriteRenderer = visibleCharacters[currentOverlapCheck].spriteObject.GetComponent<SpriteRenderer>();
                    thisSpriteRenderer.sortingOrder = visibleCharacters [currentOverlapCheck].spriteObject.GetComponent<SpriteBatchFix>().atlasSortOrder;
                }

                ++currentOverlapCheck;
                ++currentBatchCheck;
            }

            if (currentOverlapCheck >= visibleCharacters.Count)
            {
                //The checking cycle is over for all sprites. Reinitialize the Collision Grid for the next cycle.
                InitializeCollisionGrid();
                currentOverlapCheck = 0;
            }
        }
    }
}

I confirm this also happens with Quads using different materials with 2 or more atlases as well; it’s not just confined to Sprites. So the solutions above should fix that case as well -just replace references to the SpriteRenderer to its MeshRenderer counterparts-