Sprite Generation Freeze in Unity

Greetings everyone, and thank you for joining me in this exploration of optimizing sprite generation!

I’ve encountered a frustrating issue where my program freezes during the creation of a layered sprite object. The bottleneck appears to be within the nested loop responsible for “Creating & Blending,” which, according to timestamps, takes a whopping 9983ms.

My current understanding is that the process of copying and applying pixels is computationally expensive. Therefore, I’m eager to explore ways to optimize the operations within this nested loop.

However, I’m also curious about the limitations of certain approaches. Specifically, is it true that even utilizing techniques like coroutines or threading wouldn’t effectively address the program freeze?

Thank you for your time reading this!

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using System.Diagnostics;
using System.IO;
using System.Text;

// This class, SpriteMaker, is used to generate a sprite from multiple texture layers

[RequireComponent(typeof(SpriteRenderer))]
public class SpriteMaker : MonoBehaviour
{
    [System.Serializable]
    public struct LayerData
    {
        public Texture2D texture;
        public int offsetX;
        public int offsetY;
        public float scale;
    }
   
    public string m_SourceFolderName = "folder";
    public LayerData[] layers;
    public string m_SaveFolderName = "FinalPhoto";
    //float scaleFactor = 10.0f;
    public SceneLoad scene;

 
    public void StartHere()
    {
        // Show the settings
        ShowSettings();
        genSprite();
    }

