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);- 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.
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.
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).
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);
}
}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.
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
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.
// 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,
});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.
// Never written to the disk cache (e.g. sensitive or frequently changing images).
await TextureLoader.Load(url, this, options: TextureLoader.Options.THUMBNAIL_SMALL_NOCACHE);// 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"));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;
}
}// 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;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.
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 againTexKeep 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.