Files
healer-man/src/avatar/appearanceTextureCache.ts
T
2026-08-14 15:56:39 -04:00

122 lines
4.0 KiB
TypeScript

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<string, Promise<AvatarTextureSet>>();
const decodedImages = new Map<string, Promise<HTMLImageElement>>();
const textureLoader = new TextureLoader();
function loadImage(url: string): Promise<HTMLImageElement> {
const cached = decodedImages.get(url);
if (cached) {
decodedImages.delete(url);
decodedImages.set(url, cached);
return cached;
}
const promise = new Promise<HTMLImageElement>((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<AvatarTextureSet> {
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<AvatarTextureSet> {
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;
}