From d619f4508d64811f5837a89b174c45fd99e29aa2 Mon Sep 17 00:00:00 2001 From: Terncode Date: Thu, 6 Aug 2026 12:32:51 +0200 Subject: [PATCH] Speed up sprite compilation --- src/ts/tools/create-sprites.ts | 170 ++++++++++++++++++++++++--------- src/ts/tools/sprite-sheet.ts | 150 +++++++++++++++++------------ 2 files changed, 212 insertions(+), 108 deletions(-) diff --git a/src/ts/tools/create-sprites.ts b/src/ts/tools/create-sprites.ts index b34e31d..727aeab 100644 --- a/src/ts/tools/create-sprites.ts +++ b/src/ts/tools/create-sprites.ts @@ -52,6 +52,58 @@ const getPngs = (directory: string) => fs.readdirSync(directory).filter(isPng); const getFrames = (layers: Layer[]) => layers.filter(nameMatches(/^frame/)).sort(compareLayers); 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(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) { return imageToCanvas(loadImage(fileName)); } @@ -1055,36 +1107,42 @@ function createResult(): Result { }; } -function createPonySprites(result: Result) { - getEyesFromPsd(result, ponyPsd('eyes.psd'), ponyPsd('irises.psd')); - getMuzzlesFromPsd(result, ponyPsd('muzzles.psd')); - getBlushFromPsd(result, ponyPsd('blush.psd')); - getPonyShadowsAndSelection(result, ponyPsd('shadows.psd')); - - result.objects2.cms = addCMSprite(result.sprites, false); - result.objects2.cmsFlip = addCMSprite(result.sprites, true); +function createPonySprites(result: Result, log: boolean) { + logStep('eyes', () => { + getEyesFromPsd(result, ponyPsd('eyes.psd'), ponyPsd('irises.psd')); + }, log); + logStep('muzzles', () => getMuzzlesFromPsd(result, ponyPsd('muzzles.psd')), log); + logStep('blush', () => getBlushFromPsd(result, ponyPsd('blush.psd')), log); + logStep('shadows', () => getPonyShadowsAndSelection(result, ponyPsd('shadows.psd')), log); + logStep('cms', () => { + result.objects2.cms = addCMSprite(result.sprites, false); + result.objects2.cmsFlip = addCMSprite(result.sprites, true); + }, log); sheets .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')); // result.objects2['wings: AnimatedSprites'].slice(3) // .forEach((frame: any, i: number) => frame[1] = [pegasus[i]]); - getTreesFromPsds(result, path.join(sourcePath, 'trees')); - getObjectsFromPsds(result, path.join(sourcePath, 'objects')); + logStep('trees', () => getTreesFromPsds(result, path.join(sourcePath, 'trees')), log); + logStep('objects', () => getObjectsFromPsds(result, path.join(sourcePath, 'objects')), log); - createButtons(result, path.join(sourcePath, 'buttons')); - createAnimations(result, path.join(sourcePath, 'animations')); - createOtherSprites(result, path.join(sourcePath, 'sprites')); - createOtherSpritesPalette(result, path.join(sourcePath, 'sprites-palette')); - createIcons(result, path.join(sourcePath, 'icons')); - createOtherSpritesAnimations(result, path.join(sourcePath, 'sprites-animations')); - createEmoteAnimations(result, path.join(sourcePath, 'emotes')); - createWalls(result, path.join(sourcePath, 'walls')); + logStep('buttons', () => createButtons(result, path.join(sourcePath, 'buttons')), log); + logStep('animations', () => createAnimations(result, path.join(sourcePath, 'animations')), log); + logStep('sprites', () => createOtherSprites(result, path.join(sourcePath, 'sprites')), log); + logStep('sprites-palette', () => createOtherSpritesPalette(result, path.join(sourcePath, 'sprites-palette')), log); + logStep('icons', () => createIcons(result, path.join(sourcePath, 'icons')), log); + logStep('sprites-animations', () => createOtherSpritesAnimations(result, path.join(sourcePath, 'sprites-animations')), log); + logStep('emotes', () => createEmoteAnimations(result, path.join(sourcePath, 'emotes')), log); + logStep('walls', () => createWalls(result, path.join(sourcePath, 'walls')), log); - createPixelSprites(result); + logStep('pixel sprites', () => createPixelSprites(result), log); } interface WallConfig { @@ -1329,15 +1387,7 @@ const SUPPORTER1 = 0xf86754ff; const SUPPORTER2_BANDS = [0xffdfc1ff, 0xffcd99ff, 0xff9f3bff, 0xd97e09ff]; const SUPPORTER3_BANDS = [0xffffffff, 0xfffda4ff, 0xffea3bff, 0xfdbb0bff]; -export function createSprites(log: boolean) { - mkdir(destPath); - mkdir(generatedPath); - - const result = createResult(); - - createPonySprites(result); - createTileSprites(result); - +function createFonts(result: Result) { const mainFont = fontCanvas('main.psd'); const mainEmoji = fontCanvas('emoji.psd'); const tinyFont = fontCanvas('tiny.psd'); @@ -1368,10 +1418,44 @@ export function createSprites(log: boolean) { const monoFontSpritesPal = createFont(stripedMonoFont, 8, 9, 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); - const ponySheet2 = createSpriteSheet('ponySheet2', result.sprites, log, 1024, 'black', true); +export function createSprites(log: boolean) { + 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(ponySheet2.sprites, result.objects2, 'pixelRect2', 'pixel2'); @@ -1395,18 +1479,8 @@ export function createSprites(log: boolean) { paletteSprites: ponySheet2.sprites, result, palettes, - fonts: { - font: fontSprites, - emoji: emojiSprites, - fontSmall: smallFontSprites, - fontMono: monoFontSprites, - }, - fontsPal: { - fontPal: fontSpritesPal, - emojiPal: emojiSpritesPal, - fontSmallPal: smallFontSpritesPal, - fontMonoPal: monoFontSpritesPal, - }, + fonts, + fontsPal, namedPalettes: { defaultPalette: 0, 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 - 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) { const start = Date.now(); - createSprites(true); + createSprites(false); 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} }`); } diff --git a/src/ts/tools/sprite-sheet.ts b/src/ts/tools/sprite-sheet.ts index 53a1a55..be84fa1 100644 --- a/src/ts/tools/sprite-sheet.ts +++ b/src/ts/tools/sprite-sheet.ts @@ -2,7 +2,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { times } from 'lodash'; import { Sprite, Rect } from './types'; -import { findByIndex } from './common'; import { removeItem } from '../common/utils'; import { createExtCanvas, saveCanvas } from './canvas-utils'; @@ -37,13 +36,18 @@ function isIdenticalData(a: ImageData, b: ImageData) { 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) { return false; } - const adata = a.image.getContext('2d')!.getImageData(a.ox, a.oy, a.w, a.h); - const bdata = b.image.getContext('2d')!.getImageData(b.ox, b.oy, b.w, b.h); + const adata = a.data; + const bdata = b.data; + + if (!adata || !bdata) { + return false; + } + const length = (adata.width * adata.height * 4) | 0; const adat = adata.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; } -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; 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 ix = 0; ix < w; ix++) { - if (taken[getIndex((ix + x) | 0, (iy + y) | 0, outputWidth)] !== 0) { - return false; + const spans = lines[y + iy]; + let found = 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 { 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; 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; 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 }; } } @@ -175,7 +192,7 @@ function getFirstFreePacked(outputWidth: number, width: number, height: number, const maxY = outputWidth - height; 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) { const spans = lines[y]; @@ -186,7 +203,7 @@ function getFirstFreePacked(outputWidth: number, width: number, height: number, const end = (start + span.length - width) | 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 }; } } @@ -211,17 +228,12 @@ function positionSprite(sprite: ExtSprite, outputWidth: number, taken: Taken[], const right = x + w; 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++) { const yy = y + iy; 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++) { const span = spans[i]; 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 }); } } - - // 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) { - const data = s.image.getContext('2d')!.getImageData(s.ox, s.oy, s.w, s.h); +function hasShading(s: ExtSprite) { + const data = s.data; + + if (!data) { + return false; + } for (let y = 0; y < data.height; y++) { for (let x = 0; x < data.width; x++) { @@ -305,6 +299,18 @@ function getSpriteImageData(s: ExtSprite): ImageData | undefined { 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) { const maxLayers = 4; const sprites = images.slice(); @@ -323,16 +329,27 @@ export function createSpriteSheet(name: string, images: ExtSprite[], log: boolea sprites .forEach(s => s.data = getSpriteImageData(s)); + const seen: Map = new Map(); + sprites - .forEach((s, i) => { - if (s.w && s.h) { - for (let j = 0; j < i; j++) { - if (!s.duplicateOf && isIdenticalSprite(sprites[j], s)) { - s.duplicateOf = sprites[j]; + .forEach(s => { + if (s.w && s.h && s.data) { + const key = `${s.w}x${s.h}|${getDataHash(s.data)}`; + const candidates = seen.get(key); + + if (candidates) { + const duplicate = candidates.find(c => isIdenticalSprite(c, s)); + + if (duplicate) { + s.duplicateOf = duplicate; 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, () => ({ lines: times(outputWidth, () => [{ start: 0, length: outputWidth }]), - data: new Uint8Array(outputWidth * outputWidth), })); sprites @@ -439,17 +455,17 @@ export function createSpriteSheet(name: string, images: ExtSprite[], log: boolea const alphaData = alphaContext.getImageData(0, 0, image.width, image.height); sprites - .filter(s => !s.duplicateOf && s.w && s.h) + .filter(s => !s.duplicateOf && s.w && s.h && s.data) .forEach(s => { 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 { - 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) { - 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)); } + const byIndex = new Map(); + + sprites.forEach(s => { + if (s.index != null) { + byIndex.set(s.index, s); + } + }); + return { - sprites: images.map((_, index) => findByIndex(sprites, index) || null), + sprites: images.map((_, index) => byIndex.get(index) || null), image, alpha, }; } function drawChannel( - src: HTMLCanvasElement, dst: ImageData, srcChannel: number, dstChannel: number, - sx: number, sy: number, dx: number, dy: number, w: number, h: number + src: ImageData, dst: ImageData, srcChannel: number, dstChannel: 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++) { + const sOffset = ((y * srcWidth) * 4 + srcChannel) | 0; + const dOffset = ((((y + dy) * dstWidth) + dx) * 4 + dstChannel) | 0; + for (let x = 0; x < w; x++) { - dst.data[((x + dx) + (y + dy) * dst.width) * 4 + dstChannel] = - srcData.data[(x + y * srcData.width) * 4 + srcChannel]; + dst.data[dOffset + (x * 4) | 0] = srcData[sOffset + (x * 4) | 0]; } } }