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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112import type { BitmapFont } from '../assets/BitmapFont';import type { Palette } from '../assets/Palette';import { CycleEffect, FadeEffect, FadeRangeEffect, FlashEffect, PaletteEffectManager, paletteSwap,} from '../assets/PaletteEffect';import type { SpriteSheet } from '../assets/SpriteSheet';import { createSystemFont } from '../assets/SystemFont';import { GamepadInput } from '../input/GamepadInput';import { KeyboardInput } from '../input/KeyboardInput';import { PointerInput } from '../input/PointerInput';import type { Effect } from '../render/effects/Effect';import type { IRenderer } from '../render/IRenderer';import { SoftwareRenderer } from '../render/SoftwareRenderer';import { SoftwareTicker } from '../render/SoftwareTicker';import { WebGpuRenderer } from '../render/WebGpuRenderer';import { applyCanvasLayoutStyles, DEFAULT_MAX_CANVAS_DISPLAY_SIZE } from '../utils/CanvasLayoutStyles';import type { Color32 } from '../utils/Color32';import type { EasingFunction } from '../utils/Easing';import { noActivePaletteError, paletteIndexNegativeError, paletteIndexOutOfRangeError, spriteNotIndexizedError,} from '../utils/errorMessages';import type { Rect2i } from '../utils/Rect2i';import { RenderDimensionLimitError, validateRenderDimensions } from '../utils/RenderLimits';import { Vector2i } from '../utils/Vector2i';import type { FrameDropCallback, FrameDropEvent } from './GameLoop';import { GameLoop } from './GameLoop';import type { HardwareSettings, IBlitTechDemo, RendererBackend } from './IBlitTechDemo';import { defaultConfig, mergeHardwareSettings } from './IBlitTechDemo';import { initWebGPU } from './WebGPUContext';
/** * Central runtime facade for Blit-Tech engine services. * * `BTAPI` owns engine initialization, keeps references to the active WebGPU * objects and renderer, and exposes the drawing/camera methods used by demos. * It is a singleton; access it through `BTAPI.instance`. */export class BTAPI { // #region Version Constants
/** * Major semantic-version component. */ public static readonly VERSION_MAJOR = 1;
/** Minor version number. */ public static readonly VERSION_MINOR = 0;
/** Patch version number. */ public static readonly VERSION_PATCH = 4;
// #endregion
// #region Module State - Demo Instance
/** Current demo instance implementing IBlitTechDemo. */ private demo: IBlitTechDemo | null = null;
// #endregion
// #region Module State - Hardware Settings
/** Hardware configuration settings from the demo. */ private hwSettings: HardwareSettings | null = null;
/** WebGPU device for GPU operations. */ private device: GPUDevice | null = null;
// #endregion
// #region Module State - WebGPU Resources
/** WebGPU canvas context for presenting frames. */ private context: GPUCanvasContext | null = null;
/** HTML canvas element used for rendering. */ private canvas: HTMLCanvasElement | null = null;
/** Renderer subsystem for all drawing operations. */ private renderer: IRenderer | null = null;
/** Backend that was successfully initialized, or null before init. */ private activeBackend: RendererBackend | null = null;
/** Software-mode ticker overlay; non-null only when the software backend is active. */ private ticker: SoftwareTicker | null = null;
/** Active engine palette used by palette-first rendering. */ private palette: Palette | null = null;
/** Registry of all sprite sheets that have been passed to drawSprite, for spritesRefresh. */ private readonly spriteSheets: Set<SpriteSheet> = new Set();
// #endregion
// #region Module State - Subsystems
/** Game loop managing fixed-timestep updates and variable-rate rendering. */ private loop: GameLoop | null = null;
/** Manages animated palette effects (cycling, fading, flashing). */ private readonly paletteEffects = new PaletteEffectManager();
/** Built-in 6x14 system font for BT.systemPrint(). */ private systemFont: BitmapFont | null = null;
/** Pointer / mouse / touch input subsystem. Created during {@link init}. */ private pointer: PointerInput | null = null;
/** Keyboard input (VV-134). Created during {@link init}. */ private keyboard: KeyboardInput | null = null;
/** Gamepad input (VV-135). Created during {@link init}. */ private gamepad: GamepadInput | null = null;
// TODO: Additional subsystems for future implementation: // AudioManager, AssetManager
// #endregion
// #region Singleton / Constructor
/** * 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
/** * Gets the lazily created singleton instance. * * @returns The global BTAPI instance. */ public static get instance(): BTAPI { if (!BTAPI._instance) { BTAPI._instance = new BTAPI(); }
return BTAPI._instance; }
// #endregion
// #region Initialization
/** * Initializes the engine for a demo and starts the main loop on success. * * The initialization sequence is: * - read hardware settings from the demo (`configure()` or defaults) * - initialize WebGPU and create the renderer * - 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 init(demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise<boolean> { console.log(`[BT] Initializing engine v${BTAPI.VERSION_MAJOR}.${BTAPI.VERSION_MINOR}.${BTAPI.VERSION_PATCH}`);
this.demo = demo; this.canvas = canvas;
// Hardware settings: demo hook or defaults (320x240 @ 60 FPS). console.log('[BT] Reading hardware configuration');
let configured: Partial<HardwareSettings> | undefined;
try { configured = demo.configure?.(); } catch (error) { console.error('[BT] demo.configure() threw; falling back to defaultConfig()', error);
this.hwSettings = defaultConfig();
return false; }
this.hwSettings = mergeHardwareSettings(configured); this.applyRendererQueryOverride();
const { targetFPS } = this.hwSettings; const renderDimensionError = validateRenderDimensions(this.hwSettings);
if (renderDimensionError) { console.error(`[BT] ${renderDimensionError}`);
return false; }
if (!Number.isFinite(targetFPS) || targetFPS <= 0) { console.error(`[BT] Invalid targetFPS: ${targetFPS}. Must be a finite number > 0.`);
return false; }
const updateInterval = 1000 / targetFPS;
console.log('[BT] Hardware settings:', { displaySize: `${this.hwSettings.displaySize.x}x${this.hwSettings.displaySize.y}`, targetFPS: this.hwSettings.targetFPS, });
if (!(await this.initRenderer(canvas, this.hwSettings))) { return false; }
// Create the built-in system font (synchronous, no GPU needed yet). this.systemFont = createSystemFont();
// Initialize pointer input. Listeners attach to the canvas; per-frame // resets are wired to GameLoop.onTickStart below. this.pointer?.detach(); this.pointer = new PointerInput(); this.pointer.attach(canvas, this.hwSettings.displaySize);
this.keyboard?.detach(); this.keyboard = new KeyboardInput(); this.keyboard.attach(canvas, { getTicks: () => this.loop?.getTicks() ?? 0, });
this.gamepad?.detach(); this.gamepad = new GamepadInput(); this.gamepad.attach();
// TODO: Initialize audio.
// Initialize the demo. console.log('[BT] Initializing demo');
if (!(await this.runDemoInit(demo))) { return false; }
// Start the loop. The GameLoop's double-RAF delay ensures the canvas is // fully ready before the first tick. const onFrameDrop: FrameDropCallback | undefined = this.hwSettings.detectDroppedFrames === true ? (event) => this.handleFrameDrop(event) : undefined;
this.loop = new GameLoop( updateInterval, () => this.demo?.update(), () => { if (this.renderer) { this.renderer.beginFrame(); this.demo?.render();
// Palette effects run after demo render (so user's explicit palette // changes in render() are respected) but before endFrame (so effects // are visible this frame via the dirty-flag GPU upload). if (this.palette && this.paletteEffects.activeCount > 0) { this.paletteEffects.update(this.palette); }
// Software ticker renders on top of the demo, after user draw calls. if (this.ticker && this.systemFont) { this.ticker.render( this.renderer, this.hwSettings?.displaySize.x ?? 0, this.systemFont, this.pointer, );
if (this.ticker.isDismissed) { this.ticker = null; } }
this.renderer.endFrame(); }
// Snapshot pointer state for next frame's edge detection / delta. // Must run AFTER demo.update + demo.render have read the current // state, so prev = "state when update last looked", letting any // event that arrives before the next tick be visible as a transition. this.pointer?.endFrame();
const tick = this.loop?.getTicks() ?? 0;
this.keyboard?.endFrame(tick); this.gamepad?.endFrame(tick); }, onFrameDrop, );
this.loop.start();
console.log('[BT] Initialization complete');
return true; }
/** * Stops the active game loop and detaches input subsystems. * * Pointer, keyboard, and gamepad subsystems are detached so listeners and * polling state do not leak across engine restarts (relevant in tests where * the same DOM persists). */ public stop(): void { this.loop?.stop(); this.clearInputSubsystems(); }
/** * Gets the current tick count. * Ticks increment once per fixed update step (target rate set by `targetFPS`). * * @returns Number of update ticks since initialization or last reset. */ public getTicks(): number { return this.loop?.getTicks() ?? 0; }
/** * Resets the tick counter to zero. * Useful for timing-based demo events and animations. */ public resetTicks(): void { this.loop?.resetTicks(); }
// #endregion
// #region Demo State Accessors
/** * Gets the hardware settings used for the active demo (from `configure()` or * {@link defaultConfig}). * * @returns Hardware configuration, or null if not initialized. */ public getHardwareSettings(): HardwareSettings | null { return this.hwSettings; }
/** * Gets the initialized WebGPU device. * * @returns GPU device, or null if not initialized. */ public getDevice(): GPUDevice | null { return this.device; }
/** * Gets the configured WebGPU canvas context. * * @returns Canvas context, or null if not initialized. */ public getContext(): GPUCanvasContext | null { return this.context; }
// #endregion
// #region WebGPU Resource Accessors
/** * Gets the canvas bound during initialization. * * @returns HTML canvas element, or null if not initialized. */ public getCanvas(): HTMLCanvasElement | null { return this.canvas; }
/** * Gets the renderer created during initialization. * * @returns Renderer instance, or null if not initialized. */ public getRenderer(): IRenderer | null { return this.renderer; }
/** * Returns the renderer backend that was actually initialized. * * @returns `'webgpu'` or `'software'` after successful init; `null` before init or on failure. */ public getActiveBackend(): RendererBackend | null { return this.activeBackend; }
/** * Gets the pointer input subsystem created during initialization. * * @returns Pointer input instance, or null when the engine has not been * initialized yet (or has been stopped). */ public getPointer(): PointerInput | null { return this.pointer; }
/** * Gets the keyboard input subsystem created during initialization. * * @returns Keyboard input instance, or null when the engine has not been * initialized yet (or has been stopped). */ public getKeyboard(): KeyboardInput | null { return this.keyboard; }
/** * Gets the gamepad input subsystem created during initialization. * * @returns Gamepad input instance, or null when the engine has not been * initialized yet (or has been stopped). */ public getGamepad(): GamepadInput | null { return this.gamepad; }
// #endregion
// #region Rendering API - Palette
/** * Gets the active engine palette. * * @returns Active palette, or null if none has been set. */ public getPalette(): Palette | null { return this.palette; }
/** * Sets the active engine palette and propagates it to the renderer. * * If sprite sheets have already been indexized, emits a warning: the indexed * pixel data will be stale until `spritesRefresh()` is called. * * @param palette - Palette to store as the active engine palette. */ public setPalette(palette: Palette): void { if (this.spriteSheets.size > 0) { console.warn('[BT] Active palette structure changed. Call BT.spritesRefresh() to update loaded sprites.'); }
// In-flight effects hold snapshots of the old palette. Drop them so they // don't apply stale colors to the new palette. this.paletteEffects.clear();
this.palette = palette; this.renderer?.setPalette(palette); }
// #endregion
// #region Rendering API - Clear Operations
/** * Sets the background clear color for each frame using a palette index. * * @param paletteIndex - Palette index for the clear color. */ public setClearColor(paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.setClearColor(paletteIndex); }
/** * Fills a rectangular region with a palette-indexed color. * * @param rect - Region to fill in pixel coordinates. * @param paletteIndex - Palette color index. */ public clearRect(rect: Rect2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.clearRect(rect, paletteIndex); }
/** * Draws a single pixel at the specified position. * * @param pos - Pixel coordinates. * @param paletteIndex - Palette color index. */ public drawPixel(pos: Vector2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawPixel(pos, paletteIndex); }
/** * Draws a line between two points using Bresenham's algorithm. * Produces pixel-perfect lines without antialiasing. * * @param p0 - Start point. * @param p1 - End point. * @param paletteIndex - Palette color index. */ public drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawLine(p0, p1, paletteIndex); }
// #endregion
// #region Rendering API - Primitives
/** * Draws a rectangle outline (unfilled). * * @param rect - Rectangle bounds. * @param paletteIndex - Palette color index. */ public drawRect(rect: Rect2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawRect(rect, paletteIndex); }
/** * Draws a filled rectangle. * * @param rect - Rectangle bounds. * @param paletteIndex - Palette color index. */ public drawRectFill(rect: Rect2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawRectFill(rect, paletteIndex); }
/** * Draws text using the built-in 6x14 system font. * * The system font stores foreground pixels as palette index 1. The * `paletteIndex` parameter is converted to a sprite pipeline palette * offset so that each foreground pixel maps to `palette[paletteIndex]`. * * @param pos - Text position (top-left corner). * @param paletteIndex - Palette color index for the text. * @param text - String to display. */ public drawSystemText(pos: Vector2i, paletteIndex: number, text: string): void { this.assertPaletteIndex(paletteIndex);
// Palette index 0 is transparent - nothing to draw. if (paletteIndex === 0) { return; }
if (this.systemFont) { // Offset math: font stores foreground as index 1. // Shader computes 1 + (paletteIndex - 1) = paletteIndex. this.renderer?.drawBitmapText(this.systemFont, pos, text, paletteIndex - 1); } }
/** * Returns the built-in system font, or null if not yet initialized. * * @returns The system BitmapFont instance. */ public getSystemFont(): BitmapFont | null { return this.systemFont; }
/** * Draws a sprite region from an indexed sprite sheet. * The renderer batches compatible sprite draws internally. * * @param spriteSheet - Source sprite sheet (must have been indexized via spriteSheet.indexize()). * @param srcRect - Region to copy from the sprite sheet. * @param destPos - Screen position to draw in (the top-left corner). * @param paletteOffset - Palette index offset applied at draw time (default 0). * @throws If the sprite sheet has not been indexized. */ public drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, paletteOffset: number = 0): void { this.assertPaletteIndex(paletteOffset); this.requireIndexizedSheet(spriteSheet); this.renderer?.drawSprite(spriteSheet, srcRect, destPos, paletteOffset); }
/** * Draws text using a bitmap font with variable-width glyphs. * Supports Unicode characters and per-glyph render offsets. * * @param font - Bitmap font containing character glyphs (underlying sheet must be indexized). * @param pos - Text position (top-left corner). * @param text - String to render. * @param paletteOffset - Palette index offset applied to all glyphs (default 0). * @throws If the font's sprite sheet has not been indexized. */ public drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, paletteOffset: number = 0): void { this.assertPaletteIndex(paletteOffset); this.requireIndexizedSheet(font.getSpriteSheet()); this.renderer?.drawBitmapText(font, pos, text, paletteOffset); }
// #endregion
// #region Rendering API - Sprites
/** * Re-indexizes all tracked sprite sheets against the current active palette. * * Call this after swapping or modifying the active palette to keep all loaded * sprite sheets in sync with the new color-to-index mapping. * * @throws If no active palette has been set. */ public spritesRefresh(): void { if (!this.palette) { throw new Error(noActivePaletteError()); }
let refreshed = 0;
for (const sheet of this.spriteSheets) { if (!sheet.isIndexized()) { this.spriteSheets.delete(sheet); continue; }
try { sheet.reindexize(this.palette); refreshed++; } catch (e) { console.error('[BT] spritesRefresh: failed to reindexize sheet, removing from registry:', e); this.spriteSheets.delete(sheet); } }
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. * * @returns Promise resolving to a PNG Blob. * @throws Error if the renderer is not initialized. */ public captureFrame(): Promise<Blob> { if (!this.renderer) { return Promise.reject(new Error("Can't capture frame: renderer not initialized")); }
return this.renderer.captureFrame(); }
// #endregion
// #region Camera API
/** * Sets the camera offset for scrolling effects. * The offset is applied to subsequent renderer draw calls. * * @param offset - Camera position offset in pixels. */ public setCameraOffset(offset: Vector2i): void { this.renderer?.setCameraOffset(offset); }
/** * Gets the current camera offset. * * @returns Current camera position offset. */ public getCameraOffset(): Vector2i { return this.renderer?.getCameraOffset() ?? Vector2i.zero(); }
/** * Resets the camera offset to (0, 0). */ public resetCamera(): void { this.renderer?.resetCamera(); }
// #endregion
// #region Palette Effects API
/** * Starts rotating a range of palette entries at a constant speed. * * Classic water/fire/plasma animation. Runs indefinitely until cancelled * via {@link paletteClearEffects}. * * @param start - First palette index in the cycling range (inclusive). * @param end - Last palette index in the cycling range (inclusive). * @param speed - Steps per second. Positive = forward, negative = backward. */ public paletteCycle(start: number, end: number, speed: number): void { if (!Number.isFinite(speed)) { throw new Error(`paletteCycle: 'speed' should be a number (got ${speed}).`); }
if (!Number.isInteger(start) || !Number.isInteger(end) || start >= end) { throw new Error(`paletteCycle: start must be an integer less than end, got [${start}, ${end}].`); }
this.paletteEffects.add(new CycleEffect(start, end, speed)); }
/** * Smoothly interpolates all palette entries toward a target over time. * * Snapshots the current palette at start. Auto-removes when complete. * * @param target - Target palette to fade toward. * @param durationMs - Fade duration in milliseconds. * @param easing - Easing curve. Defaults to `'linear'`. */ public paletteFade(target: Palette, durationMs: number, easing?: EasingFunction): void { if (!this.palette) { throw new Error(noActivePaletteError()); }
this.assertFiniteDuration('paletteFade', durationMs); this.paletteEffects.add(new FadeEffect(this.palette, target, durationMs, easing)); }
/** * Fades only a subset of palette indices toward a target over time. * * @param start - First palette index to fade (inclusive). * @param end - Last palette index to fade (inclusive). * @param target - Target palette to fade toward. * @param durationMs - Fade duration in milliseconds. * @param easing - Easing curve. Defaults to `'linear'`. */ public paletteFadeRange( start: number, end: number, target: Palette, durationMs: number, easing?: EasingFunction, ): void { if (!this.palette) { throw new Error(noActivePaletteError()); }
this.assertFiniteDuration('paletteFadeRange', durationMs); this.paletteEffects.add(new FadeRangeEffect(start, end, this.palette, target, durationMs, easing)); }
/** * Temporarily sets all non-zero palette entries to a single color, then restores. * * Index 0 (transparent) is preserved. Auto-removes after duration. * * @param color - Flash color applied to all non-zero entries. * @param durationMs - How long the flash lasts in milliseconds. */ public paletteFlash(color: Color32, durationMs: number): void { if (!this.palette) { throw new Error(noActivePaletteError()); }
this.assertFiniteDuration('paletteFlash', durationMs); this.paletteEffects.add(new FlashEffect(color, durationMs)); }
/** * Instantly exchanges two palette entries. * * This is an immediate operation, not an animated effect. * * @param indexA - First palette index. * @param indexB - Second palette index. */ public paletteSwap(indexA: number, indexB: number): void { if (!this.palette) { throw new Error(noActivePaletteError()); }
paletteSwap(this.palette, indexA, indexB); }
/** * Cancels all running palette effects immediately. * * The palette stays at whatever state it was in when cancelled. */ public paletteClearEffects(): void { this.paletteEffects.clear(); }
// #endregion
// #region Post-Process Effects API
/** * Appends a fullscreen post-processing effect to the chain. * * The first registered effect causes the scene to render into an offscreen * texture starting on the next frame. Effects run in registration order. * * @param effect - Effect instance to append. * @throws Error if the renderer has not been initialized. */ public effectAdd(effect: Effect): void { if (!this.renderer) { throw new Error('Cannot add effect: renderer not initialized.'); }
this.renderer.addEffect(effect); }
/** * Removes a previously registered post-processing effect. * * Calls the effect's optional dispose hook. Removing an effect that was * never added is a no-op. When the last effect is removed the renderer * reverts to drawing directly to the swap chain on the next frame. * * @param effect - Effect instance to remove. * @throws Error if the renderer has not been initialized. */ public effectRemove(effect: Effect): void { if (!this.renderer) { throw new Error('Cannot remove effect: renderer not initialized.'); }
this.renderer.removeEffect(effect); }
/** * Removes every registered post-processing effect. * * @throws Error if the renderer has not been initialized. */ public effectClear(): void { if (!this.renderer) { throw new Error('Cannot clear effects: renderer not initialized.'); }
this.renderer.clearEffects(); }
// #endregion
// #region Private - Initialization Helpers
/** * Constructs and initializes the renderer for the active hardware settings. * * Logs the selected backend name, creates the requested renderer backend, * calls {@link IRenderer.init}, and reports success or failure. * * @param canvas - Render target canvas. * @param hw - Active hardware settings. * @returns `true` when the renderer is ready; `false` on failure. */ private async initRenderer(canvas: HTMLCanvasElement, hw: HardwareSettings): Promise<boolean> { applyCanvasLayoutStyles(canvas, { displaySize: hw.displaySize, maxCanvasDisplaySize: hw.maxCanvasDisplaySize ?? new Vector2i(DEFAULT_MAX_CANVAS_DISPLAY_SIZE.x, DEFAULT_MAX_CANVAS_DISPLAY_SIZE.y), ...(hw.canvasDisplaySize !== undefined ? { canvasDisplaySize: hw.canvasDisplaySize } : {}), });
const requestedBackend = hw.renderer ?? 'webgpu';
if (requestedBackend !== 'software') { // Try WebGPU. initWebGPU returns null when navigator.gpu is absent and // throws when the adapter or device cannot be created. Both cases fall // through to the software renderer below. let webGPUResult: Awaited<ReturnType<typeof initWebGPU>> = null; try { webGPUResult = await initWebGPU(canvas, hw.displaySize, hw.canvasDisplaySize); } catch (error) { if (error instanceof RenderDimensionLimitError) { return false; }
// Adapter/device unavailable; fall through to software. }
if (webGPUResult) { this.device = webGPUResult.device; this.context = webGPUResult.context; console.log('[BT] Initializing renderer (backend: webgpu)');
this.renderer = new WebGpuRenderer( webGPUResult.device, webGPUResult.context, hw.displaySize, // Only forward an explicit outputSize when canvasDisplaySize was // provided; that is the signal that unlocks the display tier. hw.canvasDisplaySize !== undefined ? webGPUResult.drawingBufferSize : undefined, hw.outputUpscaleFilter ?? 'nearest', );
if (!(await this.renderer.init())) { console.error('[BT] Failed to initialize renderer');
return false; }
this.activeBackend = 'webgpu'; this.ticker = null; console.log('[BT] Renderer initialized');
return true; }
console.warn('[BT] WebGPU unavailable, falling back to software renderer'); }
// Software renderer path: explicit selection or automatic fallback. this.device = null; this.context = null; console.log('[BT] Initializing renderer (backend: software)');
this.renderer = new SoftwareRenderer(canvas, hw.displaySize, hw.canvasDisplaySize);
if (!(await this.renderer.init())) { console.error('[BT] Failed to initialize renderer');
return false; }
this.activeBackend = 'software'; this.ticker = new SoftwareTicker(`${BTAPI.VERSION_MAJOR}.${BTAPI.VERSION_MINOR}.${BTAPI.VERSION_PATCH}`); console.log('[BT] Renderer initialized');
return true; }
/** * Applies URL renderer override from `?renderer=...` when present. * * Supported values: * - `software` * * Unknown values are ignored so accidental query typos do not break startup. */ private applyRendererQueryOverride(): void { if (!this.hwSettings) { return; }
const override = BTAPI.getRendererQueryOverride(); if (!override) { return; }
this.hwSettings.renderer = override; console.info(`[BT] URL override selected renderer backend: ${override}`); }
/** * Reads renderer override from the current URL query string. * * @returns Supported renderer backend override, or null when absent/invalid. */ private static getRendererQueryOverride(): RendererBackend | null { const search = typeof globalThis.location?.search === 'string' ? globalThis.location.search : typeof window !== 'undefined' ? window.location?.search : '';
if (!search) { return null; }
try { const renderer = new URLSearchParams(search).get('renderer'); if (renderer === 'software') { return 'software'; } } catch (error) { console.warn('[BT] Failed to parse renderer query override:', error); }
return null; }
/** * 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<boolean> { 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; } }
// #endregion
// #region Private Helpers
/** * Logs a dropped-frame event from the {@link GameLoop} as a console warning. * * One line per event. The {@link GameLoop} auto-calibrates its baseline * to the actual rAF cadence, so sustained slowness re-baselines instead * of generating sustained log spam, leaving only genuine missed-vsync * events to be reported here. * * @param event - Dropped-frame event. */ private handleFrameDrop(event: FrameDropEvent): void { console.warn( `[BT] Dropped ${event.droppedFrames} frame(s) ` + `(frame time ${event.deltaTime.toFixed(1)}ms, expected ${event.expectedInterval.toFixed(1)}ms)`, ); }
/** * Validates that a sprite sheet has been indexized and registers it for refresh tracking. * * @param sheet - Sprite sheet to validate. * @throws If the sprite sheet has not been indexized. */ private requireIndexizedSheet(sheet: SpriteSheet): void { if (!sheet.isIndexized()) { throw new Error(spriteNotIndexizedError()); }
this.spriteSheets.add(sheet); }
/** * 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(`${method}: the time should be a non-negative number of milliseconds (got ${durationMs}).`); } }
/** * Validates that a palette index is a non-negative integer and, when a palette * is active, that the index is within its range. * * The non-integer/negative check always runs regardless of palette state. * The range check only runs when a palette has been set. * * @param index - Palette index to validate. * @throws Error if the index is not a non-negative integer. * @throws Error if a palette is active and the index is out of its range. */ private assertPaletteIndex(index: number): void { if (!Number.isInteger(index) || index < 0) { throw new Error(paletteIndexNegativeError(index)); }
if (this.palette && index >= this.palette.size) { throw new Error(paletteIndexOutOfRangeError(index, this.palette.size)); } }
// #endregion}