Something went wrong. Try again.
A WebGPU retro game engine for TypeScript, inspired by RetroBlit. Build pixel-perfect 2D games with a clean, fantasy-console-style API.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BitmapFont } from '../assets/BitmapFont';import { Palette } from '../assets/Palette';import { SpriteSheet } from '../assets/SpriteSheet';import { Color32 } from '../utils/Color32';import { Rect2i } from '../utils/Rect2i';import { Vector2i } from '../utils/Vector2i';import type { Effect } from './effects/Effect';import { SoftwareRenderer } from './SoftwareRenderer';
// #region Types and helpers
type MockContext = { imageSmoothingEnabled: boolean; createImageData: (w: number, h: number) => ImageData; putImageData: ReturnType<typeof vi.fn>; clearRect: ReturnType<typeof vi.fn>; drawImage: ReturnType<typeof vi.fn>; lastImageData: ImageData | null;};
function makeMockContext(): MockContext { const instance: MockContext = { imageSmoothingEnabled: false, createImageData: (w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h, }) as ImageData, putImageData: vi.fn((imageData: ImageData) => { instance.lastImageData = imageData; }), clearRect: vi.fn(), drawImage: vi.fn(), lastImageData: null, }; return instance;}
const context = makeMockContext();const logicalContext = makeMockContext();
class MockOffscreenCanvas { constructor( public width: number, public height: number, ) {} getContext(contextId?: string): MockContext | null { return contextId === '2d' ? logicalContext : null; }}
function canvasGet2d(ctx: MockContext): (type?: string) => MockContext | null { return (type?: string) => (type === '2d' ? ctx : null);}
function getPixel(imageData: ImageData, width: number, x: number, y: number): [number, number, number, number] { const index = (y * width + x) * 4; /* eslint-disable security/detect-object-injection */ return [ imageData.data[index] ?? 0, imageData.data[index + 1] ?? 0, imageData.data[index + 2] ?? 0, imageData.data[index + 3] ?? 0, ]; /* eslint-enable security/detect-object-injection */}
function makePalette(): Palette { const palette = new Palette(16); palette.set(1, new Color32(255, 0, 0, 255)); palette.set(2, new Color32(0, 0, 255, 255)); palette.set(3, new Color32(0, 255, 0, 255)); return palette;}
function hashPixels(imageData: ImageData): number { let hash = 2166136261 >>> 0; for (const value of imageData.data) { hash ^= value; hash = Math.imul(hash, 16777619) >>> 0; } return hash >>> 0;}
// #endregion
describe('SoftwareRenderer', () => { // #region Setup
beforeEach(() => { vi.clearAllMocks(); context.lastImageData = null; logicalContext.lastImageData = null; vi.stubGlobal( 'ImageData', class MockImageData { constructor( public width: number, public height: number, public data: Uint8ClampedArray = new Uint8ClampedArray(width * height * 4), ) {} }, ); vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas); });
afterEach(() => { vi.unstubAllGlobals(); });
// #endregion
// #region Basic lifecycle
it('requires palette before beginFrame', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init();
expect(() => renderer.beginFrame()).toThrow('No palette set yet. Call BT.paletteSet'); });
// #endregion
// #region Rendering behaviors
it('renders primitives with camera offset applied', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
renderer.beginFrame(); renderer.setCameraOffset(new Vector2i(1, 1)); renderer.drawPixel(new Vector2i(2, 2), 1); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 1, 1)).toEqual([255, 0, 0, 255]); });
it('renders indexed sprites with transparent index and palette offsets', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init();
const palette = makePalette(); palette.set(4, new Color32(255, 255, 0, 255)); renderer.setPalette(palette); renderer.setClearColor(4);
const sheet = SpriteSheet.fromIndexedPixels(2, 1, new Uint8Array([1, 0])); renderer.beginFrame(); renderer.drawSprite(sheet, new Rect2i(0, 0, 2, 1), new Vector2i(0, 0), 1); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([0, 0, 255, 255]); expect(getPixel(frame as ImageData, 4, 1, 0)).toEqual([255, 255, 0, 255]); });
it('skips non-integer sprite source rectangles in software rendering', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const sheet = SpriteSheet.fromIndexedPixels(2, 2, new Uint8Array([1, 2, 3, 4])); const srcRect = new Rect2i(0, 0, 2, 2); srcRect.x = 0.5;
renderer.beginFrame(); renderer.drawSprite(sheet, srcRect, new Vector2i(0, 0), 0); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([0, 0, 0, 0]); });
it('skips sprite source rectangles fully outside the sheet', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const sheet = SpriteSheet.fromIndexedPixels(2, 2, new Uint8Array([1, 2, 3, 4])); renderer.beginFrame(); renderer.drawSprite(sheet, new Rect2i(10, 10, 2, 2), new Vector2i(0, 0), 0); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([0, 0, 0, 0]); });
it('skips invalid sprite source rectangles in software rendering', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const sheet = SpriteSheet.fromIndexedPixels(2, 2, new Uint8Array([1, 2, 3, 4])); renderer.beginFrame(); renderer.drawSprite(sheet, new Rect2i(0, 0, 2, 2), new Vector2i(0, 0), 0); renderer.drawSprite(sheet, new Rect2i(2, 0, -1, 2), new Vector2i(2, 0), 0); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([255, 0, 0, 255]); expect(getPixel(frame as ImageData, 4, 2, 0)).toEqual([0, 0, 0, 0]); });
it('clips partial sprite source rectangles while preserving destination alignment', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const sheet = SpriteSheet.fromIndexedPixels(2, 2, new Uint8Array([1, 2, 3, 0])); renderer.beginFrame(); renderer.drawSprite(sheet, new Rect2i(-1, 0, 2, 2), new Vector2i(0, 0), 0); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([0, 0, 0, 0]); expect(getPixel(frame as ImageData, 4, 1, 0)).toEqual([255, 0, 0, 255]); expect(getPixel(frame as ImageData, 4, 1, 1)).toEqual([0, 255, 0, 255]); });
it('renders bitmap text through sprite-backed glyphs', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const sheet = SpriteSheet.fromIndexedPixels(1, 1, new Uint8Array([1])); const glyphs = new Map([ [ 'A', { rect: new Rect2i(0, 0, 1, 1), offsetX: 0, offsetY: 0, advance: 1, }, ], ]); const font = BitmapFont.createFromGlyphs(sheet, glyphs, 'test', 8, 1, 1);
renderer.beginFrame(); renderer.drawBitmapText(font, new Vector2i(0, 0), 'A', 0); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([255, 0, 0, 255]); });
it('throws clear unsupported errors for fullscreen effects', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init();
const effect: Effect = { tier: 'pixel', init: vi.fn(), updateUniforms: vi.fn(), encodePass: vi.fn() };
expect(() => renderer.addEffect(effect)).toThrow("doesn't support fullscreen effects"); expect(() => renderer.removeEffect(effect)).toThrow("doesn't support fullscreen effects"); expect(() => renderer.clearEffects()).toThrow("doesn't support fullscreen effects"); });
// #endregion
// #region Capture behavior
it('resolves captureFrame on next endFrame', async () => { const toBlob = vi.fn((callback: (blob: Blob | null) => void) => callback(new Blob(['png'], { type: 'image/png' })), ); const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const capture = renderer.captureFrame(); renderer.beginFrame(); renderer.endFrame();
const blob = await capture; expect(blob.type).toBe('image/png'); expect(toBlob).toHaveBeenCalledOnce(); });
it('replaces an older pending capture request with a clear error', async () => { const toBlob = vi.fn((callback: (blob: Blob | null) => void) => callback(new Blob(['png'], { type: 'image/png' })), ); const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const firstCapture = renderer.captureFrame(); const secondCapture = renderer.captureFrame();
renderer.beginFrame(); renderer.endFrame();
await expect(firstCapture).rejects.toThrow('A capture is already in progress'); await expect(secondCapture).resolves.toBeInstanceOf(Blob); });
it('rejects captureFrame when canvas.toBlob is unavailable', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: undefined, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const capture = renderer.captureFrame(); renderer.beginFrame(); renderer.endFrame();
await expect(capture).rejects.toThrow("doesn't support canvas image export"); });
it('rejects captureFrame when canvas.toBlob returns no image data', async () => { const toBlob = vi.fn((callback: (blob: Blob | null) => void) => callback(null)); const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); await renderer.init(); renderer.setPalette(makePalette());
const capture = renderer.captureFrame(); renderer.beginFrame(); renderer.endFrame();
await expect(capture).rejects.toThrow('something went wrong exporting the canvas image'); });
// #endregion
// #region Determinism
it('produces deterministic output for the same command sequence', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(8, 8)); await renderer.init(); renderer.setPalette(makePalette());
const runSequence = (): number => { renderer.beginFrame(); renderer.setClearColor(3); renderer.drawRectFill(new Rect2i(1, 1, 3, 3), 1); renderer.drawLine(new Vector2i(0, 7), new Vector2i(7, 0), 2); renderer.setCameraOffset(new Vector2i(1, 0)); renderer.drawRect(new Rect2i(2, 2, 4, 4), 1); renderer.resetCamera(); renderer.endFrame();
const frame = logicalContext.lastImageData; expect(frame).not.toBeNull(); return hashPixels(frame as ImageData); };
const first = runSequence(); const second = runSequence();
expect(second).toBe(first); });
// #endregion});