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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511/** * Unit tests for {@link PostProcessChain}. * * Covers the lazy GPU resource model: * - constructor does not touch the GPU * - {@link PostProcessChain.isActive} reflects effect-list contents * - first {@link PostProcessChain.add} allocates `texA` and a sampler; second * allocates `texB` * - tier mismatch on {@link PostProcessChain.add} throws a clear error * - {@link PostProcessChain.remove} of the last effect and * {@link PostProcessChain.clear} both destroy the offscreen textures and * revert to the no-effect path * - {@link PostProcessChain.getInputView} guard rails before activation * - {@link PostProcessChain.encode} orchestrates effects in registration order * * Effects are stubbed via a minimal `Effect` implementation that records * lifecycle calls; the chain orchestration is tested without involving the * real WGSL shaders. */
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createMockGPUDevice, installMockNavigatorGPU, uninstallMockNavigatorGPU } from '../__test__/webgpu-mock';import { Vector2i } from '../utils/Vector2i';import type { Effect, EffectTier } from './effects/Effect';import { PostProcessChain } from './PostProcessChain';
// #region Test Helpers
const FORMAT: GPUTextureFormat = 'bgra8unorm';const DISPLAY_SIZE = new Vector2i(320, 240);
interface CallLog { init: number; update: number; encode: number; dispose: number;}
/** Creates a stub effect that records lifecycle calls. */function createStubEffect(tier: EffectTier = 'pixel'): Effect & { calls: CallLog } { const calls: CallLog = { init: 0, update: 0, encode: 0, dispose: 0 };
const stub: Effect & { calls: CallLog } = { calls, tier, init: () => { calls.init++; }, updateUniforms: () => { calls.update++; }, encodePass: () => { calls.encode++; }, dispose: () => { calls.dispose++; }, };
return stub;}
// #endregion
// #region Mock setup
beforeAll(() => { installMockNavigatorGPU();});
afterAll(() => { uninstallMockNavigatorGPU();});
// #endregion
// #region Constructor
describe('PostProcessChain constructor', () => { it('does not allocate GPU textures', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture');
const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel');
expect(chain).toBeInstanceOf(PostProcessChain); expect(createTexture).not.toHaveBeenCalled(); });
it('reports isActive() === false before any effect is added', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel');
expect(chain.isActive()).toBe(false); });
it('exposes its tier via the read-only getter', () => { const pixel = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const display = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'display');
expect(pixel.tier).toBe('pixel'); expect(display.tier).toBe('display'); });});
// #endregion
// #region Add / Remove / Clear
describe('add()', () => { it('initializes the effect with the chain device, format, and chain size', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const effect = createStubEffect('pixel'); const initSpy = vi.spyOn(effect, 'init');
chain.add(effect);
expect(initSpy).toHaveBeenCalledTimes(1); expect(initSpy).toHaveBeenCalledWith(device, FORMAT, DISPLAY_SIZE); });
it('flips isActive() to true on first add', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel');
chain.add(createStubEffect('pixel'));
expect(chain.isActive()).toBe(true); });
it('lazily allocates texA on the first add', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel');
chain.add(createStubEffect('pixel'));
expect(createTexture).toHaveBeenCalledTimes(1); });
it('lazily allocates texB only on the second add', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel');
chain.add(createStubEffect('pixel')); expect(createTexture).toHaveBeenCalledTimes(1);
chain.add(createStubEffect('pixel')); expect(createTexture).toHaveBeenCalledTimes(2); });
it('does not allocate additional textures for a third or fourth effect', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel');
chain.add(createStubEffect('pixel')); chain.add(createStubEffect('pixel')); chain.add(createStubEffect('pixel')); chain.add(createStubEffect('pixel'));
expect(createTexture).toHaveBeenCalledTimes(2); });
it('throws when the same effect instance is added twice', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const effect = createStubEffect('pixel');
chain.add(effect);
expect(() => chain.add(effect)).toThrow(/already registered/); });
it('throws when the effect tier does not match the chain tier', () => { const pixelChain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const displayEffect = createStubEffect('display');
expect(() => pixelChain.add(displayEffect)).toThrow(/does not match chain tier/); expect(pixelChain.isActive()).toBe(false); });
it('reuses the existing texB when re-adding a second effect after a 2 -> 1 remove', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel'); const c = createStubEffect('pixel');
chain.add(a); chain.add(b); expect(createTexture).toHaveBeenCalledTimes(2);
chain.remove(b); // texB stays allocated until the chain is fully cleared. chain.add(c);
// No new texture allocations - existing texA + texB are reused. expect(createTexture).toHaveBeenCalledTimes(2); });
it('calls effect.init() exactly once per successful add', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const effect = createStubEffect('pixel');
chain.add(effect);
expect(effect.calls.init).toBe(1); });});
describe('remove()', () => { it('disposes the effect and removes it from the active set', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel');
chain.add(a); chain.add(b); const removed = chain.remove(a);
expect(removed).toBe(true); expect(a.calls.dispose).toBe(1); expect(b.calls.dispose).toBe(0); expect(chain.isActive()).toBe(true); });
it('returns false and leaves the chain unchanged when the effect was never added', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel');
chain.add(a); chain.add(b); const removed = chain.remove(createStubEffect('pixel')); // not in chain
expect(removed).toBe(false); expect(chain.isActive()).toBe(true); expect(a.calls.dispose).toBe(0); expect(b.calls.dispose).toBe(0); });
it('flips isActive() back to false and destroys textures when the last effect is removed', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const effect = createStubEffect('pixel');
chain.add(effect); chain.add(createStubEffect('pixel'));
chain.remove(effect); expect(chain.isActive()).toBe(true);
const textures = createTexture.mock.results.map((result) => result.value as GPUTexture); const destroySpies = textures.map((t) => vi.spyOn(t, 'destroy'));
chain.clear();
expect(chain.isActive()).toBe(false); for (const spy of destroySpies) { expect(spy).toHaveBeenCalled(); } });});
describe('clear()', () => { it('disposes every registered effect', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel'); const c = createStubEffect('pixel');
chain.add(a); chain.add(b); chain.add(c); chain.clear();
expect(a.calls.dispose).toBe(1); expect(b.calls.dispose).toBe(1); expect(c.calls.dispose).toBe(1); expect(chain.isActive()).toBe(false); });
it('is a no-op when there are no effects', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel');
expect(() => { chain.clear(); }).not.toThrow(); expect(createTexture).not.toHaveBeenCalled(); expect(chain.isActive()).toBe(false); });
it('re-allocates textures on a subsequent add() after clear()', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel');
chain.add(createStubEffect('pixel')); chain.clear(); chain.add(createStubEffect('pixel'));
// First add allocated 1, second add (post-clear) allocated 1 more. expect(createTexture).toHaveBeenCalledTimes(2); });});
// #endregion
// #region Input view
describe('getInputView()', () => { it('throws before any effect has been added', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel');
expect(() => chain.getInputView()).toThrow(/no active effects/i); });
it('returns a stable view across consecutive calls', () => { const chain = new PostProcessChain(createMockGPUDevice(), FORMAT, DISPLAY_SIZE, 'pixel');
chain.add(createStubEffect('pixel'));
const a = chain.getInputView(); const b = chain.getInputView();
expect(a).toBe(b); });});
// #endregion
// #region Encode
describe('encode()', () => { function makeDestView(): GPUTextureView { return { label: 'DestView' } as unknown as GPUTextureView; }
it('is a no-op when there are no effects', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const encoder = device.createCommandEncoder(); const beginSpy = vi.spyOn(encoder, 'beginRenderPass');
chain.encode(encoder, 16, makeDestView());
expect(beginSpy).not.toHaveBeenCalled(); });
it('runs each effect updateUniforms with the chain size', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel');
chain.add(a); chain.add(b); chain.encode(device.createCommandEncoder(), 16, makeDestView());
expect(a.calls.update).toBe(1); expect(b.calls.update).toBe(1); });
it('single-effect: source = inputView, dest = destinationView', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const effect = createStubEffect('pixel'); const encodeSpy = vi.spyOn(effect, 'encodePass');
chain.add(effect); const inputView = chain.getInputView(); const destView = makeDestView();
chain.encode(device.createCommandEncoder(), 16, destView);
expect(encodeSpy).toHaveBeenCalledTimes(1); const args = encodeSpy.mock.calls[0]; expect(args?.[1]).toBe(inputView); expect(args?.[2]).toBe(destView); });
it('two effects: input -> texB, texB -> destination', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel'); const aSpy = vi.spyOn(a, 'encodePass'); const bSpy = vi.spyOn(b, 'encodePass');
chain.add(a); chain.add(b);
const inputView = chain.getInputView(); const destView = makeDestView();
chain.encode(device.createCommandEncoder(), 16, destView);
// First effect reads input view and writes to texB; second effect // reads texB and writes to the destination. const firstArgs = aSpy.mock.calls[0]; const secondArgs = bSpy.mock.calls[0];
expect(firstArgs?.[1]).toBe(inputView); const intermediateView = firstArgs?.[2]; expect(intermediateView).not.toBe(inputView); expect(intermediateView).not.toBe(destView);
expect(secondArgs?.[1]).toBe(intermediateView); expect(secondArgs?.[2]).toBe(destView); });
it('three effects ping-pong correctly: A -> B, B -> A, A -> destination', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const a = createStubEffect('pixel'); const b = createStubEffect('pixel'); const c = createStubEffect('pixel'); const aSpy = vi.spyOn(a, 'encodePass'); const bSpy = vi.spyOn(b, 'encodePass'); const cSpy = vi.spyOn(c, 'encodePass');
chain.add(a); chain.add(b); chain.add(c);
const inputView = chain.getInputView(); const destView = makeDestView();
chain.encode(device.createCommandEncoder(), 16, destView);
const aArgs = aSpy.mock.calls[0]; const bArgs = bSpy.mock.calls[0]; const cArgs = cSpy.mock.calls[0];
// Pass 1: input (texA) -> texB. const texBView = aArgs?.[2]; expect(aArgs?.[1]).toBe(inputView); expect(texBView).not.toBe(inputView); expect(texBView).not.toBe(destView);
// Pass 2: texB -> texA (ping-pong returns to the input texture). expect(bArgs?.[1]).toBe(texBView); expect(bArgs?.[2]).toBe(inputView);
// Pass 3 (last): texA -> destination. expect(cArgs?.[1]).toBe(inputView); expect(cArgs?.[2]).toBe(destView); });
it('preserves effect registration order across encode passes', () => { const device = createMockGPUDevice(); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const order: string[] = [];
function trackingEffect(name: string): Effect { return { tier: 'pixel', init: () => {}, updateUniforms: () => { order.push(`${name}.update`); }, encodePass: () => { order.push(`${name}.encode`); }, }; }
chain.add(trackingEffect('a')); chain.add(trackingEffect('b')); chain.add(trackingEffect('c'));
chain.encode(device.createCommandEncoder(), 16, makeDestView());
expect(order).toEqual(['a.update', 'a.encode', 'b.update', 'b.encode', 'c.update', 'c.encode']); });});
// #endregion
// #region Dispose
describe('dispose()', () => { it('destroys every effect and offscreen texture', () => { const device = createMockGPUDevice(); const createTexture = vi.spyOn(device, 'createTexture'); const chain = new PostProcessChain(device, FORMAT, DISPLAY_SIZE, 'pixel'); const effect = createStubEffect('pixel');
chain.add(effect); chain.add(createStubEffect('pixel'));
const textures = createTexture.mock.results.map((r) => r.value as GPUTexture); const destroySpies = textures.map((t) => vi.spyOn(t, 'destroy'));
chain.dispose();
expect(effect.calls.dispose).toBe(1); for (const spy of destroySpies) { expect(spy).toHaveBeenCalled(); } expect(chain.isActive()).toBe(false); });});
// #endregion