Skip to content

Commit a0a6c1f

Browse files
perf: find sprites by content key, and draw stand-ins in tests
Every sprite image was a PNG encoded on the main thread as the modules loaded, in the browser and in every test worker alike, with no way to hand the game an image built ahead of time. Each sprite now has a content key: a 53 bit hash of a pixel map's size, rows, and palette, or of a baked sprite's runs. src/spriteAtlas.ts holds an atlas, a manifest from key to a box in a prebuilt file, and a sprite the atlas holds is drawn as a cropped region of that file with no pixel work. A key it lacks is encoded as before, so nothing changes until a build provides an atlas, and the browser's images are byte for byte the same (one hash over every built source before and after). Tests run in stand-in mode, set in src/vitest-setup.ts: a sprite gets a data URL named by its key instead of an encoded PNG, and its pixels are kept so src/testPng.ts reads them back as before. Importing the game in a fresh test module now takes about 300 ms instead of 395 ms, steady state over four files, so each worker spends about 90 ms less.
1 parent e82bf7d commit a0a6c1f

6 files changed

Lines changed: 356 additions & 16 deletions

File tree

‎src/png.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ const scanlinesOf = (
142142
return lines
143143
}
144144

145+
// NOTE: the RGBA pixels the PNG holds, row after row with no filter bytes,
146+
// for tests and the atlas generator to read without a PNG decoder.
147+
export const rasterOf = (
148+
width: number,
149+
height: number,
150+
fills: ReadonlyArray<Fill>,
151+
): Uint8Array => {
152+
const lines = scanlinesOf(width, height, fills)
153+
const raster = new Uint8Array(width * height * BYTES_PER_PIXEL)
154+
for (let y = 0; y < height; y++) {
155+
const start = y * rowLength(width) + 1
156+
raster.set(
157+
lines.subarray(start, start + width * BYTES_PER_PIXEL),
158+
y * width * BYTES_PER_PIXEL,
159+
)
160+
}
161+
return raster
162+
}
163+
145164
// PNG
146165

147166
const CRC_TABLE: ReadonlyArray<number> = Array.makeBy(256, byte =>

‎src/sprite.scene.test.ts‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Array, Option, Record, pipe } from 'effect'
22
import { Canvas } from 'foldkit'
33
import type { Html, HtmlBuilder } from 'foldkit/html'
44
import { given, scene } from 'foldkit/scene'
5-
import { describe, expect, test } from 'vitest'
5+
import { afterEach, describe, expect, test } from 'vitest'
66

77
import { playerSprites } from './appearance'
88
import { COIN_SPRITE, GLYPH_SPRITES } from './font'
@@ -18,6 +18,7 @@ import {
1818
spriteDataUrl,
1919
spriteShapes,
2020
} from './sprite'
21+
import { standInKeyOf, useSpriteAtlas, useSpriteMode } from './spriteAtlas'
2122
import { decodePng, hexOf } from './testPng'
2223

2324
// PIXEL MAPS
@@ -180,6 +181,88 @@ describe('sprite images', () => {
180181
})
181182
})
182183

