Speed up sprite compilation

This commit is contained in:
2026-08-06 12:32:51 +02:00
parent 254bc01d83
commit d619f4508d
2 changed files with 212 additions and 108 deletions
+123 -47
View File
@@ -52,6 +52,58 @@ const getPngs = (directory: string) => fs.readdirSync(directory).filter(isPng);
const getFrames = (layers: Layer[]) => layers.filter(nameMatches(/^frame/)).sort(compareLayers); const getFrames = (layers: Layer[]) => layers.filter(nameMatches(/^frame/)).sort(compareLayers);
const ponyPsd = (name: string) => openPsd(path.join(ponyPath, name)); const ponyPsd = (name: string) => openPsd(path.join(ponyPath, name));
// profiling / progress logging
interface ProfileStep {
name: string;
elapsed: number;
}
let spritesLog = false;
let profileStart = 0;
const profileSteps: ProfileStep[] = [];
function formatTime(ms: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
}
function logStep<T>(name: string, fn: () => T, log = spritesLog): T {
const start = Date.now();
if (log) {
const total = formatTime(start - profileStart);
console.log(`[sprites] ${name}... (${total} elapsed)`);
}
const result = fn();
const elapsed = Date.now() - start;
profileSteps.push({ name, elapsed });
if (log) {
console.log(`[sprites] ${name}: ${formatTime(elapsed)}`);
}
return result;
}
function printProfile() {
if (!spritesLog) {
return;
}
const total = profileSteps.reduce((sum, step) => sum + step.elapsed, 0);
const maxNameLength = profileSteps.length ? Math.max(...profileSteps.map(step => step.name.length)) : 0;
console.log(`\n[sprites] profile:`);
for (const { name, elapsed } of profileSteps) {
const percent = total ? ((elapsed / total) * 100).toFixed(1) : '0.0';
console.log(`[sprites] ${formatTime(elapsed).padStart(7)} (${percent}%) ${name.padEnd(maxNameLength)}`);
}
console.log(`[sprites] ${formatTime(total).padStart(7)} (100.0%) ${'TOTAL'.padEnd(maxNameLength)}`);
}
function openPng(fileName: string) { function openPng(fileName: string) {
return imageToCanvas(loadImage(fileName)); return imageToCanvas(loadImage(fileName));
} }
@@ -1055,36 +1107,42 @@ function createResult(): Result {
}; };
} }
function createPonySprites(result: Result) { function createPonySprites(result: Result, log: boolean) {
getEyesFromPsd(result, ponyPsd('eyes.psd'), ponyPsd('irises.psd')); logStep('eyes', () => {
getMuzzlesFromPsd(result, ponyPsd('muzzles.psd')); getEyesFromPsd(result, ponyPsd('eyes.psd'), ponyPsd('irises.psd'));
getBlushFromPsd(result, ponyPsd('blush.psd')); }, log);
getPonyShadowsAndSelection(result, ponyPsd('shadows.psd')); logStep('muzzles', () => getMuzzlesFromPsd(result, ponyPsd('muzzles.psd')), log);
logStep('blush', () => getBlushFromPsd(result, ponyPsd('blush.psd')), log);
result.objects2.cms = addCMSprite(result.sprites, false); logStep('shadows', () => getPonyShadowsAndSelection(result, ponyPsd('shadows.psd')), log);
result.objects2.cmsFlip = addCMSprite(result.sprites, true); logStep('cms', () => {
result.objects2.cms = addCMSprite(result.sprites, false);
result.objects2.cmsFlip = addCMSprite(result.sprites, true);
}, log);
sheets sheets
.filter(s => 'name' in s && !!s.file && !s.skipImport) .filter(s => 'name' in s && !!s.file && !s.skipImport)
.forEach(s => importSprites(result, s as Sheet)); .forEach(s => {
const sheet = s as Sheet;
logStep(`import ${sheet.name}`, () => importSprites(result, sheet), log);
});
// const bug = getFramesFromPSD(result, ponyPsd('fly-bug.psd')); // const bug = getFramesFromPSD(result, ponyPsd('fly-bug.psd'));
// result.objects2['wings: AnimatedSprites'].slice(3) // result.objects2['wings: AnimatedSprites'].slice(3)
// .forEach((frame: any, i: number) => frame[1] = [pegasus[i]]); // .forEach((frame: any, i: number) => frame[1] = [pegasus[i]]);
getTreesFromPsds(result, path.join(sourcePath, 'trees')); logStep('trees', () => getTreesFromPsds(result, path.join(sourcePath, 'trees')), log);
getObjectsFromPsds(result, path.join(sourcePath, 'objects')); logStep('objects', () => getObjectsFromPsds(result, path.join(sourcePath, 'objects')), log);
createButtons(result, path.join(sourcePath, 'buttons')); logStep('buttons', () => createButtons(result, path.join(sourcePath, 'buttons')), log);
createAnimations(result, path.join(sourcePath, 'animations')); logStep('animations', () => createAnimations(result, path.join(sourcePath, 'animations')), log);
createOtherSprites(result, path.join(sourcePath, 'sprites')); logStep('sprites', () => createOtherSprites(result, path.join(sourcePath, 'sprites')), log);
createOtherSpritesPalette(result, path.join(sourcePath, 'sprites-palette')); logStep('sprites-palette', () => createOtherSpritesPalette(result, path.join(sourcePath, 'sprites-palette')), log);
createIcons(result, path.join(sourcePath, 'icons')); logStep('icons', () => createIcons(result, path.join(sourcePath, 'icons')), log);
createOtherSpritesAnimations(result, path.join(sourcePath, 'sprites-animations')); logStep('sprites-animations', () => createOtherSpritesAnimations(result, path.join(sourcePath, 'sprites-animations')), log);
createEmoteAnimations(result, path.join(sourcePath, 'emotes')); logStep('emotes', () => createEmoteAnimations(result, path.join(sourcePath, 'emotes')), log);
createWalls(result, path.join(sourcePath, 'walls')); logStep('walls', () => createWalls(result, path.join(sourcePath, 'walls')), log);
createPixelSprites(result); logStep('pixel sprites', () => createPixelSprites(result), log);
} }
interface WallConfig { interface WallConfig {
@@ -1329,15 +1387,7 @@ const SUPPORTER1 = 0xf86754ff;
const SUPPORTER2_BANDS = [0xffdfc1ff, 0xffcd99ff, 0xff9f3bff, 0xd97e09ff]; const SUPPORTER2_BANDS = [0xffdfc1ff, 0xffcd99ff, 0xff9f3bff, 0xd97e09ff];
const SUPPORTER3_BANDS = [0xffffffff, 0xfffda4ff, 0xffea3bff, 0xfdbb0bff]; const SUPPORTER3_BANDS = [0xffffffff, 0xfffda4ff, 0xffea3bff, 0xfdbb0bff];
export function createSprites(log: boolean) { function createFonts(result: Result) {
mkdir(destPath);
mkdir(generatedPath);
const result = createResult();
createPonySprites(result);
createTileSprites(result);
const mainFont = fontCanvas('main.psd'); const mainFont = fontCanvas('main.psd');
const mainEmoji = fontCanvas('emoji.psd'); const mainEmoji = fontCanvas('emoji.psd');
const tinyFont = fontCanvas('tiny.psd'); const tinyFont = fontCanvas('tiny.psd');
@@ -1368,10 +1418,44 @@ export function createSprites(log: boolean) {
const monoFontSpritesPal = createFont(stripedMonoFont, 8, 9, const monoFontSpritesPal = createFont(stripedMonoFont, 8, 9,
canvas => addSprite(result.sprites, canvas, undefined, smallFontPalette), { noChinese: true, mono: 4, onlyBase: true }); canvas => addSprite(result.sprites, canvas, undefined, smallFontPalette), { noChinese: true, mono: 4, onlyBase: true });
const lights = createLights(result, path.join(sourcePath, 'lights')); return {
emojiPalette,
fonts: {
font: fontSprites,
emoji: emojiSprites,
fontSmall: smallFontSprites,
fontMono: monoFontSprites,
},
fontsPal: {
fontPal: fontSpritesPal,
emojiPal: emojiSpritesPal,
fontSmallPal: smallFontSpritesPal,
fontMonoPal: monoFontSpritesPal,
},
};
}
const ponySheet = createSpriteSheet('ponySheet', result.images.map(imageToSprite), log, 1024); export function createSprites(log: boolean) {
const ponySheet2 = createSpriteSheet('ponySheet2', result.sprites, log, 1024, 'black', true); spritesLog = log;
profileStart = Date.now();
profileSteps.length = 0;
mkdir(destPath);
mkdir(generatedPath);
const result = createResult();
createPonySprites(result, log);
logStep('tiles', () => createTileSprites(result), log);
const { fonts, fontsPal, emojiPalette } = logStep('fonts', () => createFonts(result), log);
const lights = logStep('lights', () => createLights(result, path.join(sourcePath, 'lights')), log);
const ponySheet = logStep(
'pack ponySheet', () => createSpriteSheet('ponySheet', result.images.map(imageToSprite), log, 1024), log);
const ponySheet2 = logStep(
'pack ponySheet2', () => createSpriteSheet('ponySheet2', result.sprites, log, 1024, 'black', true), log);
fixPixelRect(ponySheet.sprites, result.objects, 'pixelRect', 'pixel'); fixPixelRect(ponySheet.sprites, result.objects, 'pixelRect', 'pixel');
fixPixelRect(ponySheet2.sprites, result.objects2, 'pixelRect2', 'pixel2'); fixPixelRect(ponySheet2.sprites, result.objects2, 'pixelRect2', 'pixel2');
@@ -1395,18 +1479,8 @@ export function createSprites(log: boolean) {
paletteSprites: ponySheet2.sprites, paletteSprites: ponySheet2.sprites,
result, result,
palettes, palettes,
fonts: { fonts,
font: fontSprites, fontsPal,
emoji: emojiSprites,
fontSmall: smallFontSprites,
fontMono: monoFontSprites,
},
fontsPal: {
fontPal: fontSpritesPal,
emojiPal: emojiSpritesPal,
fontSmallPal: smallFontSpritesPal,
fontMonoPal: monoFontSpritesPal,
},
namedPalettes: { namedPalettes: {
defaultPalette: 0, defaultPalette: 0,
emojiPalette: addPalette(emojiPalette), emojiPalette: addPalette(emojiPalette),
@@ -1423,15 +1497,17 @@ export function createSprites(log: boolean) {
}, },
}; };
createSpritesTS(path.join(generatedPath, 'sprites.ts'), spritesConfig); logStep('generate sprites.ts', () => createSpritesTS(path.join(generatedPath, 'sprites.ts'), spritesConfig), log);
// Other exports // Other exports
saveCanvasAsRaw(path.join(outputPath, 'pony2.raw'), ponySheet2.image); logStep('save raw', () => saveCanvasAsRaw(path.join(outputPath, 'pony2.raw'), ponySheet2.image), log);
printProfile();
} }
if (require.main === module) { if (require.main === module) {
const start = Date.now(); const start = Date.now();
createSprites(true); createSprites(false);
const time = ((Date.now() - start) / 1000).toFixed(2); const time = ((Date.now() - start) / 1000).toFixed(2);
console.log(`[sprites] done: ${time}s, stats: { w: ${0}-${maxW} h: ${0}-${maxH} ox: ${minOX}-${maxOX} oy: ${minOY}-${maxOY} }`); console.log(`[sprites] done: ${time}s, stats: { w: ${0}-${maxW} h: ${0}-${maxH} ox: ${minOX}-${maxOX} oy: ${minOY}-${maxOY} }`);
} }
+89 -61
View File
@@ -2,7 +2,6 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { times } from 'lodash'; import { times } from 'lodash';
import { Sprite, Rect } from './types'; import { Sprite, Rect } from './types';
import { findByIndex } from './common';
import { removeItem } from '../common/utils'; import { removeItem } from '../common/utils';
import { createExtCanvas, saveCanvas } from './canvas-utils'; import { createExtCanvas, saveCanvas } from './canvas-utils';
@@ -37,13 +36,18 @@ function isIdenticalData(a: ImageData, b: ImageData) {
return true; return true;
} }
function isIdenticalChannel(a: Sprite | undefined, b: Sprite | undefined, channel: number) { function isIdenticalChannel(a: ExtSprite | undefined, b: ExtSprite | undefined, channel: number) {
if (!a || !b || a.w !== b.w || a.h !== b.h) { if (!a || !b || a.w !== b.w || a.h !== b.h) {
return false; return false;
} }
const adata = a.image.getContext('2d')!.getImageData(a.ox, a.oy, a.w, a.h); const adata = a.data;
const bdata = b.image.getContext('2d')!.getImageData(b.ox, b.oy, b.w, b.h); const bdata = b.data;
if (!adata || !bdata) {
return false;
}
const length = (adata.width * adata.height * 4) | 0; const length = (adata.width * adata.height * 4) | 0;
const adat = adata.data; const adat = adata.data;
const bdat = bdata.data; const bdat = bdata.data;
@@ -121,7 +125,7 @@ function getIndex(x: number, y: number, outputWidth: number) {
return ((x | 0) + (((y | 0) * outputWidth) | 0)) | 0; return ((x | 0) + (((y | 0) * outputWidth) | 0)) | 0;
} }
function isEmpty(x: number, y: number, w: number, h: number, outputWidth: number, taken: Uint8Array) { function isEmpty(x: number, y: number, w: number, h: number, outputWidth: number, lines: Line[][]) {
outputWidth = outputWidth | 0; outputWidth = outputWidth | 0;
if (((x + w) | 0) > outputWidth || ((y + h) | 0) > outputWidth) { if (((x + w) | 0) > outputWidth || ((y + h) | 0) > outputWidth) {
@@ -129,10 +133,24 @@ function isEmpty(x: number, y: number, w: number, h: number, outputWidth: number
} }
for (let iy = 0; iy < h; iy++) { for (let iy = 0; iy < h; iy++) {
for (let ix = 0; ix < w; ix++) { const spans = lines[y + iy];
if (taken[getIndex((ix + x) | 0, (iy + y) | 0, outputWidth)] !== 0) { let found = false;
return false;
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
if (span.start > x) { // spans are sorted and non-overlapping
break;
} }
if (x + w <= span.start + span.length) {
found = true;
break;
}
}
if (!found) {
return false;
} }
} }
@@ -146,10 +164,9 @@ interface Line {
interface Taken { interface Taken {
lines: Line[][]; lines: Line[][];
data: Uint8Array; // TODO: remove completely, just use lines
} }
function getFirstFree(outputWidth: number, width: number, height: number, { data, lines }: Taken) { function getFirstFree(outputWidth: number, width: number, height: number, { lines }: Taken) {
const maxY = (outputWidth - height) | 0; const maxY = (outputWidth - height) | 0;
for (let y = 0; y < maxY; y = (y + 1) | 0) { for (let y = 0; y < maxY; y = (y + 1) | 0) {
@@ -161,7 +178,7 @@ function getFirstFree(outputWidth: number, width: number, height: number, { data
const end = (start + span.length - width) | 0; const end = (start + span.length - width) | 0;
for (let x = start; x < end; x = (x + 1) | 0) { for (let x = start; x < end; x = (x + 1) | 0) {
if (isEmpty(x, y, width, height, outputWidth, data)) { if (isEmpty(x, y, width, height, outputWidth, lines)) {
return { x, y, layer: 0 }; return { x, y, layer: 0 };
} }
} }
@@ -175,7 +192,7 @@ function getFirstFreePacked(outputWidth: number, width: number, height: number,
const maxY = outputWidth - height; const maxY = outputWidth - height;
for (let layer = 0; layer < takens.length; layer = (layer + 1) | 0) { for (let layer = 0; layer < takens.length; layer = (layer + 1) | 0) {
const { data, lines } = takens[layer]; const { lines } = takens[layer];
for (let y = 0; y < maxY; y = (y + 1) | 0) { for (let y = 0; y < maxY; y = (y + 1) | 0) {
const spans = lines[y]; const spans = lines[y];
@@ -186,7 +203,7 @@ function getFirstFreePacked(outputWidth: number, width: number, height: number,
const end = (start + span.length - width) | 0; const end = (start + span.length - width) | 0;
for (let x = start | 0; x < end; x = (x + 1) | 0) { for (let x = start | 0; x < end; x = (x + 1) | 0) {
if (isEmpty(x, y, width, height, outputWidth, data)) { if (isEmpty(x, y, width, height, outputWidth, lines)) {
return { x, y, layer }; return { x, y, layer };
} }
} }
@@ -211,17 +228,12 @@ function positionSprite(sprite: ExtSprite, outputWidth: number, taken: Taken[],
const right = x + w; const right = x + w;
for (let il = 0; il < layers; il++) { for (let il = 0; il < layers; il++) {
const { data, lines } = taken[il + layer]; const { lines } = taken[il + layer];
for (let iy = 0; iy < sprite.h; iy++) { for (let iy = 0; iy < sprite.h; iy++) {
const yy = y + iy; const yy = y + iy;
const spans = lines[yy]; const spans = lines[yy];
for (let ix = 0; ix < sprite.w; ix++) {
const xx = x + ix;
data[getIndex(xx, yy, outputWidth)] = 1;
}
for (let i = 0; i < spans.length; i++) { for (let i = 0; i < spans.length; i++) {
const span = spans[i]; const span = spans[i];
const start = span.start; const start = span.start;
@@ -254,35 +266,17 @@ function positionSprite(sprite: ExtSprite, outputWidth: number, taken: Taken[],
spans.splice(i + 1, 0, { start: right, length: end - right }); spans.splice(i + 1, 0, { start: right, length: end - right });
} }
} }
// let start = span.start;
// let lineMoved = false;
// for (let ix = 0; ix < sprite.w; ix++) {
// const xx = x + ix;
// data[getIndex(xx, yy, outputWidth)] = 1;
// if (xx === start) {
// start++;
// lineMoved = true;
// }
// }
// if (lineMoved) {
// for (let x = start; x < outputWidth && data[getIndex(x, yy, outputWidth)] !== 0; x++) {
// start++;
// }
// span.start = start;
// span.length = outputWidth - start;
// }
} }
} }
} }
} }
function hasShading(s: Sprite) { function hasShading(s: ExtSprite) {
const data = s.image.getContext('2d')!.getImageData(s.ox, s.oy, s.w, s.h); const data = s.data;
if (!data) {
return false;
}
for (let y = 0; y < data.height; y++) { for (let y = 0; y < data.height; y++) {
for (let x = 0; x < data.width; x++) { for (let x = 0; x < data.width; x++) {
@@ -305,6 +299,18 @@ function getSpriteImageData(s: ExtSprite): ImageData | undefined {
return { width, height, data } as ImageData; return { width, height, data } as ImageData;
} }
function getDataHash(data: ImageData): number {
const bytes = data.data;
const length = bytes.length | 0;
let hash = 0x811c9dc5;
for (let i = 0; i < length; i++) {
hash = Math.imul(hash ^ bytes[i], 0x01000193);
}
return hash >>> 0;
}
export function createSpriteSheet(name: string, images: ExtSprite[], log: boolean, size: number, bg?: string, pack = false) { export function createSpriteSheet(name: string, images: ExtSprite[], log: boolean, size: number, bg?: string, pack = false) {
const maxLayers = 4; const maxLayers = 4;
const sprites = images.slice(); const sprites = images.slice();
@@ -323,16 +329,27 @@ export function createSpriteSheet(name: string, images: ExtSprite[], log: boolea
sprites sprites
.forEach(s => s.data = getSpriteImageData(s)); .forEach(s => s.data = getSpriteImageData(s));
const seen: Map<string, ExtSprite[]> = new Map();
sprites sprites
.forEach((s, i) => { .forEach(s => {
if (s.w && s.h) { if (s.w && s.h && s.data) {
for (let j = 0; j < i; j++) { const key = `${s.w}x${s.h}|${getDataHash(s.data)}`;
if (!s.duplicateOf && isIdenticalSprite(sprites[j], s)) { const candidates = seen.get(key);
s.duplicateOf = sprites[j];
if (candidates) {
const duplicate = candidates.find(c => isIdenticalSprite(c, s));
if (duplicate) {
s.duplicateOf = duplicate;
deduplicated++; deduplicated++;
//console.log('duplicate', sprite.name, '==', sprites[j].name);
break;
} }
else {
candidates.push(s);
}
}
else {
seen.set(key, [s]);
} }
} }
}); });
@@ -378,7 +395,6 @@ export function createSpriteSheet(name: string, images: ExtSprite[], log: boolea
const taken: Taken[] = times(maxLayers, () => ({ const taken: Taken[] = times(maxLayers, () => ({
lines: times(outputWidth, () => [{ start: 0, length: outputWidth }]), lines: times(outputWidth, () => [{ start: 0, length: outputWidth }]),
data: new Uint8Array(outputWidth * outputWidth),
})); }));
sprites sprites
@@ -439,17 +455,17 @@ export function createSpriteSheet(name: string, images: ExtSprite[], log: boolea
const alphaData = alphaContext.getImageData(0, 0, image.width, image.height); const alphaData = alphaContext.getImageData(0, 0, image.width, image.height);
sprites sprites
.filter(s => !s.duplicateOf && s.w && s.h) .filter(s => !s.duplicateOf && s.w && s.h && s.data)
.forEach(s => { .forEach(s => {
if (s.layer === 3) { if (s.layer === 3) {
drawChannel(s.image, alphaData, 0, 0, s.ox, s.oy, s.x, s.y, s.w, s.h); drawChannel(s.data!, alphaData, 0, 0, s.x, s.y, s.w, s.h);
} }
else { else {
drawChannel(s.image, data, 0, s.layer || 0, s.ox, s.oy, s.x, s.y, s.w, s.h); drawChannel(s.data!, data, 0, s.layer || 0, s.x, s.y, s.w, s.h);
} }
if (s.shade) { if (s.shade) {
drawChannel(s.image, data, 1, 1, s.ox, s.oy, s.x, s.y, s.w, s.h); drawChannel(s.data!, data, 1, 1, s.x, s.y, s.w, s.h);
} }
}); });
@@ -462,23 +478,35 @@ export function createSpriteSheet(name: string, images: ExtSprite[], log: boolea
.forEach(s => context.drawImage(s.image, s.ox, s.oy, s.w, s.h, s.x, s.y, s.w, s.h)); .forEach(s => context.drawImage(s.image, s.ox, s.oy, s.w, s.h, s.x, s.y, s.w, s.h));
} }
const byIndex = new Map<number, ExtSprite>();
sprites.forEach(s => {
if (s.index != null) {
byIndex.set(s.index, s);
}
});
return { return {
sprites: images.map((_, index) => findByIndex(sprites, index) || null), sprites: images.map((_, index) => byIndex.get(index) || null),
image, image,
alpha, alpha,
}; };
} }
function drawChannel( function drawChannel(
src: HTMLCanvasElement, dst: ImageData, srcChannel: number, dstChannel: number, src: ImageData, dst: ImageData, srcChannel: number, dstChannel: number,
sx: number, sy: number, dx: number, dy: number, w: number, h: number dx: number, dy: number, w: number, h: number
) { ) {
const srcData = src.getContext('2d')!.getImageData(sx, sy, w, h); const srcData = src.data;
const srcWidth = src.width | 0;
const dstWidth = dst.width | 0;
for (let y = 0; y < h; y++) { for (let y = 0; y < h; y++) {
const sOffset = ((y * srcWidth) * 4 + srcChannel) | 0;
const dOffset = ((((y + dy) * dstWidth) + dx) * 4 + dstChannel) | 0;
for (let x = 0; x < w; x++) { for (let x = 0; x < w; x++) {
dst.data[((x + dx) + (y + dy) * dst.width) * 4 + dstChannel] = dst.data[dOffset + (x * 4) | 0] = srcData[sOffset + (x * 4) | 0];
srcData.data[(x + y * srcData.width) * 4 + srcChannel];
} }
} }
} }