openvibers/openvibe.games Commits
Games M6: inventory icons baked into the browser (drawn once, keyed by look and recipe) (#68)
Changes
nnew file mode 100644@@@ -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()++ })++})nnew file mode 100644@@@ -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.++ }++ }++}@@@ -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()@@@ -142,6 +142,10 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegachauls, unlocks and guest adoptions posted from the local tables (`GAMES_PLAY_URL`). **Still to build:** the in-gameboards 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); initialJS 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 itnnew file mode 100644@@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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.
+ }
+ }
+}
@@ -9,6 +9,7 @@ import { Scene } from '@babylonjs/core/scene.js'
@@ -9,6 +9,7 @@ import { Scene } from '@babylonjs/core/scene.js'
import type { ContentRegistry } from '@openvibe/content'
import type { ContentRegistry } from '@openvibe/content'
import { createToolProp } from '../render/avatar/toolProps.js'
import { createToolProp } from '../render/avatar/toolProps.js'
import { meshForShape } from '../render/sceneSetup.js'
import { meshForShape } from '../render/sceneSetup.js'
+import { IconCache, iconKey } from './iconCache.js'
/**
/**
* Generates inventory icons by RENDERING each item's actual 3D model —
* Generates inventory icons by RENDERING each item's actual 3D model —
@@ -19,7 +20,9 @@ import { meshForShape } from '../render/sceneSetup.js'
@@ -19,7 +20,9 @@ import { meshForShape } from '../render/sceneSetup.js'
* scene's atmosphere/HDR pipeline must never touch icon captures (it
* scene's atmosphere/HDR pipeline must never touch icon captures (it
* washed them out to pure white), and a second engine proved flaky
* washed them out to pure white), and a second engine proved flaky
* headless. Generation is async and serialized; `onReady` fires so the
* 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)
const STAGE = new Vector3(0, 0, 0)
@@ -31,6 +34,7 @@ export class IconFactory {
@@ -31,6 +34,7 @@ export class IconFactory {
private readonly queued = new Set<string>()
private readonly queued = new Set<string>()
private chain: Promise<void> = Promise.resolve()
private chain: Promise<void> = Promise.resolve()
private readonly iconScene: Scene
private readonly iconScene: Scene
+ private readonly baked = new IconCache()
constructor(
constructor(
scene: Scene,
scene: Scene,
@@ -44,11 +48,29 @@ export class IconFactory {
@@ -44,11 +48,29 @@ export class IconFactory {
hemi.groundColor = new Color3(0.35, 0.33, 0.3)
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)
const key = new DirectionalLight('icon-key', new Vector3(-0.5, -1, -0.4), this.iconScene)
key.intensity = 0.7
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 {
iconFor(defId: string): string {
const cached = this.cache.get(defId)
const cached = this.cache.get(defId)
if (cached) return cached
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)) {
if (!this.queued.has(defId)) {
this.queued.add(defId)
this.queued.add(defId)
this.chain = this.chain
this.chain = this.chain
@@ -100,7 +122,10 @@ export class IconFactory {
@@ -100,7 +122,10 @@ export class IconFactory {
width: 128,
width: 128,
height: 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 {
} finally {
camera.dispose()
camera.dispose()
root.dispose()
root.dispose()
@@ -142,6 +142,10 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -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
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).
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.
- **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
*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
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
(Vite alias + `vite-plugin-wasm`): the same engine and results, its wasm a 3.1 MB file the browser compiles as it
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.