184+
// PREBUILT SPRITES
185+
186+
const keyOf = (shapes: ReadonlyArray<Canvas.Shape>): string =>
187+
Option.getOrThrow(standInKeyOf(onlyImage(shapes).src))
188+
189+
const ATLAS_FILE = 'sprites.png'
190+
191+
describe('prebuilt sprites', () => {
192+
afterEach(() => {
193+
useSpriteAtlas({ sources: [], entries: {} })
194+
useSpriteMode('StandIn')
195+
})
196+
197+
test('a stand-in holds the same pixels as the encoded PNG', () => {
198+
const standIn = onlyImage(spriteShapes(PIXELS, PALETTE))
199+
expect(standInKeyOf(standIn.src)).toEqual(Option.some(keyOf([standIn])))
200+
expect(decodePng(standIn.src)).toEqual(
201+
Option.flatMap(spriteDataUrl(PIXELS, PALETTE), decodePng),
202+
)
203+
})
204+
205+
test('the same pixel map and colors always get the same key', () => {
206+
expect(keyOf(spriteShapes([...PIXELS], { ...PALETTE }))).toBe(
207+
keyOf(spriteShapes(PIXELS, PALETTE)),
208+
)
209+
expect(keyOf(spriteShapes(PIXELS, { ...PALETTE, R: '#000000' }))).not.toBe(
210+
keyOf(spriteShapes(PIXELS, PALETTE)),
211+
)
212+
})
213+
214+
test('a sprite in the atlas is a cropped region of its file, placed by its anchor', () => {
215+
const key = keyOf(spriteShapes(PIXELS, PALETTE))
216+
useSpriteAtlas({
217+
sources: [ATLAS_FILE],
218+
entries: { [key]: [0, 7, 9, 5, 6] },
219+
})
220+
expect(onlyImage(spriteShapes(PIXELS, PALETTE, { x: 2, y: 6 }))).toEqual(
221+
Canvas.Image({
222+
src: ATLAS_FILE,
223+
x: -2,
224+
y: -6,
225+
width: 5,
226+
height: 6,
227+
crop: { x: 7, y: 9, width: 5, height: 6 },
228+
rendering: 'Pixelated',
229+
}),
230+
)
231+
})
232+
233+
test('a sprite the atlas marks empty draws nothing', () => {
234+
const key = keyOf(spriteShapes(PIXELS, PALETTE))
235+
useSpriteAtlas({ sources: [ATLAS_FILE], entries: { [key]: null } })
236+
expect(spriteShapes(PIXELS, PALETTE)).toEqual([])
237+
})
238+
239+
test('baked rectangles in the atlas are found by their content, wherever they stand', () => {
240+
const rects = (x: number) => [
241+
Canvas.Rect({ x, y: 3, width: 3, height: 2, fill: '#000000' }),
242+
Canvas.Rect({ x: x + 1, y: 3, width: 1, height: 1, fill: '#ffffff' }),
243+
]
244+
const key = keyOf(bakeShapes(rects(2)))
245+
expect(keyOf(bakeShapes(rects(40)))).toBe(key)
246+
useSpriteAtlas({
247+
sources: [ATLAS_FILE],
248+
entries: { [key]: [0, 1, 1, 3, 2] },
249+
})
250+
expect(onlyImage(bakeShapes(rects(40)))).toMatchObject({
251+
src: ATLAS_FILE,
252+
x: 40,
253+
y: 3,
254+
crop: { x: 1, y: 1, width: 3, height: 2 },
255+
})
256+
})
257+
258+
test('a sprite the atlas lacks is encoded as a PNG outside the tests', () => {
259+
useSpriteMode('Encode')
260+
expect(onlyImage(spriteShapes(PIXELS, PALETTE)).src).toBe(
261+
Option.getOrThrow(spriteDataUrl(PIXELS, PALETTE)),
262+
)
263+
})
264+
})
265+
183266
// PAINTER
184267

185268
type DrawCall = ReadonlyArray<unknown>

‎src/sprite.ts‎

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import { Canvas } from 'foldkit'
33

44
import type { PickupKind, TrapKind, WeaponKind, ZombieKind } from './model'
55
import { type Fill, parseColor, pngDataUrl } from './png'
6+
import {
7+
type AtlasHit,
8+
atlasLookup,
9+
spriteKeyOf,
10+
spriteMode,
11+
standInKeyOf,
12+
standInSource,
13+
} from './spriteAtlas'
614
import type { TrapStage } from './traps'
715

816
export type Palette = Readonly<Record<string, string>>
@@ -213,26 +221,114 @@ const imageAt = (
213221
return Canvas.Image({ src, x, y, width, height, rendering: 'Pixelated' })
214222
}
215223

