PngMaterialReplacer.cs - A solution for image handling in Unity3D

Unity3D - I love the ease of multi-platform development, but as the name implies, it neglects its 2D tools. Considering the rise of Unity Engine for mobile gaming, this is totally unacceptable. A true oversight - one that we will correct today - is handing Textures, the 2D images that are a core element of almost every game ever created.

Despite Unity implementing PNG and JPG save/load methods for Textures in script, there is no built-in ability to compile your image assets into these formats. The benefits would be immense however, as one could increase quality of graphics with compressed, lossless formats. On mobile moreso, it would be powerful to have the ability to select between PNG or JPG, per-image, to achieve maximum visual/filesize balance.

If you aren't convinced, consider this - I made the script below, and had it handle my 12 largest textures. My compiled APK size went from 34mb to 27mb - a 20% reduction in file size!

Unity's handing of images could be considered useful - it can read multiple formats, from PSD to PNG; JPG to TGA. through a simple GUI-based editor, it can recompile every asset into a graphics-card friendly format, ensuring maximum speed and interoperability. However, for all the different formats supported, it really breaks down into four types:

Unity Texture Formats
  • Compressed: Compressed RGB texture. This is the most common format for diffuse textures. 4 bits per pixel (32 KB for a 256x256 texture).
  • Crunched: A lossy compression format on top of DXTR, which helps achieve the lowest possible size footprint on disk and for downloads. Crunch textures can take very long to compress, but decompression at runtime is very fast.
  • Truecolor: Lossless format. This is the highest quality, but 256 KB for a 256x256 texture.
  • 16-bit: Low-quality truecolor. Has 16 levels of red, green, blue and alpha.

- from http://docs.unity3d.com/Manual/class-TextureImporter.html

Looking at the list, there is only one format capable of storing a lossless, 32-Bit, ARGB image, and the file format is an uncompressed 32-bit-per-pixel bitmap! Don't even try the compressed formats for your UI textures or hi-res sprites - the results look horrible, it makes JPG look almost-lossless by comparison. Even better, the world already has an amazing, lossless, 32-bit ARGB format, PNG.

We must find a way to use these formats! I designed my approach based on these thoughts:

  1. In the editor, downscaled, heavily compressed versions of the textures are assigned to the Materials.
  2. Materials can have their mainTexture swapped out for another at any time.
  3. Use mainTexture.name property is used to find a matching file in a Resources folder.
  4. Texture2D.LoadImage can make 32-bit ARGB textures from a byte[] of PNG or JPG image data.
  5. Pro tip: messing with Materials' textures in the runtime can have weird effects if you're not careful!

PngMaterialReplacer.cs

using UnityEngine;
using System.Collections;

public class PngMaterialReplacer : MonoBehaviour
{
    public Material[] MatsToUpdate;
    public float PercentComplete = 0;

    Material[] matsToRevert;
    Texture2D[] toRevert;

    // Use this for initialization
    IEnumerator Start()
    {
        PercentComplete = 0;
        DontDestroyOnLoad(gameObject);
        yield return null;

        int count = MatsToUpdate.Length;

        //bufs are the temp textures where the PNG/JPG data is loaded
        Texture2D[] bufs = new Texture2D[count];
        for (int i = 0; i < count; i++)
            bufs[i] = new Texture2D(1, 1);

        //these help us replace the low-res textures at unload (to prevent data corruption in the editor)
        matsToRevert = new Material[count];
        toRevert = new Texture2D[count];

        //Main processing loop, each iter replaces one Material's texture
        for (int i = 0; i < count; i++)
        {
            Material mat = MatsToUpdate[i];
            string path = mat.mainTexture.name;

            //save the old textures for later
            matsToRevert[i] = mat;
            toRevert[i] = mat.mainTexture as Texture2D;

            TextAsset txt = null;
            try { (txt = Resources.Load<TextAsset>(path + ".png")).ToString(); } //try NAME.png.bytes first
            catch
            {
                //try NAME.jpg.bytes if png didn't work. Note you never add the file extension (here, ".bytes") when using Resources.Load
                try { txt = Resources.Load<TextAsset>(path + ".jpg"); }
                catch { Debug.LogError("Couldn't find texture " + path); }
            }

            if (txt == null)
                continue;

            //get the bytes from the TextAsset and use them to replace our buf Texture
            byte[] b = txt.bytes;
            bufs[i].LoadImage(b);
            //may want to copy some properties (so you can still set them in the editor). you may wish to add others (mipmaps?)
            bufs[i].name = mat.mainTexture.name;
            bufs[i].wrapMode = mat.mainTexture.wrapMode;
            bufs[i].filterMode = mat.mainTexture.filterMode;

            //assign the newly-loaded texture
            mat.mainTexture = bufs[i];


            //Update our completion tracking, and emit some debug info
            PercentComplete = (float)(i + 1) / MatsToUpdate.Length;
            Debug.Log((int)(PercentComplete * 100) + "% mat=" + mat.mainTexture.name + ", path=" + path + ", b=" + (b == null ? "nul" : b.Length + "") + ", tex.w=" + (bufs[i].width));

            //Load one Texture per frame
            yield return null;
        }
    }


    void OnDestroy() //Without this your Materials will lose their Texture references in the Unity Editor!
    {
        for (int i = 0; i < toRevert.Length; i++)
            matsToRevert[i].mainTexture = toRevert[i];
    }
}



There are definitely other ways to do this, but I think my method is simple enough to understand, yet powerful enough for many games' needs. To use this code, put it on an object in your scene. Whenever that object is Start()ed it will begin replacing the Textures in your Materials with those loaded from a Resources file. The OnDestroy() method prevents the editor from losing references to your downscaled textures, but I think is unnecessary for a final build. You could wrap that in #if UNITY_EDITOR ... #endif to optimize a small bit.

Note 1: The files must be in the root of a Resources folder, with the same file name as the Material's texture, but with .bytes as the file extension. The extension tells Unity that this is a binary file we will be loading ourselves, as a TextAsset.

Note 2: Make sure that you change the settings for the Textures you see in-editor. I suggest setting Max Size to something as low as 32x32, and Format of Compressed, or Crunched with high compression. Their total memory footprint will be very small, ~1 to 2kb each.

Note 3: To discover exactly which Textures need your attention, check the Editor Log! First you must compile a build, such as an APK. Then, in the Unity Editor Console, click the upper-right dropdown menu button, and choose "Open Editor Log". Look for a section in the log which says "Used Assets and files from the Resources folder, sorted by uncompressed size", which will help you find the best Textures to focus on.

I hope you find this helpful! If you do let me know on Twitter - @TheUnallied