import { CanvasTexture, LinearMipmapLinearFilter, SRGBColorSpace, Texture, TextureLoader, } from "three"; import { resolveContentUrl } from "../content/contentManager"; import type { AvatarManifest, AvatarManifestEntry } from "./characterManifest"; import type { ResolvedAvatarAppearance } from "./appearanceResolver"; export interface AvatarTextureSet { body: CanvasTexture; hair: Texture | null; cloak: Texture | null; } const MAX_TEXTURE_SETS = 12; const MAX_DECODED_IMAGES = 96; const textureSets = new Map>(); const decodedImages = new Map>(); const textureLoader = new TextureLoader(); function loadImage(url: string): Promise { const cached = decodedImages.get(url); if (cached) { decodedImages.delete(url); decodedImages.set(url, cached); return cached; } const promise = new Promise((resolve, reject) => { const image = new Image(); image.decoding = "async"; image.onload = () => resolve(image); image.onerror = () => { if (decodedImages.get(url) === promise) decodedImages.delete(url); reject(new Error(`Avatar texture failed to load: ${url}`)); }; image.src = resolveContentUrl(url); }); decodedImages.set(url, promise); if (decodedImages.size > MAX_DECODED_IMAGES) { const oldest = decodedImages.keys().next().value as string | undefined; if (oldest) decodedImages.delete(oldest); } return promise; } async function createTextureSet( manifest: AvatarManifest, resolved: ResolvedAvatarAppearance, ): Promise { const canvas = document.createElement("canvas"); canvas.width = manifest.textureLayout.width; canvas.height = manifest.textureLayout.height; const context = canvas.getContext("2d", { alpha: true }); if (!context) throw new Error("Canvas 2D is unavailable for avatar textures"); context.clearRect(0, 0, canvas.width, canvas.height); const images = await Promise.all(resolved.bodyLayers.map((layer) => loadImage(layer.url))); resolved.bodyLayers.forEach((layer, index) => { const [x, y, width, height] = layer.region; context.drawImage(images[index], x, y, width, height); }); const body = new CanvasTexture(canvas); body.name = `avatar-body-${resolved.cacheKey}`; body.colorSpace = SRGBColorSpace; body.flipY = false; body.generateMipmaps = true; body.minFilter = LinearMipmapLinearFilter; body.needsUpdate = true; const [hair, cloak] = await Promise.all([ resolved.hairUrl ? textureLoader.loadAsync(resolveContentUrl(resolved.hairUrl)) : null, resolved.cloakUrl ? textureLoader.loadAsync(resolveContentUrl(resolved.cloakUrl)) : null, ]); if (hair) { hair.name = `avatar-hair-${resolved.cacheKey}`; hair.colorSpace = SRGBColorSpace; hair.flipY = false; hair.needsUpdate = true; } if (cloak) { cloak.name = `avatar-cloak-${resolved.cacheKey}`; cloak.colorSpace = SRGBColorSpace; cloak.flipY = false; cloak.needsUpdate = true; } return { body, hair, cloak }; } export function loadAvatarTextureSet( manifest: AvatarManifest, entry: AvatarManifestEntry, resolved: ResolvedAvatarAppearance, ): Promise { const key = `${manifest.sourceBuild}:${entry.raceId}:${entry.gender}:${resolved.cacheKey}`; const cached = textureSets.get(key); if (cached) { textureSets.delete(key); textureSets.set(key, cached); return cached; } const promise = createTextureSet(manifest, resolved).catch((error) => { textureSets.delete(key); throw error; }); textureSets.set(key, promise); if (textureSets.size > MAX_TEXTURE_SETS) { const oldest = textureSets.keys().next().value as string | undefined; if (oldest) { const evicted = textureSets.get(oldest); textureSets.delete(oldest); void evicted?.then(({ body, hair, cloak }) => { body.dispose(); hair?.dispose(); cloak?.dispose(); }).catch(() => undefined); } } return promise; }