From 834ac5f968e367421b57a001658dff38043bd0b8 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Thu, 7 May 2026 18:36:56 +0200 Subject: [PATCH 1/5] refactor(api): rename initialize to init across engine lifecycle Renames IBlitTechDemo.initialize(), BT.initialize(), BTAPI.initialize(), Renderer.initialize(), PrimitivePipeline.initialize(), SpritePipeline.initialize(), and initializeWebGPU() to their shorter init equivalents. Updates all tests, visual fixtures, and docs. Signed-off-by: Vaclav Vancura --- CLAUDE.md | 2 +- README.md | 12 +- docs/input.md | 10 +- docs/post-process-effects.md | 2 +- src/BlitTech.test.ts | 18 +- src/BlitTech.ts | 6 +- src/core/BTAPI.test.ts | 54 ++-- src/core/BTAPI.ts | 206 ++++++++------- src/core/IBlitTechDemo.ts | 4 +- src/core/WebGPUContext.test.ts | 22 +- src/core/WebGPUContext.ts | 2 +- src/render/PrimitivePipeline.test.ts | 20 +- src/render/PrimitivePipeline.ts | 8 +- src/render/Renderer.test.ts | 50 ++-- src/render/Renderer.ts | 320 ++++++++++++------------ src/render/SpritePipeline.test.ts | 6 +- src/render/SpritePipeline.ts | 10 +- src/render/effects/FullscreenEffect.ts | 21 +- src/utils/Bootstrap.test.ts | 20 +- src/utils/Bootstrap.ts | 6 +- tests/visual/fixtures/camera.html | 4 +- tests/visual/fixtures/fonts.html | 4 +- tests/visual/fixtures/mixed.html | 4 +- tests/visual/fixtures/post-process.html | 2 +- tests/visual/fixtures/primitives.html | 4 +- tests/visual/fixtures/sprites.html | 6 +- 26 files changed, 408 insertions(+), 415 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 05085d5..ebb43fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ fantasy-console-style API. ## Architecture All engine functionality is accessed through the static `BT` namespace. Demos implement the `IBlitTechDemo` interface -(`queryHardware`, `initialize`, `update`, `render`). +(`queryHardware`, `init`, `update`, `render`). ```text src/ diff --git a/README.md b/README.md index c0a48ab..316aefe 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ class MyDemo implements IBlitTechDemo { * * @returns Promise resolving to true when initialization succeeds. */ - async initialize(): Promise { + async init(): Promise { // Define the palette — all rendering uses indices into this table. const palette = new Palette(16); palette.set(BG, new Color32(20, 30, 40, 255)); @@ -213,7 +213,7 @@ if (!checkWebGPUSupport()) { } else { const canvas = getCanvas('my-canvas-id'); if (canvas) { - await BT.initialize(new MyDemo(), canvas); + await BT.init(new MyDemo(), canvas); } } ``` @@ -303,15 +303,15 @@ getCanvas(canvasId?); // Get canvas element safely ### Initialization ```ts -BT.initialize(demo, canvas); // Start the engine (low-level) +BT.init(demo, canvas); // Start the engine (low-level) BT.displaySize(); // Get display resolution BT.fps(); // Get target FPS BT.ticks(); // Get current tick count BT.ticksReset(); // Reset tick counter ``` -A palette must be set via `BT.paletteSet()` before any draw calls are made. The recommended place is `initialize()` in -the demo, before loading any sprite sheets. +A palette must be set via `BT.paletteSet()` before any draw calls are made. The recommended place is `init()` in the +demo, before loading any sprite sheets. ### Palette @@ -395,7 +395,7 @@ return { targetFPS: 60, }; -// In initialize(): mix and match effects from both tiers. +// In init(): mix and match effects from both tiers. BT.effectAdd(new PixelGlitch()); // tier='pixel' on the effect routes automatically BT.effectAdd(new BarrelDistortion()); BT.effectAdd(new Scanlines()); diff --git a/docs/input.md b/docs/input.md index 421ebaa..47eabb5 100644 --- a/docs/input.md +++ b/docs/input.md @@ -90,7 +90,7 @@ The mapping follows the RetroBlit canonical order, not the DOM `PointerEvent.but ## Keyboard -Listeners attach to the canvas during `BT.initialize()`. For reliable delivery, ensure the canvas can receive focus (for +Listeners attach to the canvas during `BT.init()`. For reliable delivery, ensure the canvas can receive focus (for example `canvas.tabIndex = 0` and `canvas.focus()`). The library follows `KeyboardEvent.code` (physical-key semantics), not `event.key` layout text. @@ -212,7 +212,7 @@ scroll while the canvas has focus. ## Cursor Control ```ts -// In initialize(): hide the OS cursor and draw your own crosshair / sprite +// In init(): hide the OS cursor and draw your own crosshair / sprite BT.hideCursor(); // Restore at any time @@ -245,7 +245,7 @@ means: - `contextmenu` with `preventDefault()` — prevents the OS context menu on right-click so `BTN_POINTER_B` works. `detach()` reverses all three and removes all event listeners. This happens automatically when the engine stops or when -`demo.initialize()` throws. +`demo.init()` throws. ## Coordinate Conversion @@ -266,6 +266,6 @@ Coordinates are clamped to `[0, displaySize - 1]` on each axis. The conversion i - Default keyboard tables live in `defaultKeyboardMap.ts`; runtime remaps are stored in the `BT` facade and reset with `BT.inputMapReset()`. - The `POINTER_SLOT_COUNT` constant (value `4`) is exported for demos that iterate over slots. -- `PointerInput`, `KeyboardInput`, and `GamepadInput` are created and attached inside `BTAPI.initialize()`, so they are - ready before `demo.initialize()` runs. +- `PointerInput`, `KeyboardInput`, and `GamepadInput` are created and attached inside `BTAPI.init()`, so they are ready + before `demo.init()` runs. - `stop()` calls `detach()` on all three input subsystems and clears references to prevent listener leaks. diff --git a/docs/post-process-effects.md b/docs/post-process-effects.md index 056a75f..895ce40 100644 --- a/docs/post-process-effects.md +++ b/docs/post-process-effects.md @@ -31,7 +31,7 @@ class Demo { }; } - async initialize() { + async init() { // ... palette, sprites ... // Pixel tier: chunky glitch on the 320x240 framebuffer diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 94ae96e..06b0e7a 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -25,40 +25,40 @@ const mockHardwareSettings = (displaySize = new Vector2i(320, 240), targetFPS = // #endregion -// #region BT.initialize +// #region BT.init -describe('BT.initialize', () => { +describe('BT.init', () => { beforeEach(() => { vi.restoreAllMocks(); }); - it('delegates to BTAPI.instance.initialize and returns its result', async () => { - const spy = vi.spyOn(BTAPI.instance, 'initialize').mockResolvedValue(true); + it('delegates to BTAPI.instance.init and returns its result', async () => { + const spy = vi.spyOn(BTAPI.instance, 'init').mockResolvedValue(true); const demo = { queryHardware: vi.fn(), - initialize: vi.fn(), + init: vi.fn(), update: vi.fn(), render: vi.fn(), }; const canvas = {} as HTMLCanvasElement; - const result = await BT.initialize(demo, canvas); + const result = await BT.init(demo, canvas); expect(spy).toHaveBeenCalledWith(demo, canvas); expect(result).toBe(true); }); it('forwards a failure result from BTAPI', async () => { - vi.spyOn(BTAPI.instance, 'initialize').mockResolvedValue(false); + vi.spyOn(BTAPI.instance, 'init').mockResolvedValue(false); const demo = { queryHardware: vi.fn(), - initialize: vi.fn(), + init: vi.fn(), update: vi.fn(), render: vi.fn(), }; - const result = await BT.initialize(demo, {} as HTMLCanvasElement); + const result = await BT.init(demo, {} as HTMLCanvasElement); expect(result).toBe(false); }); diff --git a/src/BlitTech.ts b/src/BlitTech.ts index b483c54..f4489db 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -278,8 +278,8 @@ export const BT = { * @param canvas - Canvas used as the engine render target. * @returns `true` when initialization succeeds; otherwise `false`. */ - initialize: async (demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise => { - return await BTAPI.instance.initialize(demo, canvas); + init: async (demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise => { + return await BTAPI.instance.init(demo, canvas); }, // #endregion @@ -704,7 +704,7 @@ export const BT = { /** * Hides the native OS cursor while the pointer is over the canvas. * - * Call once from `initialize()` when the demo draws its own crosshair or + * Call once from `init()` when the demo draws its own crosshair or * cursor sprite in place of the system arrow. The cursor is restored * automatically when the engine shuts down. * diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 01f09d6..de8b364 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -44,7 +44,7 @@ function makeMockDemo(targetFPS = 60, initResult = true): IBlitTechDemo { canvasDisplaySize: new Vector2i(640, 480), targetFPS, }), - initialize: vi.fn().mockResolvedValue(initResult), + init: vi.fn().mockResolvedValue(initResult), update: vi.fn(), render: vi.fn(), }; @@ -286,23 +286,23 @@ describe('BTAPI', () => { // #endregion - // #region initialize() + // #region init() - describe('initialize', () => { + describe('init', () => { it('should return false for NaN targetFPS', async () => { - const result = await BTAPI.instance.initialize(makeMockDemo(NaN), makeMockCanvas()); + const result = await BTAPI.instance.init(makeMockDemo(NaN), makeMockCanvas()); expect(result).toBe(false); }); it('should return false for zero targetFPS', async () => { - const result = await BTAPI.instance.initialize(makeMockDemo(0), makeMockCanvas()); + const result = await BTAPI.instance.init(makeMockDemo(0), makeMockCanvas()); expect(result).toBe(false); }); it('should return false for negative targetFPS', async () => { - const result = await BTAPI.instance.initialize(makeMockDemo(-30), makeMockCanvas()); + const result = await BTAPI.instance.init(makeMockDemo(-30), makeMockCanvas()); expect(result).toBe(false); }); @@ -310,7 +310,7 @@ describe('BTAPI', () => { it('should return false when navigator.gpu is absent', async () => { uninstallMockNavigatorGPU(); - const result = await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(false); }); @@ -328,7 +328,7 @@ describe('BTAPI', () => { configurable: true, }); - const result = await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(false); }); @@ -353,20 +353,20 @@ describe('BTAPI', () => { configurable: true, }); - const result = await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(false); }); - it('should return false when demo.initialize() returns false', async () => { - const result = await BTAPI.instance.initialize(makeMockDemo(60, false), makeMockCanvas()); + it('should return false when demo.init() returns false', async () => { + const result = await BTAPI.instance.init(makeMockDemo(60, false), makeMockCanvas()); expect(result).toBe(false); }); it('should return true and populate accessors on success', async () => { const canvas = makeMockCanvas(); - const result = await BTAPI.instance.initialize(makeMockDemo(), canvas); + const result = await BTAPI.instance.init(makeMockDemo(), canvas); expect(result).toBe(true); expect(BTAPI.instance.getDevice()).not.toBeNull(); @@ -380,7 +380,7 @@ describe('BTAPI', () => { }); it('stop detaches pointer and keyboard input so subsequent accessors return null', async () => { - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(BTAPI.instance.getPointer()).not.toBeNull(); expect(BTAPI.instance.getKeyboard()).not.toBeNull(); @@ -393,10 +393,10 @@ describe('BTAPI', () => { expect(BTAPI.instance.getGamepad()).toBeNull(); }); - it('double initialize without stop detaches prior pointer and keyboard before reattaching', async () => { + it('double init without stop detaches prior pointer and keyboard before reattaching', async () => { const canvas = makeMockCanvas(); - await BTAPI.instance.initialize(makeMockDemo(), canvas); + await BTAPI.instance.init(makeMockDemo(), canvas); const pointerBefore = BTAPI.instance.getPointer(); const keyboardBefore = BTAPI.instance.getKeyboard(); @@ -406,7 +406,7 @@ describe('BTAPI', () => { vi.mocked(canvas.removeEventListener).mockClear(); - await BTAPI.instance.initialize(makeMockDemo(), canvas); + await BTAPI.instance.init(makeMockDemo(), canvas); expect(BTAPI.instance.getPointer()).not.toBe(pointerBefore); expect(BTAPI.instance.getKeyboard()).not.toBe(keyboardBefore); @@ -418,7 +418,7 @@ describe('BTAPI', () => { }); it('should start the game loop on success', async () => { - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(requestAnimationFrame).toHaveBeenCalled(); }); @@ -433,7 +433,7 @@ describe('BTAPI', () => { }), ); - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const gamepad = BTAPI.instance.getGamepad(); expect(gamepad).not.toBeNull(); @@ -467,13 +467,13 @@ describe('BTAPI', () => { }); it('stop should not throw after successful initialization', async () => { - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(() => BTAPI.instance.stop()).not.toThrow(); }); it('captureFrame returns a blob after successful initialization', async () => { - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const renderer = BTAPI.instance.getRenderer(); @@ -526,20 +526,20 @@ describe('BTAPI', () => { }; } - it('effectAdd throws before initialize', () => { + it('effectAdd throws before init', () => { expect(() => BTAPI.instance.effectAdd(makeStubEffect())).toThrow('renderer not initialized'); }); - it('effectRemove throws before initialize', () => { + it('effectRemove throws before init', () => { expect(() => BTAPI.instance.effectRemove(makeStubEffect())).toThrow('renderer not initialized'); }); - it('effectClear throws before initialize', () => { + it('effectClear throws before init', () => { expect(() => BTAPI.instance.effectClear()).toThrow('renderer not initialized'); }); - it('effectAdd / effectClear delegate to the renderer after initialize', async () => { - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + it('effectAdd / effectClear delegate to the renderer after init', async () => { + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const renderer = BTAPI.instance.getRenderer(); expect(renderer).not.toBeNull(); @@ -557,8 +557,8 @@ describe('BTAPI', () => { expect(clearSpy).toHaveBeenCalled(); }); - it('effectRemove delegates to the renderer after initialize', async () => { - await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + it('effectRemove delegates to the renderer after init', async () => { + await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const renderer = BTAPI.instance.getRenderer(); const removeSpy = vi.spyOn(renderer as NonNullable, 'removeEffect'); diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 94ef4c4..e906c60 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -22,7 +22,7 @@ import { Vector2i } from '../utils/Vector2i'; import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; import { GameLoop } from './GameLoop'; import type { HardwareSettings, IBlitTechDemo } from './IBlitTechDemo'; -import { initializeWebGPU } from './WebGPUContext'; +import { initWebGPU } from './WebGPUContext'; /** * Central runtime facade for Blit-Tech engine services. @@ -94,13 +94,13 @@ export class BTAPI { /** Built-in 6x14 system font for BT.systemPrint(). */ private systemFont: BitmapFont | null = null; - /** Pointer / mouse / touch input subsystem. Created during {@link initialize}. */ + /** Pointer / mouse / touch input subsystem. Created during {@link init}. */ private pointer: PointerInput | null = null; - /** Keyboard input (VV-134). Created during {@link initialize}. */ + /** Keyboard input (VV-134). Created during {@link init}. */ private keyboard: KeyboardInput | null = null; - /** Gamepad input (VV-135). Created during {@link initialize}. */ + /** Gamepad input (VV-135). Created during {@link init}. */ private gamepad: GamepadInput | null = null; // TODO: Additional subsystems for future implementation: @@ -110,14 +110,14 @@ export class BTAPI { // #region Singleton / Constructor - /** Singleton instance of BTAPI. */ - private static _instance: BTAPI | null = null; - /** * Private constructor to enforce singleton access via `BTAPI.instance`. */ private constructor() {} + /** Singleton instance of BTAPI. */ + private static _instance: BTAPI | null = null; + // #endregion // #region Singleton Access @@ -139,63 +139,20 @@ export class BTAPI { // #region Initialization - /** - * Removes pointer, keyboard, and gamepad subsystems. - * - * Pointer/keyboard detach DOM listeners; gamepad detaches polling state and - * clears subsystem references. - */ - private clearInputSubsystems(): void { - this.pointer?.detach(); - this.pointer = null; - this.keyboard?.detach(); - this.keyboard = null; - this.gamepad?.detach(); - this.gamepad = null; - } - - /** - * Runs the demo's async {@link IBlitTechDemo.initialize}. On throw or - * `false`, clears input subsystems that were attached earlier in the init - * sequence. - * - * @param demo - Active demo instance. - * @returns `true` when the demo reports success. - */ - private async runDemoInitialize(demo: IBlitTechDemo): Promise { - try { - const ok = await demo.initialize(); - - if (!ok) { - console.error('[BT] Demo initialization failed'); - this.clearInputSubsystems(); - - return false; - } - - return true; - } catch (err) { - console.error('[BT] Demo initialization threw', err); - this.clearInputSubsystems(); - - return false; - } - } - /** * Initializes the engine for a demo and starts the main loop on success. * * The initialization sequence is: * - query hardware settings from the demo * - initialize WebGPU and create the renderer - * - run the demo's async `initialize()` + * - run the demo's async `init()` * - start the fixed-timestep game loop * * @param demo - Demo implementing the IBlitTechDemo interface. * @param canvas - HTML canvas element for WebGPU rendering. * @returns `true` when initialization succeeds; otherwise `false`. */ - public async initialize(demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise { + public async init(demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise { console.log(`[BT] Initializing engine v${BTAPI.VERSION_MAJOR}.${BTAPI.VERSION_MINOR}.${BTAPI.VERSION_PATCH}`); this.demo = demo; @@ -222,11 +179,7 @@ export class BTAPI { }); // Initialize WebGPU. - const webGPUResult = await initializeWebGPU( - canvas, - this.hwSettings.displaySize, - this.hwSettings.canvasDisplaySize, - ); + const webGPUResult = await initWebGPU(canvas, this.hwSettings.displaySize, this.hwSettings.canvasDisplaySize); if (!webGPUResult) { console.error('[BT] Failed to initialize WebGPU'); @@ -250,7 +203,7 @@ export class BTAPI { this.hwSettings.outputUpscaleFilter ?? 'nearest', ); - if (!(await this.renderer.initialize())) { + if (!(await this.renderer.init())) { console.error('[BT] Failed to initialize renderer'); return false; @@ -282,7 +235,7 @@ export class BTAPI { // Initialize the demo. console.log('[BT] Initializing demo'); - if (!(await this.runDemoInitialize(demo))) { + if (!(await this.runDemoInit(demo))) { return false; } @@ -342,10 +295,6 @@ export class BTAPI { this.clearInputSubsystems(); } - // #endregion - - // #region Demo State Accessors - /** * Gets the current tick count. * Ticks increment once per fixed update step (target rate set by `targetFPS`). @@ -364,6 +313,10 @@ export class BTAPI { this.loop?.resetTicks(); } + // #endregion + + // #region Demo State Accessors + /** * Gets the hardware settings returned by the active demo. * @@ -373,10 +326,6 @@ export class BTAPI { return this.hwSettings; } - // #endregion - - // #region WebGPU Resource Accessors - /** * Gets the initialized WebGPU device. * @@ -395,6 +344,10 @@ export class BTAPI { return this.context; } + // #endregion + + // #region WebGPU Resource Accessors + /** * Gets the canvas bound during initialization. * @@ -473,10 +426,6 @@ export class BTAPI { this.renderer?.setPalette(palette); } - // #endregion - - // #region Rendering API - Clear Operations - /** * Sets the background clear color for each frame using a palette index. * @@ -500,7 +449,7 @@ export class BTAPI { // #endregion - // #region Rendering API - Primitives + // #region Rendering API - Clear Operations /** * Draws a single pixel at the specified position. @@ -526,6 +475,10 @@ export class BTAPI { this.renderer?.drawLine(p0, p1, paletteIndex); } + // #endregion + + // #region Rendering API - Primitives + /** * Draws a rectangle outline (unfilled). * @@ -583,10 +536,6 @@ export class BTAPI { return this.systemFont; } - // #endregion - - // #region Rendering API - Sprites - /** * Draws a sprite region from an indexed sprite sheet. * The renderer batches compatible sprite draws internally. @@ -619,6 +568,10 @@ export class BTAPI { this.renderer?.drawBitmapText(font, pos, text, paletteOffset); } + // #endregion + + // #region Rendering API - Sprites + /** * Re-indexizes all tracked sprite sheets against the current active palette. * @@ -652,10 +605,6 @@ export class BTAPI { console.log(`[BT] Refreshed ${refreshed} sprite sheet(s) against current palette`); } - // #endregion - - // #region Frame Capture API - /** * Captures the next rendered frame as a PNG blob. * The capture occurs on the next completed render cycle. @@ -671,10 +620,6 @@ export class BTAPI { return this.renderer.captureFrame(); } - // #endregion - - // #region Camera API - /** * Sets the camera offset for scrolling effects. * The offset is applied to subsequent renderer draw calls. @@ -685,6 +630,10 @@ export class BTAPI { this.renderer?.setCameraOffset(offset); } + // #endregion + + // #region Frame Capture API + /** * Gets the current camera offset. * @@ -694,28 +643,15 @@ export class BTAPI { return this.renderer?.getCameraOffset() ?? Vector2i.zero(); } - /** - * Resets the camera offset to (0, 0). - */ - public resetCamera(): void { - this.renderer?.resetCamera(); - } - // #endregion - // #region Palette Effects API + // #region Camera API /** - * Validates that a duration is a finite, non-negative number. - * - * @param method - Calling method name for the error message. - * @param durationMs - Duration to validate. - * @throws Error if the duration is not finite or is negative. + * Resets the camera offset to (0, 0). */ - private assertFiniteDuration(method: string, durationMs: number): void { - if (!Number.isFinite(durationMs) || durationMs < 0) { - throw new Error(`[BT] ${method}: durationMs must be a finite non-negative number, got ${durationMs}.`); - } + public resetCamera(): void { + this.renderer?.resetCamera(); } /** @@ -758,6 +694,10 @@ export class BTAPI { this.paletteEffects.add(new FadeEffect(this.palette, target, durationMs, easing)); } + // #endregion + + // #region Palette Effects API + /** * Fades only a subset of palette indices toward a target over time. * @@ -824,10 +764,6 @@ export class BTAPI { this.paletteEffects.clear(); } - // #endregion - - // #region Post-Process Effects API - /** * Appends a fullscreen post-processing effect to the chain. * @@ -878,6 +814,66 @@ export class BTAPI { // #endregion + // #region Post-Process Effects API + + /** + * Removes pointer, keyboard, and gamepad subsystems. + * + * Pointer/keyboard detach DOM listeners; gamepad detaches polling state and + * clears subsystem references. + */ + private clearInputSubsystems(): void { + this.pointer?.detach(); + this.pointer = null; + this.keyboard?.detach(); + this.keyboard = null; + this.gamepad?.detach(); + this.gamepad = null; + } + + /** + * Runs the demo's async {@link IBlitTechDemo.init}. On throw or + * `false`, clears input subsystems that were attached earlier in the init + * sequence. + * + * @param demo - Active demo instance. + * @returns `true` when the demo reports success. + */ + private async runDemoInit(demo: IBlitTechDemo): Promise { + try { + const ok = await demo.init(); + + if (!ok) { + console.error('[BT] Demo initialization failed'); + this.clearInputSubsystems(); + + return false; + } + + return true; + } catch (err) { + console.error('[BT] Demo initialization threw', err); + this.clearInputSubsystems(); + + return false; + } + } + + /** + * Validates that a duration is a finite, non-negative number. + * + * @param method - Calling method name for the error message. + * @param durationMs - Duration to validate. + * @throws Error if the duration is not finite or is negative. + */ + private assertFiniteDuration(method: string, durationMs: number): void { + if (!Number.isFinite(durationMs) || durationMs < 0) { + throw new Error(`[BT] ${method}: durationMs must be a finite non-negative number, got ${durationMs}.`); + } + } + + // #endregion + // #region Private Helpers /** diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index c9087ea..a8991f5 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -60,7 +60,7 @@ export interface HardwareSettings { * * Engine lifecycle order: * 1. queryHardware() - Called first to configure display/FPS - * 2. initialize() - Called after WebGPU setup, load assets here + * 2. init() - Called after WebGPU setup, load assets here * 3. update() - Fixed timestep via accumulator (may run 0..N times per frame) * 4. render() - Called once per requestAnimationFrame (browser refresh rate) */ @@ -79,7 +79,7 @@ export interface IBlitTechDemo { * * @returns Promise that resolves to true if successful, false to abort. */ - initialize(): Promise; + init(): Promise; /** * Called zero or more times per frame at the fixed timestep declared by diff --git a/src/core/WebGPUContext.test.ts b/src/core/WebGPUContext.test.ts index 1786a65..bd39421 100644 --- a/src/core/WebGPUContext.test.ts +++ b/src/core/WebGPUContext.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for {@link initializeWebGPU}. + * Unit tests for {@link initWebGPU}. * * Covers the WebGPU bootstrap path used during engine initialization: * - graceful failure when WebGPU support, adapters, devices, or canvas context @@ -19,7 +19,7 @@ import { uninstallMockNavigatorGPU, } from '../__test__/webgpu-mock'; import { Vector2i } from '../utils/Vector2i'; -import { initializeWebGPU } from './WebGPUContext'; +import { initWebGPU } from './WebGPUContext'; // #region Helpers @@ -34,7 +34,7 @@ function createMockCanvas(webgpuContext: unknown = createMockGPUCanvasContext()) // #endregion -describe('initializeWebGPU', () => { +describe('initWebGPU', () => { const displaySize = new Vector2i(320, 240); afterEach(() => { @@ -53,7 +53,7 @@ describe('initializeWebGPU', () => { }); const canvas = createMockCanvas(); - const result = await initializeWebGPU(canvas, displaySize); + const result = await initWebGPU(canvas, displaySize); expect(result).toBeNull(); }); @@ -76,7 +76,7 @@ describe('initializeWebGPU', () => { }); const canvas = createMockCanvas(); - const result = await initializeWebGPU(canvas, displaySize); + const result = await initWebGPU(canvas, displaySize); expect(result).toBeNull(); }); @@ -99,7 +99,7 @@ describe('initializeWebGPU', () => { }); const canvas = createMockCanvas(); - const result = await initializeWebGPU(canvas, displaySize); + const result = await initWebGPU(canvas, displaySize); expect(result).toBeNull(); }); @@ -112,7 +112,7 @@ describe('initializeWebGPU', () => { installMockNavigatorGPU(); const canvas = createMockCanvas(null); - const result = await initializeWebGPU(canvas, displaySize); + const result = await initWebGPU(canvas, displaySize); expect(result).toBeNull(); }); @@ -126,7 +126,7 @@ describe('initializeWebGPU', () => { const canvas = createMockCanvas(); - const result = await initializeWebGPU(canvas, displaySize); + const result = await initWebGPU(canvas, displaySize); expect(result).not.toBeNull(); expect(result?.device).toBeDefined(); @@ -138,7 +138,7 @@ describe('initializeWebGPU', () => { const canvas = createMockCanvas(); - await initializeWebGPU(canvas, displaySize); + await initWebGPU(canvas, displaySize); expect(canvas.width).toBe(320); expect(canvas.height).toBe(240); @@ -150,7 +150,7 @@ describe('initializeWebGPU', () => { const canvas = createMockCanvas(); const canvasDisplaySize = new Vector2i(640, 480); - await initializeWebGPU(canvas, displaySize, canvasDisplaySize); + await initWebGPU(canvas, displaySize, canvasDisplaySize); expect(canvas.style.width).toBe('640px'); expect(canvas.style.height).toBe('480px'); @@ -161,7 +161,7 @@ describe('initializeWebGPU', () => { const canvas = createMockCanvas(); - await initializeWebGPU(canvas, displaySize); + await initWebGPU(canvas, displaySize); expect(canvas.style.width).toBe(''); expect(canvas.style.height).toBe(''); diff --git a/src/core/WebGPUContext.ts b/src/core/WebGPUContext.ts index 9b3e12b..3644101 100644 --- a/src/core/WebGPUContext.ts +++ b/src/core/WebGPUContext.ts @@ -37,7 +37,7 @@ export interface WebGPUContextResult { * @param canvasDisplaySize - Optional output drawing-buffer size in pixels. * @returns Initialized device, context, and drawing-buffer size, or null on failure. */ -export async function initializeWebGPU( +export async function initWebGPU( canvas: HTMLCanvasElement, displaySize: Vector2i, canvasDisplaySize?: Vector2i, diff --git a/src/render/PrimitivePipeline.test.ts b/src/render/PrimitivePipeline.test.ts index cd6ceac..623e280 100644 --- a/src/render/PrimitivePipeline.test.ts +++ b/src/render/PrimitivePipeline.test.ts @@ -67,7 +67,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawRectFill() does not throw before initialize', () => { + it('drawRectFill() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(0, 0, 10, 10); @@ -76,7 +76,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawPixel() does not throw before initialize', () => { + it('drawPixel() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const pos = new Vector2i(5, 5); @@ -85,7 +85,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawPixelXY() does not throw before initialize', () => { + it('drawPixelXY() does not throw before init', () => { const pipeline = new PrimitivePipeline(); expect(() => { @@ -93,7 +93,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawLine() with horizontal line does not throw before initialize', () => { + 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); @@ -103,7 +103,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawLine() with vertical line does not throw before initialize', () => { + 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); @@ -113,7 +113,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawLine() with diagonal line does not throw before initialize', () => { + 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); @@ -123,7 +123,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('drawRect() does not throw before initialize', () => { + it('drawRect() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(5, 5, 20, 20); @@ -132,7 +132,7 @@ describe('pre-initialization safety', () => { }).not.toThrow(); }); - it('clearRect() does not throw before initialize', () => { + it('clearRect() does not throw before init', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(0, 0, 100, 100); @@ -155,7 +155,7 @@ describe('with initialized pipeline', () => { const paletteBuffer = createMockPaletteBuffer(); - await pipeline.initialize(device, new Vector2i(320, 240), paletteBuffer); + await pipeline.init(device, new Vector2i(320, 240), paletteBuffer); }); afterAll(() => { @@ -267,7 +267,7 @@ describe('vertex count verification', () => { const paletteBuffer = createMockPaletteBuffer(); - await pipeline.initialize(device, new Vector2i(320, 240), paletteBuffer); + await pipeline.init(device, new Vector2i(320, 240), paletteBuffer); }); afterAll(() => { diff --git a/src/render/PrimitivePipeline.ts b/src/render/PrimitivePipeline.ts index e43c9fe..f5f33e7 100644 --- a/src/render/PrimitivePipeline.ts +++ b/src/render/PrimitivePipeline.ts @@ -35,7 +35,7 @@ const VERTEX_STRIDE = VALUES_PER_VERTEX * 4; export class PrimitivePipeline { // #region State - /** WebGPU device, set during initialize(). */ + /** WebGPU device, set during init(). */ private device: GPUDevice | null = null; /** Render pipeline for palette-indexed geometry. */ @@ -91,7 +91,7 @@ export class PrimitivePipeline { /** * Creates an empty primitive pipeline. - * Call `initialize()` before encoding GPU work. + * Call `init()` before encoding GPU work. */ constructor() { this.vertexArrayBuffer = new ArrayBuffer(MAX_PRIMITIVE_VERTICES * VERTEX_STRIDE); @@ -110,7 +110,7 @@ export class PrimitivePipeline { * @param displaySize - Render target resolution in pixels. * @param paletteBuffer - Shared palette uniform buffer (256 x vec4f = 4096 bytes). */ - async initialize(device: GPUDevice, displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { + async init(device: GPUDevice, displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { this.device = device; await this.createPipeline(displaySize, paletteBuffer); } @@ -298,7 +298,7 @@ export class PrimitivePipeline { } // Upload all vertex data at once. - // Safe assertions: these resources are created in initialize() before any rendering. + // Safe assertions: these resources are created in init() before any rendering. (this.device as GPUDevice).queue.writeBuffer( this.vertexBuffer as GPUBuffer, 0, diff --git a/src/render/Renderer.test.ts b/src/render/Renderer.test.ts index 15d6756..24cafbc 100644 --- a/src/render/Renderer.test.ts +++ b/src/render/Renderer.test.ts @@ -216,7 +216,7 @@ describe('with initialized renderer', () => { beforeAll(async () => { installMockNavigatorGPU(); - const result = await renderer.initialize(); + const result = await renderer.init(); expect(result).toBe(true); @@ -227,9 +227,9 @@ describe('with initialized renderer', () => { uninstallMockNavigatorGPU(); }); - it('initialize returns true on success', async () => { + it('init returns true on success', async () => { const r = new Renderer(device, context, displaySize); - const result = await r.initialize(); + const result = await r.init(); expect(result).toBe(true); }); @@ -387,7 +387,7 @@ describe('resolveClearColor fallbacks', () => { installMockNavigatorGPU(); - await r.initialize(); + await r.init(); // No setPalette() call — endFrame() resolves clear color to black via fallback. expect(() => r.endFrame()).not.toThrow(); @@ -400,7 +400,7 @@ describe('resolveClearColor fallbacks', () => { installMockNavigatorGPU(); - await r.initialize(); + await r.init(); // Spy on the prototype so the reference stored by setPalette also throws. const getSpy = vi.spyOn(Palette.prototype, 'get').mockImplementation(() => { @@ -462,7 +462,7 @@ describe('frame capture', () => { }, ); - await renderer.initialize(); + await renderer.init(); renderer.setPalette(createTestPalette()); const promise = renderer.captureFrame(); @@ -498,7 +498,7 @@ describe('frame capture', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); renderer.setPalette(createTestPalette()); // Stub browser APIs for PNG conversion. @@ -559,7 +559,7 @@ describe('frame capture', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); renderer.setPalette(createTestPalette()); renderer.beginFrame(); @@ -590,7 +590,7 @@ describe('endFrame error paths', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); renderer.setPalette(createTestPalette()); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -634,7 +634,7 @@ describe('endFrame error paths', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); renderer.setPalette(createTestPalette()); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -655,7 +655,7 @@ describe('endFrame error paths', () => { }); }); -describe('initialize error paths', () => { +describe('init error paths', () => { it('returns false when pipeline creation throws', async () => { const throwingDevice = { ...createMockGPUDevice(), @@ -671,7 +671,7 @@ describe('initialize error paths', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { - const result = await renderer.initialize(); + const result = await renderer.init(); expect(result).toBe(false); expect(errorSpy).toHaveBeenCalled(); @@ -715,7 +715,7 @@ describe('palette dirty-flag auto-propagation', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); const palette = new Palette(16); @@ -743,7 +743,7 @@ describe('palette dirty-flag auto-propagation', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); const palette = new Palette(16); @@ -777,7 +777,7 @@ describe('palette dirty-flag auto-propagation', () => { installMockNavigatorGPU(); - await renderer.initialize(); + await renderer.init(); const palette = new Palette(16); @@ -841,28 +841,28 @@ describe('post-process effects', () => { }; } - it('addEffect throws before initialize', () => { + it('addEffect throws before init', () => { const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => r.addEffect(createStubEffect())).toThrow(/not initialized/); }); - it('removeEffect throws before initialize', () => { + it('removeEffect throws before init', () => { const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => r.removeEffect(createStubEffect())).toThrow(/not initialized/); }); - it('clearEffects throws before initialize', () => { + it('clearEffects throws before init', () => { const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => r.clearEffects()).toThrow(/not initialized/); }); - it('addEffect / clearEffects work after initialize', async () => { + it('addEffect / clearEffects work after init', async () => { const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); - await r.initialize(); + await r.init(); const effect = createStubEffect(); @@ -889,7 +889,7 @@ describe('post-process effects', () => { const r = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); - await r.initialize(); + await r.init(); r.setPalette(createTestPalette()); r.beginFrame(); @@ -903,7 +903,7 @@ describe('post-process effects', () => { const device = createMockGPUDevice(); const r = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); - await r.initialize(); + await r.init(); r.setPalette(createTestPalette()); const effect = createStubEffect(); @@ -926,7 +926,7 @@ describe('post-process effects', () => { const device = createMockGPUDevice(); const r = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); - await r.initialize(); + await r.init(); r.setPalette(createTestPalette()); const effectA = createStubEffect(); @@ -969,7 +969,7 @@ describe('post-process effects', () => { const r = new Renderer(device, context, new Vector2i(320, 240)); - await r.initialize(); + await r.init(); r.setPalette(createTestPalette()); const effect = createStubEffect(); @@ -1019,7 +1019,7 @@ describe('post-process effects', () => { const r = new Renderer(device, context, new Vector2i(320, 240)); - await r.initialize(); + await r.init(); r.setPalette(createTestPalette()); r.beginFrame(); diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts index 01bba0a..5c2c908 100644 --- a/src/render/Renderer.ts +++ b/src/render/Renderer.ts @@ -183,9 +183,9 @@ export class Renderer { * * @returns `true` when GPU resources are ready; otherwise `false`. */ - async initialize(): Promise { + async init(): Promise { try { - // Release any GPU resources from a previous initialize() call (e.g. + // Release any GPU resources from a previous init() call (e.g. // device-loss recovery) so nothing leaks. this.paletteBuffer?.destroy(); this.paletteBuffer = null; @@ -208,14 +208,14 @@ export class Renderer { // Mark the palette dirty so the new buffer is populated on the first // endFrame(), even if the palette data has not changed since the last - // initialize() call (e.g. after a WebGPU device-loss recovery). + // init() call (e.g. after a WebGPU device-loss recovery). this.paletteDirty = true; // Primitive and sprite pipelines run at logical resolution. The // viewport is automatic since each pass binds a target view; only // the camera scaling here cares about logical size. - await this.primitives.initialize(this.device, this.displaySize, this.paletteBuffer); - await this.sprites.initialize(this.device, this.displaySize, this.paletteBuffer); + await this.primitives.init(this.device, this.displaySize, this.paletteBuffer); + await this.sprites.init(this.device, this.displaySize, this.paletteBuffer); this.swapFormat = navigator.gpu.getPreferredCanvasFormat(); @@ -342,148 +342,6 @@ export class Renderer { this.submitFrame(commandEncoder, swapTexture); } - /** - * Tries to acquire the swap-chain texture and validate its dimensions. - * Returns null and resets pipeline state when the texture is unavailable. - * - * @returns Current swap-chain texture, or null when the frame must be skipped. - */ - private acquireSwapTexture(): GPUTexture | null { - let swapTexture: GPUTexture; - - try { - swapTexture = this.context.getCurrentTexture(); - } catch (error) { - console.error('[Renderer] Failed to get current texture:', error); - this.primitives.reset(); - this.sprites.reset(); - return null; - } - - if (swapTexture.width === 0 || swapTexture.height === 0) { - console.warn('[Renderer] Texture has zero dimensions, skipping frame'); - this.primitives.reset(); - this.sprites.reset(); - return null; - } - - return swapTexture; - } - - /** - * Uploads the palette uniform buffer when the active palette has changed - * since the last frame. - */ - private flushPaletteIfDirty(): void { - if (this.palette && this.paletteBuffer && (this.paletteDirty || this.palette.dirty)) { - this.palette.toFloat32ArrayInto(this.paletteStaging); - this.device.queue.writeBuffer(this.paletteBuffer, 0, this.paletteStaging); - this.paletteDirty = false; - this.palette.clearDirty(); - } - } - - /** - * Encodes the primitive + sprite scene render pass into the supplied target view. - * - * @param encoder - Active command encoder. - * @param sceneView - View to render the scene into. - */ - private encodeScenePass(encoder: GPUCommandEncoder, sceneView: GPUTextureView): void { - const clearColor = this.resolveClearColor(); - const renderPass = encoder.beginRenderPass({ - label: 'Render Pass', - colorAttachments: [ - { - view: sceneView, - // WebGPU expects linear 0-1 floats; Color32 stores 0-255 integers. - clearValue: { - r: clearColor.r / 255, - g: clearColor.g / 255, - b: clearColor.b / 255, - a: clearColor.a / 255, - }, - loadOp: 'clear', - storeOp: 'store', - }, - ], - }); - - this.primitives.encodePass(renderPass); - this.sprites.encodePass(renderPass); - renderPass.end(); - } - - /** - * Encodes the optional pixel chain, upscale pass, and display chain in the - * correct order based on which chains are active. - * - * @param encoder - Active command encoder. - * @param swapChainView - Current swap-chain view (final destination). - * @param pixelActive - Whether the pixel chain has any registered effects. - * @param displayActive - Whether the display chain has any registered effects. - * @param deltaMs - Wall-clock milliseconds since the previous frame. - */ - private encodePostProcess( - encoder: GPUCommandEncoder, - swapChainView: GPUTextureView, - pixelActive: boolean, - displayActive: boolean, - deltaMs: number, - ): void { - if (pixelActive && this.pixelChain) { - const dest = this.pixelChainDestView(swapChainView, displayActive); - this.pixelChain.encode(encoder, deltaMs, dest); - } - - // sceneTex is the shared bridge between the logical (pixel-tier) output - // and the upscale input. When pixelActive is true, pixelChain writes its - // final pass into sceneTex via pixelChainDestView(); when pixelActive is - // false the scene primitives render directly into sceneTex. Either way - // upscalePass reads from requireSceneTexView() and copies to the display - // chain input (or the swap chain when no display effects are active). - if (this.hasUpscale && this.upscalePass) { - const upscaleSrc = this.requireSceneTexView(); - const upscaleDest = displayActive ? this.requireDisplayChainInput() : swapChainView; - this.upscalePass.encode(encoder, upscaleSrc, upscaleDest); - } - - if (displayActive && this.displayChain) { - this.displayChain.encode(encoder, deltaMs, swapChainView); - } - } - - /** - * Adds the optional frame-capture readback, submits the command buffer, - * and resets per-frame pipeline state. - * - * @param encoder - Active command encoder. - * @param swapTexture - Current swap-chain texture (capture source). - */ - private submitFrame(encoder: GPUCommandEncoder, swapTexture: GPUTexture): void { - const capturing = this.frameCapture.hasPendingCapture(); - - if (capturing) { - this.frameCapture.executeCaptureInEncoder(this.device, swapTexture, encoder); - } - - this.device.queue.submit([encoder.finish()]); - - if (capturing) { - void this.frameCapture.resolveCapture(this.device); - } - - // Defensive reset so the pipeline state is clean even if beginFrame() is not - // called next. beginFrame() also resets; this prevents stale data from - // persisting across frames. - this.primitives.reset(); - this.sprites.reset(); - } - - // #endregion - - // #region Primitive Drawing - /** * Draws a filled rectangle using two triangles. * @@ -538,7 +396,7 @@ export class Renderer { // #endregion - // #region Sprite Drawing + // #region Primitive Drawing /** * Draws a sprite region from an indexed sprite sheet. @@ -565,10 +423,6 @@ export class Renderer { this.sprites.drawBitmapText(font, pos, text, paletteOffset); } - // #endregion - - // #region Frame Capture - /** * Captures the next rendered frame as a PNG blob. * The capture happens on the next `endFrame()` call. @@ -580,10 +434,6 @@ export class Renderer { return this.frameCapture.requestCapture(); } - // #endregion - - // #region Camera - /** * Sets the camera offset for scrolling. * The offset is propagated to both internal pipelines. @@ -605,6 +455,10 @@ export class Renderer { return this.cameraOffset.clone(); } + // #endregion + + // #region Sprite Drawing + /** * Resets the camera to the origin (0, 0). */ @@ -614,10 +468,6 @@ export class Renderer { this.sprites.setCameraOffset(this.cameraOffset); } - // #endregion - - // #region Post-Process Effects - /** * Appends a fullscreen post-processing effect to the chain matching its * declared {@link Effect.tier}. @@ -646,6 +496,10 @@ export class Renderer { chain.add(effect); } + // #endregion + + // #region Frame Capture + /** * Removes a previously registered post-processing effect. * @@ -670,6 +524,10 @@ export class Renderer { } } + // #endregion + + // #region Camera + /** * Removes every registered post-processing effect across both tiers. * @@ -684,6 +542,148 @@ export class Renderer { this.displayChain.clear(); } + /** + * Tries to acquire the swap-chain texture and validate its dimensions. + * Returns null and resets pipeline state when the texture is unavailable. + * + * @returns Current swap-chain texture, or null when the frame must be skipped. + */ + private acquireSwapTexture(): GPUTexture | null { + let swapTexture: GPUTexture; + + try { + swapTexture = this.context.getCurrentTexture(); + } catch (error) { + console.error('[Renderer] Failed to get current texture:', error); + this.primitives.reset(); + this.sprites.reset(); + return null; + } + + if (swapTexture.width === 0 || swapTexture.height === 0) { + console.warn('[Renderer] Texture has zero dimensions, skipping frame'); + this.primitives.reset(); + this.sprites.reset(); + return null; + } + + return swapTexture; + } + + /** + * Uploads the palette uniform buffer when the active palette has changed + * since the last frame. + */ + private flushPaletteIfDirty(): void { + if (this.palette && this.paletteBuffer && (this.paletteDirty || this.palette.dirty)) { + this.palette.toFloat32ArrayInto(this.paletteStaging); + this.device.queue.writeBuffer(this.paletteBuffer, 0, this.paletteStaging); + this.paletteDirty = false; + this.palette.clearDirty(); + } + } + + // #endregion + + // #region Post-Process Effects + + /** + * Encodes the primitive + sprite scene render pass into the supplied target view. + * + * @param encoder - Active command encoder. + * @param sceneView - View to render the scene into. + */ + private encodeScenePass(encoder: GPUCommandEncoder, sceneView: GPUTextureView): void { + const clearColor = this.resolveClearColor(); + const renderPass = encoder.beginRenderPass({ + label: 'Render Pass', + colorAttachments: [ + { + view: sceneView, + // WebGPU expects linear 0-1 floats; Color32 stores 0-255 integers. + clearValue: { + r: clearColor.r / 255, + g: clearColor.g / 255, + b: clearColor.b / 255, + a: clearColor.a / 255, + }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + + this.primitives.encodePass(renderPass); + this.sprites.encodePass(renderPass); + renderPass.end(); + } + + /** + * Encodes the optional pixel chain, upscale pass, and display chain in the + * correct order based on which chains are active. + * + * @param encoder - Active command encoder. + * @param swapChainView - Current swap-chain view (final destination). + * @param pixelActive - Whether the pixel chain has any registered effects. + * @param displayActive - Whether the display chain has any registered effects. + * @param deltaMs - Wall-clock milliseconds since the previous frame. + */ + private encodePostProcess( + encoder: GPUCommandEncoder, + swapChainView: GPUTextureView, + pixelActive: boolean, + displayActive: boolean, + deltaMs: number, + ): void { + if (pixelActive && this.pixelChain) { + const dest = this.pixelChainDestView(swapChainView, displayActive); + this.pixelChain.encode(encoder, deltaMs, dest); + } + + // sceneTex is the shared bridge between the logical (pixel-tier) output + // and the upscale input. When pixelActive is true, pixelChain writes its + // final pass into sceneTex via pixelChainDestView(); when pixelActive is + // false the scene primitives render directly into sceneTex. Either way + // upscalePass reads from requireSceneTexView() and copies to the display + // chain input (or the swap chain when no display effects are active). + if (this.hasUpscale && this.upscalePass) { + const upscaleSrc = this.requireSceneTexView(); + const upscaleDest = displayActive ? this.requireDisplayChainInput() : swapChainView; + this.upscalePass.encode(encoder, upscaleSrc, upscaleDest); + } + + if (displayActive && this.displayChain) { + this.displayChain.encode(encoder, deltaMs, swapChainView); + } + } + + /** + * Adds the optional frame-capture readback, submits the command buffer, + * and resets per-frame pipeline state. + * + * @param encoder - Active command encoder. + * @param swapTexture - Current swap-chain texture (capture source). + */ + private submitFrame(encoder: GPUCommandEncoder, swapTexture: GPUTexture): void { + const capturing = this.frameCapture.hasPendingCapture(); + + if (capturing) { + this.frameCapture.executeCaptureInEncoder(this.device, swapTexture, encoder); + } + + this.device.queue.submit([encoder.finish()]); + + if (capturing) { + void this.frameCapture.resolveCapture(this.device); + } + + // Defensive reset so the pipeline state is clean even if beginFrame() is not + // called next. beginFrame() also resets; this prevents stale data from + // persisting across frames. + this.primitives.reset(); + this.sprites.reset(); + } + // #endregion // #region Private Helpers diff --git a/src/render/SpritePipeline.test.ts b/src/render/SpritePipeline.test.ts index d866c52..13c25e7 100644 --- a/src/render/SpritePipeline.test.ts +++ b/src/render/SpritePipeline.test.ts @@ -81,7 +81,7 @@ describe('with initialized pipeline', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240), createMockPaletteBuffer()); + await pipeline.init(device, new Vector2i(320, 240), createMockPaletteBuffer()); }); afterAll(() => { @@ -149,7 +149,7 @@ describe('drawSprite', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240), createMockPaletteBuffer()); + await pipeline.init(device, new Vector2i(320, 240), createMockPaletteBuffer()); }); afterAll(() => { @@ -395,7 +395,7 @@ describe('drawBitmapText', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240), createMockPaletteBuffer()); + await pipeline.init(device, new Vector2i(320, 240), createMockPaletteBuffer()); }); afterAll(() => { diff --git a/src/render/SpritePipeline.ts b/src/render/SpritePipeline.ts index d680e46..8bbb8e6 100644 --- a/src/render/SpritePipeline.ts +++ b/src/render/SpritePipeline.ts @@ -36,7 +36,7 @@ const VERTEX_STRIDE = 20; export class SpritePipeline { // #region State - /** WebGPU device, set during initialize(). */ + /** WebGPU device, set during init(). */ private device: GPUDevice | null = null; /** Render pipeline for indexed sprites. */ @@ -104,7 +104,7 @@ export class SpritePipeline { /** * Creates an empty sprite pipeline. - * Call `initialize()` before encoding GPU work. + * Call `init()` before encoding GPU work. */ constructor() { this.vertexArrayBuffer = new ArrayBuffer(MAX_SPRITE_VERTICES * VERTEX_STRIDE); @@ -123,7 +123,7 @@ export class SpritePipeline { * @param displaySize - Render target resolution in pixels. * @param paletteBuffer - Shared palette uniform buffer (256 x vec4f). */ - async initialize(device: GPUDevice, displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { + async init(device: GPUDevice, displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { this.device = device; this.paletteBuffer = paletteBuffer; @@ -209,7 +209,7 @@ export class SpritePipeline { } // Upload all sprite vertices at once. - // Safe assertions: these resources are created in initialize() before any rendering. + // Safe assertions: these resources are created in init() before any rendering. (this.device as GPUDevice).queue.writeBuffer( this.vertexBuffer as GPUBuffer, 0, @@ -512,7 +512,7 @@ export class SpritePipeline { return existing; } - // Safe assertions: pipeline is created in initialize() before any drawing. + // Safe assertions: pipeline is created in init() before any drawing. const bindGroup = (this.device as GPUDevice).createBindGroup({ label: 'Sprite Texture Bind Group', layout: (this.pipeline as GPURenderPipeline).getBindGroupLayout(1), diff --git a/src/render/effects/FullscreenEffect.ts b/src/render/effects/FullscreenEffect.ts index 1d3f8de..000b655 100644 --- a/src/render/effects/FullscreenEffect.ts +++ b/src/render/effects/FullscreenEffect.ts @@ -48,36 +48,26 @@ export abstract class FullscreenEffect implements Effect { /** WGSL fragment shader source. Must declare `Params`, `src`, `samp`. */ protected abstract readonly fragmentShader: string; - - /** Writes the per-frame uniform values into {@link uniformData}. */ - protected abstract writeUniforms(deltaMs: number, sourceSize: Vector2i): void; + protected device: GPUDevice | null = null; // #region GPU State - - protected device: GPUDevice | null = null; protected pipeline: GPURenderPipeline | null = null; protected uniformBuffer: GPUBuffer | null = null; protected sampler: GPUSampler | null = null; protected bindGroupLayout: GPUBindGroupLayout | null = null; - /** * Backing array for the uniform block. Lazily allocated in {@link init} * after subclass constants are set on `this`. */ protected uniformData: Float32Array | null = null; - /** * Per-source-view bind group cache. The chain ping-pongs between two * stable views, so a `WeakMap` keyed by view is safe. * Reassigned on every `dispose()` so stale bind groups are not reused - * after a re-initialize cycle. + * after a re-init cycle. */ private bindGroups = new WeakMap(); - // #endregion - - // #region Effect lifecycle - /** * Creates the GPU pipeline, uniform buffer, sampler, and bind-group layout. * @@ -126,6 +116,10 @@ export abstract class FullscreenEffect implements Effect { }); } + // #endregion + + // #region Effect lifecycle + /** * Calls {@link writeUniforms} then uploads the uniform block to the GPU. * @@ -187,6 +181,9 @@ export abstract class FullscreenEffect implements Effect { this.bindGroups = new WeakMap(); } + /** Writes the per-frame uniform values into {@link uniformData}. */ + protected abstract writeUniforms(deltaMs: number, sourceSize: Vector2i): void; + // #endregion // #region Private Helpers diff --git a/src/utils/Bootstrap.test.ts b/src/utils/Bootstrap.test.ts index 0e3d939..d70784b 100644 --- a/src/utils/Bootstrap.test.ts +++ b/src/utils/Bootstrap.test.ts @@ -6,7 +6,7 @@ * Covers the DOM-facing startup flow: * - optional waiting for `DOMContentLoaded` * - WebGPU support validation and canvas lookup failures - * - success and failure paths around `BTAPI.initialize()` + * - success and failure paths around `BTAPI.init()` * - default and custom canvas/container identifiers * - propagation of success and error callbacks * @@ -30,7 +30,7 @@ class MockDemo implements IBlitTechDemo { return { displaySize: new Vector2i(320, 240), targetFPS: 60 }; } - async initialize() { + async init() { return true; } @@ -81,7 +81,7 @@ function unstubWebGPU(): void { describe('bootstrap', () => { beforeEach(() => { - vi.spyOn(BTAPI.instance, 'initialize').mockResolvedValue(true); + vi.spyOn(BTAPI.instance, 'init').mockResolvedValue(true); Object.defineProperty(document, 'readyState', { value: 'complete', writable: true, configurable: true }); }); @@ -185,12 +185,12 @@ describe('bootstrap', () => { // #region Engine initialization describe('engine initialization', () => { - it('should return false and call onError when BTAPI.initialize returns false', async () => { + it('should return false and call onError when BTAPI.init returns false', async () => { setupDOM(); stubWebGPU(); - vi.spyOn(BTAPI.instance, 'initialize').mockResolvedValue(false); + vi.spyOn(BTAPI.instance, 'init').mockResolvedValue(false); const onError = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); @@ -199,12 +199,12 @@ describe('bootstrap', () => { expect(onError).toHaveBeenCalledOnce(); }); - it('should return false and call onError when BTAPI.initialize throws', async () => { + it('should return false and call onError when BTAPI.init throws', async () => { setupDOM(); stubWebGPU(); - vi.spyOn(BTAPI.instance, 'initialize').mockRejectedValue(new Error('Init exploded')); + vi.spyOn(BTAPI.instance, 'init').mockRejectedValue(new Error('Init exploded')); const onError = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); @@ -277,13 +277,13 @@ describe('bootstrap', () => { // #region Unexpected errors describe('unexpected errors', () => { - it('should return false when BTAPI.initialize throws synchronously', async () => { + it('should return false when BTAPI.init throws synchronously', async () => { setupDOM(); stubWebGPU(); - vi.spyOn(BTAPI.instance, 'initialize').mockImplementation(() => { - throw new Error('The synchronous throw from initialize'); + vi.spyOn(BTAPI.instance, 'init').mockImplementation(() => { + throw new Error('The synchronous throw from init'); }); const onError = vi.fn(); diff --git a/src/utils/Bootstrap.ts b/src/utils/Bootstrap.ts index 9e7f5cb..3edd7b6 100644 --- a/src/utils/Bootstrap.ts +++ b/src/utils/Bootstrap.ts @@ -191,7 +191,7 @@ function validateCanvas( * @param onError - Optional error callback. * @returns BootstrapResult with success status. */ -async function initializeDemo( +async function initDemo( DemoClass: DemoConstructor, canvas: HTMLCanvasElement, containerId: string, @@ -203,7 +203,7 @@ async function initializeDemo( let initError: Error | undefined; try { - initialized = await BTAPI.instance.initialize(demo, canvas); + initialized = await BTAPI.instance.init(demo, canvas); } catch (err) { initError = err instanceof Error ? err : new Error(String(err)); console.error('[BT] Engine initialization threw an unexpected error:', initError); @@ -308,7 +308,7 @@ export async function bootstrap(DemoClass: DemoConstructor, options: BootstrapOp canvas.focus(); } - const initResult = await initializeDemo(DemoClass, canvas, containerId, onSuccess, onError); + const initResult = await initDemo(DemoClass, canvas, containerId, onSuccess, onError); success = initResult.success; } diff --git a/tests/visual/fixtures/camera.html b/tests/visual/fixtures/camera.html index cfcace8..7fe0762 100644 --- a/tests/visual/fixtures/camera.html +++ b/tests/visual/fixtures/camera.html @@ -12,7 +12,7 @@
- +