From afdbacffb834fde4a46afd2f72935a12bc599149 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 10:29:43 +0200 Subject: [PATCH 1/3] refactor(renderer): extract IRenderer contract and migrate to WebGpuRenderer Introduce a backend-agnostic IRenderer interface covering all public renderer methods (palette, frame lifecycle, primitives, sprites, camera, post-process effects, frame capture). Migrate the existing WebGPU implementation from Renderer to WebGpuRenderer which now implements the interface. BTAPI holds IRenderer internally, enabling backend selection in future tickets. Add RendererBackend ('webgpu' | 'software') to HardwareSettings so demos can opt into future backends via configure(). Export the type from the public BT API surface. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- CLAUDE.md | 3 +- docs/testing.md | 2 +- src/BlitTech.ts | 13 +- src/core/BTAPI.ts | 63 ++++-- src/core/IBlitTechDemo.ts | 22 ++ src/render/IRenderer.ts | 201 ++++++++++++++++++ ...enderer.test.ts => WebGpuRenderer.test.ts} | 138 ++++++++---- src/render/{Renderer.ts => WebGpuRenderer.ts} | 34 +-- 8 files changed, 395 insertions(+), 81 deletions(-) create mode 100644 src/render/IRenderer.ts rename src/render/{Renderer.test.ts => WebGpuRenderer.test.ts} (85%) rename src/render/{Renderer.ts => WebGpuRenderer.ts} (95%) diff --git a/CLAUDE.md b/CLAUDE.md index 766f141..d034912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,8 @@ src/ GameLoop.ts # Fixed-timestep game loop WebGPUContext.ts # WebGPU adapter/device/context setup render/ - Renderer.ts # High-level renderer (coordinates pipelines + chains) + IRenderer.ts # Backend-agnostic renderer contract (interface) + WebGpuRenderer.ts # WebGPU concrete renderer implementing IRenderer PrimitivePipeline.ts # Batched colored geometry (pixels, lines, rects) SpritePipeline.ts # Batched textured quads (sprites, bitmap text) PostProcessChain.ts # Tier-aware fullscreen effect chain diff --git a/docs/testing.md b/docs/testing.md index 20d01eb..cac9e17 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,7 +29,7 @@ and `vi` for browser API stubs. Tests that need a full DOM (Bootstrap, Bootstrap - **BitmapFont** - glyph lookup, text measurement (Node + vi stubs) - **BootstrapHelpers** - WebGPU support detection, canvas lookup (happy-dom) - **Bootstrap** - full bootstrap lifecycle (happy-dom) -- **Renderer** - frame lifecycle, camera, pipeline delegation (Node + GPU mocks) +- **WebGpuRenderer** - frame lifecycle, camera, pipeline delegation (Node + GPU mocks) - **PrimitivePipeline** - vertex buffer math, line algorithm (Node + GPU mocks) - **SpritePipeline** - texture batching, UV coordinates (Node + GPU mocks) - **WebGPUContext** - initialization with mock adapter/device (Node + GPU mocks) diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 04a8abe..8a91e64 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -18,7 +18,7 @@ import { Palette } from './assets/Palette'; import type { IndexedSpriteLoadResult } from './assets/SpriteSheet'; import { SpriteSheet } from './assets/SpriteSheet'; import { BTAPI } from './core/BTAPI'; -import { defaultConfig, type HardwareSettings, type IBlitTechDemo } from './core/IBlitTechDemo'; +import { defaultConfig, type HardwareSettings, type IBlitTechDemo, type RendererBackend } from './core/IBlitTechDemo'; import { createDefaultKeyboardRuntimeMaps, DEFAULT_KEYBOARD_PLAYER1, @@ -1477,7 +1477,16 @@ export { Vector2i, Vignette, }; -export type { BootstrapOptions, EasingFunction, Effect, EffectTier, HardwareSettings, IBlitTechDemo, TextSize }; +export type { + BootstrapOptions, + EasingFunction, + Effect, + EffectTier, + HardwareSettings, + IBlitTechDemo, + RendererBackend, + TextSize, +}; export type { IndexedSpriteLoadResult }; // #endregion diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 6db4750..8143740 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -14,7 +14,8 @@ import { GamepadInput } from '../input/GamepadInput'; import { KeyboardInput } from '../input/KeyboardInput'; import { PointerInput } from '../input/PointerInput'; import type { Effect } from '../render/effects/Effect'; -import { Renderer } from '../render/Renderer'; +import type { IRenderer } from '../render/IRenderer'; +import { WebGpuRenderer } from '../render/WebGpuRenderer'; import type { Color32 } from '../utils/Color32'; import type { EasingFunction } from '../utils/Easing'; import { @@ -29,6 +30,7 @@ import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; import { GameLoop } from './GameLoop'; import type { HardwareSettings, IBlitTechDemo } from './IBlitTechDemo'; import { defaultConfig } from './IBlitTechDemo'; +import type { WebGPUContextResult } from './WebGPUContext'; import { initWebGPU } from './WebGPUContext'; /** @@ -80,7 +82,7 @@ export class BTAPI { private canvas: HTMLCanvasElement | null = null; /** Renderer subsystem for all drawing operations. */ - private renderer: Renderer | null = null; + private renderer: IRenderer | null = null; /** Active engine palette used by palette-first rendering. */ private palette: Palette | null = null; @@ -209,27 +211,10 @@ export class BTAPI { this.device = webGPUResult.device; this.context = webGPUResult.context; - // Initialize subsystems. - console.log('[BT] Initializing renderer'); - - this.renderer = new Renderer( - this.device, - this.context, - this.hwSettings.displaySize, - // Only forward an explicit outputSize when canvasDisplaySize was - // provided; that is the signal that unlocks the display tier. - this.hwSettings.canvasDisplaySize !== undefined ? webGPUResult.drawingBufferSize : undefined, - this.hwSettings.outputUpscaleFilter ?? 'nearest', - ); - - if (!(await this.renderer.init())) { - console.error('[BT] Failed to initialize renderer'); - + if (!(await this.initRenderer(webGPUResult, this.hwSettings))) { return false; } - console.log('[BT] Renderer initialized'); - // Create the built-in system font (synchronous, no GPU needed yet). this.systemFont = createSystemFont(); @@ -382,7 +367,7 @@ export class BTAPI { * * @returns Renderer instance, or null if not initialized. */ - public getRenderer(): Renderer | null { + public getRenderer(): IRenderer | null { return this.renderer; } @@ -844,6 +829,42 @@ export class BTAPI { // #region Private — Initialization Helpers + /** + * Constructs and initializes the renderer for the active hardware settings. + * + * Logs the selected backend name, creates a {@link WebGpuRenderer}, calls + * {@link IRenderer.init}, and reports success or failure. + * + * @param webGPUResult - Initialized WebGPU device, context, and drawing-buffer size. + * @param hw - Active hardware settings. + * @returns `true` when the renderer is ready; `false` on failure. + */ + private async initRenderer(webGPUResult: WebGPUContextResult, hw: HardwareSettings): Promise { + const backend = hw.renderer ?? 'webgpu'; + + console.log(`[BT] Initializing renderer (backend: ${backend})`); + + 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; + } + + console.log('[BT] Renderer initialized'); + + return true; + } + /** * Removes pointer, keyboard, and gamepad subsystems. * diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index ed4aebf..a9e9d47 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -8,6 +8,19 @@ import { Vector2i } from '../utils/Vector2i'; */ export type OutputUpscaleFilter = 'nearest' | 'linear'; +/** + * Renderer backend selection for {@link HardwareSettings.renderer}. + * + * - `'webgpu'` - Hardware-accelerated WebGPU renderer (default). Supports all + * draw primitives, sprites, palette, camera, and fullscreen post-process + * effects. + * - `'software'` - Canvas 2D software fallback. Supports draw primitives, + * sprites, palette, and camera. Fullscreen shader effects are not available + * and will throw when added. Implemented in VV-490; actual backend selection + * is wired in VV-491. + */ +export type RendererBackend = 'webgpu' | 'software'; + /** * Engine-facing hardware configuration returned by `configure()` when a demo * implements that optional hook, or by {@link defaultConfig} otherwise. @@ -54,6 +67,15 @@ export interface HardwareSettings { * at `targetFPS`. */ detectDroppedFrames?: boolean; + + /** + * Renderer backend to use. Defaults to `'webgpu'`. + * + * Set to `'software'` to opt into the Canvas 2D fallback backend (VV-490). + * The engine auto-selects WebGPU first regardless of this setting until + * backend selection is fully wired (VV-491). + */ + renderer?: RendererBackend; } /** diff --git a/src/render/IRenderer.ts b/src/render/IRenderer.ts new file mode 100644 index 0000000..282c37b --- /dev/null +++ b/src/render/IRenderer.ts @@ -0,0 +1,201 @@ +import type { BitmapFont } from '../assets/BitmapFont'; +import type { Palette } from '../assets/Palette'; +import type { SpriteSheet } from '../assets/SpriteSheet'; +import type { Rect2i } from '../utils/Rect2i'; +import type { Vector2i } from '../utils/Vector2i'; +import type { Effect } from './effects/Effect'; + +/** + * Backend-agnostic renderer contract. + * + * Any rendering backend (WebGPU, software Canvas 2D, headless test stub, etc.) + * must satisfy this interface. {@link BTAPI} holds a reference to `IRenderer` + * and never depends on the concrete implementation directly, allowing the engine + * to swap backends at initialization time based on {@link HardwareSettings.renderer}. + * + * Lifecycle: + * 1. Call {@link init} once after construction. + * 2. Per frame: {@link beginFrame} -> draw calls -> {@link endFrame}. + * 3. Palette must be set via {@link setPalette} before the first {@link beginFrame}. + */ +export interface IRenderer { + // #region Initialization + + /** + * Initializes GPU or canvas resources for rendering. + * + * @returns `true` when resources are ready; `false` on failure. + */ + init(): Promise; + + // #endregion + + // #region Palette + + /** + * Sets the active palette used for all color lookups this frame and beyond. + * + * @param palette - Palette to activate. + */ + setPalette(palette: Palette): void; + + /** + * Returns a snapshot of the active palette, or `null` if none has been set. + * + * @returns Clone of the active palette, or `null`. + */ + getPalette(): Palette | null; + + // #endregion + + // #region Frame Management + + /** + * Begins a new frame. Resets per-frame draw batches. + * + * @throws Error if no palette has been set via {@link setPalette}. + */ + beginFrame(): void; + + /** + * Sets the background clear color for the current frame using a palette index. + * + * @param paletteIndex - Palette index for the clear color. + */ + setClearColor(paletteIndex: number): void; + + /** + * Ends the current frame and presents the result to the display. + */ + endFrame(): void; + + // #endregion + + // #region Drawing - Primitives + + /** + * Draws a filled rectangle. + * + * @param rect - Rectangle bounds. + * @param paletteIndex - Palette color index. + */ + drawRectFill(rect: Rect2i, paletteIndex: number): void; + + /** + * Draws a single pixel. + * + * @param pos - Pixel position. + * @param paletteIndex - Palette color index. + */ + drawPixel(pos: Vector2i, paletteIndex: number): void; + + /** + * Draws a line between two points. + * + * @param p0 - Start point. + * @param p1 - End point. + * @param paletteIndex - Palette color index. + */ + drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void; + + /** + * Draws a rectangle outline. + * + * @param rect - Rectangle bounds. + * @param paletteIndex - Palette color index. + */ + drawRect(rect: Rect2i, paletteIndex: number): void; + + /** + * Fills a rectangular region with a palette-indexed color. + * + * @param rect - Region to fill. + * @param paletteIndex - Palette color index. + */ + clearRect(rect: Rect2i, paletteIndex: number): void; + + // #endregion + + // #region Drawing - Sprites + + /** + * Draws a sprite region from an indexed sprite sheet. + * + * @param spriteSheet - Source sprite sheet (must be indexized). + * @param srcRect - Region to copy from the sprite sheet. + * @param destPos - Screen position to draw at. + * @param paletteOffset - Palette index offset applied at draw time (default 0). + */ + drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, paletteOffset?: number): void; + + /** + * Draws text using a bitmap font. + * + * @param font - Bitmap font with 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). + */ + drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, paletteOffset?: number): void; + + // #endregion + + // #region Frame Capture + + /** + * Captures the next rendered frame as a PNG blob. + * + * @returns Promise resolving to a PNG Blob after the next {@link endFrame}. + */ + captureFrame(): Promise; + + // #endregion + + // #region Camera + + /** + * Sets the camera offset for scrolling. + * + * @param offset - Camera position in pixels. + */ + setCameraOffset(offset: Vector2i): void; + + /** + * Gets the current camera offset. + * + * @returns Copy of the current camera position. + */ + getCameraOffset(): Vector2i; + + /** + * Resets the camera offset to the origin (0, 0). + */ + resetCamera(): void; + + // #endregion + + // #region Post-Process Effects + + /** + * Appends a fullscreen post-processing effect. + * + * Backends that do not support shader effects must throw with a clear message. + * + * @param effect - Effect instance to append. + */ + addEffect(effect: Effect): void; + + /** + * Removes a previously registered post-processing effect. + * + * @param effect - Effect instance to remove. + */ + removeEffect(effect: Effect): void; + + /** + * Removes every registered post-processing effect. + */ + clearEffects(): void; + + // #endregion +} diff --git a/src/render/Renderer.test.ts b/src/render/WebGpuRenderer.test.ts similarity index 85% rename from src/render/Renderer.test.ts rename to src/render/WebGpuRenderer.test.ts index cc88cbb..82c89cf 100644 --- a/src/render/Renderer.test.ts +++ b/src/render/WebGpuRenderer.test.ts @@ -1,7 +1,7 @@ /** - * Unit tests for {@link Renderer}. + * Unit tests for {@link WebGpuRenderer}. * - * Exercises the engine's render coordinator: + * Exercises the engine's WebGPU render coordinator: * - constructor behavior and pre-initialization safety * - camera state ownership and copy semantics * - successful renderer initialization and repeated frame lifecycles @@ -29,7 +29,7 @@ import { Color32 } from '../utils/Color32'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import type { Effect } from './effects/Effect'; -import { Renderer } from './Renderer'; +import { WebGpuRenderer } from './WebGpuRenderer'; // #region Test Helpers @@ -53,15 +53,15 @@ function createTestPalette(): Palette { // #region Constructor -describe('Renderer constructor', () => { +describe('WebGpuRenderer constructor', () => { it('creates an instance with mock objects', () => { const device = createMockGPUDevice(); const context = createMockGPUCanvasContext(); const displaySize = new Vector2i(320, 240); - const renderer = new Renderer(device, context, displaySize); + const renderer = new WebGpuRenderer(device, context, displaySize); expect(renderer).toBeDefined(); - expect(renderer).toBeInstanceOf(Renderer); + expect(renderer).toBeInstanceOf(WebGpuRenderer); }); }); @@ -71,7 +71,11 @@ describe('Renderer constructor', () => { describe('pre-initialization methods', () => { it('setClearColor does not throw', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); expect(() => { renderer.setClearColor(1); @@ -79,7 +83,11 @@ describe('pre-initialization methods', () => { }); it('setCameraOffset does not throw', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); expect(() => { renderer.setCameraOffset(new Vector2i(10, 20)); @@ -87,7 +95,11 @@ describe('pre-initialization methods', () => { }); it('getCameraOffset returns a zero vector initially', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); const offset = renderer.getCameraOffset(); expect(offset.x).toBe(0); @@ -95,7 +107,11 @@ describe('pre-initialization methods', () => { }); it('resetCamera sets camera back to zero', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); renderer.setCameraOffset(new Vector2i(50, 75)); renderer.resetCamera(); @@ -107,7 +123,11 @@ describe('pre-initialization methods', () => { }); it('beginFrame throws without active palette', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); expect(() => { renderer.beginFrame(); @@ -115,7 +135,11 @@ describe('pre-initialization methods', () => { }); it('beginFrame succeeds with active palette', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); renderer.setPalette(createTestPalette()); @@ -131,7 +155,11 @@ describe('pre-initialization methods', () => { describe('camera operations', () => { it('getCameraOffset returns a copy, not the internal reference', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); renderer.setCameraOffset(new Vector2i(42, 84)); @@ -150,7 +178,11 @@ describe('camera operations', () => { }); it('setCameraOffset stores the offset correctly', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); renderer.setCameraOffset(new Vector2i(100, 200)); @@ -161,7 +193,11 @@ describe('camera operations', () => { }); it('setCameraOffset clones the input vector', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); const input = new Vector2i(30, 60); renderer.setCameraOffset(input); @@ -182,7 +218,11 @@ describe('camera operations', () => { describe('palette enforcement', () => { it('setPalette stores and returns palette', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); const palette = createTestPalette(); renderer.setPalette(palette); @@ -197,7 +237,11 @@ describe('palette enforcement', () => { }); it('getPalette returns null when no palette is set', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); expect(renderer.getPalette()).toBeNull(); }); @@ -211,7 +255,7 @@ describe('with initialized renderer', () => { const device = createMockGPUDevice(); const context = createMockGPUCanvasContext(); const displaySize = new Vector2i(320, 240); - const renderer = new Renderer(device, context, displaySize); + const renderer = new WebGpuRenderer(device, context, displaySize); beforeAll(async () => { installMockNavigatorGPU(); @@ -228,7 +272,7 @@ describe('with initialized renderer', () => { }); it('init returns true on success', async () => { - const r = new Renderer(device, context, displaySize); + const r = new WebGpuRenderer(device, context, displaySize); const result = await r.init(); expect(result).toBe(true); @@ -383,7 +427,7 @@ describe('with initialized renderer', () => { describe('resolveClearColor fallbacks', () => { it('returns black (no throw) when no palette is set', async () => { - const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -396,7 +440,7 @@ describe('resolveClearColor fallbacks', () => { }); it('returns black (no throw) when palette.get throws', async () => { - const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -435,7 +479,7 @@ describe('frame capture', () => { }); const context = createMockGPUCanvasContext(); - const renderer = new Renderer(device, context, new Vector2i(4, 4)); + const renderer = new WebGpuRenderer(device, context, new Vector2i(4, 4)); installMockNavigatorGPU(); @@ -494,7 +538,7 @@ describe('frame capture', () => { }); const context = createMockGPUCanvasContext(); - const renderer = new Renderer(device, context, new Vector2i(4, 4)); + const renderer = new WebGpuRenderer(device, context, new Vector2i(4, 4)); installMockNavigatorGPU(); @@ -555,7 +599,7 @@ describe('frame capture', () => { }); const context = createMockGPUCanvasContext(); - const renderer = new Renderer(device, context, new Vector2i(320, 240)); + const renderer = new WebGpuRenderer(device, context, new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -586,7 +630,7 @@ describe('endFrame error paths', () => { }, } as unknown as GPUCanvasContext; - const renderer = new Renderer(device, throwingContext, new Vector2i(320, 240)); + const renderer = new WebGpuRenderer(device, throwingContext, new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -630,7 +674,7 @@ describe('endFrame error paths', () => { }), } as unknown as GPUCanvasContext; - const renderer = new Renderer(device, zeroTextureContext, new Vector2i(320, 240)); + const renderer = new WebGpuRenderer(device, zeroTextureContext, new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -664,7 +708,7 @@ describe('init error paths', () => { }, } as unknown as GPUDevice; - const renderer = new Renderer(throwingDevice, createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer(throwingDevice, createMockGPUCanvasContext(), new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -689,7 +733,11 @@ describe('init error paths', () => { describe('palette dirty-flag auto-propagation', () => { it('setPalette stores a reference, not a clone', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); const palette = createTestPalette(); renderer.setPalette(palette); @@ -702,7 +750,11 @@ describe('palette dirty-flag auto-propagation', () => { }); it('getPalette still returns a clone, not the internal reference', () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); const palette = createTestPalette(); renderer.setPalette(palette); @@ -711,7 +763,11 @@ describe('palette dirty-flag auto-propagation', () => { }); it('palette.dirty is cleared after endFrame uploads', async () => { - const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer( + createMockGPUDevice(), + createMockGPUCanvasContext(), + new Vector2i(320, 240), + ); installMockNavigatorGPU(); @@ -739,7 +795,7 @@ describe('palette dirty-flag auto-propagation', () => { const device = createMockGPUDevice(); const writeBufferSpy = vi.spyOn(device.queue, 'writeBuffer'); - const renderer = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -773,7 +829,7 @@ describe('palette dirty-flag auto-propagation', () => { const device = createMockGPUDevice(); const writeBufferSpy = vi.spyOn(device.queue, 'writeBuffer'); - const renderer = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); + const renderer = new WebGpuRenderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); installMockNavigatorGPU(); @@ -842,25 +898,25 @@ describe('post-process effects', () => { } it('addEffect throws before init', () => { - const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => r.addEffect(createStubEffect())).toThrow(/not initialized/); }); it('removeEffect throws before init', () => { - const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => r.removeEffect(createStubEffect())).toThrow(/not initialized/); }); it('clearEffects throws before init', () => { - const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => r.clearEffects()).toThrow(/not initialized/); }); it('addEffect / clearEffects work after init', async () => { - const r = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); await r.init(); @@ -887,7 +943,7 @@ describe('post-process effects', () => { return encoder; }); - const r = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); await r.init(); r.setPalette(createTestPalette()); @@ -901,7 +957,7 @@ describe('post-process effects', () => { it('endFrame drives chain.encode for each registered effect', async () => { const device = createMockGPUDevice(); - const r = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); await r.init(); r.setPalette(createTestPalette()); @@ -924,7 +980,7 @@ describe('post-process effects', () => { it('endFrame drives every effect when multiple are stacked', async () => { const device = createMockGPUDevice(); - const r = new Renderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); + const r = new WebGpuRenderer(device, createMockGPUCanvasContext(), new Vector2i(320, 240)); await r.init(); r.setPalette(createTestPalette()); @@ -967,7 +1023,7 @@ describe('post-process effects', () => { getCurrentTexture: () => swapTexture, } as unknown as GPUCanvasContext; - const r = new Renderer(device, context, new Vector2i(320, 240)); + const r = new WebGpuRenderer(device, context, new Vector2i(320, 240)); await r.init(); r.setPalette(createTestPalette()); @@ -1017,7 +1073,7 @@ describe('post-process effects', () => { getCurrentTexture: () => swapTexture, } as unknown as GPUCanvasContext; - const r = new Renderer(device, context, new Vector2i(320, 240)); + const r = new WebGpuRenderer(device, context, new Vector2i(320, 240)); await r.init(); r.setPalette(createTestPalette()); diff --git a/src/render/Renderer.ts b/src/render/WebGpuRenderer.ts similarity index 95% rename from src/render/Renderer.ts rename to src/render/WebGpuRenderer.ts index f70bf73..115d330 100644 --- a/src/render/Renderer.ts +++ b/src/render/WebGpuRenderer.ts @@ -7,6 +7,7 @@ import { FrameCapture } from '../utils/FrameCapture'; import type { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import type { Effect } from './effects/Effect'; +import type { IRenderer } from './IRenderer'; import { PostProcessChain } from './PostProcessChain'; import { PrimitivePipeline } from './PrimitivePipeline'; import { SpritePipeline } from './SpritePipeline'; @@ -31,11 +32,11 @@ const SCENE_TARGET_USAGE = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.T // #endregion /** - * High-level renderer that coordinates primitive and sprite pipelines. + * WebGPU renderer implementing {@link IRenderer}. * - * `Renderer` owns frame begin/end, clear color, camera state, palette buffer, - * frame capture, and the two-tier post-process pipeline. Actual draw batching - * is delegated to {@link PrimitivePipeline} and {@link SpritePipeline}. + * `WebGpuRenderer` owns frame begin/end, clear color, camera state, palette + * buffer, frame capture, and the two-tier post-process pipeline. Actual draw + * batching is delegated to {@link PrimitivePipeline} and {@link SpritePipeline}. * * Per-frame stage flow when post-process is active: * @@ -51,7 +52,7 @@ const SCENE_TARGET_USAGE = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.T * and `canvasDisplaySize` matching `displaySize`, the renderer draws straight * to the swap chain (zero offscreen allocations). */ -export class Renderer { +export class WebGpuRenderer implements IRenderer { // #region State /** WebGPU device for GPU operations. */ @@ -236,7 +237,7 @@ export class Renderer { return true; } catch (error) { - console.error('[Renderer] Initialization failed:', error); + console.error('[WebGpuRenderer] Initialization failed:', error); return false; } @@ -497,12 +498,12 @@ export class Renderer { */ addEffect(effect: Effect): void { if (!this.pixelChain || !this.displayChain) { - throw new Error('Renderer.addEffect: renderer not initialized.'); + throw new Error('WebGpuRenderer.addEffect: renderer not initialized.'); } if (effect.tier === 'display' && !this.displayTierEnabled) { throw new Error( - 'Renderer.addEffect: display-tier effects require canvasDisplaySize to be set in configure().', + 'WebGpuRenderer.addEffect: display-tier effects require canvasDisplaySize to be set in configure().', ); } @@ -523,7 +524,7 @@ export class Renderer { */ removeEffect(effect: Effect): void { if (!this.pixelChain || !this.displayChain) { - throw new Error('Renderer.removeEffect: renderer not initialized.'); + throw new Error('WebGpuRenderer.removeEffect: renderer not initialized.'); } const [primary, fallback] = @@ -541,7 +542,7 @@ export class Renderer { */ clearEffects(): void { if (!this.pixelChain || !this.displayChain) { - throw new Error('Renderer.clearEffects: renderer not initialized.'); + throw new Error('WebGpuRenderer.clearEffects: renderer not initialized.'); } this.pixelChain.clear(); @@ -564,14 +565,14 @@ export class Renderer { try { swapTexture = this.context.getCurrentTexture(); } catch (error) { - console.error('[Renderer] Failed to get current texture:', error); + console.error('[WebGpuRenderer] 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'); + console.warn('[WebGpuRenderer] Texture has zero dimensions, skipping frame'); this.primitives.reset(); this.sprites.reset(); return null; @@ -750,7 +751,7 @@ export class Renderer { private requireSceneTexView(): GPUTextureView { if (!this.sceneTexView) { if (!this.swapFormat) { - throw new Error('Renderer.requireSceneTexView: swap format not initialized.'); + throw new Error('WebGpuRenderer.requireSceneTexView: swap format not initialized.'); } this.sceneTex = this.device.createTexture({ label: 'Renderer Scene Framebuffer', @@ -773,7 +774,7 @@ export class Renderer { private requireDisplayChainInput(): GPUTextureView { const chain = this.displayChain; if (!chain?.isActive()) { - throw new Error('Renderer.requireDisplayChainInput: display chain inactive.'); + throw new Error('WebGpuRenderer.requireDisplayChainInput: display chain inactive.'); } return chain.getInputView(); } @@ -808,7 +809,10 @@ export class Renderer { try { return this.palette.get(this.clearPaletteIndex); } catch (error) { - console.warn('[Renderer] resolveClearColor: clearPaletteIndex out of range, falling back to black:', error); + console.warn( + '[WebGpuRenderer] resolveClearColor: clearPaletteIndex out of range, falling back to black:', + error, + ); return Color32.black(); } -- 2.51.2 From be379f57299dbeee414656b8e4c7f5f95c7325e2 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 11:08:18 +0200 Subject: [PATCH 2/3] fix(renderer): warn when non-webgpu backend is requested before VV-491 initRenderer computed `backend` from hw.renderer but never acted on it, always constructing WebGpuRenderer regardless. Add an explicit warn when backend !== 'webgpu' so the unimplemented fallback is surfaced at runtime rather than silently ignored. Full selection logic lands in VV-491. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/core/BTAPI.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 8143740..6f09310 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -833,7 +833,8 @@ export class BTAPI { * Constructs and initializes the renderer for the active hardware settings. * * Logs the selected backend name, creates a {@link WebGpuRenderer}, calls - * {@link IRenderer.init}, and reports success or failure. + * {@link IRenderer.init}, and reports success or failure. Emits a warning + * when a non-`'webgpu'` backend is requested but not yet implemented. * * @param webGPUResult - Initialized WebGPU device, context, and drawing-buffer size. * @param hw - Active hardware settings. @@ -842,6 +843,10 @@ export class BTAPI { private async initRenderer(webGPUResult: WebGPUContextResult, hw: HardwareSettings): Promise { const backend = hw.renderer ?? 'webgpu'; + if (backend !== 'webgpu') { + console.warn(`[BT] Backend '${backend}' is not yet implemented; falling back to WebGPU (see VV-491)`); + } + console.log(`[BT] Initializing renderer (backend: ${backend})`); this.renderer = new WebGpuRenderer( -- 2.51.2 From a4ca1ac31939679a8b12e113d3e589f4406d91e3 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 11:14:57 +0200 Subject: [PATCH 3/3] fix(renderer): log actual backend in initRenderer, not requested backend The console.log following the unimplemented-backend warn still printed the requested value (e.g. 'software'), misleading anyone reading the startup output. Rename 'backend' to 'requestedBackend' and hardcode 'webgpu' in the log line, since WebGpuRenderer is always what is constructed until VV-491 lands. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/core/BTAPI.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 6f09310..20c2ef5 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -841,13 +841,15 @@ export class BTAPI { * @returns `true` when the renderer is ready; `false` on failure. */ private async initRenderer(webGPUResult: WebGPUContextResult, hw: HardwareSettings): Promise { - const backend = hw.renderer ?? 'webgpu'; + const requestedBackend = hw.renderer ?? 'webgpu'; - if (backend !== 'webgpu') { - console.warn(`[BT] Backend '${backend}' is not yet implemented; falling back to WebGPU (see VV-491)`); + if (requestedBackend !== 'webgpu') { + console.warn( + `[BT] Backend '${requestedBackend}' is not yet implemented; falling back to WebGPU (see VV-491)`, + ); } - console.log(`[BT] Initializing renderer (backend: ${backend})`); + console.log('[BT] Initializing renderer (backend: webgpu)'); this.renderer = new WebGpuRenderer( webGPUResult.device, -- 2.51.2