Skip to content

Repository files navigation

TexKeep

Runtime texture loading for Unity — two-level cache, GPU resize, and automatic release by ownership.

TexKeep loads png/jpg images from URLs or local files into Texture2D/Sprite assets, and keeps them alive exactly as long as someone needs them. No Release() call to forget, no leaked textures.

// The sprite stays cached as long as `this` is alive, then gets destroyed automatically.
Sprite sprite = await TextureLoader.LoadAsSprite(url, owner: this);

// Or refine the lifetime with a predicate: the sprite stays cached
// as long as our Image is actually displaying it.
Sprite profileSprite = await TextureLoader.LoadAsSprite(url, owner: profilePic,
    surviveCondition: (tex) => profilePic.overrideSprite.texture == tex);

Features

  • Two-level cache — memory first, then disk, then download. The same image is never downloaded or decoded twice, and it survives app restarts thanks to the disk cache.
  • Concurrent request deduplication — ten components requesting the same URL in the same frame produce a single download.
  • GPU resize — textures are resized on the GPU with a multisampled blit (anti-aliased downscaling), not on the CPU.
  • ETC-safe compression — power-of-two and multiple-of-4 constraints are handled automatically. Images are padded or snapped when it is invisible, and compression is skipped when it would deform the image.
  • Automatic release by ownership — each load registers an owner (any UnityEngine.Object) and optionally a survival predicate. A texture is destroyed only when all its owners are null or all their predicates return false.

Why not Addressables?

Addressables manage assets that are packaged in bundles at build time. TexKeep solves a different problem: loading arbitrary images at runtime (user avatars, server-side thumbnails, user-generated content...) with resizing, compression and caching. The two are complementary.

Compared to a manual refcounting API (Acquire/Release), TexKeep's ownership model means a destroyed GameObject automatically releases its textures — the most common source of leaks simply cannot happen.

Installation

In the Unity Package Manager, choose Add package from git URL... and enter:

https://github.com/Kushulain/Unity-Texture-Keeper.git

Or add it directly to your Packages/manifest.json:

{
  "dependencies": {
    "com.kushulain.texkeep": "https://github.com/Kushulain/Unity-Texture-Keeper.git"
  }
}

Requires Unity 2021.3+. Depends on com.unity.nuget.newtonsoft-json (resolved automatically).

Quick start

1. Load textures

using TexKeep;
using UnityEngine;
using UnityEngine.UI;

public class Thumbnail : MonoBehaviour
{
    [SerializeField] Image _image;

    public async void SetImage(string url)
    {
        // The texture lives as long as this component does.
        _image.sprite = await TextureLoader.LoadAsSprite(url, owner: this,
            options: TextureLoader.Options.THUMBNAIL_SMALL);
    }
}

2. Run the cleanup

TexKeep never destroys textures behind your back mid-frame: dead textures are collected when you decide. Add the TexKeepCleaner component to any persistent GameObject, or drive it yourself:

using TexKeep;

CoolDownEvent _cleanTextureCD = new CoolDownEvent(5f);

void Update()
{
    if (_cleanTextureCD.TryGo())
        AssetMemoryStorage<Texture2D>.RemoveDeadAssets();
}

That's it. When the owners of a texture are gone, the next cleanup pass destroys it.

How it works

Every load resolves through the cache levels in order, stopping at the first hit:

flowchart TD
    request["Load(url, owner, options)"] --> memExact{"Exact variant<br/>in memory?"}
    memExact -->|yes| done["Return texture"]
    memExact -->|no| memOriginal{"Original size<br/>in memory?"}
    memOriginal -->|"yes"| resizeMem["GPU resize"] --> done
    memOriginal -->|no| diskResized{"Resized variant<br/>in disk cache?"}
    diskResized -->|"yes"| loadDisk["Load from disk"] --> done
    diskResized -->|no| diskOriginal{"Original size<br/>in disk cache?"}
    diskOriginal -->|"yes"| resizeDisk["Load + GPU resize<br/>+ cache resized"] --> done
    diskOriginal -->|no| download["Download / read file"] --> cacheAll["Cache to disk + memory"] --> done
Loading

Each resized variant found along the way is written back to the disk cache, so the next launch skips straight to a disk hit.

The memory cache key includes every option (url|Height:256|Compressed:True|Mipmaps:False|...), so different variants of the same image coexist without conflict. The disk cache lives in Application.persistentDataPath/TexKeepCache/, with a JSON index mapping keys to uuid-named png files.

Examples

Presets

// Original size, compressed, mipmapped — good default for world-space rendering.
await TextureLoader.Load(url, this);

// 256px-high thumbnail, no mipmaps — good for UI lists.
await TextureLoader.LoadAsSprite(url, this, options: TextureLoader.Options.THUMBNAIL_SMALL);

// Custom options.
await TextureLoader.Load(url, this, options: new TextureLoader.Options
{
    Height = 512,
    Mipmaps = false,
    Compressed = true,
});

