# @zakkster/lite-sprite-cache v1.0.0

> Zero-GC off-thread ImageBitmap loader with strict VRAM budgeting, LRU eviction, URL deduplication, ref-counted bitmap lifecycle, and Safari fallback destruction.

## Overview

SpriteCache is a single-class browser asset manager that loads images into `ImageBitmap` objects for zero-copy GPU rendering. It enforces a strict VRAM budget via LRU eviction, deduplicates simultaneous fetches for the same URL, ref-counts shared bitmaps, and aggressively flushes VRAM on disposal (including Safari-specific fallbacks). Zero dependencies.

**When to use SpriteCache:** You are building an HTML5 Canvas or WebGL game or visualization that loads image assets at runtime and needs to prevent iOS Safari VRAM crashes, deduplicate asset loading, and retrieve textures in a 60fps RAF loop with zero allocations.

**When NOT to use SpriteCache:** You only need a single static image, you are using a full engine (PixiJS, Phaser, Three.js) that has its own asset pipeline, or you need WebGL texture objects directly (SpriteCache manages ImageBitmaps, not WebGL textures).

**Runtime environment:** Browser only. Requires `fetch`, `createImageBitmap`, and `performance.now`. Uses `OffscreenCanvas` when available, falls back to DOM canvas.

## Import

```javascript
import { SpriteCache } from '@zakkster/lite-sprite-cache';
```

## Constructor

```javascript
new SpriteCache(options?)
```

| Option | Type | Default | Description |
|---|---|---|---|
| `maxMemoryMB` | number | `200` | VRAM budget in megabytes. LRU eviction fires when exceeded. |
| `fetchTimeoutMs` | number | `15000` | Fetch timeout in milliseconds. AbortController fires on timeout. |
| `onEvict` | function \| null | `null` | Callback invoked on LRU eviction. Receives the evicted `id` string. |

## API Reference

### `.load(id: string, url: string): Promise<ImageBitmap | null>`

Loads an image with triple-gate deduplication:

1. **Cache hit** — `id` already cached → return bitmap immediately.
2. **ID dedup** — `id` already in-flight → return the existing promise.
3. **URL dedup** — same `url` being fetched for another `id` → share the fetch promise.

On failure (network error, HTTP error, timeout, invalid MIME), returns `null`. Never throws.

The fetch uses `AbortController` with the configured `fetchTimeoutMs`. MIME validation rejects non-`image/*` responses.

### `.loadAll(assets: { id: string, url: string }[]): Promise<(ImageBitmap | null)[]>`

Parallel batch load via `Promise.all`. Each asset follows the same deduplication rules as `.load()`. Returns results in input order.

### `.prerender(id, width, height, renderFn, options?): Promise<ImageBitmap | null>`

Renders synchronous canvas drawing commands into a cached ImageBitmap.

- Creates an `OffscreenCanvas` (or DOM canvas fallback) at `width × dpr` by `height × dpr` physical pixels.
- Pre-scales the 2D context by `dpr` so `renderFn` draws in logical (CSS) coordinates.
- `renderFn(ctx, width, height)` — **MUST be synchronous.** Async drawing will not be captured.
- If `id` already exists, it is disposed first.
- `options.dpr` defaults to `1`.

Returns `null` on render failure or VRAM allocation failure (with automatic canvas cleanup).

### `.get(id: string): ImageBitmap | undefined`

O(1) hot-path retrieval for the RAF loop. Updates the LRU access timestamp. Returns `undefined` if `id` is not cached.

### `.unloadUnused(maxAgeMs?: number): void`

Disposes all assets whose last access timestamp is older than `maxAgeMs` milliseconds. Default `30000` (30 seconds).

### `.dispose(id: string): void`

Explicitly removes an asset from the cache. Decrements the ref-count on the underlying bitmap. When the ref-count reaches zero:

- Chromium/Firefox: calls `bitmap.close()` for explicit VRAM release.
- Safari fallback: sets `bitmap.width = 0; bitmap.height = 0`.

Safe to call on non-existent IDs (no-op).

### `.stats(): SpriteCacheStats`

Returns a snapshot object:

```javascript
{
    items: number,       // Cached asset count
    pending: number,     // In-flight fetch/prerender count
    memoryMB: string,    // Estimated VRAM usage, e.g. "45.50"
    maxMemoryMB: string  // Configured ceiling, e.g. "150.00"
}
```

### `.clearAll(): void`

Disposes every cached asset. Equivalent to calling `.dispose()` on each ID.

