Version: Unity 6.6 (6000.6)
Language : English
Reference content in a content directory
Include scenes in a content build

ScriptableObject designs for content directories

A content directory build starts at the root assets you pass to BuildPipeline.BuildContentDirectory and follows every tracked reference it finds. You define the ScriptableObject types that hold those references, which determines how your code finds content at runtime, and how much of it Unity loads into memory when you register the content directory.

The following are examples of ways that you can design ScriptableObject instances to suit your project. Each illustrates a design pattern rather than a finished implementation:

  • Look up assets by key: Map a string key to a loadable reference in a dictionary, which your code uses at runtime to work out which asset it needs.
  • Track additional data: Make the dictionary value a type of your own, so each entry holds extra data next to the reference. This approach is useful to get data about an asset without loading the asset.
  • Track assets of a single type: Reference the assets with a Loadable<T> instance rather than a LoadableObjectId. Use this design if you want a collection to hold only one kind of asset.
  • Populate entries from a folder: Organize entries into folders if your project has a large number of assets that you can’t maintain manually.
  • Populate a root asset during a build: Create a temporary root asset in a build script, fill it from the project, then delete it after the build. Use this design if a rule automatically decides the content of a build, rather than a person manually choosing each entry.
  • Keep entries in step with a folder: Refill a list whenever the folder it tracks changes, so nobody has to remember to refresh it. Use this design if the list has to be correct at every moment.
  • Match the design to your game: Nest ScriptableObject types that mirror the structure of your game, and choose a direct or a loadable reference for each field. This approach is useful if you want to control which content Unity loads immediately and which it loads on demand.

In your own project, add the methods and data structures your code needs, and a custom Inspector where you want a different display than the one Unity generates. For more information, refer to Customize the dictionary display and Create a custom Inspector.

These examples use loadable references and direct references. For an explanation of the reference types and how Unity follows them, refer to Reference content in a content directory. For a walkthrough of defining a root asset, creating it, and running a build, refer to Create content directories.

Look up assets by key

A root asset doesn’t have to hold a collection. When you know each asset as you author the project, give it a named field, in the way that the content pack root asset does. Use a dictionary when your code has to find an asset from a key it works out at runtime.

A dictionary that maps a string to a loadable reference gives your code a lookup similar to what the Resources folder provides for Player builds. Unity includes every asset the dictionary names in the content directory build, and loads each one only when your code asks for it.

using System.Collections.Generic;
using Unity.Loading;
using UnityEngine;

// Maps a key to any asset in the project. Use it as a root asset, or reference it from one,
// to include every asset it lists in a content directory build.
[CreateAssetMenu(fileName = "AssetList", menuName = "Content/Asset List")]
public class AssetList : ScriptableObject
{
    [SerializeField]
    [DictionaryDisplay(keyLabel = "Key", valueLabel = "Asset")]
    Dictionary<string, LoadableObjectId> m_Assets = new();

    // Constructs a Loadable of the requested type, or returns null when the key is missing,
    // so that a caller can search several asset lists. A LoadableObjectId doesn't record the
    // type of the object it points at, so the caller states the type it expects, or
    // UnityEngine.Object to accept any Unity object.
    public Loadable<T> Find<T>(string key) where T : UnityEngine.Object
    {
        if (m_Assets.TryGetValue(key, out var id))
            // A new Loadable each time, so every caller gets its own reference.
            return new Loadable<T>(id);

        return null;
    }
}

The dictionary stores a LoadableObjectId rather than a Loadable<T>, so a caller can ask for the type it expects. To choose between the two for your own design, refer to Determine which loadable reference type to use.

The following example looks up an entry when each asset list is a root asset:

using Unity.Loading;
using UnityEngine;

// Loads a mesh that an AssetList root asset tracks, and releases it when this component is
// destroyed.
[RequireComponent(typeof(MeshFilter))]
public class AssetListLookup : MonoBehaviour
{
    public string key;

    // Not serialized, because the asset list supplies the Loadable at runtime.
    Loadable<Mesh> m_Loadable;

    void Start()
    {
        foreach (var list in ContentLoadManager.GetRootAssets<AssetList>())
        {
            var loadable = list.Find<Mesh>(key);
            if (loadable == null)
                continue;

            m_Loadable = loadable;
            GetComponent<MeshFilter>().sharedMesh = loadable.Load();
            return;
        }

        Debug.LogError($"No registered asset list has an entry for the key {key}");
    }

