2
\$\begingroup\$

When I build a game in Unity, it packs the assets into strange data files with strange extensions like .split2 or .split1.

But what if I want to give the player the ability to replace textures and modify the game without reverse engineering these files?

I want the Assets folder as I see it in the editor to be accessible with the same files in the build.

\$\endgroup\$
2
\$\begingroup\$

This process Unity uses is called "data baking" and it accomplishes a lot of useful things for your game:

  • It converts the textures into appropriate sizes for your build target (scaling huge or non-power-of-two source assets into something practical for runtime use).
  • It packs sprites into atlas textures for drawing lots of them efficiently.
  • It converts the textures into GPU-friendly formats that can be pushed straight into video memory (possibly using compressed formats to save space). GPUs don't speak jpeg/png directly, so you pay this transcoding cost any time you want to turn an image into a texture.
  • Similar tricks happen with sounds, meshes, fonts, etc - preparing them all into the format that the destination platform likes best.
  • Finally, it packs-together assets that are used together into batches that can be loaded all at once, instead of going file-by-file, making more efficient use of disc access and memory space.
  • Now that all the files are in known offsets inside these batches, game content referencing them can do so directly by ID, without needing to know the whole asset path or going through extra indirection steps.

All of this adds up to faster loading times and more predictable performance when the game is being played. By doing the complicated data transformations at build time, the runtime's job is much simpler: just pull this data off disc and start using it, no extra translation required.

So while you can load arbitrary unbaked files at runtime, using for example the WWW class, doing so puts all of this work right in the middle of when the player is trying to play the game, slowing down the game for everyone. It also increases the complexity of your scripts, since you're now responsible for figuring out when an asset needs to be loaded, which objects should reference which of these runtime-loaded assets, and when those assets can be unloaded safely.

One way you can open the game to modding without impacting all of your load times is to think of it as a runtime asset swap:

  1. You build your game normally, with all the assets loaded from packed files as usual - so you get fast load times and the usual convenient scene authoring.

  2. You add an extra script that, on startup, looks for a "mod manifest" text file that the player can put in a specific location.

    • This file can be created & edited by the player to say things like "all Enemy 1 textures should instead use C:\Users\PlayerName\Documents\MyAwesomeNinja.jpg"

    • If the file is missing, then you skip over it, and the player gets the "vanilla" version of the game with minimal extra overhead.

  3. If the mod manifest file is found, your script parses it to figure out what the modder wants to change, and loads their replacement assets into an index.

  4. Whenever you want to use one of these moddable assets, check this index for a replacement - if it has one, then use the modder's replaced asset instead.

This means a little extra load time and memory use for the modders, since they're pulling in all the (fast) baked assets AND the (slow) custom assets, but at that point the slowness is opt-in, and it's on the modders to make their mods efficient with appropriate texture formats/sizes/etc, without impacting the base game experience much at all.

Here's a simplistic version that works for replacing the main texture for a collection of materials:

// Put this script on any GameObject that needs to use custom materials.
public class ModdableMaterial : MonoBehaviour {
    public Renderer targetRenderer;

    private void Start() {
        targetRenderer.sharedMaterial =
            ModMaterialCache.GetModdedVersion(targetRenderer.sharedMaterial);

        // My work here is done.
        Destroy(this);
    }
}

// Use this script to load the mod manifest and generate mappings
// of original to replaced assets.
public class ModMaterialCache : MonoBehaviour {
    // Singleton reference for ease of lookup.
    public static ModMaterialCache _instance;

    // Where should we expect the player to put the mod manifest?
    // (Relative to the game's persistent data path)
    public string manifestPath = "mod_manifest.txt";

    // Expose a mapping of names to materials to populate in the Inspector.
    [System.Serializable]
    public struct ModdableMaterial {
        public string name;
        public Material originalMaterial;
    }
    public List<ModdableMaterial> moddables;

    // Keep a mapping of materials with active mods applied.
    Dictionary<Material, Material> _activeMods 
        = new Dictionary<Material, Material>();

    // And let other scripts request the modded versions.
    public static Material GetModdedVersion(Material originalMaterial) {
        // If there's no mod cache in the scene, then just use the original.
        if (_instance == null)
            return originalMaterial;

        // If there is a mod cache, then check it for a replacement.
        Material modded;
        if (_instance._activeMods.TryGetValue(originalMaterial, out modded))
            return modded;

        // No replacement found - keep using the original.
        return originalMaterial;
    }

    // Ensure this has time to load all its materials before you instantiate
    // any assets that use them. Putting it on a title screen with 
    // DontDestroyOnLoad can be a good way to choreograph this.
    IEnumerator Start() {
        // Quick Singleton pattern to make a unique instance of this script
        // easy to find & reference when we need it.
        if (_instance != null) {
            Destroy(gameObject);
            yield break;
        }
        _instance = this;
        DontDestroyOnLoad(gameObject);

        // Construct the full path to our mod manifest:
        var manifest = new WWW(Application.persistentDataPath + manifestPath);

        // Let the game run a frame or so until this file has been loaded.
        yield return manifest;

        // If there's no manifest, carry on - but log the issue just in case.
        if(string.IsNullOrEmpty(manifest.error) == false) {
            Debug.LogWarningFormat(
                "Error loading mod manifest: {0}", manifest.error);
            yield break;
        }

        // Walk through each line of the manifest, looking for mods.
        foreach(var line in manifest.text.Split('\n')) {

            // A mod line has the form "name = path/to/the_asset.extension"
            // so we skip lines without an equals sign past the 1st character.
            int separator = line.IndexOf('=');
            if (separator <= 0) continue;

            // Pull out the name of the modded material.
            string name = line.Substring(0, separator).Trim();

            // Check if that name is one in our material list.
            int index = moddables.FindIndex(m => m.name == name);
            if (index < 0) {
                Debug.LogWarningFormat("Unknown moddable name: {0}", name);
                continue;
            }

            // Prepare a modded version of the material:
            Material original = moddables[index].originalMaterial;
            Material modded = Instantiate<Material>(original);

            // Store the original-> modded mapping for ease of lookup later.
            _activeMods.Add(original, modded);

            // Fire off another coroutine to asynchronously load
            // the modded texture to populate this material.
            string path = line.Substring(separator + 1).Trim();
            StartCoroutine(LoadMod(modded, path));            
        }
    }

    IEnumerator LoadMod(Material material, string path) {
        // This takes time because we're reading a file off the disk,
        // and translating it to a GPU-friendly format.
        var loader = new WWW(path);
        yield return loader;
        // TODO: error checking...

        // Note that this loads the image as uncompressed ARGB32.
        // You can instead use LoadImageIntoTexture to apply compression.
        material.mainTexture = loader.texture;
    }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.