forked from phenom/healer-man
122 lines
4.0 KiB
TypeScript
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;
|
|
}
|