    void OnDestroy()
    {
        m_Loadable?.Release();
    }
}

The example retrieves the root assets using ContentLoadManager.GetRootAssets<T>. It searches them in turn and stops at the first one that has the key, rather than assuming the array always has a single element. If your project only registers one asset list, you can index the array directly instead.

The component holds the loadable reference in a field it doesn’t serialize, and releases it in OnDestroy. That ties the lifetime of the reference to the lifetime of the code that uses the asset, which is the pattern to follow whenever you load on demand.

GetRootAssets<T> finds root assets only. This lookup therefore works when each asset list is itself a root asset of its content directory.

If there are many asset lists, for example based on different kinds of content, avoid making them all root assets. Instead reference them from a single root asset per content directory, in a structure that fits your project. Unity follows the references from the root asset to each list, so every asset the lists reference is still part of the build. Retrieve that root asset and read the lists from it in the way that the character roster does.

Track additional data

A key and a reference aren’t always enough when you reference assets in a collection. You might want to track additional data that your code can read without loading the asset, for example to filter by label or to record a loading priority. To store that data next to the reference, make the dictionary value a struct or class of your own, which can be any type that Unity serializes.

using System;
using System.Collections.Generic;
using Unity.Loading;
using UnityEngine;

// One entry in a LabeledAssetList. Any serializable type works as a dictionary value, so an
// entry can carry whatever data your project needs alongside the asset reference.
[Serializable]
public struct LabeledAsset
{
    public LoadableObjectId asset;
    public List<string> labels;
    public int priority;
}

[CreateAssetMenu(fileName = "LabeledAssetList", menuName = "Content/Labeled Asset List")]
public class LabeledAssetList : ScriptableObject
{
    // An entry has fields of its own, so keep each one behind a foldout to stop a long list
    // from filling the Inspector.
    [SerializeField]
    [DictionaryDisplay(layout = DictionaryLayout.OneColumnWithValueFoldout,
        keyLabel = "Key", valueLabel = "Entry")]
    Dictionary<string, LabeledAsset> m_Assets = new();

    public Loadable<T> Find<T>(string key) where T : UnityEngine.Object
    {
        if (m_Assets.TryGetValue(key, out var entry))
            return new Loadable<T>(entry.asset);

        Debug.LogError($"{name} has no asset with the key {key}");
        return null;
    }

    public IEnumerable<string> FindKeysWithLabel(string label)
    {
        foreach (var entry in m_Assets)
        {
            if (entry.Value.labels != null && entry.Value.labels.Contains(label))
                yield return entry.Key;
        }
    }
}

An alternative is to store the loadable reference and its extra data on individual ScriptableObject assets, and reference those from the dictionary. This suits data that’s large or structured, such as all the attributes of an item or a character in a game. For more information, refer to Match the design to your game.

Track assets of a single type

When a list holds one kind of asset, state that in the type. The Inspector window then accepts only assets of that type, so Unity catches a wrong assignment while you edit rather than while your code runs.

using System.Collections.Generic;
using Unity.Loading;
using UnityEngine;

// Maps a name to an audio clip. The dictionary value is a Loadable<AudioClip> rather than a
// LoadableObjectId, so the Inspector only accepts audio clips.
[CreateAssetMenu(fileName = "AudioLibrary", menuName = "Content/Audio Library")]
public class AudioLibrary : ScriptableObject
{
    [SerializeField]
    [DictionaryDisplay(keyLabel = "Name", valueLabel = "Audio Clip")]
    Dictionary<string, Loadable<AudioClip>> m_Clips = new();

    public Loadable<AudioClip> Find(string clipName)
    {
        if (m_Clips.TryGetValue(clipName, out var clip))
            // A new Loadable each time, so every caller gets its own reference.
            return new Loadable<AudioClip>(clip.LoadableObjectId);

        Debug.LogError($"{name} has no clip called {clipName}");
        return null;
    }

    public void Clear() => m_Clips.Clear();

    // Refuses a name that is already in use, rather than replacing the entry, so that a
    // caller can report the collision instead of losing a clip.
    public bool Add(string clipName, Loadable<AudioClip> clip)
    {
        if (m_Clips.ContainsKey(clipName))
            return false;

        m_Clips[clipName] = clip;
        return true;
    }
}