## VRAM Lifecycle

### Memory Estimation

Every bitmap's VRAM cost is estimated as `width × height × 4` bytes (RGBA uncompressed). This is stored on the bitmap as `bitmap._estimatedBytes` (internal tracking property — do not rely on it).

### LRU Eviction

When `_store()` detects that `estimatedMemory + newAssetBytes > maxMemoryBytes`, it enters the eviction loop: find the asset with the oldest `access` timestamp, fire `onEvict` if configured, call `dispose()`, and repeat until the new asset fits.

### Reference Counting

Multiple cache IDs can share the same `ImageBitmap` object (via URL deduplication). The `refs` Map tracks `bitmap → count`. Disposing an ID decrements the count. The bitmap is only flushed from VRAM when the count reaches zero.

## Correct Usage Patterns

### Basic Load + Render Loop

```javascript
const cache = new SpriteCache({ maxMemoryMB: 150 });
await cache.load('hero', '/sprites/hero.png');

function draw() {
    const hero = cache.get('hero');
    if (hero) ctx.drawImage(hero, x, y);
    requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
```

### Level Batch Load

```javascript
await cache.loadAll([
    { id: 'bg',    url: '/levels/forest/bg.png' },
    { id: 'hero',  url: '/sprites/hero.png' },
    { id: 'enemy', url: '/sprites/goblin.png' }
]);
```

### Scene Transition

```javascript
// Dispose old scene assets
oldSceneAssets.forEach(a => cache.dispose(a.id));
// Load new scene
await cache.loadAll(newSceneAssets);
```

### Procedural Prerender (Particle Texture)

```javascript
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(1, 'rgba(255,30,0,0)');
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, w, h);
}, { dpr: window.devicePixelRatio });
```

### iOS-Safe Budget

```javascript
// iOS Safari crashes at ~200–250MB VRAM
const cache = new SpriteCache({ maxMemoryMB: 150 });
```

### Telemetry

```javascript
const { items, memoryMB, maxMemoryMB } = cache.stats();
console.log(`${memoryMB}MB / ${maxMemoryMB}MB (${items} assets)`);
```

## Common Mistakes

```javascript
// WRONG: Mutating a shared bitmap — bleeds to all references
const bitmap = cache.get('hero');
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(bitmap, 0, 0);
// ... modify tempCanvas ...
// bitmap is an ImageBitmap — you cannot draw ON it, only FROM it.
// If you need a modified copy, use prerender() to create a new cached variant.

// WRONG: Async renderFn in prerender
await cache.prerender('tex', 64, 64, async (ctx, w, h) => {
    const img = await loadImage(); // This will NOT be captured
    ctx.drawImage(img, 0, 0);
});
// renderFn MUST be synchronous. The bitmap is captured immediately after renderFn returns.

// WRONG: Assuming .get() returns non-undefined
const bitmap = cache.get('hero');
ctx.drawImage(bitmap, 0, 0); // TypeError if 'hero' was evicted or never loaded
// Always guard: if (bitmap) ctx.drawImage(bitmap, 0, 0);

// WRONG: Not setting a VRAM budget for mobile
const cache = new SpriteCache(); // Default 200MB — may crash iOS Safari
// Always set maxMemoryMB explicitly for mobile targets.

// WRONG: Ignoring null returns from load()
const bitmap = await cache.load('hero', 'https://bad-url.com/404.png');
ctx.drawImage(bitmap, 0, 0); // TypeError — bitmap is null
// Always check: if (bitmap) { ... }

// WRONG: Calling dispose() and then get() in the same frame
cache.dispose('hero');
const bitmap = cache.get('hero'); // undefined — disposed
// dispose() is immediate. The asset is gone.

// WRONG: Relying on _estimatedBytes or other internal properties
console.log(bitmap._estimatedBytes); // Internal tracking — not public API
// Use cache.stats().memoryMB instead.
```

## Performance Characteristics

- `.get()`: O(1) Map lookup + one `performance.now()` call. Zero allocations.
- `.load()` cache hit: O(1) Map lookup. No fetch, no decode.
- `.load()` URL dedup: returns existing promise. Zero additional network or decode work.
- `._evictOldest()`: O(n) linear scan of access Map. Acceptable for caches under ~1000 assets. For larger pools, consider periodic `unloadUnused()` instead of relying solely on budget-triggered eviction.
- Memory estimation: `width × height × 4` bytes. Does not account for GPU-specific compression, mipmaps, or driver overhead. Treat as a conservative upper bound.
