Monday, January 23, 2012

Reducing build size in Unity games

Creating games with Unity, having a large number of custom levels, while trying to get under that App Store and Android Market limit of 20mb? This is a problem we've faced a few times, and finally i've gotten around to look into creating a method to minimize the size of the final build by serializing the scenes and just instantiating prefabs at level load. We are currently working on a game which will more than likely have 100+ levels, and this method will help us cut down drastically on the final build size. The game has a one-button type gameplay and currently has the very ingenious working title “Rollers”.

The current version limits itself to instantiating prefabs that does not differ from the blueprint, although it should be a rather simple fix to do that, I just don't need it myself :)

You need to use JSONFX to complete this tutorial

In this example I have a number of worlds, each with a number of levels.


Each of these levels contains a number of walls (All instances of a wall prefab with the tag “Wall”)



Additionally the level has a gameobject called info with the following monobehaviour attached: Basically the only this here is we have the ability to mark each level with a specific world, not at all necessary for everybody, but I need it :)
using UnityEngine;
using System.Collections;

public class LevelInfo : MonoBehaviour
{
    public GameEnum.WorldEnum World;
}

public class GameEnum
{
    public enum WorldEnum
    {
        World1,
        World2,
        World3
    }

    public enum PrefabEnum
    {
        PrefabWalls
    }
}

The following script must be placed in a Assets/Editor folder, and adds a menuitem under Tools/CustomHelpers. It runs through the currently open scene, locates all prefabs with tag “Wall” and serializes the scene to a textasset which is placed in a corresponding folder.
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.IO;

public class SerializeScene : ScriptableWizard
{
    public string Folder = "Levels";
    string assetPath;

    [UnityEditor.MenuItem("Tools/CustomHelpers/Serialize Scene")]
    static void SerializeOpenScene()
    {
        SerializeScene ss = (SerializeScene)ScriptableWizard.DisplayWizard("Serialize Scene", typeof(SerializeScene));
    }

    void OnWizardCreate()
    {
        //Find Levelinfo and extract info on which world the level belongs to
        LevelInfo li = FindObjectOfType(typeof(LevelInfo)) as LevelInfo;
        GameEnum.WorldEnum world = li.World;

        // Get the path we'll use to write our assets:
        assetPath = Application.dataPath + "/Resources/" + Folder + "/" + world.ToString() + "/";
        Debug.Log("Save at " + assetPath);

        // Create the folder that will hold our assets:
        Directory.CreateDirectory(assetPath);

        FindAssets();

        // Make sure the new assets are (re-)imported:
        AssetDatabase.Refresh();

    }

    private void FindAssets()
    {
        List<GameObject> objList = new List<GameObject>();

        LevelData newLevel = new LevelData();

        //Trim string to leave out folder information
        newLevel.Name = trimStringToSceneName(EditorApplication.currentScene);

        //Walls
        GameObject[] walls = GameObject.FindGameObjectsWithTag("Wall");
        Debug.Log("We found " + walls.Length + " walls");

        addObjects(walls, GameEnum.PrefabEnum.PrefabWalls, ref newLevel);

        string newObject = JsonFx.Json.JsonWriter.Serialize(newLevel);

        FileStream fs = new FileStream(assetPath + newLevel.Name + ".txt", FileMode.OpenOrCreate, FileAccess.Write);
        StreamWriter sw = new StreamWriter(fs);
        sw.Write(newObject);
        sw.Close();
        fs.Close();

    }

    private void addObjects(GameObject[] objList, GameEnum.PrefabEnum prefabEnum, ref LevelData level)
    {
        for (int i = 0; i < objList.Length; ++i)
        {
            //Only look for instances of prefabs
            if (PrefabType.PrefabInstance == EditorUtility.GetPrefabType(objList[i]))
            {
                GameObject root = EditorUtility.FindPrefabRoot(objList[i]);

                //Only add to list if its the root to make sure the same object is not added several times
                if (root == objList[i])
                {
                    Debug.Log("It's the Root");
                    level.AddNewPrefab(objList[i], prefabEnum);
                }
                else
                {
                    Debug.Log("It's not the root, so we dont add it to list");
                }
            }
            else
            {
                Debug.Log("NOT A PREFAB");
            }
        }
    }

    private string trimStringToSceneName(string path)
    {
        string sceneName = string.Empty;

        //Removing all but levelname + ".unity"
        string tmpString = path.Remove(0, path.Length - 13);

        //removing ".unity"
        sceneName = tmpString.Substring(0, tmpString.Length - 6);

        return sceneName;
    }
}

You will need these classes as well:
using System.Collections.Generic;
using UnityEngine;

public class LevelData
{
    public string Name;
    public List<SpawnType> ObjectsToSpawn = new List<SpawnType>();

    public void AddNewPrefab(GameObject newObj, GameEnum.PrefabEnum prefabEnum)
    {
        Vector3 pos = newObj.transform.position;
        ObjectsToSpawn.Add(new SpawnType(prefabEnum, pos));
    }
}

public class SpawnType
{
    public GameEnum.PrefabEnum PrefabType;

    public float PosX;
    public float PosY;
    public float PosZ;

    public SpawnType()
    {
    }

    public SpawnType(GameEnum.PrefabEnum prefabEnum, Vector3 _transformVector)
    {
        PrefabType = prefabEnum;
        PosX = _transformVector.x;
        PosY = _transformVector.y;
        PosZ = _transformVector.z;
    }


    public Vector3 GetVector3()
    {
        return new Vector3(PosX, PosY, PosZ);
    }

}


Ok, so now we're able to serialize a scene, saving the type and location of prefabs, and tag it with the corresponding world. Now we need a scene which is able to deserialize the textassets and instantiate the prefabs. Create a new scene called LevelLoader, create an empty gameobject, name it “Loader” and give it the following LevelLoader script.



using UnityEngine;
using System.Collections;

public class LevelLoader : MonoBehaviour
{
    public TextAsset LevelToLoad;
    private LevelData levelData;
    // Use this for initialization
    void Start()
    {

        levelData = JsonFx.Json.JsonReader.Deserialize<LevelData>(LevelToLoad.text);
        Debug.Log("leveldata loaded from textasset and deserialized");

        initiateLevel();
    }

    private void initiateLevel()
    {
        GameObject wallParent = new GameObject();
        Transform walltransform = wallParent.transform;
        wallParent.name = "Walls";

        foreach (SpawnType go in levelData.ObjectsToSpawn)
        {
            //You need to have you Prefabs placed in a "Prefabs" folder
            string resourcePos = "Prefabs/" + go.PrefabType.ToString();
            Object objLoaded = Resources.Load(resourcePos);
            Vector3 pos = go.GetVector3();

            GameObject newObj = Instantiate(objLoaded, pos, Quaternion.identity) as GameObject;
            newObj.transform.parent = walltransform;
        }
    }
}
The above code creates an empty gameobject called walls to use as a parent for all the instantiated prefab, feel free to make this more dynamic. I'll do it myself at a later stage but for the time being it suits my needs. This should allow you to instantiate any number of levels by serializing them and then just include a single scene in your final build that can deserialize it and spawn the relevant gameobjects. Screenshot of the Leveloader scene with the instantiated prefabs:


Hope you found this helpful, although it's mainly a proof of concept. I'll make it more dynamic when I get around to it.

Here's an example: Unity Package

8 comments:

  1. Hi, all.
    It has come to my attention that there were a few typos in the code above, so that has been corrected.

    Has added a Unity Package as well so you can see it in action

    ReplyDelete
  2. Hi at all
    i became alway this error when i load the level

    ArgumentException: The prefab you want to instantiate is null.
    UnityEngine.Object.CheckNullArgument (System.Object arg, System.String message) (at C:/BuildAgent/work/b0bcff80449a48aa/Runtime/ExportGenerated/Editor/UnityEngineObject.cs:71)
    UnityEngine.Object.Instantiate (UnityEngine.Object original, Vector3 position, Quaternion rotation) (at C:/BuildAgent/work/b0bcff80449a48aa/Runtime/ExportGenerated/Editor/UnityEngineObject.cs:52)
    LevelLoader.initiateLevel () (at Assets/Script/LevelLoader.cs:30)
    LevelLoader.Start () (at Assets/Script/LevelLoader.cs:14)

    ReplyDelete
    Replies
    1. Just tried to load the unitypackage from the example and everything works.
      I think you might have either not placed the prefab you want to spawn in a folder named "Prefabs". Or there is a mismatch between your prefab name and the enum. They must be the same.

      Example:

      public enum PrefabEnum
      {
      PrefabWalls
      }

      Try to open the unitypackage from the tutorial, if you cant make it work

      Delete
  3. Hi Kristian, thanks a lot for the example. It works great with my game. However, it´s not working when trying to build to Android or iOS because AssetDatabase.Refresh() only works when I´m in the Editor Player. Is there any way to refresh assets in Resources folder without using UnityEditor?

    ReplyDelete
    Replies
    1. Hi Ignacio. Sorry I didnt respond sooner, just didnt notide your post. I dont get direct message.

      I'm thinking that perhaps the problem might be you're using a JSON library that relies on system.dll, and youre building with a stripping level that doesnt include it? i.e. UseMicroMSCorlib

      I cant understand why an editor script could have anything to do with it not working when running an iOS/Android build.

      Cant test it myself at the moment, but I would think that either disabling the strippinglevel or finding a JSON library that does not use system.xml

      Are you using this: https://bitbucket.org/darktable/jsonfx-for-unity3d/src

      Delete
  4. Hi Kristian, I would like to ask. How to serialize enum as integer not string, with JsonFX
    Thanks in advance

    ReplyDelete
    Replies
    1. Hi Jerry

      That was more tricky than I expected, apparently its just the way jsonfx serializes enums.

      Tried stuff like:
      [System.Xml.Serialization.XmlEnum(Name = "0")]
      World1 = 0,

      But that did nothing...

      It seems though, that you can just choose another json parser that allows serialization of the enum value type.

      Havent tried it, but this here seems to do the trick:
      http://www.dustinhorne.com/post/Json-NET-for-Unity-Developers

      Delete