Combine textures and meshes (reduce draw calls)

This script takes an array of GameObjects that have mesh renderer and mesh filter components attached. It combines their textures into a single texture sheet which is then used by a material shared by each object. Finally it combines the meshes for any objects marked as static.

Handy for saving a few draw calls on iOS without having to go to your artist.

License is MIT … you can do whatever you want with this, If you don’t want to have to include the MIT license send me a PM and I will waive that requirement. Links or credits to www.jnamobile.com are appreciated but not required.

Note this is pretty rough (an hour or two of messing around), it doesn’t check for conditions it can’t handle, has limited comments, some poorly named variables, etc. Feel free to post improvements.

using UnityEngine;
using System.Collections;

public class CombineMeshesAndTextures : MonoBehaviour {
	
	public GameObject[] objectsToCombine; // The objects to combine, each should have a mesh filter and renderer with a single material.
	public bool useMipMaps = true;
	public TextureFormat textureFormat = TextureFormat.RGB24;
	
	void Start () {
		Combine();
	}
	
	/*
	 * Combines all object textures into a single texture then creates a material used by all objects.
	 * The materials properties are based on those of the material of the object at position[0].
	 *
	 * Also combines any meshes marked as static into a single mesh.
	 */
	private void Combine() {

		int size;
		int originalSize;
		int pow2;
		Texture2D combinedTexture;
		Material material;
		Texture2D texture;
		Mesh mesh;
		Hashtable textureAtlas = new Hashtable();
		
		if (objectsToCombine.Length > 1) {
			originalSize = objectsToCombine[0].renderer.material.mainTexture.width;
			pow2 = GetTextureSize(objectsToCombine);
			size =  pow2 * originalSize;
			combinedTexture = new Texture2D(size, size, textureFormat, useMipMaps);

			// Create the combined texture (remember to ensure the total size of the texture isn't 
			// larger than the platform supports)
			for (int i = 0; i < objectsToCombine.Length; i++) {
				texture = (Texture2D)objectsToCombine[i].renderer.material.mainTexture;
				if (!textureAtlas.ContainsKey(texture)) {
					combinedTexture.SetPixels((i % pow2) * originalSize, (i / pow2) * originalSize, originalSize, originalSize, texture.GetPixels());
					textureAtlas.Add(texture, new Vector2(i % pow2, i / pow2));
				}
			}
			combinedTexture.Apply();
			material = new Material(objectsToCombine[0].renderer.material);
			material.mainTexture = combinedTexture;
			
			// Update texture co-ords for each mesh (this will only work for meshes with coords betwen 0 and 1).
			for (int i = 0; i < objectsToCombine.Length; i++) {				
				mesh = objectsToCombine[i].GetComponent<MeshFilter>().mesh;
				Vector2[] uv = new Vector2[mesh.uv.Length];
				Vector2 offset;
				if (textureAtlas.ContainsKey(objectsToCombine[i].renderer.material.mainTexture)){
					offset = (Vector2)textureAtlas[objectsToCombine[i].renderer.material.mainTexture];
					for (int u = 0; u < mesh.uv.Length;u++) {
						uv[u] = mesh.uv[u] / (float)pow2;
						uv[u].x += ((float)offset.x) / (float)pow2;
						uv[u].y += ((float)offset.y) / (float)pow2;
					}
				} else {
					// This happens if you use the same object more than once, don't do it :)
				}
				
				mesh.uv = uv;
				objectsToCombine[i].renderer.material = material;
			}
			
			// Combine each mesh marked as static
			int staticCount = 0;
			CombineInstance[] combine = new CombineInstance[objectsToCombine.Length];
			for ( int i = 0; i < objectsToCombine.Length; i++){
				if (objectsToCombine[i].isStatic) {
					staticCount++;
					combine[i].mesh = objectsToCombine[i].GetComponent<MeshFilter>().mesh;
					combine[i].transform = objectsToCombine[i].transform.localToWorldMatrix;				
				}
			}
			
			// Create a mesh filter and renderer
			if (staticCount > 1) {
				MeshFilter filter = gameObject.AddComponent<MeshFilter>();
				MeshRenderer renderer = gameObject.AddComponent<MeshRenderer>();			
				filter.mesh = new Mesh();
				filter.mesh.CombineMeshes(combine);
				renderer.material = material;
				
				// Disable all the static object renderers
				for ( int i = 0; i < objectsToCombine.Length; i++){
					if (objectsToCombine[i].isStatic) {
						objectsToCombine[i].GetComponent<MeshFilter>().mesh = null;
						objectsToCombine[i].renderer.material = null;
						objectsToCombine[i].renderer.enabled = false;
					}
				}
			}
			
			Resources.UnloadUnusedAssets();
		}
	}
	
