openvibers/openvibe.games Commits

Games M6: inventory icons baked into the browser (drawn once, keyed by look and recipe) (#68)

OP OpenVibers committed 30 days ago · fef18e4f3b · 5 files, +203 −2

M6 "baked icons": inventory icons are drawn once per browser, not once per session.

Mods add items at runtime, so a build cannot bake the icons. The factory still draws each icon from the item's model, then `ui/iconCache.ts` keeps the 128 px picture in local storage:

- **Key:** the item id, a hash of its look (its tool kind, or its shape and colour) and `ICON_RECIPE` (the lighting, camera and size). A changed item, or a changed icon scene, gets a new key and is drawn again. Icons of older recipes are removed at start.
- **Storage full:** the cache clears only its own icons, once, and stores again; other keys (sign-in state) are untouched.
- **No storage, or storage that throws** (private windows, blocked site data): icons are drawn every session, as before.

A returning player's hotbar and inventory show their icons at once, and the first seconds of a session spend no render passes on them.

Tests: `iconCache.test.ts` (5 cases: key naming, a round trip across sessions, refusing non-images, pruning older recipes, quota recovery, absent or throwing storage). Typecheck and eslint are clean. The ADR M6 note is updated.

Changes

ddiff --git a/apps/client/src/ui/iconCache.test.ts b/apps/client/src/ui/iconCache.test.ts
nnew file mode 100644
iindex 0000000..4bf54d0
---- /dev/null
++++ b/apps/client/src/ui/iconCache.test.ts
@@@ -0,0 +1,85 @@
++import { describe, expect, it } from 'vitest'
++import { ICON_RECIPE, IconCache, iconKey } from './iconCache.js'
++
++/** A Storage with a byte quota, like a browser's. */
++function fakeStorage(quota = Infinity) {
++ const m = new Map<string, string>()
++ const used = () => [...m].reduce((n, [k, v]) => n + k.length + v.length, 0)
++ return {
++ m,
++ get length() {
++ return m.size
++ },
++ key: (i: number) => [...m.keys()][i] ?? null,
++ getItem: (k: string) => m.get(k) ?? null,
++ setItem: (k: string, v: string) => {
++ if (used() - (m.get(k)?.length ?? 0) + v.length + k.length > quota)
++ throw new Error('QuotaExceededError')
++ m.set(k, v)
++ },
++ removeItem: (k: string) => void m.delete(k),
++ }
++}
++
++const PNG = 'data:image/png;base64,' + 'A'.repeat(300)
++
++describe('iconKey', () => {
++ it('names the look and the recipe, not only the item', () => {
++ const box = { shape: { type: 'box', size: [1, 1, 1] }, color: '#aa6633' }
++ expect(iconKey('crate', box)).toBe(iconKey('crate', { ...box }))
++ expect(iconKey('crate', box)).not.toBe(iconKey('crate', { ...box, color: '#336699' }))
++ expect(iconKey('crate', box)).not.toBe(iconKey('barrel', box))
++ expect(iconKey('crate', box).startsWith(`ovg.icon.${ICON_RECIPE}.crate.`)).toBe(true)
++ })
++})
++
++describe('IconCache', () => {
++ it('keeps an icon across sessions and refuses what is not a picture', () => {
++ const storage = fakeStorage()
++ new IconCache(storage).set('ovg.icon.1.crate.0', PNG)
++ expect(new IconCache(storage).get('ovg.icon.1.crate.0')).toBe(PNG)
++ storage.setItem('ovg.icon.1.bad.0', 'not an image')
++ expect(new IconCache(storage).get('ovg.icon.1.bad.0')).toBeNull()
++ })
++
++ it('drops the icons of older recipes and leaves other keys alone', () => {
++ const storage = fakeStorage()
++ storage.setItem(`ovg.icon.${ICON_RECIPE + 1000}.crate.0`, PNG)
++ storage.setItem(`ovg.icon.${ICON_RECIPE}.crate.0`, PNG)
++ storage.setItem('ovg_sso', 'keep')
++ new IconCache(storage).clear(true)
++ expect([...storage.m.keys()].sort()).toEqual([`ovg.icon.${ICON_RECIPE}.crate.0`, 'ovg_sso'])
++ })
++
++ it('when storage is full, clears its own icons once and stores the new one', () => {
++ const storage = fakeStorage(1000)
++ const cache = new IconCache(storage)
++ cache.set('ovg.icon.1.a.0', PNG)
++ cache.set('ovg.icon.1.b.0', PNG)
++ storage.setItem('ovg_sso', 'keep')
++ cache.set('ovg.icon.1.c.0', PNG)
++ expect(cache.get('ovg.icon.1.c.0')).toBe(PNG)
++ expect(cache.get('ovg.icon.1.a.0')).toBeNull()
++ expect(storage.getItem('ovg_sso')).toBe('keep')
++ })
++
++ it('without storage, or with storage that throws, it is a cache that keeps nothing', () => {
++ const none = new IconCache(null)
++ none.set('k', PNG)
++ expect(none.get('k')).toBeNull()
++ const throwing = {
++ length: 0,
++ key: () => null,
++ getItem: () => {
++ throw new Error('SecurityError')
++ },
++ setItem: () => {
++ throw new Error('SecurityError')
++ },
++ removeItem: () => undefined,
++ }
++ const c = new IconCache(throwing)
++ expect(() => c.set('k', PNG)).not.toThrow()
++ expect(c.get('k')).toBeNull()
++ })
++})
ddiff --git a/apps/client/src/ui/iconCache.ts b/apps/client/src/ui/iconCache.ts
nnew file mode 100644
iindex 0000000..1df68f6
---- /dev/null
++++ b/apps/client/src/ui/iconCache.ts
@@@ -0,0 +1,84 @@
++/**
++ * Baked inventory icons (plan M6). The icon factory renders each item's model into a 128 px picture; this keeps the
++ * pictures in the browser's storage, so a returning player's hotbar and inventory show them at once and the first
++ * seconds of a session no longer spend render passes on them.
++ *
++ * Items come from content packs and mods at runtime, so icons cannot be baked at build time: they are baked here, the
++ * first time each one is drawn. A key names what the picture shows (the item's visual: its tool kind, or its shape
++ * and colour) and how it is drawn (ICON_RECIPE), never only the item id: an item whose look changes, or a change to
++ * the lighting or camera, gets a new key and is drawn again. Storage that is missing, full or refusing (private
++ * windows, blocked site data) only means drawing every time, as before.
++ */
++
++/** Bump when the icon scene's lighting, camera or size changes: every stored icon is drawn again. */
++export const ICON_RECIPE = 1
++const PREFIX = 'ovg.icon.'
++
++type IconStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem' | 'key' | 'length'>
++
++/** FNV-1a, 32 bits, as 8 hex characters: a short stable name for a visual description. */
++function hash(text: string): string {
++ let h = 0x811c9dc5
++ for (let i = 0; i < text.length; i++) {
++ h ^= text.charCodeAt(i)
++ h = Math.imul(h, 0x01000193)
++ }
++ return (h >>> 0).toString(16).padStart(8, '0')
++}
++
++/** The storage key of one item's icon: its id, the hash of what it looks like, and the recipe. */
++export function iconKey(defId: string, visual: unknown): string {
++ return `${PREFIX}${ICON_RECIPE}.${defId}.${hash(JSON.stringify(visual ?? null))}`
++}
++
++function storageOrNull(): IconStorage | null {
++ try {
++ return globalThis.localStorage ?? null
++ } catch {
++ return null
++ }
++}
++
++export class IconCache {
++ constructor(private readonly storage: IconStorage | null = storageOrNull()) {}
++
++ get(key: string): string | null {
++ try {
++ const v = this.storage?.getItem(key) ?? null
++ return v && v.startsWith('data:image/') ? v : null
++ } catch {
++ return null
++ }
++ }
++
++ /** Store one icon; storage full → drop every stored icon once and try again, then give up quietly. */
++ set(key: string, dataUrl: string): void {
++ if (!this.storage) return
++ try {
++ this.storage.setItem(key, dataUrl)
++ } catch {
++ this.clear()
++ try {
++ this.storage.setItem(key, dataUrl)
++ } catch {
++ // Still refused: this browser keeps no icons; they are drawn each session.
++ }
++ }
++ }
++
++ /** Remove every stored icon, or (`olderRecipesOnly`) those drawn by another recipe, which nothing reads again. */
++ clear(olderRecipesOnly = false): void {
++ if (!this.storage) return
++ const current = `${PREFIX}${ICON_RECIPE}.`
++ try {
++ const keys: string[] = []
++ for (let i = 0; i < this.storage.length; i++) {
++ const k = this.storage.key(i)
++ if (k?.startsWith(PREFIX) && !(olderRecipesOnly && k.startsWith(current))) keys.push(k)
++ }
++ for (const k of keys) this.storage.removeItem(k)
++ } catch {
++ // Storage went away: nothing to clear.
++ }
++ }
++}
ddiff --git a/apps/client/src/ui/iconFactory.ts b/apps/client/src/ui/iconFactory.ts
iindex 98f68bb..2db6e3a 100644
---- a/apps/client/src/ui/iconFactory.ts
++++ b/apps/client/src/ui/iconFactory.ts
@@@ -9,6 +9,7 @@ import { Scene } from '@babylonjs/core/scene.js'
import type { ContentRegistry } from '@openvibe/content'
import { createToolProp } from '../render/avatar/toolProps.js'
import { meshForShape } from '../render/sceneSetup.js'
++import { IconCache, iconKey } from './iconCache.js'
/**
* Generates inventory icons by RENDERING each item's actual 3D model —
@@@ -19,7 +20,9 @@ import { meshForShape } from '../render/sceneSetup.js'
* scene's atmosphere/HDR pipeline must never touch icon captures (it
* washed them out to pure white), and a second engine proved flaky
* headless. Generation is async and serialized; `onReady` fires so the
-- * HUD can swap real icons in as they finish.
++ * HUD can swap real icons in as they finish. Each finished icon is baked
++ * into the browser's storage (iconCache.ts): the next session shows it at
++ * once and renders only what is new or changed.
*/
const STAGE = new Vector3(0, 0, 0)
@@@ -31,6 +34,7 @@ export class IconFactory {
private readonly queued = new Set<string>()
private chain: Promise<void> = Promise.resolve()
private readonly iconScene: Scene
++ private readonly baked = new IconCache()
constructor(
scene: Scene,
@@@ -44,11 +48,29 @@ export class IconFactory {
hemi.groundColor = new Color3(0.35, 0.33, 0.3)
const key = new DirectionalLight('icon-key', new Vector3(-0.5, -1, -0.4), this.iconScene)
key.intensity = 0.7
++ this.baked.clear(true)
++ }
++
++ /** The baked icon's key: what the item looks like (tool kind, or shape and colour), not only its id. */
++ private keyFor(defId: string): string {
++ const def = this.content.item(defId)
++ let visual: unknown = null
++ try {
++ visual = def?.tool ? { tool: toolVisualKind(def.tool.kind) } : this.content.worldRepOf(defId)
++ } catch {
++ // No world representation: the key still names the item; generate() reports the failure.
++ }
++ return iconKey(defId, visual)
}
iconFor(defId: string): string {
const cached = this.cache.get(defId)
if (cached) return cached
++ const baked = this.baked.get(this.keyFor(defId))
++ if (baked) {
++ this.cache.set(defId, baked)
++ return baked
++ }
if (!this.queued.has(defId)) {
this.queued.add(defId)
this.chain = this.chain
@@@ -100,7 +122,10 @@ export class IconFactory {
width: 128,
height: 128,
})
-- if (url && url.length > 200) this.cache.set(defId, url)
++ if (url && url.length > 200) {
++ this.cache.set(defId, url)
++ this.baked.set(this.keyFor(defId), url)
++ }
} finally {
camera.dispose()
root.dispose()
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex 50836be..f6a4869 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -142,6 +142,10 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
hauls, unlocks and guest adoptions posted from the local tables (`GAMES_PLAY_URL`). **Still to build:** the in-game
boards read from Play, then the local result tables go (a contract migration).
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
++ *2026-10-14, baked icons:* inventory icons are still drawn from each item's model (mods add items at runtime, so a
++ build cannot bake them), but each one is drawn once per browser: `ui/iconCache.ts` keeps the picture in local storage
++ under a key naming the item's look (tool kind, or shape and colour) and the drawing recipe (`ICON_RECIPE`), so a
++ changed item or a changed icon scene is drawn again, and a returning player's hotbar shows its icons at once.
*2026-10-14:* quality tiers and hardware scaling are in (`render/quality.ts`, the HUD's Graphics quality); initial
JS is 2.35 MB of 2.5 (579 KB gzip). The browser build now loads Rapier from the deterministic non-compat package
(Vite alias + `vite-plugin-wasm`): the same engine and results, its wasm a 3.1 MB file the browser compiles as it
ddiff --git a/test/apps__client__src__ui__iconCache.test.js b/test/apps__client__src__ui__iconCache.test.js
nnew file mode 100644
iindex 0000000..7df5ea7
---- /dev/null
++++ b/test/apps__client__src__ui__iconCache.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/ui/iconCache.test.ts'
++// covers: 'apps/client/src/ui'
++// Generated by node test/run.js --index; do not edit.