This example stores a Loadable<AudioClip> rather than a LoadableObjectId, so the Inspector window accepts only audio clips. Find builds a new Loadable<AudioClip> from the LoadableObjectId of the stored one, so each caller still gets its own reference. Return a new instance rather than the stored one, whichever of the two types you store.

Populate entries from a folder

This section and the two that follow describe how to populate a list, so you can combine any of them with the designs above.

Maintaining a list manually works for a small number of entries. To avoid manual maintenance work, you can organize entries into folders and then add a custom Inspector with a button that refills the list from the contents of the folder.

using Unity.Loading;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

// Adds a button to the AudioLibrary Inspector that fills the library from a project folder.
// The dictionary needs no UI code of its own, because Unity draws a serialized dictionary.
[CustomEditor(typeof(AudioLibrary))]
public class AudioLibraryEditor : Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        var root = new VisualElement();
        InspectorElement.FillDefaultInspector(root, serializedObject, this);
        root.Add(new Button(PopulateFromFolder) { text = "Populate from folder" });
        return root;
    }

    void PopulateFromFolder()
    {
        var library = (AudioLibrary)target;

        var absoluteFolder = EditorUtility.OpenFolderPanel("Select a folder of audio clips", "Assets", "");
        if (string.IsNullOrEmpty(absoluteFolder))
            return;

        // The folder panel returns an absolute path, but AssetDatabase needs a project relative one.
        var folder = FileUtil.GetProjectRelativePath(absoluteFolder);
        if (string.IsNullOrEmpty(folder))
        {
            Debug.LogError($"{absoluteFolder} is not inside this project");
            return;
        }

        // Replacing every entry discards whatever was there, so make it undoable.
        Undo.RecordObject(library, "Populate audio library from folder");

        library.Clear();

        // FindAssets searches subfolders, so two clips can arrive with the same name.
        foreach (var guid in AssetDatabase.FindAssets("t:AudioClip", new[] { folder }))
        {
            var path = AssetDatabase.GUIDToAssetPath(guid);
            var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(path);
            var id = LoadableObjectIdEditorUtility.CreateLoadableObjectId(clip);

            if (!library.Add(clip.name, new Loadable<AudioClip>(id)))
                Debug.LogWarning($"{path} has the same name as an earlier clip, so it was skipped");
        }

        EditorUtility.SetDirty(library);
    }
}

The button calls AssetDatabase.FindAssets with a type filter, so only audio clips in the selected folder become entries. Because FindAssets searches subfolders, two clips can share a file name and collide on the same key, which will result in a warning message.

The list reflects the folder as of the last time you selected the button. If someone adds an asset to the folder after that, the build excludes it until you refresh the list. If keeping a saved list in step with a folder becomes a burden, you can populate a root asset during the build, or keep the list in step automatically. The next two sections describe both approaches and their trade-offs.

Populate a root asset during a build

A build script can create a root asset, fill it from the project, run the build, then delete the asset. The script saves nothing in your project, so there’s no stored list that falls out of date, and the ScriptableObject still loads at runtime from the content directory like any other root asset.

Use this approach when a rule decides the content of a build, such as every audio clip under a folder or every asset that carries a given label, rather than a person choosing each entry. The temporary asset must exist at a project path when you call BuildPipeline.BuildContentDirectory, because you pass its path in BuildContentDirectoryParameters.rootAssetPaths.

This approach has a downside. The root asset exists only for the duration of the build, so a project you run in Play mode must register a previously built content directory rather than reading the ScriptableObject from the project. If your builds are out of date or missing, you get unexpected results when you enter Play mode. After the build there’s also no asset left to inspect, which can make a content problem harder to reproduce and to debug. Prefer a root asset saved in your project unless a rule genuinely decides the content of the build.

For a build script that creates and fills a temporary root asset, refer to Create a custom build script.

Keep entries in step with a folder

Each approach so far needs something to happen before the list is correct: somebody selects the button, or a build script runs. An AssetPostprocessor removes that step. It refills the list whenever the folder changes, so the saved asset is current in the Editor, in Play mode, and in a build, with nothing to remember.

This design ties an asset to a folder, so save the asset in the folder whose clips it tracks. Its own path then decides the contents, and there’s no folder field that can go stale:

using System.Collections.Generic;
using Unity.Loading;
using UnityEngine;

// An audio library that Editor code keeps in step with the folder holding the asset. Save the
// asset in the folder whose clips it tracks, because its own path is what decides the contents.
[CreateAssetMenu(fileName = "AutoAudioLibrary", menuName = "Content/Auto Audio Library")]
public class AutoAudioLibrary : ScriptableObject
{
    [SerializeField]
    [DictionaryDisplay(keyLabel = "Name", valueLabel = "Audio Clip")]
    Dictionary<string, Loadable<AudioClip>> m_Clips = new();

    public Loadable<AudioClip> Find(string clipName)
    {
        if (m_Clips.TryGetValue(clipName, out var clip))
            // A new Loadable each time, so every caller gets its own reference.
            return new Loadable<AudioClip>(clip.LoadableObjectId);

        Debug.LogError($"{name} has no clip called {clipName}");
        return null;
    }

    public void Clear() => m_Clips.Clear();

    // Refuses a name that is already in use, rather than replacing the entry, so that a
    // caller can report the collision instead of losing a clip.
    public bool Add(string clipName, Loadable<AudioClip> clip)
    {
        if (m_Clips.ContainsKey(clipName))
            return false;

        m_Clips[clipName] = clip;
        return true;
    }
}

The postprocessor finds every library in the project and refills the ones whose folder the import touched:

using Unity.Loading;
using UnityEditor;
using UnityEngine;

// Refills every AutoAudioLibrary whose folder gained, lost, or moved an audio clip, so a saved
// library never falls behind the folder it sits in.
public class AutoAudioLibraryWatcher : AssetPostprocessor
{
    static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets,
        string[] movedAssets, string[] movedFromAssetPaths)
    {
        // Unity calls this after every import in the project, and FindAssets searches the whole
        // project, so rule out a batch that holds no clip at all before paying for the search.
        if (!HasClip(importedAssets) && !HasClip(deletedAssets) &&
            !HasClip(movedAssets) && !HasClip(movedFromAssetPaths))
            return;

        foreach (var guid in AssetDatabase.FindAssets("t:AutoAudioLibrary"))
        {
            var libraryPath = AssetDatabase.GUIDToAssetPath(guid);

            // A library tracks the folder it lives in, so its own path defines the scope.
            var folder = libraryPath.Substring(0, libraryPath.LastIndexOf('/'));

            if (!HasClip(importedAssets, folder) && !HasClip(deletedAssets, folder) &&
                !HasClip(movedAssets, folder) && !HasClip(movedFromAssetPaths, folder))
                continue;

            var library = AssetDatabase.LoadAssetAtPath<AutoAudioLibrary>(libraryPath);
            Populate(library, folder);

            // Saving reimports the library, so this method runs again with the library's own
            // path in the batch. That path resolves to a library rather than a clip, so
            // MightBeClip rules it out and the second run returns at the check above. Without
            // that, saving here would loop.
            AssetDatabase.SaveAssetIfDirty(library);
        }
    }

    // No undo record here, unlike a button that a user selects: an import isn't a user action,
    // and the folder is the authority on the contents either way.
    static void Populate(AutoAudioLibrary library, string folder)
    {
        library.Clear();

        // FindAssets searches subfolders, so two clips can arrive with the same name.
        foreach (var guid in AssetDatabase.FindAssets("t:AudioClip", new[] { folder }))
        {
            var path = AssetDatabase.GUIDToAssetPath(guid);
            var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(path);
            var id = LoadableObjectIdEditorUtility.CreateLoadableObjectId(clip);

            if (!library.Add(clip.name, new Loadable<AudioClip>(id)))
                Debug.LogWarning($"{path} has the same name as an earlier clip, so it was skipped");
        }

        EditorUtility.SetDirty(library);
    }

    // Any possible clip in the batch, or in one folder when a folder is given.
    static bool HasClip(string[] paths, string folder = null)
    {
        foreach (var path in paths)
        {
            if ((folder == null || path.StartsWith(folder + "/")) && MightBeClip(path))
                return true;
        }

        return false;
    }

    // A deleted or moved-away path has no asset left to ask for a type, so treat a path that
    // resolves to nothing as a possible clip. Ignoring it would miss deletions.
    static bool MightBeClip(string path)
    {
        var type = AssetDatabase.GetMainAssetTypeAtPath(path);
        return type == null || type == typeof(AudioClip);
    }
}

Unity calls OnPostprocessAllAssets after each batch of imports finishes, and passes the paths it imported, deleted, and moved. It runs for every import in the project, so on a project with a very large number of assets prefer the button or the build script unless the list has to be correct at every moment.

Match the design to your game

A generic list keyed by a string is a good replacement for the Resources folder, but it’s not the only design a root asset can take, and it’s often not the best one. Unity follows tracked references wherever they lead, so you can nest ScriptableObject types that mirror the structure of your game and give each one the fields and keys that suit it.

For example, a game with a fixed set of characters can give each character its own asset. Use an enum for the sounds a character makes, so that the compiler checks every lookup instead of a misspelled string failing at runtime, and the Inspector window offers the sounds as a dropdown:

using System.Collections.Generic;
using Unity.Loading;
using UnityEngine;

// The sounds one character can make. An enum key means the compiler checks every lookup,
// unlike a string key, where a typo fails only at runtime.
public enum CharacterSound
{
    Laugh,
    Walk,
    Run,
    Yell,
    Sneeze,
}

[CreateAssetMenu(fileName = "CharacterSoundProfile", menuName = "Content/Character Sound Profile")]
public class CharacterSoundProfile : ScriptableObject
{
    [SerializeField]
    [DictionaryDisplay(keyLabel = "Sound", valueLabel = "Audio Clip")]
    Dictionary<CharacterSound, Loadable<AudioClip>> m_Sounds = new();

    public Loadable<AudioClip> Find(CharacterSound sound)
    {
        if (m_Sounds.TryGetValue(sound, out var clip))
            return new Loadable<AudioClip>(clip.LoadableObjectId);

        Debug.LogError($"{name} has no clip for {sound}");
        return null;
    }
}

Each character mixes its own game attributes with references to the assets it needs. The model is a loadable reference, and the sound profile is a direct reference:

using Unity.Loading;
using UnityEngine;

[CreateAssetMenu(fileName = "Character", menuName = "Content/Character")]
public class Character : ScriptableObject
{
    public string displayName;
    public int strength;
    public float speed;

    // Loadable reference, so it stays unloaded until something asks for it.
    public Loadable<GameObject> model;

    // CharacterSoundProfile holds only loadable references of its own, so it is
    // lightweight enough for a direct reference.
    public CharacterSoundProfile sounds;
}

Neither of these types is a root asset. The root asset is the list of characters:

using System.Collections.Generic;
using Unity.Loading;
using UnityEngine;

[CreateAssetMenu(fileName = "CharacterRoster", menuName = "Content/Character Roster")]
public class CharacterRoster : ScriptableObject
{
    // Tracks all the characters to include in the build. Direct references, because
    // Character is lightweight, so loading every one when the content directory is
    // registered costs little.
    public List<Character> characters = new();

    public static CharacterRoster Get() => ContentLoadManager.GetRootAssets<CharacterRoster>()[0];

    public Character Find(string displayName) =>
        characters.Find(character => character.displayName == displayName);
}

When you register this content directory, Unity loads the roster, every character, and every sound profile, because a direct reference loads with the object that holds it. These are small serialized objects, so your code can read every character’s name and inspect its sound profile without loading a model or an audio clip. The models and the clips stay unloaded behind their loadable references until something asks for them.

In a hierarchy of game-specific ScriptableObject types, you decide what loads immediately and what loads on demand by choosing a direct or a loadable reference for each field. A ScriptableObject is an asset like any other, so a nested one can sit behind a loadable reference too. Use a direct reference for the small metadata you want available as soon as you register the content directory, and a loadable reference for anything you’d rather load on demand. For more information, refer to Root asset overview.

Additional resources

Reference content in a content directory
Include scenes in a content build
Copyright © 2023 Unity Technologies
优美缔软件(上海)有限公司 版权所有
"Unity"、Unity 徽标及其他 Unity 商标是 Unity Technologies 或其附属机构在美国及其他地区的商标或注册商标。其他名称或品牌是其各自所有者的商标。
公安部备案号:
31010902002961