	private int GetTextureSize(GameObject[] o) {
		ArrayList textures = new ArrayList();
		// Find unique textures
		for (int i = 0; i < o.Length; i++) {
			if (!textures.Contains(o[i].renderer.material.mainTexture)) {
				textures.Add(o[i].renderer.material.mainTexture);
			}
		}
   		if (textures.Count == 1) return 1;
		if (textures.Count < 5) return 2;
		if (textures.Count < 17) return 4;
		if (textures.Count < 65) return 8;
		// Doesn't handle more than 64 different textures but I think you can see how to extend
		return 0;
	}
}
4 Likes

Surprised no-one said anything. This could be/ is a very useful tool. All it needs is some fancy editor magic and maybe a check for verts (>65.000 = new mesh and atlas resolution). I’ll see if I can do something about that in what spare time I’ll get during new year’s. Thank you Johnny.

This looks interesting, similar to batching tools in the asset store.

Just a bump, maybe some one is interested …

Nice one! Definitely needs to be in an editor script for IOS though, to prebake. SetPixel/GetPixel require writable texture, which prevents using any form of texture compression type which in turn causes potential memory issues in IOS.

Best would be to generate the new texture and set objects accordingly at design time, then we can compress the texture. This then leads to inevitable feature creep (needing borders around textures to prevent bleeding of compression artifacts.

At edit time you can load textures without compression and without marking texture as read/write. This can be achieved for PNG files by loading the image bytes using System.IO and then pumping those into Texture2D.LoadImage (http://docs.unity3d.com/Documentation/ScriptReference/Texture2D.LoadImage.html).

I am not sure if this is of any use…

WOW! I have to test it.

Tried this script but getting error in this linecombinedTexture.SetPixels((i % pow2) * originalSize, (i / pow2) * originalSize, originalSize, originalSize, texture.GetPixels());
and error is

any one getting this error?

bad thing of this code is manually adding gameobject which can be improved by adding parent child realtion

If you used parent child then you would have to have all your objects in the same hierarchy which would severely limit the usability but regardless you need to make the texture readable.

Just keep in mind this is a four year old hack written in a an hour or two, so I wound’t turn to it as a first point of call.

Tried it and used spheres and it shows all the spheres with their textures but all connected by geometry, instead just showing the spheres without visible connections

There is a bug in the above code, in a sparse texture enviroment but with a lot of objects the above code will fail because it is using the object index i for the texture atlas positioning. This should be replaced with a separate index that is only incremented when you have a new texture. See the code snippet below that fixes the problem.

            // Create the combined texture (remember to ensure the total size of the texture isn't
            // larger than the platform supports)
            int index = 0;
            for (int i = 0; i < objectsToCombine.Length; i++)
            {
                texture = (Texture2D)objectsToCombine[i].GetComponent<MeshRenderer>().material.mainTexture;
                if (!textureAtlas.ContainsKey(texture))
                {
                    int x = (index % pow2) * originalSize;
                    int y = (index / pow2) * originalSize;

                    combinedTexture.SetPixels(x, y, originalSize, originalSize, texture.GetPixels());

                    x = index % pow2;
                    y = index / pow2;
                    textureAtlas.Add(texture, new Vector2(x, y));
                    index++;
                }
            }