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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440/** * Unit tests for {@link PrimitivePipeline}. * * Covers the CPU-side primitive batching path: * - construction and pre-initialization safety of public drawing methods * - encode/reset behavior before and after buffered draws * - camera-offset handling across frame-style reset/encode cycles * - vertex-count expectations for filled rects, pixels, lines, outlines, text, * and clear operations * * GPU-facing behavior is exercised with the local WebGPU mock device and render * pass encoder helpers so the tests can validate command emission deterministically. */
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createMockGPUDevice, createMockPaletteBuffer, createMockRenderPassEncoder, installMockNavigatorGPU, uninstallMockNavigatorGPU,} from '../__test__/webgpu-mock';import { Rect2i } from '../utils/Rect2i';import { Vector2i } from '../utils/Vector2i';import { PrimitivePipeline } from './PrimitivePipeline';
// #region Constructor
describe('PrimitivePipeline constructor', () => { it('creates an instance without error', () => { const pipeline = new PrimitivePipeline();
expect(pipeline).toBeDefined(); expect(pipeline).toBeInstanceOf(PrimitivePipeline); });});
// #endregion
// #region Pre-initialization Safety
describe('pre-initialization safety', () => { it('reset() can be called multiple times safely', () => { const pipeline = new PrimitivePipeline();
expect(() => { pipeline.reset(); pipeline.reset(); pipeline.reset(); }).not.toThrow(); });
it('setCameraOffset() accepts a Vector2i', () => { const pipeline = new PrimitivePipeline();
expect(() => { pipeline.setCameraOffset(new Vector2i(10, 20)); }).not.toThrow(); });
it('setCameraOffset() accepts zero vector', () => { const pipeline = new PrimitivePipeline();
expect(() => { pipeline.setCameraOffset(Vector2i.zero()); }).not.toThrow(); });
it('drawRectFill() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(0, 0, 10, 10);
expect(() => { pipeline.drawRectFill(rect, 1); }).not.toThrow(); });
it('drawPixel() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const pos = new Vector2i(5, 5);
expect(() => { pipeline.drawPixel(pos, 2); }).not.toThrow(); });
it('drawPixelXY() does not throw before init', () => { const pipeline = new PrimitivePipeline();
expect(() => { pipeline.drawPixelXY(3, 7, 3); }).not.toThrow(); });
it('drawLine() with horizontal line does not throw before init', () => { const pipeline = new PrimitivePipeline(); const p0 = new Vector2i(0, 10); const p1 = new Vector2i(100, 10);
expect(() => { pipeline.drawLine(p0, p1, 4); }).not.toThrow(); });
it('drawLine() with vertical line does not throw before init', () => { const pipeline = new PrimitivePipeline(); const p0 = new Vector2i(10, 0); const p1 = new Vector2i(10, 100);
expect(() => { pipeline.drawLine(p0, p1, 5); }).not.toThrow(); });
it('drawLine() with diagonal line does not throw before init', () => { const pipeline = new PrimitivePipeline(); const p0 = new Vector2i(0, 0); const p1 = new Vector2i(50, 30);
expect(() => { pipeline.drawLine(p0, p1, 6); }).not.toThrow(); });
it('drawRect() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(5, 5, 20, 20);
expect(() => { pipeline.drawRect(rect, 7); }).not.toThrow(); });
it('clearRect() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(0, 0, 100, 100);
expect(() => { pipeline.clearRect(rect, 1); }).not.toThrow(); });});
// #endregion
// #region Initialized Pipeline
describe('with initialized pipeline', () => { const device = createMockGPUDevice(); const pipeline = new PrimitivePipeline();
beforeAll(async () => { installMockNavigatorGPU();
const paletteBuffer = createMockPaletteBuffer();
await pipeline.init(device, new Vector2i(320, 240), paletteBuffer, 'r8uint'); });
afterAll(() => { uninstallMockNavigatorGPU(); });
it('encodePass with an empty buffer is a no-op', () => { pipeline.reset();
const renderPass = createMockRenderPassEncoder();
expect(() => { pipeline.encodePass(renderPass); }).not.toThrow(); });
it('encodePass after drawing primitives completes without error', () => { pipeline.reset();
const rect = new Rect2i(0, 0, 10, 10);
pipeline.drawRectFill(rect, 1); pipeline.drawPixel(new Vector2i(5, 5), 2); pipeline.drawLine(new Vector2i(0, 0), new Vector2i(20, 0), 3);
const renderPass = createMockRenderPassEncoder();
expect(() => { pipeline.encodePass(renderPass); }).not.toThrow(); });
it('reset followed by encodePass produces no draw calls', () => { pipeline.reset();
pipeline.drawRectFill(new Rect2i(0, 0, 5, 5), 1);
pipeline.reset();
let drawCalled = false; const renderPass = { ...createMockRenderPassEncoder(), draw: () => { drawCalled = true; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
expect(drawCalled).toBe(false); });
it('encodePass calls draw on render pass when vertices exist', () => { pipeline.reset();
pipeline.drawRectFill(new Rect2i(10, 10, 20, 20), 1);
let drawCalled = false;
const renderPass = { ...createMockRenderPassEncoder(), draw: () => { drawCalled = true; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
expect(drawCalled).toBe(true); });
it('multiple begin/end frame cycles work without error', () => { expect(() => { for (let i = 0; i < 5; i++) { pipeline.reset();
pipeline.drawPixel(new Vector2i(i, i), 8); pipeline.encodePass(createMockRenderPassEncoder());
pipeline.reset(); } }).not.toThrow(); });
it('setCameraOffset affects subsequent draws without error', () => { pipeline.reset();
pipeline.setCameraOffset(new Vector2i(100, 50));
expect(() => { pipeline.drawRectFill(new Rect2i(0, 0, 10, 10), 1); pipeline.encodePass(createMockRenderPassEncoder()); }).not.toThrow();
pipeline.setCameraOffset(Vector2i.zero()); });});
// #endregion
// #region Vertex Count Verification
describe('vertex count verification', () => { const device = createMockGPUDevice(); const pipeline = new PrimitivePipeline();
beforeAll(async () => { installMockNavigatorGPU();
const paletteBuffer = createMockPaletteBuffer();
await pipeline.init(device, new Vector2i(320, 240), paletteBuffer, 'r8uint'); });
afterAll(() => { uninstallMockNavigatorGPU(); });
it('drawRectFill produces six vertices (two triangles)', () => { pipeline.reset();
pipeline.drawRectFill(new Rect2i(0, 0, 10, 10), 1);
let totalVertices = 0; const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
expect(totalVertices).toBe(6); });
it('drawPixel produces six vertices (1x1 filled rect)', () => { pipeline.reset();
pipeline.drawPixel(new Vector2i(5, 5), 2);
let totalVertices = 0;
const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
expect(totalVertices).toBe(6); });
it('horizontal line uses a single quad (six vertices)', () => { pipeline.reset();
pipeline.drawLine(new Vector2i(0, 10), new Vector2i(100, 10), 4);
let totalVertices = 0; const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass); expect(totalVertices).toBe(6); });
it('vertical line uses a single quad (six vertices)', () => { pipeline.reset();
pipeline.drawLine(new Vector2i(10, 0), new Vector2i(10, 50), 4);
let totalVertices = 0;
const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
expect(totalVertices).toBe(6); });
it('diagonal line uses six vertices per a pixel (Bresenham)', () => { pipeline.reset();
// A 45-degree diagonal from (0,0) to (4,4) = 5 pixels pipeline.drawLine(new Vector2i(0, 0), new Vector2i(4, 4), 8);
let totalVertices = 0;
const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
expect(totalVertices).toBe(5 * 6); // 5 pixels * 6 vertices each });
it('drawRect with height <= two skips vertical side lines', () => { pipeline.reset();
// height=2 means y1-y0 = 1, which is NOT > 1, so no vertical sides pipeline.drawRect(new Rect2i(0, 0, 10, 2), 1);
let totalVertices = 0;
const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
// Only top + bottom lines = 2 quads = 12 vertices (no left/right) expect(totalVertices).toBe(12); });
it('drawRect with height > two includes all four sides', () => { pipeline.reset();
pipeline.drawRect(new Rect2i(0, 0, 10, 10), 1);
let totalVertices = 0;
const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { totalVertices += vertexCount; }, } as unknown as GPURenderPassEncoder;
pipeline.encodePass(renderPass);
// 4 sides = 4 quads = 24 vertices expect(totalVertices).toBe(24); });
it('buffer overflow triggers console.warn and does not crash', () => { pipeline.reset();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { // Fill buffer: 50,000 max vertices, each drawRectFill uses 6 vertices // ~8333 rects fill the buffer; drawing more should trigger a warning for (let i = 0; i < 8400; i++) { pipeline.drawRectFill(new Rect2i(0, 0, 1, 1), 1); }
expect(warnSpy).toHaveBeenCalled();
const msg = warnSpy.mock.calls.find((c) => String(c[0]).includes('capacity exceeded'));
expect(msg).toBeDefined();
// encodePass should still work without crashing expect(() => { pipeline.encodePass(createMockRenderPassEncoder()); }).not.toThrow(); } finally { warnSpy.mockRestore(); } });});
// #endregion