Survival predicate

The owner keeps the texture alive as long as it is not null. A predicate refines that: the texture survives while the predicate returns true.

// The texture is released as soon as this window is hidden,
// even though the component still exists.
await TextureLoader.Load(url, owner: this, surviveCondition: _ => _window.IsVisible);

Multiple components can load the same URL with different owners and predicates. The texture is destroyed only when every owner is dead or every predicate says false.

Memory-only loading

// Never written to the disk cache (e.g. sensitive or frequently changing images).
await TextureLoader.Load(url, this, options: TextureLoader.Options.THUMBNAIL_SMALL_NOCACHE);

Save a copy of the downloaded file

// Downloads, caches, and also writes the original file to a path of your choice.
await TextureLoader.Load(url, this, saveToPath: Path.Combine(Application.persistentDataPath, "Exports/avatar.png"));

Thumbnail grid

public class Gallery : MonoBehaviour
{
    [SerializeField] Image _cellPrefab;
    [SerializeField] Transform _grid;

    public void Show(IEnumerable<string> urls)
    {
        foreach (string url in urls)
        {
            Image cell = Instantiate(_cellPrefab, _grid);
            LoadCell(cell, url);
        }
    }

    async void LoadCell(Image cell, string url)
    {
        // Each cell owns its own sprite. Destroying the grid (or a cell)
        // automatically releases the textures on the next cleanup pass.
        Sprite sprite = await TextureLoader.LoadAsSprite(url, owner: cell,
            options: TextureLoader.Options.THUMBNAIL_SMALL);

        if (cell != null)
            cell.sprite = sprite;
    }
}

Cache management

// Remove every cached variant of one image, from memory and disk.
TextureLoader.RemoveTextureFromCache(url);

// Wipe the whole disk cache.
AssetDiskStorage.ClearWholeCache();

// Invalidate the disk cache on app update (set before the first load).
AssetDiskStorage.version = 2;

// Log cache activity to the console.
AssetMemoryStorage<Texture2D>.DEBUG_CACHE = true;

Other featured scripts I use a lot in my projects

MultipleReasonBool

A boolean whose value is computed from multiple registered "reasons". Each reason is a delegate tied to an owner, and any number of scripts can register one without knowing about each other. Reasons whose owner has been destroyed are skipped and cleaned up automatically — this is what makes TexKeep leak-proof.

// Default value is true: the controller is displayed
// unless at least one living reason says false (veto).
public MultipleReasonBool shouldDisplayVRController = new MultipleReasonBool(true);

Each script registers its own reason for hiding the controller:

// HandTrackingManager.cs — hide the controller while hand tracking is active.
shouldDisplayVRController.Register(this, () => !HandTracking.Instance.IsActive);

// PlayerAvatar.cs — hide the controller when the local player is hidden.
shouldDisplayVRController.Register(this, () => Player.localPlayer.IsVisible);

// SettingsMenu.cs — let the user disable controllers entirely.
shouldDisplayVRController.Register(this, () => Settings.Instance.ShowControllers);

And one script consumes the result:

// VRControllerView.cs — implicit bool conversion calls Get().
void Update()
{
    _controllerModel.SetActive(shouldDisplayVRController);
}

The controller is visible only when every living reason agrees. When a script (or its GameObject) is destroyed, its reason is ignored and removed automatically: if all three scripts above die, shouldDisplayVRController falls back to its default value, true. No unregistration needed.

TexKeep uses the opposite polarity internally: each cached texture has an IsStillNeeded = new MultipleReasonBool(false) where every owner registers a reason to keep the texture alive. When all owners are dead or say false, the texture is collected.

CoolDownEvent

A tiny timer that replaces the usual "last time + delay" boilerplate:

float _lastTime;
float _delay = 4.5f;

void Update()
{
    if (Time.time > (_lastTime + _delay))
    {
        _lastTime = Time.time;
        DoSomething();
    }
}

becomes:

CoolDownEvent _delay = new CoolDownEvent(4.5f);

void Update()
{
    if (_delay.TryGo())
        DoSomething(); // executes once every 4.5s
}

Other niceties:

CoolDownEvent cd = new CoolDownEvent(3f);

cd.Go();                 // trigger, restarting the cooldown even if it is running
if (cd) { }              // implicit bool: is the cooldown currently running?
cd.Available();          // is it ready to fire again?
cd.Get01Progress();      // 0 just started, 1 just finished
cd.GetTimeLeft();        // seconds remaining
cd.TryGoWithRandom(0.2f); // TryGo with a +/- 20% randomized duration
cd.Stop();               // make it immediately available again

TexKeep uses it internally to give freshly loaded textures a 5-second grace period (so a texture loaded without an owner is not collected mid-load), and TexKeepCleaner uses it to pace cleanup passes.

License

MIT

About

Load your textures, resize them, then free them from memory when no longer used.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages