0
\$\begingroup\$

Currently, when loading from Appdata, there is a noticeable delay. See a sample video here, when it changes from the sprite set in UI to the sprite loaded from Appdata through C# script.

I want to make it so that there is no delay that is visible to the end user. If this delay in loading from Appdata is not avoidable, I'd be fine with loading everything at Start in the background, be it from Appdata or from UnityWebRequest before it is visible to the end user. I would like to know how to do that.

Currently I am loading the texture from UnityWebRequest and converting it to sprite, if Texture does not already exist in Appdata. Here is the code of the ImageLoader class:

public void GenImage(string baseUrl, string imageLin, RawImage rawImag = null, Image imag = null) {
    if(isCoroutineRunning) {
        StopAllCoroutines();
    } else {
        if(rawImag != null) StartCoroutine(LoadImage(rawImag, baseUrl+imageLin));
        if(imag != null) StartCoroutine(LoadImageSprite(imag, baseUrl, imageLin));
    }
}

// **Coroutine class to Load Sprite**
public IEnumerator LoadImageSprite(Image image, string baseUrl, string fileName) {
    isCoroutineRunning = true;
    UnityWebRequest request = UnityWebRequestTexture.GetTexture(baseUrl+fileName);
    yield return request.SendWebRequest();
    
    if(request.result == UnityWebRequest.Result.ConnectionError ||
        request.result == UnityWebRequest.Result.ProtocolError) {
        Debug.Log(request.error);
    } else {

        Texture2D nTexture = LoadCachedImage(fileName);
        if(nTexture != null) {
            Sprite nSprite = Sprite.Create(nTexture, new Rect(0, 0, nTexture.width, nTexture.height), new Vector2(0.5f, 0.5f));
            image.sprite = nSprite;
        }
        else {
            nTexture = ((DownloadHandlerTexture)request.downloadHandler).texture;
            Debug.Log("else block "+nTexture);
            Sprite nSprite = Sprite.Create(nTexture, new Rect(0, 0, nTexture.width, nTexture.height), new Vector2(0.5f, 0.5f));
            image.sprite = nSprite;
            SaveImage(nTexture, fileName);
        }
    }
    isCoroutineRunning = false;
}

// Save and Load Image
public void SaveImage(Texture2D texture, string fileName) {
    string savePath = Application.persistentDataPath +"/";
    Debug.Log(savePath + " " + fileName);

    try {
        if(!Directory.Exists(savePath)) {
            Directory.CreateDirectory(savePath);
        }
        byte[] textureBytes = texture.EncodeToJPG();
        File.WriteAllBytes(savePath + fileName, textureBytes);
    }
    catch(Exception e) {
        Debug.Log(e.Message);
    }
}

public Texture2D LoadCachedImage(string fileName) {
    string savePath = Application.persistentDataPath + "/";
    try {
        if(File.Exists(savePath + fileName)) {
            Debug.Log("Exists"+savePath+fileName);
            byte[] bytes = File.ReadAllBytes(savePath + fileName);
            Texture2D texture = new Texture2D(0,0);
            texture.LoadImage(bytes);
            return texture;
        }
        return null;
    } catch {
        return null;
    }
}

and loading image from a different script:

imageLoader.GenImage(baseUrl: ImageLoader.SPRITE_URL, imageLin: "urashiki.jpg", imag:characterIcon);

imageLoader has already been called as necessary. There is no error in Console, everything works as expected except the delay.

I understand loading from UnityWebRequest may have a noticeable delay, but I don't understand why loading from Appdata also has a noticeable delay. I would like to know how to avoid that delay, and if not possible, how to hide that delay.

\$\endgroup\$
1
  • \$\begingroup\$ I don't think you can avoid it it should depend on the device (how fast can it load the file). Best option could be to preload it and store it in a variable than just use that variable when you need it. \$\endgroup\$
    – Ivan
    Commented Oct 9, 2023 at 6:51

2 Answers 2

1
\$\begingroup\$

Edit:

Your LoadImageSprite() function does not make sense to me. It looks like you download the image from the internet, and then check if it's cached on the device. You're doing that out of order - if the image is cached, you don't need to download it.

So right now, the noticeable delay is probably because you download the image every time.


I don't understand why loading from Appdata also has a noticeable delay

The delay is not caused just by loading the image file from the user's storage drive; Unity also has to decode the JPG, convert the decoded image into a texture format that can be used by the GPU and then upload that texture into the GPU's memory. The amount of time this takes will depend on the player's hardware and the file size and resolution of the image.

I'd be fine with loading everything at Start in the background, be it from Appdata or from UnityWebRequest before it is visible to the end user. I would like to know how to do that.

We can't give you a precise answer for this if we don't know when the image should be visible to the end user.

If the image(s) should be visible to the user as soon as the scene loads, you might consider loading the image(s) synchronously rather than in the background. To do this, simply call the code that loads the image from a Start() function in any MonoBehaviour. You might wish to display a loading screen to the user while the image(s) load.

If the image isn't shown to the player immediately, there are various methods you can use to load an image in the background:

  1. You could use File.ReadAllBytesAsync() instead of File.ReadAllBytes(), although you'd then need to make sure you synchronized the loaded bytes into Unity's main thread before you called texture.LoadImage(). However, using this approach, there would still be a delay when you called texture.LoadImage() as Unity converts the image into a GPU texture format.

  2. On some platforms, you can also use UnityWebRequestTexture.GetTexture() with the path to the file on the hard drive, using the "file://" protocol (just add "file://" before the image path - for example, "file://c:/folder/image.jpg"). This lets you load the image in the background and monitor its progress with a coroutine. For example:

[SerializeField] private Image image;
[SerializeField] private Transform loadingScreen;

void Start() {
    StartCoroutine(LoadImage());
}

IEnumerator LoadImage() {
    using (UnityWebRequest uwr = UnityWebRequestTexture.GetTexture("file://c:/folder/image.jpg"))
    {
        yield return uwr.SendWebRequest();

        if (uwr.result != UnityWebRequest.Result.Success) {
            Debug.Log(uwr.error);
        } else {
            var texture = DownloadHandlerTexture.GetContent(uwr);
            if (texture != null) {
                Sprite nSprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
                image.sprite = nSprite;
                loadingScreen.gameObject.SetActive(false);
            }
        }
    }
}
\$\endgroup\$
0
\$\begingroup\$

Kicking off the load to run in the background from the moment the scene starts-up is a good idea - that way, often the image will already be available by the time the user wants to see it, and they perceive no loading hitch at all.

But this does present a new problem: what if the player tries to display the image while the load is still in progress, then the load finishes while the empty placeholder for it is still displayed? How do we go find the UI element that tried to display the image and provide it the texture after the fact?

I had to solve something similar a while back, so here's the code I wrote for that case. I have not tailored this for your use case, so take this as an example only:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

// This was set up as an asset file so I could provide some
// "built in" assets through the same interface as downloaded ones.
[CreateAssetMenu(fileName ="ImageCache.Asset", menuName = "Cards/Image Cache")]
public class ImageCache : ScriptableObject
{
    // Using a singleton pattern since I only ever used one cache.
    public static ImageCache Instance { get; private set; }

    // Associating a lookup key with built-in assets.
    [System.Serializable]
    public struct BuiltIn<T> {
        public string name;
        public T asset;
    }

    // Holds a weak reference to an image component that wants to
    // display a sprite that might not yet be loaded.
    struct Request {
        // The weak reference allows the image to be garbage collected
        // if it gets deleted while the load is still pending.
        System.WeakReference<Image> _destination;        

        public Request(Image destination) {
            _destination = new System.WeakReference<Image>(destination);
        }

        public static bool IsInvalid(Request request) {
            return !request._destination.TryGetTarget(out Image target);
        }

        public static bool IsNull(Request request) {
            return request._destination == null;
        }

        // Called when the sprite has loaded, to update any requestors.
        public bool TryApply(Sprite sprite) {
            if (_destination.TryGetTarget(out Image target)) {
                target.sprite = sprite;
                return true;
            }
            _destination = null;
            return false;
        }
    }

    // Represents one sprite to be loaded, and cached once loaded.
    class CacheEntry {
        public Sprite sprite;

        // One sprite could be requested by many different display instances.
        public List<Request> uses = new List<Request>();
        int pendingCount;

        // Used to "pre warm" the cache for built-in assets.
        public CacheEntry(Sprite sprite) {
            this.sprite = sprite;
        }

        // Used when an image tries to display an already-cached image.
        // (In case we want to swap all uses of sprite A for sprite B)
        public void RecordUse(Image destination) {
            uses.RemoveAll(Request.IsInvalid);
            uses.Add(new Request(destination));
        }

        // Used when making a new request for a not-yet-loaded image.
        // Returns true if this is the first request.
        public bool EnqueueRequest(Image destination) {
            RecordUse(destination);
            return ++pendingCount == 1;
        }

        // Callback invoked once the asynchronous load finishes.
        public void HandleResult(SanitizedString name, Texture2D texture) {         
            Sprite tempSprite;
            if (texture == null) {
                tempSprite = Instance.errorPlaceholder;
            } else {
                // In this case, we needed mipmaps because images were seen in 3D.
                var mipped = new Texture2D(texture.width, texture.height, texture.format, true, false);
                mipped.SetPixels32(texture.GetPixels32());
                mipped.Apply();

                tempSprite = Sprite.Create(mipped, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
                sprite = tempSprite;

                Destroy(texture);
            }            

            // Apply the newly-loaded sprite to any outstanding requestors.
            for (int i = 0; i < uses.Count; i++)
                uses[i].TryApply(tempSprite);

            uses.RemoveAll(Request.IsNull);

            pendingCount = 0;
        }
    }

    [SerializeField]    
    BuiltIn<Sprite>[] _builtInSprites;

    [SerializeField]
    BuiltIn<TMP_FontAsset>[] _builtInFonts;

    public Sprite downloadPlaceholder;
    public Sprite errorPlaceholder;

    static Dictionary<string, CacheEntry> _cache;

    private void OnEnable() {
        //Debug.Log("Enabling Image Cache.");
        Instance = this;
        if (_cache != null)
            return;

        _cache = new Dictionary<string, CacheEntry>();
        foreach (var builtIn in _builtInSprites) _cache.Add((new SanitizedString(builtIn.name)).value, new CacheEntry(builtIn.asset));

        for (int i = 0; i < _builtInFonts.Length; i++) {
            var font = _builtInFonts[i];
            font.name = font.name.ToLowerInvariant();
            _builtInFonts[i] = font;
        }
    }

    public void Insert(SanitizedString name, Texture2D image) {
        if (!_cache.TryGetValue(name.value, out CacheEntry entry)) {
            //Debug.Log("...creating blank cache entry.");
            entry = new CacheEntry(null);
            _cache.Add(name.value, entry);
        }

        entry.HandleResult(name, image);
    }

    public void RequestImage(Image destination, string name) {

        var sanitized = new SanitizedString(name);

        //Debug.Log("Requesting image " + name);

        if(string.IsNullOrWhiteSpace(sanitized.value)) {
            //Debug.Log("...substituting default sprite.");
            destination.sprite = null;
            return;
        }

        if (!_cache.TryGetValue(sanitized.value, out CacheEntry entry)) {
            //Debug.Log("...creating blank cache entry.");
            entry = new CacheEntry(null);
            _cache.Add(sanitized.value, entry);
        }

        if (!ReferenceEquals(entry.sprite, null)) {
            //Debug.Log("...serving cached image.");
            destination.sprite = entry.sprite;
            entry.RecordUse(destination);
            return;
        }
        
        destination.sprite = downloadPlaceholder;

        if (entry.EnqueueRequest(destination)) {
            //Debug.Log("...downloading.");
            // Load happens asynchronously in the background, then entry is
            // updated once it's ready.
            NetworkManager.Instance.DownloadTexture(sanitized, entry.HandleResult);
        } else {
            //Debug.Log("...piggybacking on existing download.");
        }
    }

    public bool HasAvailable(SanitizedString file) {
        if (!_cache.TryGetValue(file.value, out CacheEntry entry))
            return false;

        return entry.sprite != null;
    }

    public TMP_FontAsset RequestFont(string name) {
        name = name.Trim().ToLowerInvariant();
        int index = System.Array.FindIndex(_builtInFonts, (e => e.name == name));
        return _builtInFonts[Mathf.Max(0, index)].asset;
    }

    public Sprite FetchSprite(SanitizedString key) {
        return _cache[key.value].sprite;
    }
}
\$\endgroup\$
6
  • \$\begingroup\$ Your code references a NetworkManager that isn't a built-in Unity class AFAIK; it might be helpful to add a note about that. \$\endgroup\$
    – Kevin
    Commented Oct 12, 2023 at 0:23
  • \$\begingroup\$ Here is the note I included about that: "I had to solve something similar a while back, so here's the code I wrote for that case. I have not tailored this for your use case, so take this as an example only:" From the function signature one can infer that it loads the texture at the provided path asynchronously, then calls the provided callback, so it's straightforward to map that to one's own code performing similar functions. \$\endgroup\$
    – DMGregory
    Commented Oct 12, 2023 at 0:36
  • \$\begingroup\$ "I have not tailored this for your use case" does not have the same meaning to me as "this script references another script NetworkManager which is not included in this answer." I understand, but keep in mind that many of our users are inexperienced and will be confused when they (inevitably) paste your code into a script and then get an error message. One additional sentence of clarification (e.g. a comment above the call to NetworkManager) would help alleviate confusion. \$\endgroup\$
    – Kevin
    Commented Oct 12, 2023 at 1:18
  • \$\begingroup\$ Since the context of the OP's question suggests that they do not fully understand asynchronous loading, they're likely still going to struggle with that part. \$\endgroup\$
    – Kevin
    Commented Oct 12, 2023 at 1:22
  • \$\begingroup\$ That's covered in your answer though, so I see no need to try to duplicate/compete with it. Just to offer this trick of caching, keeping track of, and following up on outstanding texture display attempts, which was not covered thus far that I could see. \$\endgroup\$
    – DMGregory
    Commented Oct 12, 2023 at 1:24

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .