@zakkster/lite-sprite-cache
v1.0.0
Published
Zero-GC off-thread ImageBitmap loader with strict VRAM limits, URL deduplication, and LRU eviction to prevent mobile browser crashes.
Downloads
126
Maintainers
Readme
@zakkster/lite-sprite-cache
Zero-GC off-thread ImageBitmap loader with strict VRAM budgeting, LRU eviction, URL deduplication, ref-counted bitmap lifecycle, and Safari fallback destruction. Zero dependencies. One class. Prevents iOS mobile browser crashes.
Why lite-sprite-cache?
| Feature | lite-sprite-cache | PixiJS | Phaser | HTMLImageElement | createImageBitmap |
|---|---|---|---|---|---|
| Off-thread decode | Yes | Yes | Yes | No | Yes |
| URL deduplication | Yes | No | No | No | No |
| ID deduplication | Yes | No | No | No | No |
| Ref-counted VRAM | Yes | No | No | No | No |
| LRU eviction | Yes | No | No | No | No |
| Memory budget enforcement | Yes | No | No | No | No |
| DPR-aware prerender | Yes | No | No | No | No |
| Safari fallback handling | Yes | No | No | No | No |
| Zero-GC hot-path .get() | Yes | Yes | No | Yes | Yes |
| Fetch timeout + abort | Yes | No | No | No | No |
| MIME validation | Yes | No | No | No | No |
Installation
npm install @zakkster/lite-sprite-cacheQuick Start
import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache({ maxMemoryMB: 150 });
// Load assets
await cache.load('hero', '/sprites/hero.png');
await cache.load('enemy', '/sprites/enemy.png');
// In RAF loop — O(1), zero allocations
function draw() {
const hero = cache.get('hero');
if (hero) ctx.drawImage(hero, x, y);
requestAnimationFrame(draw);
}The VRAM Pipeline
Off-Thread Decode + URL Deduplication
When load(id, url) is called, three deduplication gates fire in order:
| Gate | Check | Result |
|---|---|---|
| Cache hit | id already in cache | Return bitmap immediately. O(1). |
| ID dedup | id already in-flight | Return the existing promise. Zero redundant fetches. |
| URL dedup | Another id is fetching the same url | Share the fetch promise. One decode for N consumers. |
Only if all three gates miss does a new fetch + createImageBitmap pipeline begin. The bitmap is decoded off the main thread — no jank in the RAF loop.
VRAM Budgeting + LRU Eviction
Every stored bitmap's VRAM cost is estimated as width × height × 4 bytes (RGBA). When a new asset would exceed maxMemoryMB, the LRU eviction loop removes the least-recently-accessed asset and repeats until the new asset fits.
Access timestamps are updated on every load() cache hit and every get() call — meaning assets used every frame are never evicted, while level-transition textures naturally age out.
Ref-Counted Lifecycle
When two IDs load the same URL, they share a single bitmap via URL deduplication. The bitmap's reference count tracks how many IDs point to it. dispose(id) decrements the ref-count — the bitmap is only flushed from VRAM when the count reaches zero.
VRAM flushing uses bitmap.close() on Chromium/Firefox, with a Safari fallback that zeros the bitmap dimensions (width = 0; height = 0).
DPR-Aware Prerendering
prerender() creates an OffscreenCanvas (or DOM canvas fallback) at width × dpr by height × dpr physical pixels. The 2D context is pre-scaled by dpr, so the renderFn callback draws in logical CSS coordinates. The resulting bitmap is transferred to VRAM and cached with the same LRU/ref-counting as fetched assets.
Caveats
- Immutability. Bitmaps loaded from the same URL are shared via ref-counting. Do not mutate the returned ImageBitmap — mutations bleed to all references.
- Sync prerender. The
renderFnpassed toprerender()must be synchronous. Async drawing commands will not be captured in the cached bitmap.
Benchmark Results
Tested on Apple M2 Pro, Chrome 120, 20 sequential loads of 512×512 PNG textures.
Load Latency (20 Loads)
| Loader | Time (ms) | Notes | |---|---:|---| | lite-sprite-cache | ~85 | Off-thread decode + dedup | | createImageBitmap(blob) | ~110 | Fast, no caching | | PIXI.Texture.from() | ~180 | BaseTexture + Resource overhead | | Phaser.Loader | ~260 | Event-driven pipeline | | HTMLImageElement | ~350 | Main-thread decode |
Parallel Loads (10 Simultaneous, Same URL)
| Loader | Time (ms) | Notes | |---|---:|---| | lite-sprite-cache | ~95 | URL dedup collapses 10 loads → 1 decode | | createImageBitmap(blob) | ~120 | Parallel but no dedup | | PIXI.Texture.from() | ~240 | Creates 10 BaseTextures | | Phaser.Loader | ~300 | Sequential-ish pipeline | | HTMLImageElement | ~500 | Main-thread decode bottleneck |
VRAM Upload Cost (512×512 PNG)
| Loader | Upload Time | Notes | |---|---|---| | lite-sprite-cache | ~0.2 ms | ImageBitmap → GPU direct | | createImageBitmap(blob) | ~0.2 ms | Same | | PIXI.Texture.from() | ~0.8 ms | Texture wrapping overhead | | Phaser.Loader | ~1.0 ms | Texture manager overhead | | HTMLImageElement | ~1.5 ms | CPU → GPU copy |
Recipes
import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache({ maxMemoryMB: 150 });
const levelAssets = [
{ id: 'bg', url: '/levels/forest/bg.png' },
{ id: 'ground', url: '/levels/forest/ground.png' },
{ id: 'hero', url: '/sprites/hero.png' },
{ id: 'enemy', url: '/sprites/goblin.png' },
];
await cache.loadAll(levelAssets);
// All assets cached — start the game loopprerender() snapshots the canvas into an ImageBitmap the instant renderFn returns. The callback must be purely synchronous — any setTimeout, requestAnimationFrame, await, or deferred drawing inside the callback will not be captured. The bitmap will snapshot a blank or partially-drawn canvas.
import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache();
// ✅ CORRECT — fully synchronous drawing commands
await cache.prerender('ember', 64, 64, (ctx, w, h) => {
const grad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w/2);
grad.addColorStop(0, 'rgba(255, 180, 50, 1)');
grad.addColorStop(0.5, 'rgba(255, 80, 20, 0.5)');
grad.addColorStop(1, 'rgba(255, 30, 0, 0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
}, { dpr: window.devicePixelRatio });
// ❌ WRONG — async renderFn captures a blank canvas:
// await cache.prerender('tex', 64, 64, async (ctx, w, h) => {
// const img = await loadSomeImage(); // Too late — bitmap already snapshotted
// ctx.drawImage(img, 0, 0);
// });
// Use in particle system — zero Canvas API calls per frame
const ember = cache.get('ember');
if (ember) ctx.drawImage(ember, x, y);During level transitions, always call .dispose() explicitly on outgoing scene assets. Do not rely on LRU eviction via maxMemoryMB to clean up — during massive scene swaps, the incoming assets are loaded before the LRU loop catches up, which can momentarily spike VRAM above the crash threshold on iOS.
import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache({
maxMemoryMB: 150,
onEvict: (id) => console.log(`LRU evicted: ${id}`)
});
// Load scene A
await cache.loadAll(sceneA_Assets);
// ✅ CORRECT — explicit teardown before loading the next scene.
// VRAM is freed immediately. The incoming scene loads into clean headroom.
function transitionToSceneB() {
sceneA_Assets.forEach(a => cache.dispose(a.id));
return cache.loadAll(sceneB_Assets);
}
// ❌ WRONG — loading scene B without disposing scene A first.
// Both scenes coexist in VRAM briefly. On a 150MB budget with two
// 80MB scenes, this spikes to 160MB before LRU eviction kicks in.
// On iOS Safari, that spike is enough to crash the tab.
dispose()vsunloadUnused(): Usedispose()for deterministic teardowns where you know exactly which assets to free (level transitions, cutscene exits). UseunloadUnused(maxAgeMs)for gradual background reclamation in open-world or idle scenarios — it scans all access timestamps and evicts anything stale.
import { SpriteCache } from '@zakkster/lite-sprite-cache';
// iOS Safari crashes at ~200–250MB VRAM — stay under 150MB
const cache = new SpriteCache({
maxMemoryMB: 150,
fetchTimeoutMs: 8000, // Tight timeout for mobile networks
});
// LRU eviction automatically prevents crashes
// Assets accessed every frame (hero, UI) survive
// Background textures age out naturallyWhen multiple entities, components, or particle emitters request the same texture simultaneously, SpriteCache collapses all concurrent fetches into a single network request and a single createImageBitmap decode. This is critical in ECS architectures where hundreds of entities may initialize in the same frame.
import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache({ maxMemoryMB: 150 });
// 50 entities spawn simultaneously, all requesting the same texture.
// Without deduplication: 50 fetches, 50 decodes, 50× bandwidth.
// With SpriteCache: 1 fetch, 1 decode, 50 cache entries sharing 1 bitmap.
const batch = Array.from({ length: 50 }, (_, i) =>
cache.load(`enemy-${i}`, '/sprites/goblin.png')
);
await Promise.all(batch);
// Proof: only 1 bitmap in VRAM, ref-counted 50 times
console.log(cache.stats().items); // 50 cache entries
// But estimated VRAM = 1 bitmap's worth, not 50×import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache({ maxMemoryMB: 256 });
// Every 10 seconds, flush assets unused for 30s
setInterval(() => cache.unloadUnused(30000), 10000);import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache({ maxMemoryMB: 200 });
function renderStats() {
const { items, pending, memoryMB, maxMemoryMB } = cache.stats();
hud.textContent = `${memoryMB}MB / ${maxMemoryMB}MB | ${items} cached | ${pending} loading`;
requestAnimationFrame(renderStats);
}
requestAnimationFrame(renderStats);import { SpriteCache } from '@zakkster/lite-sprite-cache';
const cache = new SpriteCache();
const dpr = window.devicePixelRatio || 1;
// Prerender a full sprite atlas at device resolution
await cache.prerender('atlas', 512, 512, (ctx, w, h) => {
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
drawFrame(ctx, col * 64, row * 64, 64, 64, row * 8 + col);
}
}
}, { dpr });
// In RAF loop — draw from atlas with source rect
const atlas = cache.get('atlas');
if (!atlas) return; // Always guard — never assume the asset survived eviction
ctx.drawImage(atlas, sx, sy, sw, sh, dx, dy, dw, dh);API
new SpriteCache(options?)
| Option | Type | Default | Description |
|---|---|---|---|
| maxMemoryMB | number | 200 | VRAM budget in MB. LRU eviction fires when exceeded. |
| fetchTimeoutMs | number | 15000 | Fetch timeout in ms. Aborts stalled requests. |
| onEvict | function | null | null | Callback fired on LRU eviction. Receives evicted id. |
Methods
| Method | Returns | Description |
|---|---|---|
| .load(id, url) | Promise<ImageBitmap \| null> | Load with URL + ID deduplication. Null on failure. |
| .loadAll(assets) | Promise<(ImageBitmap \| null)[]> | Parallel batch load. assets = [{ id, url }]. |
| .prerender(id, w, h, fn, opts?) | Promise<ImageBitmap \| null> | Cache sync canvas drawing as bitmap. opts = { dpr }. |
| .get(id) | ImageBitmap \| undefined | O(1) hot-path retrieval. Updates LRU timestamp. |
| .unloadUnused(maxAgeMs?) | void | Evict assets idle longer than maxAgeMs. Default 30000. |
| .dispose(id) | void | Decrement ref-count. Flush VRAM at zero. No-op if missing. |
| .stats() | SpriteCacheStats | Snapshot: { items, pending, memoryMB, maxMemoryMB }. |
| .clearAll() | void | Dispose all cached assets. |
License
MIT
Part of the @zakkster ecosystem
Zero-GC, deterministic, tree-shakeable micro-libraries for high-performance web applications.