    public void genSprite()
    {
        // Start a timer
        System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();
        long startTime;
        // --- Section 1: Loading Textures ---
        startTime = stopwatch.ElapsedMilliseconds;

        string streamingAssetPath = string.IsNullOrEmpty(m_SourceFolderName) ? Application.streamingAssetsPath : Path.Combine(Application.streamingAssetsPath, m_SourceFolderName);
        string[] fileNames = { "background.jpg", "A.jpg", "B.jpg", "C.jpg", "D.png" }; // Replace with your actual file names

        for (int i = 0; i < Mathf.Min(layers.Length, fileNames.Length); i++)
        {
            string filePathSrc = Path.Combine(streamingAssetPath, fileNames[i]);

            if (File.Exists(filePathSrc))
            {
                byte[] bytes = File.ReadAllBytes(filePathSrc);
                Texture2D layerTexture = new Texture2D(2, 2);
                layerTexture.LoadImage(bytes);
                layers[i].texture = layerTexture;
            }
            else
            {
                UnityEngine.Debug.LogError($"File not found: {filePathSrc}");
                // Consider handling the missing file case (e.g., using a default texture)
            }
        }
        UnityEngine.Debug.Log($"Loading Textures: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Section 2: Calculating Combined Texture Size ---
        startTime = stopwatch.ElapsedMilliseconds;
        // Calculate the size of the combined texture (taking offsets into account)
        int maxWidth = 0;
        int maxHeight = 0;
        foreach (LayerData layerData in layers)
        {
            maxWidth = Mathf.Max(maxWidth, layerData.texture.width + layerData.offsetX);
            maxHeight = Mathf.Max(maxHeight, layerData.texture.height + layerData.offsetY);
            //UnityEngine.Debug.Log("W:" + layerData.texture.width + "H:" + layerData.texture.height);
        }
        UnityEngine.Debug.Log($"Calculating Size: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Section 3: Creating and Blending Textures ---
        startTime = stopwatch.ElapsedMilliseconds;
        // Create a new texture
        Texture2D combinedTexture = new Texture2D(maxWidth , maxHeight);
       
        // Array to store the accumulated alpha values for each pixel
        float[] accumulatedAlpha = new float[maxWidth * maxHeight];

        // Iterate through each layer
        for (int i = 0; i < layers.Length; i++)
        {
            Color[] layerPixels = layers[i].texture.GetPixels();
            float layerScale = layers[i].scale;

            for (int y = 0; y < layers[i].texture.height; y++)
            {
                for (int x = 0; x < layers[i].texture.width; x++)
                {
                    int pixelIndex = (y * layers[i].texture.width) + x;
                    Color pixel = layerPixels[pixelIndex];

                    // Calculate the target position in the combined texture with offset
                    int targetX = Mathf.RoundToInt(x * layerScale) + layers[i].offsetX;
                    int targetY = Mathf.RoundToInt(y * layerScale) + layers[i].offsetY;
                    int targetIndex = (targetY * maxWidth) + targetX;

                    // Check if target position is within bounds
                    if (targetX >= 0 && targetX < maxWidth && targetY >= 0 && targetY < maxHeight)
                    {
                        // Calculate the blended color using accumulated alpha
                        float alpha = pixel.a;
                        float newAlpha = alpha + accumulatedAlpha[targetIndex] * (1 - alpha);
                        Color blendedColor = (pixel * alpha + combinedTexture.GetPixel(targetX, targetY) * accumulatedAlpha[targetIndex] * (1 - alpha)) / newAlpha;

                        combinedTexture.SetPixel(targetX, targetY, blendedColor);
                        accumulatedAlpha[targetIndex] = newAlpha;
                    }
                }
            }
        }

        combinedTexture.Apply();
        UnityEngine.Debug.Log($"Creating & Blending: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Section 4: Saving the Sprite ---
        startTime = stopwatch.ElapsedMilliseconds;
        // Create a new sprite from the combined texture
        Sprite combinedSprite = Sprite.Create(combinedTexture, new Rect(0, 0, maxWidth, maxHeight), new Vector2(0.5f, 0.5f));
        // Encode the combined sprite to JPG
        byte[] jpgData = EncodeSpriteToJPG(combinedSprite);

        // Save the JPG data to a file (example)
        string dirPath = System.IO.Path.Combine(Application.streamingAssetsPath, m_SaveFolderName);
        if (!System.IO.Directory.Exists(dirPath))
        {
            System.IO.Directory.CreateDirectory(dirPath);
        }
        string filePath = System.IO.Path.Combine(dirPath, "Result" + ".jpg");
        System.IO.File.WriteAllBytes(filePath, jpgData);
        UnityEngine.Debug.Log(jpgData.Length / 1024 + "Kb was saved as: " + filePath);
        // Assign the combined sprite to a SpriteRenderer (assuming you have one in your scene)
        GetComponent<SpriteRenderer>().sprite = combinedSprite;
        // Scale down the GameObject
        transform.localScale = new Vector3(1f, 1f, 1.0f);
        UnityEngine.Debug.Log($"Saving Sprite: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Total Processing Time ---
        stopwatch.Stop();
        TimeSpan elapsedTime = stopwatch.Elapsed;
        UnityEngine.Debug.Log($"genSprite processing time: {elapsedTime.TotalMilliseconds} ms");
    }

    public static byte[] EncodeSpriteToJPG(Sprite sprite, int quality = 75)
    {
        Texture2D texture = sprite.texture;
        Rect rect = sprite.rect;

        // Create a temporary RenderTexture
        RenderTexture renderTexture = RenderTexture.GetTemporary(texture.width, texture.height, 0, RenderTextureFormat.ARGB32);
        Graphics.Blit(texture, renderTexture);

        // Activate the RenderTexture
        RenderTexture.active = renderTexture;

        // Create a new Texture2D with the content of the RenderTexture
        Texture2D tempTexture = new Texture2D(texture.width, texture.height, TextureFormat.RGB24, false);
        tempTexture.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
        tempTexture.Apply();

        // Deactivate the RenderTexture
        RenderTexture.active = null;

        // Release the temporary RenderTexture
        RenderTexture.ReleaseTemporary(renderTexture);

        // Crop the texture based on the sprite's rect
        Color[] pixels = tempTexture.GetPixels((int)rect.x, (int)rect.y, (int)rect.width, (int)rect.height);
        Texture2D croppedTexture = new Texture2D((int)rect.width, (int)rect.height, TextureFormat.RGB24, false);
        croppedTexture.SetPixels(pixels);
        croppedTexture.Apply();

        // Encode the cropped texture to JPG
        byte[] jpgData = croppedTexture.EncodeToJPG(quality);

        // Destroy the temporary texture
        Destroy(tempTexture);

        return jpgData;
    }

}

This is an A+ first post by the way. You’re asking in the right way. :slight_smile:

Functions like GetPixel/SetPixel are expensive. You’re going to be better off operating on a color array then use SetPixels only once.

After you’ve load the byte[ ] into the texture, you can use get the pixels in a Color[ ] or Color32[ ] array and operate on the layers that way, indexing into the correct pixel position. This replaces all GetPixel. In case it’s not clear, Color[ ] would be a single-dimensional array where index = x + y * width.

Likewise, for your output index into a color array yourself to avoid the expense of SetPixel. Since you’re just operating on arrays, this manipulation can be in a background thread before you finally SetPixels on your output texture and texture.Apply.

1 Like

if you use coroutines it will prevent the freeze, it will still take the same time, maybe more, but it wont cause the app to hang or go unresponsive and you can add a loading bar that gets progressively filled up to 100% based on current progress of the operation

1 Like

Receiving the response from from the community which means a lot to me, it is hard to tell how exciting I am because of taking action to ask for help after some trial and error. Thank you both especially the encouragement from @Lo-renzo .

After replacing GetPixel and SetPixel with direct array manipulation, processing time cost for “Creating & Blending” dropped from 9983ms to around 4104ms. Then tried on Thread but causing error so I switched to apply coroutines to further reduce time cost to 3887ms. Visually, when generating sprite, the program will split to a few freeze moment compare with my original situation - a long freeze period of time. Here is the updated genSprite function:

public void genSprite()
    {
        StartCoroutine(BlendTexturesCoroutine());
    }

    private IEnumerator BlendTexturesCoroutine()
    {
        // Start a timer
        System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();
        long startTime;
        // --- Section 1: Loading Textures ---
        startTime = stopwatch.ElapsedMilliseconds;

        string streamingAssetPath = string.IsNullOrEmpty(m_SourceFolderName) ? Application.streamingAssetsPath : Path.Combine(Application.streamingAssetsPath, m_SourceFolderName);
        string[] fileNames = { "background.jpg", "A.jpg", "B.jpg", "C.jpg", "D.png" }; // Replace with your actual file names

        for (int i = 0; i < Mathf.Min(layers.Length, fileNames.Length); i++)
        {
            string filePathSrc = Path.Combine(streamingAssetPath, fileNames[i]);

            if (File.Exists(filePathSrc))
            {
                byte[] bytes = File.ReadAllBytes(filePathSrc);
                Texture2D layerTexture = new Texture2D(2, 2);
                layerTexture.LoadImage(bytes);
                layers[i].texture = layerTexture;
            }
            else
            {
                UnityEngine.Debug.LogError($"File not found: {filePathSrc}");
                // Consider handling the missing file case (e.g., using a default texture)
            }
        }
        UnityEngine.Debug.Log($"Loading Textures: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Section 2: Calculating Combined Texture Size ---
        startTime = stopwatch.ElapsedMilliseconds;
        // Calculate the size of the combined texture (taking offsets into account)
        int maxWidth = 0;
        int maxHeight = 0;
        foreach (LayerData layerData in layers)
        {
            maxWidth = Mathf.Max(maxWidth, layerData.texture.width + layerData.offsetX);
            maxHeight = Mathf.Max(maxHeight, layerData.texture.height + layerData.offsetY);
            //UnityEngine.Debug.Log("W:" + layerData.texture.width + "H:" + layerData.texture.height);
        }
        UnityEngine.Debug.Log($"Calculating Size: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Section 3: Creating and Blending Textures ---
        startTime = stopwatch.ElapsedMilliseconds;
        // Create a new texture
        Texture2D combinedTexture = new Texture2D(maxWidth , maxHeight);
        Color[] combinedPixels = combinedTexture.GetPixels();
        // Array to store the accumulated alpha values for each pixel
        float[] accumulatedAlpha = new float[maxWidth * maxHeight];

        // Iterate through each layer
        for (int i = 0; i < layers.Length; i++)
        {
            Color[] layerPixels = layers[i].texture.GetPixels();
            float layerScale = layers[i].scale;

            for (int y = 0; y < layers[i].texture.height; y++)
            {
                for (int x = 0; x < layers[i].texture.width; x++)
                {
                    int pixelIndex = (y * layers[i].texture.width) + x;
                    Color pixel = layerPixels[pixelIndex];

                    // Calculate the target position in the combined texture with offset
                    int targetX = Mathf.RoundToInt(x * layerScale) + layers[i].offsetX;
                    int targetY = Mathf.RoundToInt(y * layerScale) + layers[i].offsetY;
                    int targetIndex = (targetY * maxWidth) + targetX;

                    // Check if target position is within bounds
                    if (targetX >= 0 && targetX < maxWidth && targetY >= 0 && targetY < maxHeight)
                    {
                        // Calculate the blended color using accumulated alpha
                        float alpha = pixel.a;
                        float newAlpha = alpha + accumulatedAlpha[targetIndex] * (1 - alpha);
                        combinedPixels[targetIndex] = (pixel * alpha + combinedPixels[targetIndex] * accumulatedAlpha[targetIndex] * (1 - alpha)) / newAlpha;
                        accumulatedAlpha[targetIndex] = newAlpha;
                    }
                }
            }
            // Yield control back to Unity after processing each layer
            yield return null;
        }
        // Set the pixels only once and apply
        combinedTexture.SetPixels(combinedPixels);
        combinedTexture.Apply();
        UnityEngine.Debug.Log($"Creating & Blending: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Section 4: Saving the Sprite ---
        startTime = stopwatch.ElapsedMilliseconds;
        // Create a new sprite from the combined texture
        Sprite combinedSprite = Sprite.Create(combinedTexture, new Rect(0, 0, maxWidth, maxHeight), new Vector2(0.5f, 0.5f));
        // Encode the combined sprite to JPG
        byte[] jpgData = EncodeSpriteToJPG(combinedSprite);

        // Save the JPG data to a file (example)
        string dirPath = System.IO.Path.Combine(Application.streamingAssetsPath, m_SaveFolderName);
        if (!System.IO.Directory.Exists(dirPath))
        {
            System.IO.Directory.CreateDirectory(dirPath);
        }
        string filePath = System.IO.Path.Combine(dirPath, "Result" + ".jpg");
        System.IO.File.WriteAllBytes(filePath, jpgData);
        UnityEngine.Debug.Log(jpgData.Length / 1024 + "Kb was saved as: " + filePath);
        // Assign the combined sprite to a SpriteRenderer (assuming you have one in your scene)
        GetComponent<SpriteRenderer>().sprite = combinedSprite;
        // Scale down the GameObject
        transform.localScale = new Vector3(1f, 1f, 1.0f);
        UnityEngine.Debug.Log($"Saving Sprite: {stopwatch.ElapsedMilliseconds - startTime} ms");

        // --- Total Processing Time ---
        stopwatch.Stop();
        TimeSpan elapsedTime = stopwatch.Elapsed;
        UnityEngine.Debug.Log($"genSprite processing time: {elapsedTime.TotalMilliseconds} ms");
    }
1 Like

If you were using threads directly, it is instead much easier to use C# Tasks. If that’s new to you then try to find a youtube tutorial on them since they can look scarier than they are.

If you’re in 2023.1+, the Awaitable API is available to you too which makes switching off the main thread and back on easy within Unity, no need for Tasks.

With all threading though, Unity can be very particular about what parts of its API it allows you to interact with. When it yells at you the answer is to make sure you’re interacting with that part of the Unity’s API on the main thread. Since the heavy stuff here is normal C# array manipulation, if you arrange it right it will be possible to do all that work off of the main thread. Then come back to the main thread when needing to to use the Unity API again.

If you want to stick with coroutines instead, you could “yield return null” more often to do less work each frame, perhaps every 2000 pixels or something. This one is simplest and requires the least reworking of what you got. And of course if you’re satisfied right now, don’t optimize any further.

Welcome to the forums :slight_smile: