Search Unity

  1. New Unity Live Help updates. Check them out here!

    Dismiss Notice

Patterns for dealing with async Addressables without loading screens?

Discussion in 'Addressables' started by SamOld, Mar 26, 2020.

  1. SamOld

    SamOld

    Joined:
    Aug 17, 2018
    Posts:
    126
    I've just started looking at Addressables, literally in the last hour or so. One thing I'm finding it hard to find good info on is how we're meant to deal with the async patterns in practical use cases. It's not obvious to me how they tie into the standard Unity patterns which are mostly synchronous.

    I'm in the early prototyping stages of a project, and looking for something flexible and fast to work with without too much setup. I thought that it would make sense to swap a
    Resources.Load<Texture3D>("path")
    call out for the Addressables system, but it's proving a pain because of the sync/async difference.

    I'm building most of my logic in plain C# classes so that I can be free to use it from either the legacy MonoBehaviours workflow or from DOTS. I have a plain rendering helper class that builds some command buffers for use on cameras and lights. That helper class needs access to a texture resource. The obvious way to deal with that would be to make my
    RenderHelper.SetupLightCommandBuffer()
    methods async, but they need to be able to be called from MB
    Update
    methods, so I don't think that's much of an option. The only half decent solution that's coming to mind is to load the texture in the helper class's constructor and just force it to happen synchronously via the
    Task
    API. That feels like I'm doing it wrong, although it's basically just what I would be doing with
    Resources.Load
    anyway.

    This one texture will always be loading from the local disk and it isn't too large, so I could probably get away with just loading it synchronously when it's first needed, which will be the first frame of the scene. More generally though, are there any good established patterns for dealing with async resource loading like this? I can envision ways I would go about building whole async level loading systems that await the loading of assets behind a loading screen, but the amount of boilerplate setup for that means it's a bit out of reach for rapid prototype development where I'm just jumping in and out of play mode in one scene.
     
  2. ProtoTerminator

    ProtoTerminator

    Joined:
    Nov 19, 2013
    Posts:
    161
    Async loading is mostly useful for background loading like seamless worlds and anything behind a load screen (if you have any loading animations, sync loading will drop the frame-rate).

    I would not recommend forcing an async operation to sync using Task's API, you are likely to cause a dead lock, especially in single-threaded apps like WebGL.

    Instead, what I recommend is to preload everything that you know you will always need behind a load screen at the start of your game. You can cache those objects in some static location so that you can instantiate them synchronously if you wish.

    For any seamless world streaming, you can probably simply fire-and-forget the async loading. They'll pop in whenever they're done, no extra work from you.

    For anything else, like a ui panel that you want to show on-demand without preloading, I suggest you wrap the addressables system in your own asset manager that puts up a soft load screen while those assets are being loaded. (Soft means it doesn't completely cover everything behind it, like just a simple spinner. Your initial load screen would be a hard load screen.) This is exactly what I do in my project right now.

    Then, I suggest you simply convert your code to async functions. Then it's convenient to write them just like you would synchronously before, you just add the
    await
    keyword before any async calls.

    I haven't worked with DOTS at all myself, so I have no idea how well that system works with async APIs (I'm guessing it doesn't?)


    [EDIT] As for your worries about writing boiler-plate code for this for rapid prototyping, writing your own asset manager to implement that simple functionality only needs to be done once, and it's fairly easy to do. Then you can re-use that for all of your prototypes.
     
    Last edited: Mar 27, 2020
  3. SamOld

    SamOld

    Joined:
    Aug 17, 2018
    Posts:
    126
    Thanks @ProtoTerminator. I think I already understand most of that. Working with async code generally is no problem for me, and the loading screen scenario is no problem either as I said in my last paragraph. If I can make my whole scene loading process async, that's easy.

    What I'm not sure about is scenarios where I can't do that. The two main ones are pressing the play button in the editor to drop directly into the open scene, and
    [ExecuteAlways]
    behaviours that are running in the editor. There's no opportunity to await an async operation before the first frame happens.

    If a MonoBehaviour's Update method depends on an asset loaded by an async Addressable, what do I do? I could just skip the update until it's ready, but that's a mess of indeterminate behaviour and race conditions I have no intention of getting into. A sync load in the style of the old
    Resources.Load()
    API actually seems like the safest option, because the first frame stuttering is a fairly minor issue.

    I suppose what I need is some form of synchronous system for loading assets which I would use at the consumption site, but which I could also preload asynchronously during a loading screen when I'm getting into the scene by that route. This seems like a common enough requirement that I expected to find it in the API. Is this a problem which we're all expected to solve independently? I can, but it's an unnecessary hassle. Or am I doing something wrong to be getting myself into this position at all?
     
  4. SamOld

    SamOld

    Joined:
    Aug 17, 2018
    Posts:
    126
    I've just discovered that
    [InitializeOnLoadMethod]
    and
    [RuntimeInitializeOnLoadMethod]
    can be used on async methods. I'm not sure that this completely solves the problem in every case, but it does meet the needs of my helper class. I can simply load the texture via Addressables with that. It should work for now, at least.

    The more general question remains. What do we do when a MB (or an ECS system) needs an asset to run its update, and when it's either running in play mode or under
    [ExecuteAlways]
    ? The best idea I have is still to build a sync wrapper around Addressables with optional async pre loading. I still feel that I must be missing something, because it doesn't seem right that we're all meant to reinvent the wheel for this.

    I suppose the answer may be DI/IoC. Maybe I should never be fetching assets from my MBs or systems at all. Unity doesn't provide many good opportunities to inject these things though, particularly not in code that's agnostic between MBs and DOTS. Making it a fiddly thing that I have to set up in the editor via drag-and-drop isn't a good option.
     
    Last edited: Mar 27, 2020
  5. ProtoTerminator

    ProtoTerminator

    Joined:
    Nov 19, 2013
    Posts:
    161
    You could try disabling the component while the asset is being loaded, that way you don't need to do the check in Update, it just won't run.
     
  6. SamOld

    SamOld

    Joined:
    Aug 17, 2018
    Posts:
    126
    True, that would work in some simple cases, but it's still creating indeterminate behaviour that could be undesirable in the general case.
     
  7. SamOld

    SamOld

    Joined:
    Aug 17, 2018
    Posts:
    126
    I've got a prototype of a wrapper that fixes this. I'm putting it here for reference, but pay attention to the word "prototype". This is not ready for production use. I may update this further if I do more work on it, but feel free to take this and run with it any way you please.

    The basic idea is to separate async loading and sync consumption.
    AssetLoader
    provides sync methods to fetch assets by address from synchronous contexts like MB methods. It works by having them already pre-cached, which you would typically do asynchronously during a loading screen. In a build, it just fails if the asset has not been pre-loaded.

    The magic is special editor functionality. If you call the synchronous
    AssetLoader.GetAsset
    method in the editor without pre-loading the asset, it just synchronously loads it from AssetDatabase. This means that dropping into a scene without a loading screen works seamlessly. It also allows this API to work outside of play mode, so assets can be fetched by address in editor scripts and
    [ExecuteAlways]
    MBs.

    To give an example of when this is useful, I have a component that adds functionality to lights by injecting a command buffer. To build that command buffer, it needs access to a compute shader and a lookup texture. Those things are an implementation detail of the component, and should not be configurable properties in the editor. I don't want users of this component to have to supply those each time it's added. Using this system, it can synchronously fetch those assets on first load and be working on the very first frame without any undetermined behaviour or race conditions, and it runs seamlessly in edit mode as well as play mode so my lighting previews are correct.

    From my experience working on this (it's my first time using Addessables, so I'm still a noob) I would say that something like this functionality should be included in the main API. @DavidUnity3d, @unity_bill, have you considered something like this as a practical bridge between sync and async?

    Code (CSharp):
    1. // VERY PROTOTYPE
    2. // TODO: Switch to conditional compilation for editor features
    3. // TODO: Proper ref counting and releasing of assets
    4. // TODO: Testing
    5. // TODO: Better exception handling
    6. // TODO: Argument validation
    7. // TODO: Support labels? multiples?
    8. // TODO: Performance - no attention paid yet
    9. static class AssetLoader
    10. {
    11.     private static bool addressPathMapInitialised = false;
    12.     private static readonly Dictionary<object, string> addressPathMap = new Dictionary<object, string>();
    13.  
    14.  
    15.     // Call during loading screens etc
    16.     public static async Task PreloadAssetAsync<T>(object key)
    17.         where T : UnityEngine.Object
    18.     {
    19.         await LoadAssetAsync<T>(key);
    20.     }
    21.  
    22.     public static async Task<T> LoadAssetAsync<T>(object key)
    23.         where T : UnityEngine.Object
    24.     {
    25.         if (TypedCache<T>.Map.TryGetValue(key, out var asset)) return asset;
    26.  
    27.         // Must load from AssetDatabase because Addressables don't work
    28.         if (Application.isEditor && !Application.isPlaying) return LoadAssetFromDatabase<T>(key);
    29.  
    30.         asset = await Addressables.LoadAssetAsync<T>(key).Task;
    31.         TypedCache<T>.Map.Add(key, asset);
    32.         return asset;
    33.     }
    34.  
    35.     public static void UnloadAsset<T>(object key)
    36.             where T : UnityEngine.Object
    37.     {
    38.         // TODO: Track, release Addressables
    39.         TypedCache<T>.Map.Remove(key);
    40.     }
    41.  
    42.     public static bool TryGetAsset<T>(object key, out T asset)
    43.             where T : UnityEngine.Object
    44.     {
    45.         return TypedCache<T>.Map.TryGetValue(key, out asset);
    46.     }
    47.  
    48.     public static T GetAsset<T>(object key) where T : UnityEngine.Object
    49.     {
    50.         if (TypedCache<T>.Map.TryGetValue(key, out var asset)) return asset;
    51.         if (Application.isEditor) return LoadAssetFromDatabase<T>(key);
    52.         throw new Exception("Asset not loaded.");
    53.     }
    54.  
    55.  
    56.     [InitializeOnLoadMethod]
    57.     private static void Init()
    58.     {
    59.         if (!addressPathMapInitialised) BuildAddressMap();
    60.  
    61.         // Just rebuild entirely every time something changes.
    62.         // Could be doing something more intelligent based on the specific modification event.
    63.         // Quick and dirty. Good enough for now.
    64.         UnityEditor.AddressableAssets.Settings.AddressableAssetSettings.OnModificationGlobal += (s, e, o) => BuildAddressMap();
    65.     }
    66.  
    67.     private static void BuildAddressMap()
    68.     {
    69.         // Can only run in editor
    70.         if (!Application.isEditor)
    71.         {
    72.             addressPathMapInitialised = true;
    73.             return;
    74.         }
    75.  
    76.         var settings = UnityEditor.AddressableAssets.AddressableAssetSettingsDefaultObject.Settings;
    77.  
    78.         // If it can't load settings, but there's a single settings object in the DB, use that.
    79.         // It fails to load settings during startup, so this is a workaround for that.
    80.         // It seems to work fine...
    81.         if (settings == null)
    82.         {
    83.             var allSettingsObjects = AssetDatabase.FindAssets("t:AddressableAssetSettings");
    84.  
    85.             if (allSettingsObjects.Length == 1)
    86.             {
    87.                 var path = AssetDatabase.GUIDToAssetPath(allSettingsObjects[0]);
    88.                 settings = AssetDatabase.LoadAssetAtPath<UnityEditor.AddressableAssets.Settings.AddressableAssetSettings>(path);
    89.             }
    90.             else
    91.             {
    92.                 // TODO: Look at doing something more sensible here.
    93.                 throw new InvalidOperationException("Addressables settings not found!");
    94.             }
    95.  
    96.         }
    97.  
    98.         addressPathMap.Clear();
    99.  
    100.         foreach (var group in settings.groups)
    101.         {
    102.             foreach (var entry in group.entries)
    103.             {
    104.                 addressPathMap.Add(entry.address, entry.AssetPath);
    105.             }
    106.         }
    107.  
    108.         addressPathMapInitialised = true;
    109.     }
    110.  
    111.     private static bool TryLoadAssetFromDatabase<T>(object key, out T asset) where T : UnityEngine.Object
    112.     {
    113.         // Fixes a race condition when AssetLoader is called from [InitializeOnLoad] and similar.
    114.         // They all run in undetermined order, so it's possible for the map to not yet be initialised when this is called.
    115.         if (!addressPathMapInitialised) BuildAddressMap();
    116.  
    117.         if (!addressPathMap.TryGetValue(key, out var path))
    118.         {
    119.             asset = default;
    120.             return false;
    121.         }
    122.  
    123.         asset = AssetDatabase.LoadAssetAtPath<T>(path);
    124.         TypedCache<T>.Map.Add(key, asset);
    125.         return true;
    126.     }
    127.  
    128.     private static T LoadAssetFromDatabase<T>(object key) where T : UnityEngine.Object
    129.     {
    130.         if (TryLoadAssetFromDatabase<T>(key, out var asset)) return asset;
    131.         throw new Exception("Unable to load asset from AssetDatabase.");
    132.     }
    133.  
    134.  
    135.     private static class TypedCache<T>
    136.     {
    137.         public static readonly Dictionary<object, T> Map = new Dictionary<object, T>();
    138.     }
    139. }
     
unityunity