Skip to content

Commit b69e12c

Browse files
authored
perf: improve caching (#136)
1 parent 7359cdf commit b69e12c

File tree

4 files changed

+126
-46
lines changed

4 files changed

+126
-46
lines changed

src/worker/services/hytale/assets.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,21 @@ let loadingPromise: Promise<HytaleAssets> | null = null;
2323
* Assets are loaded from R2 or disk asynchronously.
2424
* Returns cached assets after first load.
2525
*/
26-
export async function loadHytaleAssets(): Promise<HytaleAssets> {
26+
export async function loadHytaleAssets(skinPath: string = 'Common/Characters/Player_Textures/Player_Greyscale.png', ctx: ExecutionContext): Promise<HytaleAssets> {
2727
if (cacheState === 'loaded') {
2828
return cache!;
2929
}
3030

3131
if (cacheState === 'loading') {
3232
return loadingPromise!;
3333
}
34+
skinPath = skinPath.startsWith('Common/') ? skinPath : `Common/${skinPath}`;
3435

3536
cacheState = 'loading';
3637
loadingPromise = (async () => {
37-
const playerTextureData = await readAssetFile('Common/Characters/Player_Textures/Player_Greyscale.png', env);
38-
const playerModelJson = await readAssetFile('Common/Characters/Player.blockymodel', env);
39-
const idleAnimationJson = await readAssetFile('Common/Characters/Animations/Default/Idle.blockyanim', env);
38+
const playerTextureData = await readAssetFile(skinPath, env, ctx);
39+
const playerModelJson = await readAssetFile('Common/Characters/Player.blockymodel', env, ctx);
40+
const idleAnimationJson = await readAssetFile('Common/Characters/Animations/Default/Idle.blockyanim', env, ctx);
4041

4142
const textureBytes = new Uint8Array(playerTextureData);
4243

src/worker/services/hytale/cosmetic-registry.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import facesJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Face
2121
import facialHairJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/FacialHair.json';
2222
import glovesJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Gloves.json';
2323
import gradientSetsJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/GradientSets.json';
24+
import haircutFallbacksJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/HaircutFallbacks.json';
2425
import haircutsJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Haircuts.json';
2526
import headAccessoryJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/HeadAccessory.json';
2627
import mouthsJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Mouths.json';
@@ -79,6 +80,39 @@ const DEFINITION_FILES: SlotDefinitions = {
7980
Underwear: parseJson<CosmeticDefinition[]>(underwearJson),
8081
};
8182

83+
const COSMETIC_FILES = [
84+
{ path: 'Cosmetics/CharacterCreator/Faces.json', content: facesJson },
85+
{ path: 'Cosmetics/CharacterCreator/Eyes.json', content: eyesJson },
86+
{ path: 'Cosmetics/CharacterCreator/Eyebrows.json', content: eyebrowsJson },
87+
{ path: 'Cosmetics/CharacterCreator/Mouths.json', content: mouthsJson },
88+
{ path: 'Cosmetics/CharacterCreator/Ears.json', content: earsJson },
89+
{ path: 'Cosmetics/CharacterCreator/Haircuts.json', content: haircutsJson },
90+
{ path: 'Cosmetics/CharacterCreator/FacialHair.json', content: facialHairJson },
91+
{ path: 'Cosmetics/CharacterCreator/Underwear.json', content: underwearJson },
92+
{ path: 'Cosmetics/CharacterCreator/FaceAccessory.json', content: faceAccessoryJson },
93+
{ path: 'Cosmetics/CharacterCreator/Capes.json', content: capesJson },
94+
{ path: 'Cosmetics/CharacterCreator/EarAccessory.json', content: earAccessoryJson },
95+
{ path: 'Cosmetics/CharacterCreator/Gloves.json', content: glovesJson },
96+
{ path: 'Cosmetics/CharacterCreator/HeadAccessory.json', content: headAccessoryJson },
97+
{ path: 'Cosmetics/CharacterCreator/GradientSets.json', content: gradientSetsJson },
98+
{ path: 'Cosmetics/CharacterCreator/Overpants.json', content: overpantsJson },
99+
{ path: 'Cosmetics/CharacterCreator/Overtops.json', content: overtopsJson },
100+
{ path: 'Cosmetics/CharacterCreator/Pants.json', content: pantsJson },
101+
{ path: 'Cosmetics/CharacterCreator/Shoes.json', content: shoesJson },
102+
{ path: 'Cosmetics/CharacterCreator/Undertops.json', content: undertopsJson },
103+
{ path: 'Cosmetics/CharacterCreator/HaircutFallbacks.json', content: haircutFallbacksJson },
104+
{ path: 'Cosmetics/CharacterCreator/BodyCharacteristics.json', content: bodyCharacteristicsJson },
105+
];
106+
107+
export interface SkinDefinition {
108+
Id: string;
109+
Model: string;
110+
GradientSet: string;
111+
GreyscaleTexture: string;
112+
Name: string;
113+
IsDefaultAsset?: boolean;
114+
}
115+
82116
type CacheState = 'uninitialized' | 'loading' | 'loaded';
83117

84118
let cacheState: CacheState = 'uninitialized';
@@ -236,11 +270,21 @@ export async function resolveCosmetic(
236270
}
237271
}
238272

273+
const fullModelPath = modelPath ? `Common/${modelPath}` : null;
274+
const fullTexturePath = texturePath ? `Common/${texturePath}` : null;
275+
276+
const basePlayerAssets = new Set([
277+
'Common/Characters/Player.blockymodel',
278+
'Common/Characters/Animations/Default/Idle.blockyanim',
279+
'Common/Characters/Player_Textures/Player_Greyscale.png',
280+
'Common/Characters/Player_Textures/Player_Muscular_Greyscale.png',
281+
]);
282+
239283
return {
240284
slot,
241285
id: parsed.id,
242-
modelPath: modelPath ? `Common/${modelPath}` : null,
243-
texturePath: texturePath ? `Common/${texturePath}` : null,
286+
modelPath: fullModelPath && !basePlayerAssets.has(fullModelPath) ? fullModelPath : null,
287+
texturePath: fullTexturePath && !basePlayerAssets.has(fullTexturePath) ? fullTexturePath : null,
244288
gradientSetId,
245289
colorId,
246290
gradientTexturePath: gradientTexturePath ? `Common/${gradientTexturePath}` : null,
@@ -361,3 +405,23 @@ export function getRequiredAssetPaths(resolvedSkin: ResolvedSkin): {
361405
gradients: [...gradients],
362406
};
363407
}
408+
409+
export function getCosmeticJsonBytes(): { path: string; bytes: Uint8Array; }[] {
410+
const reusableTextEncoder = new TextEncoder();
411+
return COSMETIC_FILES.map(({ path, content }) => {
412+
return {
413+
path,
414+
bytes: reusableTextEncoder.encode(typeof content === 'string' ? content : JSON.stringify(content)),
415+
};
416+
});
417+
}
418+
419+
export function getCosmeticJson<T>(path: string): T {
420+
// Return this as as an object
421+
const content = COSMETIC_FILES.find(file => file.path === path)?.content;
422+
if (!content) {
423+
console.warn(`Cosmetic JSON not found: ${path}`);
424+
return {} as T;
425+
}
426+
return JSON.parse(typeof content === 'string' ? content : JSON.stringify(content));
427+
}

src/worker/services/hytale/service.ts

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import { createHash } from 'node:crypto';
2+
13
import pLimit from 'p-limit';
24

35
import * as hytaleApi from './api';
46
import { loadHytaleAssets } from './assets';
5-
import { getRequiredAssetPaths, resolveSkin } from './cosmetic-registry';
7+
import {
8+
type SkinDefinition,
9+
getCosmeticJson,
10+
getCosmeticJsonBytes,
11+
getRequiredAssetPaths,
12+
resolveSkin,
13+
} from './cosmetic-registry';
614
import { render_hytale_3d, render_text_avatar } from '../../../../pkg/mcavatar';
715
import { EMPTY } from '../../data';
816
import { IdentityKind, RequestedKind } from '../../request';
@@ -14,14 +22,16 @@ import {
1422
uuidVersion,
1523
} from '../../util/uuid';
1624

17-
import type { HytaleProfile } from './api';
25+
import type { HytaleProfile, HytaleSkin } from './api';
1826
import type { CraftheadRequest } from '../../request';
1927
import type { CacheComputeResult } from '../../util/cache-helper';
2028

2129
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
2230

23-
function getRenderCacheKey(request: CraftheadRequest): string {
24-
return `renders/${request.requested}/${request.armored}/${request.model || 'regular'}/${request.identity.toLowerCase()}/${request.size}`;
31+
function getRenderCacheKey(skin: HytaleSkin): string {
32+
// Hash the skin to generate a unique key per unique skin combo (so we can share renders with multiple users)
33+
const hash = createHash('sha256').update(JSON.stringify(skin)).digest('hex');
34+
return `renders/${hash}`;
2535
}
2636

2737
async function getCachedRender(cacheKey: string, env: Cloudflare.Env): Promise<Response | null> {
@@ -160,67 +170,54 @@ function generateAndReturnTextAvatar(username: string, request: CraftheadRequest
160170
* Uses R2 caching for 24 hours to reduce computational cost.
161171
*/
162172
export async function renderAvatar(incomingRequest: Request, request: CraftheadRequest, env: Cloudflare.Env, ctx: ExecutionContext): Promise<Response> {
163-
const cacheKey = getRenderCacheKey(request);
164-
165-
const cachedRender = await getCachedRender(cacheKey, env);
166-
if (cachedRender) {
167-
return cachedRender;
168-
}
169-
170173
const { profile } = await normalizeRequest(incomingRequest, request);
171174
const username = profile?.name ?? request.identity;
172175
if (!profile?.skin) {
173176
// TODO: Replace with a deterministic skin generator
174177
return generateAndReturnTextAvatar(username, request);
175178
}
179+
const cacheKey = getRenderCacheKey(profile.skin);
180+
181+
const cachedRender = await getCachedRender(cacheKey, env);
182+
if (cachedRender) {
183+
return cachedRender;
184+
}
176185

177186
try {
178187
// Load bundled Hytale assets (base model and animation)
179-
const assets = await loadHytaleAssets();
188+
const skinDefinitionJson = getCosmeticJson<SkinDefinition[]>('Cosmetics/CharacterCreator/BodyCharacteristics.json');
189+
// Look for their skin type
190+
const skinType = profile.skin.bodyCharacteristic.split('.')[0] ?? 'Default';
191+
const skinPath = skinDefinitionJson.find(skin => skin.Id === skinType)?.GreyscaleTexture ?? 'Common/Characters/Player_Textures/Player_Greyscale.png';
192+
const assets = await loadHytaleAssets(skinPath, ctx);
180193

181194
const resolvedSkin = await resolveSkin(profile.skin);
182195
const assetPaths: string[] = [];
183196
const assetBytes: Uint8Array[] = [];
184197

185198
const requiredAssets = getRequiredAssetPaths(resolvedSkin);
186199

187-
const assetSet = new Set<string>([
200+
const skinSpecificAssetSet = new Set<string>([
188201
...requiredAssets.models,
189202
...requiredAssets.textures,
190203
...requiredAssets.gradients,
191-
'Cosmetics/CharacterCreator/HaircutFallbacks.json',
192-
'Cosmetics/CharacterCreator/Faces.json',
193-
'Cosmetics/CharacterCreator/Eyes.json',
194-
'Cosmetics/CharacterCreator/Eyebrows.json',
195-
'Cosmetics/CharacterCreator/Mouths.json',
196-
'Cosmetics/CharacterCreator/Ears.json',
197-
'Cosmetics/CharacterCreator/Haircuts.json',
198-
'Cosmetics/CharacterCreator/FacialHair.json',
199-
'Cosmetics/CharacterCreator/Underwear.json',
200-
'Cosmetics/CharacterCreator/FaceAccessory.json',
201-
'Cosmetics/CharacterCreator/Capes.json',
202-
'Cosmetics/CharacterCreator/EarAccessory.json',
203-
'Cosmetics/CharacterCreator/Gloves.json',
204-
'Cosmetics/CharacterCreator/HeadAccessory.json',
205-
'Cosmetics/CharacterCreator/GradientSets.json',
206-
'Cosmetics/CharacterCreator/Overpants.json',
207-
'Cosmetics/CharacterCreator/Overtops.json',
208-
'Cosmetics/CharacterCreator/Pants.json',
209-
'Cosmetics/CharacterCreator/Shoes.json',
210-
'Cosmetics/CharacterCreator/SkinFeatures.json',
211-
'Cosmetics/CharacterCreator/Undertops.json',
212204
]);
213205

206+
for (const { path, bytes } of getCosmeticJsonBytes()) {
207+
assetPaths.push(`assets/Common/${path}`);
208+
assetBytes.push(bytes);
209+
}
210+
214211
const limit = pLimit(5);
215-
const assetPromises = [...assetSet].map(async (assetPath) => {
216-
const data = await limit(() => readAssetFile(assetPath, env));
212+
const skinAssetPromises = [...skinSpecificAssetSet].map(async (assetPath) => {
213+
const data = await limit(() => readAssetFile(assetPath, env, ctx));
217214
const providerPath = assetPath.startsWith('Common/')
218215
? `assets/${assetPath}`
219216
: `assets/Common/${assetPath}`;
220217
return { providerPath, bytes: new Uint8Array(data) };
221218
});
222219

223-
const results = await Promise.all(assetPromises);
220+
const results = await Promise.all(skinAssetPromises);
224221
for (const result of results) {
225222
assetPaths.push(result.providerPath);
226223
assetBytes.push(result.bytes);

src/worker/util/files.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
* in Cloudflare Workers while still supporting local development.
77
*/
88

9+
export async function generateCacheKey(filePath: string): Promise<string> {
10+
return `https://crafthead.net/_hytale_assets/${filePath}`;
11+
}
12+
913
/**
1014
* Reads an asset file from R2 (production) or disk (local development)
1115
*
@@ -17,19 +21,33 @@
1721
export async function readAssetFile(
1822
filePath: string,
1923
env: Cloudflare.Env,
24+
ctx: ExecutionContext,
2025
): Promise<ArrayBuffer> {
2126
// There is intentionally no fallback to disk here. You should just upload the files to b2 locally.
2227
// There's a script: ./scripts/upload-assets-to-r2.ts, re-enable the debug endpoint and run it to upload the assets.
28+
const cacheKey = await generateCacheKey(filePath);
29+
// Check to see if it's already cached and if so, return the cached response
30+
const cachedResponse = await caches.default.match(new Request(cacheKey));
31+
if (cachedResponse) {
32+
return cachedResponse.arrayBuffer();
33+
}
2334
// Check if R2 binding is available (production)
2435
if (env.HYTALE_ASSETS) {
2536
const object = await env.HYTALE_ASSETS.get(filePath);
2637
if (!object) {
2738
console.log('No object for R2', filePath);
2839
throw new Error(`Asset file not found in R2: ${filePath}`);
2940
}
30-
console.log('serving asset from R2', filePath);
31-
return object.arrayBuffer();
41+
const arrayBuffer = await object.arrayBuffer();
42+
const cachedResponse = new Response(arrayBuffer, {
43+
headers: {
44+
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
45+
'Cache-Control': 'max-age=604800', // 7 days
46+
},
47+
});
48+
ctx.waitUntil(caches.default.put(new Request(cacheKey), cachedResponse));
49+
return arrayBuffer;
3250
}
3351

34-
throw new Error(`Asset file not found in R2: ${filePath}`);
52+
throw new Error(`Asset file not found: ${filePath}`);
3553
}

0 commit comments

Comments
 (0)