216-
// NOTE: a sprite is one image of its pixel map, drawn with a single
217-
// drawImage, instead of a rectangle for each run of same colored pixels. A
218-
// palette color that is not a hex or rgb() color keeps the rectangles.
219-
export const spriteShapes = (
224+
// NOTE: a prebuilt sprite is a cropped region of one of the atlas's files.
225+
const atlasImageAt = (hit: AtlasHit, x: number, y: number): Canvas.Image => {
226+
builtSpriteSources.add(hit.src)
227+
return Canvas.Image({
228+
src: hit.src,
229+
x,
230+
y,
231+
width: hit.width,
232+
height: hit.height,
233+
crop: { x: hit.x, y: hit.y, width: hit.width, height: hit.height },
234+
rendering: 'Pixelated',
235+
})
236+
}
237+
238+
// CONTENT
239+
240+
export type SpriteContent = Readonly<{
241+
width: number
242+
height: number
243+
fills: ReadonlyArray<Fill>
244+
}>
245+
246+
const contentsByKey = new Map<string, SpriteContent>()
247+
248+
// NOTE: every sprite built in stand-in mode, by key, with its pixels as the
249+
// fills to paint. The atlas generator packs these, and tests read them.
250+
export const spriteContents = (): ReadonlyMap<string, SpriteContent> =>
251+
contentsByKey
252+
253+
export const spriteContentOf = (src: string): Option.Option<SpriteContent> =>
254+
Option.flatMap(standInKeyOf(src), key =>
255+
Option.fromNullishOr(contentsByKey.get(key)),
256+
)
257+
258+
// NOTE: the image for a sprite the atlas does not hold: a stand-in named by
259+
// its key in stand-in mode, else a PNG data URL encoded here. None when a
260+
// palette color cannot be read, so the caller keeps rectangles.
261+
const builtSource = (
262+
key: string,
263+
width: number,
264+
height: number,
265+
runs: ReadonlyArray<PixelRun>,
266+
): Option.Option<string> => {
267+
if (spriteMode() === 'Encode') {
268+
return runsDataUrl(width, height, runs)
269+
}
270+
return Option.map(fillsOf(runs), fills => {
271+
contentsByKey.set(key, { width, height, fills })
272+
return standInSource(key)
273+
})
274+
}
275+
276+
// NOTE: a pixel map's key: its size, its rows, and its palette in symbol
277+
// order, so the same map and colors always find the same prebuilt image.
278+
const pixelsKeyOf = (
279+
width: number,
220280
pixels: ReadonlyArray<string>,
221281
palette: Palette,
222-
anchor: Anchor = centerAnchor(pixels),
282+
): string => {
283+
let text = `p${width}x${pixels.length}`
284+
for (const row of pixels) {
285+
text += `\n${row}`
286+
}
287+
for (const symbol of Object.keys(palette).sort()) {
288+
text += `\n${symbol}=${palette[symbol] ?? ''}`
289+
}
290+
return spriteKeyOf(text)
291+
}
292+
293+
const builtSpriteShapes = (
294+
key: string,
295+
pixels: ReadonlyArray<string>,
296+
palette: Palette,
297+
anchor: Anchor,
298+
width: number,
223299
): ReadonlyArray<Canvas.Shape> => {
224300
const runs = rowRunsOf(pixels, palette)
225-
const width = spriteWidth(pixels)
226301
if (Array.isReadonlyArrayEmpty(runs)) {
227302
return []
228303
}
229304

230-
return Option.match(runsDataUrl(width, pixels.length, runs), {
305+
return Option.match(builtSource(key, width, pixels.length, runs), {
231306
onNone: () => spriteRects(pixels, palette, anchor),
232307
onSome: src => [imageAt(src, -anchor.x, -anchor.y, width, pixels.length)],
233308
})
234309
}
235310

311+
// NOTE: a sprite is one image of its pixel map, drawn with a single
312+
// drawImage, instead of a rectangle for each run of same colored pixels. A
313+
// prebuilt one is found by its key with no pixel work at all. A palette color
314+
// that is not a hex or rgb() color keeps the rectangles.
315+
export const spriteShapes = (
316+
pixels: ReadonlyArray<string>,
317+
palette: Palette,
318+
anchor: Anchor = centerAnchor(pixels),
319+
): ReadonlyArray<Canvas.Shape> => {
320+
const width = spriteWidth(pixels)
321+
const key = pixelsKeyOf(width, pixels, palette)
322+
return Option.match(atlasLookup(key), {
323+
onNone: () => builtSpriteShapes(key, pixels, palette, anchor, width),
324+
onSome: maybeHit =>
325+
Option.match(maybeHit, {
326+
onNone: () => [],
327+
onSome: hit => [atlasImageAt(hit, -anchor.x, -anchor.y)],
328+
}),
329+
})
330+
}
331+
236332
type Box = Readonly<{
237333
left: number
238334
top: number
@@ -325,9 +421,18 @@ export const bakeShapes = (
325421
height: run.height,
326422
color: run.color,
327423
}))
328-
return Option.match(runsDataUrl(width, height, shifted), {
329-
onNone: () => shapes,
330-
onSome: src => [imageAt(src, box.left, box.top, width, height)],
424+
const key = spriteKeyOf(`r${contentKeyOf(width, height, shifted)}`)
425+
return Option.match(atlasLookup(key), {
426+
onNone: () =>
427+
Option.match(builtSource(key, width, height, shifted), {
428+
onNone: () => shapes,
429+
onSome: src => [imageAt(src, box.left, box.top, width, height)],
430+
}),
431+
onSome: maybeHit =>
432+
Option.match(maybeHit, {
433+
onNone: () => [],
434+
onSome: hit => [atlasImageAt(hit, box.left, box.top)],
435+
}),
331436
})
332437
}
333438

0 commit comments

Comments
 (0)