diff --git a/cspell.json b/cspell.json index b5744a8..3398868 100644 --- a/cspell.json +++ b/cspell.json @@ -4,8 +4,8 @@ "allowCompoundWords": true, "dictionaries": ["typescript", "node", "html", "css", "fonts", "npm", "softwareTerms"], "words": [ - "ABXY", "ABGR", + "ABXY", "antialiasing", "bgra", "bindgroup", @@ -17,7 +17,6 @@ "Bresenham", "BTAPI", "btfont", - "cgwg", "Chebyshev", "chrom", "Cietwierkowski", @@ -26,38 +25,40 @@ "dbaeumer", "dco", "esbuild", - "fakelottes", "fract", "gamepad", - "GLSL", "gaus", + "GLSL", "Hiero", "horz", - "unmodulated", - "optimisation", - "Lottes", - "libretro", "indexization", "indexize", "indexized", "indexizes", - "indexizing", "lcov", "lerp", "lerped", "lerps", + "libretro", + "Lottes", "millis", "MULT", "nsis", + "optimisation", "PICO", "pico", "PipBoy", "Playwright", "pnpm", + "blits", + "rasterize", + "rasterized", + "rasterizes", + "Rasterizes", + "rasterizing", "rects", "reindexing", "reindexize", - "reindexizing", "retroblit", "RGBA", "rgba8unorm", @@ -68,21 +69,22 @@ "subcoord", "subsystem", "subsystems", - "texelSize", "texels", + "texelSize", "timestep", "Uints", "unflushed", + "unmodulated", "unorm", "unstub", "Vančura", "verts", "Vite", "Vitest", - "WASD", "vsync", - "Václav", "vsync", + "Václav", + "WASD", "WebGPU", "WGSL", "xadvance", diff --git a/src/assets/SpriteSheet.test.ts b/src/assets/SpriteSheet.test.ts index 3dc6442..2eb9c1f 100644 --- a/src/assets/SpriteSheet.test.ts +++ b/src/assets/SpriteSheet.test.ts @@ -586,7 +586,7 @@ describe('SpriteSheet', () => { expect(() => SpriteSheet.fromIndexedPixels(4, 4, pixels)).toThrow(RangeError); expect(() => SpriteSheet.fromIndexedPixels(4, 4, pixels)).toThrow( - 'indexedPixels length 10 does not match 4x4 (expected 16)', + 'The pixel data has 10 values, but a 4x4 sheet needs exactly 16.', ); }); diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index 4fed656..e05ce95 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -280,7 +280,7 @@ export class SpriteSheet { if (indexedPixels.length !== expectedLength) { throw new RangeError( - `indexedPixels length ${indexedPixels.length} does not match ${width}x${height} (expected ${expectedLength}).`, + `The pixel data has ${indexedPixels.length} values, but a ${width}x${height} sheet needs exactly ${expectedLength}. Make sure indexedPixels has one entry per pixel.`, ); } @@ -478,6 +478,25 @@ export class SpriteSheet { return this.indexedPixels !== null; } + /** + * Returns the palette-indexed pixel buffer. + * + * The returned view references internal storage and is read-only by + * convention. Callers must not mutate it. + * + * @returns Indexed pixels as row-major palette indices (1 byte per pixel). + * @throws If the sheet has not been indexized yet. + */ + getIndexedPixels(): Uint8Array { + if (this.indexedPixels === null) { + throw new Error( + "This sprite sheet hasn't been converted to palette indices yet. Call sheet.indexize(palette) first.", + ); + } + + return this.indexedPixels; + } + // #endregion // #region Accessors diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index ce73ca2..6a2efa8 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -316,6 +316,74 @@ describe('BTAPI', () => { expect(result).toBe(false); }); + it('initializes successfully in software mode when WebGPU is unavailable', async () => { + uninstallMockNavigatorGPU(); + vi.stubGlobal( + 'OffscreenCanvas', + class MockOffscreenCanvas { + constructor( + public width: number, + public height: number, + ) {} + getContext(): { + imageSmoothingEnabled: boolean; + createImageData: (w: number, h: number) => ImageData; + putImageData: ReturnType; + } { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + }; + } + }, + ); + const demo: IBlitTechDemo = { + configure: () => ({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + targetFPS: 60, + renderer: 'software', + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + const canvas = { + ...makeMockCanvas(), + getContext: (type: string) => { + if (type === '2d') { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn(), + }; + } + return null; + }, + toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), + } as unknown as HTMLCanvasElement; + + const result = await BTAPI.instance.init(demo, canvas); + + expect(result).toBe(true); + expect(BTAPI.instance.getDevice()).toBeNull(); + expect(BTAPI.instance.getContext()).toBeNull(); + expect(BTAPI.instance.getRenderer()).not.toBeNull(); + }); + it('should throw with WEBGPU_ADAPTER_MESSAGE when WebGPU adapter is unavailable', async () => { Object.defineProperty(globalThis, 'navigator', { value: { diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 20c2ef5..3703e74 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -15,6 +15,7 @@ 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 { WebGpuRenderer } from '../render/WebGpuRenderer'; import type { Color32 } from '../utils/Color32'; import type { EasingFunction } from '../utils/Easing'; @@ -30,7 +31,6 @@ 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'; /** @@ -199,19 +199,7 @@ export class BTAPI { targetFPS: this.hwSettings.targetFPS, }); - // Initialize WebGPU. - const webGPUResult = await initWebGPU(canvas, this.hwSettings.displaySize, this.hwSettings.canvasDisplaySize); - - if (!webGPUResult) { - console.error('[BT] Failed to initialize WebGPU'); - - return false; - } - - this.device = webGPUResult.device; - this.context = webGPUResult.context; - - if (!(await this.initRenderer(webGPUResult, this.hwSettings))) { + if (!(await this.initRenderer(canvas, this.hwSettings))) { return false; } @@ -832,23 +820,42 @@ 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. Emits a warning - * when a non-`'webgpu'` backend is requested but not yet implemented. + * Logs the selected backend name, creates the requested renderer backend, + * calls {@link IRenderer.init}, and reports success or failure. * - * @param webGPUResult - Initialized WebGPU device, context, and drawing-buffer size. + * @param canvas - Render target canvas. * @param hw - Active hardware settings. * @returns `true` when the renderer is ready; `false` on failure. */ - private async initRenderer(webGPUResult: WebGPUContextResult, hw: HardwareSettings): Promise { + private async initRenderer(canvas: HTMLCanvasElement, hw: HardwareSettings): Promise { const requestedBackend = hw.renderer ?? 'webgpu'; + if (requestedBackend === 'software') { + this.device = null; + this.context = null; + console.log('[BT] Initializing renderer (backend: software)'); + this.renderer = new SoftwareRenderer(canvas, hw.displaySize, hw.canvasDisplaySize); - if (requestedBackend !== 'webgpu') { - console.warn( - `[BT] Backend '${requestedBackend}' is not yet implemented; falling back to WebGPU (see VV-491)`, - ); + if (!(await this.renderer.init())) { + console.error('[BT] Failed to initialize renderer'); + + return false; + } + + console.log('[BT] Renderer initialized'); + + return true; + } + + const webGPUResult = await initWebGPU(canvas, hw.displaySize, hw.canvasDisplaySize); + if (!webGPUResult) { + console.error('[BT] Failed to initialize WebGPU'); + + return false; } + this.device = webGPUResult.device; + this.context = webGPUResult.context; + console.log('[BT] Initializing renderer (backend: webgpu)'); this.renderer = new WebGpuRenderer( diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index a9e9d47..4f4d1af 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -16,8 +16,7 @@ export type OutputUpscaleFilter = 'nearest' | 'linear'; * 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. + * and will throw when added. */ export type RendererBackend = 'webgpu' | 'software'; @@ -71,9 +70,7 @@ export interface HardwareSettings { /** * 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). + * Set to `'software'` to opt into the Canvas 2D fallback backend. */ renderer?: RendererBackend; } @@ -100,7 +97,7 @@ export interface IBlitTechDemo { configure?(): HardwareSettings; /** - * Called once after WebGPU and the renderer have been initialized. + * Called once after the selected renderer backend has been initialized. * Load assets and prepare a demo state here. * * @returns Promise that resolves to true if successful, false to abort. diff --git a/src/render/SoftwareRenderer.test.ts b/src/render/SoftwareRenderer.test.ts new file mode 100644 index 0000000..b9dc447 --- /dev/null +++ b/src/render/SoftwareRenderer.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BitmapFont } from '../assets/BitmapFont'; +import { Palette } from '../assets/Palette'; +import { SpriteSheet } from '../assets/SpriteSheet'; +import { Color32 } from '../utils/Color32'; +import { Rect2i } from '../utils/Rect2i'; +import { Vector2i } from '../utils/Vector2i'; +import type { Effect } from './effects/Effect'; +import { SoftwareRenderer } from './SoftwareRenderer'; + +type MockContext = { + imageSmoothingEnabled: boolean; + createImageData: (w: number, h: number) => ImageData; + putImageData: ReturnType; + clearRect: ReturnType; + drawImage: ReturnType; + lastImageData: ImageData | null; +}; + +function makeMockContext(): MockContext { + const instance: MockContext = { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn((imageData: ImageData) => { + instance.lastImageData = imageData; + }), + clearRect: vi.fn(), + drawImage: vi.fn(), + lastImageData: null, + }; + return instance; +} + +const context = makeMockContext(); +const logicalContext = makeMockContext(); + +class MockOffscreenCanvas { + constructor( + public width: number, + public height: number, + ) {} + getContext(): MockContext { + return logicalContext; + } +} + +function getPixel(imageData: ImageData, width: number, x: number, y: number): [number, number, number, number] { + const index = (y * width + x) * 4; + /* eslint-disable security/detect-object-injection */ + return [ + imageData.data[index] ?? 0, + imageData.data[index + 1] ?? 0, + imageData.data[index + 2] ?? 0, + imageData.data[index + 3] ?? 0, + ]; + /* eslint-enable security/detect-object-injection */ +} + +function makePalette(): Palette { + const palette = new Palette(16); + palette.set(1, new Color32(255, 0, 0, 255)); + palette.set(2, new Color32(0, 0, 255, 255)); + palette.set(3, new Color32(0, 255, 0, 255)); + return palette; +} + +describe('SoftwareRenderer', () => { + beforeEach(() => { + context.lastImageData = null; + logicalContext.lastImageData = null; + vi.stubGlobal( + 'ImageData', + class MockImageData { + constructor( + public width: number, + public height: number, + public data: Uint8ClampedArray = new Uint8ClampedArray(width * height * 4), + ) {} + }, + ); + vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('requires palette before beginFrame', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + + expect(() => renderer.beginFrame()).toThrow('No palette set yet. Call BT.paletteSet'); + }); + + it('renders primitives with camera offset applied', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + renderer.beginFrame(); + renderer.setCameraOffset(new Vector2i(1, 1)); + renderer.drawPixel(new Vector2i(2, 2), 1); + renderer.endFrame(); + + const frame = logicalContext.lastImageData; + expect(frame).not.toBeNull(); + expect(getPixel(frame as ImageData, 4, 1, 1)).toEqual([255, 0, 0, 255]); + }); + + it('renders indexed sprites with transparent index and palette offsets', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + + const palette = makePalette(); + palette.set(4, new Color32(255, 255, 0, 255)); + renderer.setPalette(palette); + renderer.setClearColor(4); + + const sheet = SpriteSheet.fromIndexedPixels(2, 1, new Uint8Array([1, 0])); + renderer.beginFrame(); + renderer.drawSprite(sheet, new Rect2i(0, 0, 2, 1), new Vector2i(0, 0), 1); + renderer.endFrame(); + + const frame = logicalContext.lastImageData; + expect(frame).not.toBeNull(); + expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([0, 0, 255, 255]); + expect(getPixel(frame as ImageData, 4, 1, 0)).toEqual([255, 255, 0, 255]); + }); + + it('renders bitmap text through sprite-backed glyphs', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const sheet = SpriteSheet.fromIndexedPixels(1, 1, new Uint8Array([1])); + const glyphs = new Map([ + [ + 'A', + { + rect: new Rect2i(0, 0, 1, 1), + offsetX: 0, + offsetY: 0, + advance: 1, + }, + ], + ]); + const font = BitmapFont.createFromGlyphs(sheet, glyphs, 'test', 8, 1, 1); + + renderer.beginFrame(); + renderer.drawBitmapText(font, new Vector2i(0, 0), 'A', 0); + renderer.endFrame(); + + const frame = logicalContext.lastImageData; + expect(frame).not.toBeNull(); + expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([255, 0, 0, 255]); + }); + + it('throws clear unsupported errors for fullscreen effects', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + + const effect: Effect = { tier: 'pixel', init: vi.fn(), updateUniforms: vi.fn(), encodePass: vi.fn() }; + + expect(() => renderer.addEffect(effect)).toThrow("doesn't support fullscreen effects"); + expect(() => renderer.removeEffect(effect)).toThrow("doesn't support fullscreen effects"); + expect(() => renderer.clearEffects()).toThrow("doesn't support fullscreen effects"); + }); + + it('resolves captureFrame on next endFrame', async () => { + const toBlob = vi.fn((callback: (blob: Blob | null) => void) => + callback(new Blob(['png'], { type: 'image/png' })), + ); + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const capture = renderer.captureFrame(); + renderer.beginFrame(); + renderer.endFrame(); + + const blob = await capture; + expect(blob.type).toBe('image/png'); + expect(toBlob).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/render/SoftwareRenderer.ts b/src/render/SoftwareRenderer.ts new file mode 100644 index 0000000..00895ee --- /dev/null +++ b/src/render/SoftwareRenderer.ts @@ -0,0 +1,809 @@ +import type { BitmapFont } from '../assets/BitmapFont'; +import type { Palette } from '../assets/Palette'; +import type { SpriteSheet } from '../assets/SpriteSheet'; +import { Color32 } from '../utils/Color32'; +import { noActivePaletteError } from '../utils/errorMessages'; +import { Rect2i } from '../utils/Rect2i'; +import { Vector2i } from '../utils/Vector2i'; +import type { Effect } from './effects/Effect'; +import type { IRenderer } from './IRenderer'; + +// #region Type Definitions + +/** A queued filled-rectangle, outline-rectangle, or line draw command. */ +type PrimitiveCommand = { + kind: 'rectFill' | 'rect' | 'line'; + x0: number; + y0: number; + x1: number; + y1: number; + paletteIndex: number; + cameraX: number; + cameraY: number; +}; + +/** A queued sprite blit command, storing the source sheet and destination position. */ +type SpriteCommand = { + kind: 'sprite'; + spriteSheet: SpriteSheet; + srcRect: Rect2i; + destPos: Vector2i; + paletteOffset: number; + cameraX: number; + cameraY: number; +}; + +/** A queued bitmap text draw command. Glyphs are expanded to sprite commands during replay. */ +type BitmapTextCommand = { + kind: 'bitmapText'; + font: BitmapFont; + pos: Vector2i; + text: string; + paletteOffset: number; + cameraX: number; + cameraY: number; +}; + +/** Union of all queued draw commands accumulated between `beginFrame` and `endFrame`. */ +type DrawCommand = PrimitiveCommand | SpriteCommand | BitmapTextCommand; + +/** Pending `captureFrame` promise callbacks, held until the next `endFrame`. */ +type PendingCapture = { + resolve: (blob: Blob) => void; + reject: (reason?: unknown) => void; +}; + +/** Alias for either the offscreen or on-screen 2D rendering context variant. */ +type Canvas2D = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + +// #endregion + +/** + * Canvas-2D software fallback renderer implementing {@link IRenderer}. + * + * This backend keeps rendering palette-first by rasterizing draw commands into a + * logical-resolution RGBA buffer every frame, then presenting that buffer to the + * target canvas with optional nearest-neighbor upscaling. + */ +export class SoftwareRenderer implements IRenderer { + // #region Constants + + private static readonly EFFECTS_UNSUPPORTED_MESSAGE = + "The software renderer doesn't support fullscreen effects. To use post-process effects, set renderer to 'webgpu' in configure()."; + + // #endregion + + // #region State + + private readonly canvas: HTMLCanvasElement; + private readonly displaySize: Vector2i; + private readonly outputSize: Vector2i; + private readonly explicitOutputSize: boolean; + + private outputCtx: Canvas2D | null = null; + private logicalCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; + private logicalCtx: Canvas2D | null = null; + + private palette: Palette | null = null; + private clearPaletteIndex: number = 0; + private cameraOffset: Vector2i = Vector2i.zero(); + private readonly commands: DrawCommand[] = []; + private readonly framePixels: Uint8ClampedArray; + private imageData: ImageData | null = null; + private pendingCapture: PendingCapture | null = null; + + // #endregion + + // #region Constructor + + /** + * Creates a software renderer bound to the given canvas. + * + * @param canvas - Target HTML canvas element to draw into. + * @param displaySize - Logical render resolution in pixels. + * @param outputSize - Output resolution in pixels. Defaults to `displaySize` (no upscaling). + */ + constructor(canvas: HTMLCanvasElement, displaySize: Vector2i, outputSize?: Vector2i) { + this.canvas = canvas; + this.displaySize = displaySize.clone(); + this.explicitOutputSize = outputSize !== undefined; + this.outputSize = (outputSize ?? displaySize).clone(); + this.framePixels = new Uint8ClampedArray(this.displaySize.x * this.displaySize.y * 4); + } + + // #endregion + + // #region Initialization + + /** + * Initializes the 2D canvas contexts and backing image buffer. + * + * @returns `true` when contexts are ready; otherwise `false`. + */ + async init(): Promise { + this.canvas.width = this.outputSize.x; + this.canvas.height = this.outputSize.y; + + if (this.explicitOutputSize) { + this.canvas.style.width = `${this.outputSize.x}px`; + this.canvas.style.height = `${this.outputSize.y}px`; + } + + this.outputCtx = this.canvas.getContext('2d') as Canvas2D | null; + if (!this.outputCtx) { + return false; + } + this.outputCtx.imageSmoothingEnabled = false; + + this.logicalCanvas = this.createLogicalCanvas(); + this.logicalCtx = this.logicalCanvas.getContext('2d') as Canvas2D | null; + if (!this.logicalCtx) { + return false; + } + this.logicalCtx.imageSmoothingEnabled = false; + + if ('createImageData' in this.logicalCtx) { + this.imageData = this.logicalCtx.createImageData(this.displaySize.x, this.displaySize.y); + } else { + this.imageData = new ImageData(this.displaySize.x, this.displaySize.y); + } + + return true; + } + + // #endregion + + // #region Palette + + /** + * Sets the active palette used for all color lookups during rendering. + * + * @param palette - Palette to activate. + */ + setPalette(palette: Palette): void { + this.palette = palette; + if (this.clearPaletteIndex >= this.palette.size) { + this.clearPaletteIndex = 0; + } + } + + /** + * Returns a clone of the active palette, or `null` when no palette is set. + * + * @returns Cloned active palette or `null`. + */ + getPalette(): Palette | null { + return this.palette?.clone() ?? null; + } + + // #endregion + + // #region Frame Management + + /** + * Marks the start of a new frame and clears the draw-command queue. + * Throws when no palette has been set yet. + */ + beginFrame(): void { + if (!this.palette) { + throw new Error(noActivePaletteError()); + } + this.commands.length = 0; + } + + /** + * Sets the palette index used to fill the background on each frame. + * + * @param paletteIndex - Palette entry index for the clear color. + */ + setClearColor(paletteIndex: number): void { + this.clearPaletteIndex = paletteIndex; + } + + /** + * Replays all queued draw commands into the pixel buffer and presents the frame. + * Also resolves any pending `captureFrame` promise. + */ + endFrame(): void { + const clearColor = this.resolveClearColor(); + this.fillFrame(clearColor.r, clearColor.g, clearColor.b, clearColor.a); + + for (const command of this.commands) { + this.replayCommand(command); + } + + this.presentFrame(); + this.resolvePendingCapture(); + this.commands.length = 0; + } + + // #endregion + + // #region Drawing - Primitives + + /** + * Queues a filled rectangle draw command. + * + * @param rect - Rectangle to fill in logical pixels. + * @param paletteIndex - Palette entry index for the fill color. + */ + drawRectFill(rect: Rect2i, paletteIndex: number): void { + this.commands.push({ + kind: 'rectFill', + x0: rect.x, + y0: rect.y, + x1: rect.width, + y1: rect.height, + paletteIndex, + cameraX: this.cameraOffset.x, + cameraY: this.cameraOffset.y, + }); + } + + /** + * Queues a single pixel draw command at the given position. + * + * @param pos - Pixel position in logical coordinates. + * @param paletteIndex - Palette entry index for the pixel color. + */ + drawPixel(pos: Vector2i, paletteIndex: number): void { + this.drawRectFill(new Rect2i(pos.x, pos.y, 1, 1), paletteIndex); + } + + /** + * Queues a Bresenham line draw command between two points. + * + * @param p0 - Line start position in logical coordinates. + * @param p1 - Line end position in logical coordinates. + * @param paletteIndex - Palette entry index for the line color. + */ + drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void { + this.commands.push({ + kind: 'line', + x0: p0.x, + y0: p0.y, + x1: p1.x, + y1: p1.y, + paletteIndex, + cameraX: this.cameraOffset.x, + cameraY: this.cameraOffset.y, + }); + } + + /** + * Queues an outline rectangle draw command (four lines, no fill). + * + * @param rect - Rectangle to outline in logical pixels. + * @param paletteIndex - Palette entry index for the border color. + */ + drawRect(rect: Rect2i, paletteIndex: number): void { + this.commands.push({ + kind: 'rect', + x0: rect.x, + y0: rect.y, + x1: rect.width, + y1: rect.height, + paletteIndex, + cameraX: this.cameraOffset.x, + cameraY: this.cameraOffset.y, + }); + } + + /** + * Fills the given rectangle with a palette color (alias for `drawRectFill`). + * + * @param rect - Rectangle to clear in logical pixels. + * @param paletteIndex - Palette entry index for the fill color. + */ + clearRect(rect: Rect2i, paletteIndex: number): void { + this.drawRectFill(rect, paletteIndex); + } + + // #endregion + + // #region Drawing - Sprites + + /** + * Queues a sprite blit from a source sheet rectangle to a destination position. + * + * @param spriteSheet - Source sprite sheet containing the indexed pixels. + * @param srcRect - Source region within the sprite sheet in pixels. + * @param destPos - Destination position in logical coordinates. + * @param paletteOffset - Palette index offset applied to every non-transparent pixel. + */ + drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, paletteOffset: number = 0): void { + this.commands.push({ + kind: 'sprite', + spriteSheet, + srcRect: srcRect.clone(), + destPos: destPos.clone(), + paletteOffset, + cameraX: this.cameraOffset.x, + cameraY: this.cameraOffset.y, + }); + } + + /** + * Queues a bitmap text draw command, expanding each character to a sprite blit on replay. + * + * @param font - Bitmap font containing glyph sheet and metrics. + * @param pos - Top-left position of the text in logical coordinates. + * @param text - String to render. + * @param paletteOffset - Palette index offset applied to every glyph pixel. + */ + drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, paletteOffset: number = 0): void { + this.commands.push({ + kind: 'bitmapText', + font, + pos: pos.clone(), + text, + paletteOffset, + cameraX: this.cameraOffset.x, + cameraY: this.cameraOffset.y, + }); + } + + // #endregion + + // #region Frame Capture + + /** + * Returns a promise that resolves with a PNG Blob on the next `endFrame` call. + * Any previously pending capture is rejected before the new one is registered. + * + * @returns Promise that resolves with the captured frame as a PNG `Blob`. + */ + captureFrame(): Promise { + if (this.pendingCapture) { + this.pendingCapture.reject( + new Error( + 'A capture is already in progress. Wait for the first captureFrame() to finish before requesting another.', + ), + ); + } + + return new Promise((resolve, reject) => { + this.pendingCapture = { resolve, reject }; + }); + } + + // #endregion + + // #region Camera + + /** + * Sets the camera scroll offset applied to all subsequent draw commands. + * + * @param offset - New camera offset in logical pixels. + */ + setCameraOffset(offset: Vector2i): void { + this.cameraOffset = offset.clone(); + } + + /** + * Returns the current camera scroll offset. + * + * @returns Cloned camera offset vector. + */ + getCameraOffset(): Vector2i { + return this.cameraOffset.clone(); + } + + /** Resets the camera offset to zero (no scrolling). */ + resetCamera(): void { + this.cameraOffset = Vector2i.zero(); + } + + // #endregion + + // #region Effects + + /** + * Not supported — always throws. + * + * @param _effect - Ignored. + */ + addEffect(_effect: Effect): void { + throw new Error(SoftwareRenderer.EFFECTS_UNSUPPORTED_MESSAGE); + } + + /** + * Not supported — always throws. + * + * @param _effect - Ignored. + */ + removeEffect(_effect: Effect): void { + throw new Error(SoftwareRenderer.EFFECTS_UNSUPPORTED_MESSAGE); + } + + /** Not supported — always throws. */ + clearEffects(): void { + throw new Error(SoftwareRenderer.EFFECTS_UNSUPPORTED_MESSAGE); + } + + // #endregion + + // #region Private Helpers + + /** + * Creates an `OffscreenCanvas` when available, falling back to an off-DOM ``. + * + * @returns A canvas sized to the logical display resolution. + */ + private createLogicalCanvas(): OffscreenCanvas | HTMLCanvasElement { + if (typeof OffscreenCanvas !== 'undefined') { + return new OffscreenCanvas(this.displaySize.x, this.displaySize.y); + } + + const canvas = document.createElement('canvas'); + canvas.width = this.displaySize.x; + canvas.height = this.displaySize.y; + return canvas; + } + + /** + * Fills the entire `framePixels` buffer with a solid RGBA color. + * + * @param r - Red channel (0-255). + * @param g - Green channel (0-255). + * @param b - Blue channel (0-255). + * @param a - Alpha channel (0-255). + */ + private fillFrame(r: number, g: number, b: number, a: number): void { + for (let i = 0; i < this.framePixels.length; i += 4) { + // eslint-disable-next-line security/detect-object-injection + this.framePixels[i] = r; + this.framePixels[i + 1] = g; + this.framePixels[i + 2] = b; + this.framePixels[i + 3] = a; + } + } + + /** + * Dispatches a single draw command to the appropriate rasterizer. + * + * @param command - Command to replay into `framePixels`. + */ + private replayCommand(command: DrawCommand): void { + switch (command.kind) { + case 'rectFill': + this.rasterRectFill( + command.x0, + command.y0, + command.x1, + command.y1, + command.paletteIndex, + command.cameraX, + command.cameraY, + ); + return; + case 'rect': + this.rasterRect( + command.x0, + command.y0, + command.x1, + command.y1, + command.paletteIndex, + command.cameraX, + command.cameraY, + ); + return; + case 'line': + this.rasterLine( + command.x0, + command.y0, + command.x1, + command.y1, + command.paletteIndex, + command.cameraX, + command.cameraY, + ); + return; + case 'sprite': + this.rasterSprite(command); + return; + case 'bitmapText': + this.rasterBitmapText(command); + return; + } + } + + /** + * Rasterizes a filled rectangle into `framePixels`, clipped to display bounds. + * + * @param x - Left edge in world coordinates. + * @param y - Top edge in world coordinates. + * @param width - Rectangle width in pixels. + * @param height - Rectangle height in pixels. + * @param paletteIndex - Palette entry index for the fill color. + * @param cameraX - Horizontal camera offset to subtract. + * @param cameraY - Vertical camera offset to subtract. + */ + private rasterRectFill( + x: number, + y: number, + width: number, + height: number, + paletteIndex: number, + cameraX: number, + cameraY: number, + ): void { + const color = this.resolvePrimitiveColor(paletteIndex); + if (!color || width <= 0 || height <= 0) { + return; + } + + const startX = Math.max(0, x - cameraX); + const startY = Math.max(0, y - cameraY); + const endX = Math.min(this.displaySize.x, x - cameraX + width); + const endY = Math.min(this.displaySize.y, y - cameraY + height); + + for (let py = startY; py < endY; py++) { + for (let px = startX; px < endX; px++) { + this.writePixel(px, py, color.r, color.g, color.b, 255); + } + } + } + + /** + * Rasterizes a four-sided outline rectangle by drawing four lines. + * + * @param x - Left edge in world coordinates. + * @param y - Top edge in world coordinates. + * @param width - Rectangle width in pixels. + * @param height - Rectangle height in pixels. + * @param paletteIndex - Palette entry index for the border color. + * @param cameraX - Horizontal camera offset to subtract. + * @param cameraY - Vertical camera offset to subtract. + */ + private rasterRect( + x: number, + y: number, + width: number, + height: number, + paletteIndex: number, + cameraX: number, + cameraY: number, + ): void { + if (width <= 0 || height <= 0) { + return; + } + + const x1 = x + width - 1; + const y1 = y + height - 1; + this.rasterLine(x, y, x1, y, paletteIndex, cameraX, cameraY); + this.rasterLine(x, y1, x1, y1, paletteIndex, cameraX, cameraY); + this.rasterLine(x, y + 1, x, y1 - 1, paletteIndex, cameraX, cameraY); + this.rasterLine(x1, y + 1, x1, y1 - 1, paletteIndex, cameraX, cameraY); + } + + /** + * Rasterizes a line using Bresenham's algorithm. + * + * @param x0 - Start X in world coordinates. + * @param y0 - Start Y in world coordinates. + * @param x1 - End X in world coordinates. + * @param y1 - End Y in world coordinates. + * @param paletteIndex - Palette entry index for the line color. + * @param cameraX - Horizontal camera offset to subtract. + * @param cameraY - Vertical camera offset to subtract. + */ + private rasterLine( + x0: number, + y0: number, + x1: number, + y1: number, + paletteIndex: number, + cameraX: number, + cameraY: number, + ): void { + const color = this.resolvePrimitiveColor(paletteIndex); + if (!color) { + return; + } + + let cx = x0 - cameraX; + let cy = y0 - cameraY; + const tx = x1 - cameraX; + const ty = y1 - cameraY; + const dx = Math.abs(tx - cx); + const dy = Math.abs(ty - cy); + const sx = cx < tx ? 1 : -1; + const sy = cy < ty ? 1 : -1; + let err = dx - dy; + + while (true) { + this.writePixel(cx, cy, color.r, color.g, color.b, 255); + if (cx === tx && cy === ty) { + break; + } + const e2 = err * 2; + if (e2 > -dy) { + err -= dy; + cx += sx; + } + if (e2 < dx) { + err += dx; + cy += sy; + } + } + } + + /** + * Rasterizes a sprite by iterating its source rect and writing palette-resolved pixels. + * Index 0 is treated as transparent and skipped. + * + * @param command - Sprite draw command with sheet, source rect, destination, and camera state. + */ + private rasterSprite(command: SpriteCommand): void { + const indexedPixels = command.spriteSheet.getIndexedPixels(); + const sheetWidth = command.spriteSheet.width; + const sheetHeight = command.spriteSheet.height; + const srcRect = command.srcRect; + const destPos = command.destPos; + + for (let y = 0; y < srcRect.height; y++) { + for (let x = 0; x < srcRect.width; x++) { + const srcX = srcRect.x + x; + const srcY = srcRect.y + y; + if (srcX < 0 || srcY < 0 || srcX >= sheetWidth || srcY >= sheetHeight) { + continue; + } + + const rawIndex = indexedPixels[srcY * sheetWidth + srcX] ?? 0; + if (rawIndex === 0) { + continue; + } + + const finalIndex = (rawIndex + command.paletteOffset) >>> 0; + const color = this.resolveSpriteColor(finalIndex); + const destX = destPos.x + x - command.cameraX; + const destY = destPos.y + y - command.cameraY; + this.writePixel(destX, destY, color.r, color.g, color.b, 255); + } + } + } + + /** + * Rasterizes a bitmap text command by expanding each character to a sprite blit. + * + * @param command - Bitmap text command with font, position, text string, and camera state. + */ + private rasterBitmapText(command: BitmapTextCommand): void { + let cursorX = command.pos.x; + for (const char of command.text) { + const glyph = command.font.getGlyph(char); + if (!glyph) { + continue; + } + this.rasterSprite({ + kind: 'sprite', + spriteSheet: command.font.getSpriteSheet(), + srcRect: glyph.rect, + destPos: new Vector2i(cursorX + glyph.offsetX, command.pos.y + glyph.offsetY), + paletteOffset: command.paletteOffset, + cameraX: command.cameraX, + cameraY: command.cameraY, + }); + cursorX += glyph.advance; + } + } + + /** + * Writes one RGBA pixel into `framePixels`, bounds-checked against the display size. + * + * @param x - Pixel X in logical coordinates. + * @param y - Pixel Y in logical coordinates. + * @param r - Red channel (0-255). + * @param g - Green channel (0-255). + * @param b - Blue channel (0-255). + * @param a - Alpha channel (0-255). + */ + private writePixel(x: number, y: number, r: number, g: number, b: number, a: number): void { + if (x < 0 || y < 0 || x >= this.displaySize.x || y >= this.displaySize.y) { + return; + } + const index = (y * this.displaySize.x + x) * 4; + // eslint-disable-next-line security/detect-object-injection + this.framePixels[index] = r; + this.framePixels[index + 1] = g; + this.framePixels[index + 2] = b; + this.framePixels[index + 3] = a; + } + + /** + * Resolves a palette index to a `Color32` for primitive drawing. + * Returns `null` for out-of-range indices and fully transparent colors. + * + * @param paletteIndex - Palette entry index to look up. + * @returns Resolved color, or `null` when the pixel should not be drawn. + */ + private resolvePrimitiveColor(paletteIndex: number): Color32 | null { + if (!this.palette || paletteIndex >= this.palette.size) { + return null; + } + const color = this.palette.get(paletteIndex); + return color.a === 0 ? null : color; + } + + /** + * Resolves a palette index to a `Color32` for sprite drawing. + * Returns `Color32.black()` for out-of-range indices instead of skipping. + * + * @param paletteIndex - Palette entry index to look up. + * @returns Resolved color. + */ + private resolveSpriteColor(paletteIndex: number): Color32 { + if (!this.palette || paletteIndex >= this.palette.size) { + return Color32.black(); + } + return this.palette.get(paletteIndex); + } + + /** + * Returns the clear color from the palette. Falls back to `Color32.black()` + * when no palette is set or the index is out of range. + * + * @returns Clear color for the current frame. + */ + private resolveClearColor(): Color32 { + if (!this.palette) { + return Color32.black(); + } + try { + return this.palette.get(this.clearPaletteIndex); + } catch { + return Color32.black(); + } + } + + /** + * Copies `framePixels` into the logical canvas via `ImageData` and blits + * the logical canvas to the output canvas, applying nearest-neighbor upscaling. + */ + private presentFrame(): void { + if (!this.logicalCtx || !this.outputCtx || !this.imageData || !this.logicalCanvas) { + return; + } + + this.imageData.data.set(this.framePixels); + this.logicalCtx.putImageData(this.imageData, 0, 0); + this.outputCtx.clearRect(0, 0, this.outputSize.x, this.outputSize.y); + this.outputCtx.drawImage(this.logicalCanvas, 0, 0, this.outputSize.x, this.outputSize.y); + } + + /** + * Resolves or rejects the pending `captureFrame` promise using `canvas.toBlob`. + * Clears `pendingCapture` after handling. + */ + private resolvePendingCapture(): void { + if (!this.pendingCapture) { + return; + } + if (typeof this.canvas.toBlob !== 'function') { + this.pendingCapture.reject( + new Error( + "Can't save this frame — your browser doesn't support canvas image export. Try Chrome or Edge.", + ), + ); + this.pendingCapture = null; + return; + } + + const request = this.pendingCapture; + this.pendingCapture = null; + this.canvas.toBlob((blob) => { + if (!blob) { + request.reject( + new Error( + "Can't save this frame — something went wrong exporting the canvas image. Try again on the next frame.", + ), + ); + return; + } + request.resolve(blob); + }, 'image/png'); + } + + // #endregion +} -- 2.51.2 From c26dca61e508a14c044de4d5b7e0cdd6500b906f Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 12:36:13 +0200 Subject: [PATCH 2/8] feat(renderer): add ?renderer=software URL query override for runtime backend switching - BTAPI reads URLSearchParams at configure time and overrides the renderer to 'software' when present, logging the active backend switch - Bootstrap skips WebGPU validation when software backend is forced via URL query, allowing fallback without a WebGPU-capable device - Document ?renderer=software in HardwareSettings.renderer JSDoc - Split PrimitiveCommand into RectCommand/LineCommand discriminated union with explicit width/height vs x1/y1 field names to remove ambiguity - Return defensive copy from SpriteSheet.getIndexedPixels to prevent callers from mutating internal indexed pixel state - Clear vi.fn() call histories between SoftwareRenderer tests via vi.clearAllMocks() in beforeEach - Extend BTAPI, BT, and Bootstrap test suites to cover software-renderer selection paths and URL override behavior Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/BlitTech.test.ts | 49 +++++++++ src/assets/SpriteSheet.test.ts | 35 ++++++ src/assets/SpriteSheet.ts | 7 +- src/core/BTAPI.test.ts | 158 ++++++++++++++++++++++++++++ src/core/BTAPI.ts | 54 +++++++++- src/core/IBlitTechDemo.ts | 2 + src/render/SoftwareRenderer.test.ts | 107 +++++++++++++++++++ src/render/SoftwareRenderer.ts | 37 +++++-- src/utils/Bootstrap.test.ts | 19 ++++ src/utils/Bootstrap.ts | 32 +++++- 10 files changed, 479 insertions(+), 21 deletions(-) diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 5621612..2b69e7b 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -1285,6 +1285,55 @@ describe('BT.effectAdd / BT.effectRemove / BT.effectClear', () => { expect(spy).toHaveBeenCalled(); }); + + it('effectAdd shows a clear software-renderer unsupported message', async () => { + await withErrorContainer(async () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue({} as never); + vi.spyOn(BTAPI.instance, 'effectAdd').mockImplementation(() => { + throw new Error( + "The software renderer doesn't support fullscreen effects. To use post-process effects, set renderer to 'webgpu' in configure().", + ); + }); + + BT.effectAdd(makeStubEffect()); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain("doesn't support fullscreen effects"); + expect(text).toContain("set renderer to 'webgpu' in configure()"); + }); + }); + + it('effectRemove shows a clear software-renderer unsupported message', async () => { + await withErrorContainer(async () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue({} as never); + vi.spyOn(BTAPI.instance, 'effectRemove').mockImplementation(() => { + throw new Error( + "The software renderer doesn't support fullscreen effects. To use post-process effects, set renderer to 'webgpu' in configure().", + ); + }); + + BT.effectRemove(makeStubEffect()); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain("doesn't support fullscreen effects"); + }); + }); + + it('effectClear shows a clear software-renderer unsupported message', async () => { + await withErrorContainer(async () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue({} as never); + vi.spyOn(BTAPI.instance, 'effectClear').mockImplementation(() => { + throw new Error( + "The software renderer doesn't support fullscreen effects. To use post-process effects, set renderer to 'webgpu' in configure().", + ); + }); + + BT.effectClear(); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain("doesn't support fullscreen effects"); + }); + }); }); // #endregion diff --git a/src/assets/SpriteSheet.test.ts b/src/assets/SpriteSheet.test.ts index 2eb9c1f..f6917c7 100644 --- a/src/assets/SpriteSheet.test.ts +++ b/src/assets/SpriteSheet.test.ts @@ -605,4 +605,39 @@ describe('SpriteSheet', () => { }); // #endregion + + // #region getIndexedPixels + + describe('getIndexedPixels', () => { + it('returns contents equal to the pixels passed to fromIndexedPixels', () => { + const pixels = new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + ]) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(4, 4, pixels); + + const result = sheet.getIndexedPixels(); + + expect(result).toEqual(pixels); + }); + + it('returns a defensive copy - mutations do not affect internal state', () => { + const pixels = new Uint8Array([0, 1, 2, 3]) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(2, 2, pixels); + + const result = sheet.getIndexedPixels(); + result[0] = 99; + + expect(sheet.getIndexedPixels()[0]).toBe(0); + }); + + it('throws when the sheet has not been indexized yet', () => { + const sheet = new SpriteSheet(mockImage); + + expect(() => sheet.getIndexedPixels()).toThrow( + "This sprite sheet hasn't been converted to palette indices yet. Call sheet.indexize(palette) first.", + ); + }); + }); + + // #endregion }); diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index e05ce95..c4c0ce2 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -479,10 +479,7 @@ export class SpriteSheet { } /** - * Returns the palette-indexed pixel buffer. - * - * The returned view references internal storage and is read-only by - * convention. Callers must not mutate it. + * Returns a copy of the palette-indexed pixel buffer. * * @returns Indexed pixels as row-major palette indices (1 byte per pixel). * @throws If the sheet has not been indexized yet. @@ -494,7 +491,7 @@ export class SpriteSheet { ); } - return this.indexedPixels; + return this.indexedPixels.slice() as Uint8Array; } // #endregion diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 6a2efa8..456f196 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -384,6 +384,87 @@ describe('BTAPI', () => { expect(BTAPI.instance.getRenderer()).not.toBeNull(); }); + it('URL override ?renderer=software wins over configure().renderer=webgpu', async () => { + vi.stubGlobal('location', { search: '?renderer=software' }); + vi.stubGlobal( + 'OffscreenCanvas', + class MockOffscreenCanvas { + constructor( + public width: number, + public height: number, + ) {} + getContext(): { + imageSmoothingEnabled: boolean; + createImageData: (w: number, h: number) => ImageData; + putImageData: ReturnType; + } { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + }; + } + }, + ); + uninstallMockNavigatorGPU(); + + const demo: IBlitTechDemo = { + configure: () => ({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + targetFPS: 60, + renderer: 'webgpu', + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + + const canvas = { + ...makeMockCanvas(), + getContext: (type: string) => { + if (type === '2d') { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn(), + }; + } + return null; + }, + toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), + } as unknown as HTMLCanvasElement; + + const result = await BTAPI.instance.init(demo, canvas); + + expect(result).toBe(true); + expect(BTAPI.instance.getHardwareSettings()?.renderer).toBe('software'); + expect(BTAPI.instance.getDevice()).toBeNull(); + }); + + it('ignores unknown renderer query values and keeps configure renderer', async () => { + vi.stubGlobal('location', { search: '?renderer=banana' }); + + const demo = makeMockDemo(); + const result = await BTAPI.instance.init(demo, makeMockCanvas()); + + expect(result).toBe(true); + expect(BTAPI.instance.getHardwareSettings()?.renderer).toBeUndefined(); + expect(BTAPI.instance.getDevice()).not.toBeNull(); + }); + it('should throw with WEBGPU_ADAPTER_MESSAGE when WebGPU adapter is unavailable', async () => { Object.defineProperty(globalThis, 'navigator', { value: { @@ -576,6 +657,83 @@ describe('BTAPI', () => { expect(result).toBe(mockBlob); }); + + it('captureFrame works in software mode after a rendered frame', async () => { + vi.stubGlobal('location', { search: '?renderer=software' }); + vi.stubGlobal( + 'OffscreenCanvas', + class MockOffscreenCanvas { + constructor( + public width: number, + public height: number, + ) {} + getContext(): { + imageSmoothingEnabled: boolean; + createImageData: (w: number, h: number) => ImageData; + putImageData: ReturnType; + } { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + }; + } + }, + ); + uninstallMockNavigatorGPU(); + + const demo: IBlitTechDemo = { + configure: () => ({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + targetFPS: 60, + renderer: 'software', + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + + const canvas = { + ...makeMockCanvas(), + getContext: (type: string) => { + if (type === '2d') { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn(), + }; + } + return null; + }, + toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), + } as unknown as HTMLCanvasElement; + + await BTAPI.instance.init(demo, canvas); + BTAPI.instance.setPalette(new Palette(16)); + + const capturePromise = BTAPI.instance.captureFrame(); + const renderer = BTAPI.instance.getRenderer(); + expect(renderer).not.toBeNull(); + + renderer?.beginFrame(); + renderer?.endFrame(); + + const blob = await capturePromise; + expect(blob.type).toBe('image/png'); + }); }); // #endregion diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 3703e74..9aa5576 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -29,7 +29,7 @@ import type { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; import { GameLoop } from './GameLoop'; -import type { HardwareSettings, IBlitTechDemo } from './IBlitTechDemo'; +import type { HardwareSettings, IBlitTechDemo, RendererBackend } from './IBlitTechDemo'; import { defaultConfig } from './IBlitTechDemo'; import { initWebGPU } from './WebGPUContext'; @@ -183,6 +183,7 @@ export class BTAPI { } this.hwSettings = configured ?? defaultConfig(); + this.applyRendererQueryOverride(); const { targetFPS } = this.hwSettings; @@ -879,6 +880,57 @@ export class BTAPI { 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. * diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index 4f4d1af..0f0edc7 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -71,6 +71,8 @@ export interface HardwareSettings { * Renderer backend to use. Defaults to `'webgpu'`. * * Set to `'software'` to opt into the Canvas 2D fallback backend. + * You can also force software mode at runtime with `?renderer=software` + * in the page URL. */ renderer?: RendererBackend; } diff --git a/src/render/SoftwareRenderer.test.ts b/src/render/SoftwareRenderer.test.ts index b9dc447..afcd051 100644 --- a/src/render/SoftwareRenderer.test.ts +++ b/src/render/SoftwareRenderer.test.ts @@ -70,8 +70,18 @@ function makePalette(): Palette { return palette; } +function hashPixels(imageData: ImageData): number { + let hash = 2166136261 >>> 0; + for (const value of imageData.data) { + hash ^= value; + hash = Math.imul(hash, 16777619) >>> 0; + } + return hash >>> 0; +} + describe('SoftwareRenderer', () => { beforeEach(() => { + vi.clearAllMocks(); context.lastImageData = null; logicalContext.lastImageData = null; vi.stubGlobal( @@ -230,4 +240,101 @@ describe('SoftwareRenderer', () => { expect(blob.type).toBe('image/png'); expect(toBlob).toHaveBeenCalledOnce(); }); + + it('replaces an older pending capture request with a clear error', async () => { + const toBlob = vi.fn((callback: (blob: Blob | null) => void) => + callback(new Blob(['png'], { type: 'image/png' })), + ); + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const firstCapture = renderer.captureFrame(); + const secondCapture = renderer.captureFrame(); + + renderer.beginFrame(); + renderer.endFrame(); + + await expect(firstCapture).rejects.toThrow('A capture is already in progress'); + await expect(secondCapture).resolves.toBeInstanceOf(Blob); + }); + + it('rejects captureFrame when canvas.toBlob is unavailable', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: undefined, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const capture = renderer.captureFrame(); + renderer.beginFrame(); + renderer.endFrame(); + + await expect(capture).rejects.toThrow("doesn't support canvas image export"); + }); + + it('rejects captureFrame when canvas.toBlob returns no image data', async () => { + const toBlob = vi.fn((callback: (blob: Blob | null) => void) => callback(null)); + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const capture = renderer.captureFrame(); + renderer.beginFrame(); + renderer.endFrame(); + + await expect(capture).rejects.toThrow('something went wrong exporting the canvas image'); + }); + + it('produces deterministic output for the same command sequence', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: () => context, + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(8, 8)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const runSequence = (): number => { + renderer.beginFrame(); + renderer.setClearColor(3); + renderer.drawRectFill(new Rect2i(1, 1, 3, 3), 1); + renderer.drawLine(new Vector2i(0, 7), new Vector2i(7, 0), 2); + renderer.setCameraOffset(new Vector2i(1, 0)); + renderer.drawRect(new Rect2i(2, 2, 4, 4), 1); + renderer.resetCamera(); + renderer.endFrame(); + + const frame = logicalContext.lastImageData; + expect(frame).not.toBeNull(); + return hashPixels(frame as ImageData); + }; + + const first = runSequence(); + const second = runSequence(); + + expect(second).toBe(first); + }); }); diff --git a/src/render/SoftwareRenderer.ts b/src/render/SoftwareRenderer.ts index 00895ee..9e16eaf 100644 --- a/src/render/SoftwareRenderer.ts +++ b/src/render/SoftwareRenderer.ts @@ -10,9 +10,21 @@ import type { IRenderer } from './IRenderer'; // #region Type Definitions -/** A queued filled-rectangle, outline-rectangle, or line draw command. */ -type PrimitiveCommand = { - kind: 'rectFill' | 'rect' | 'line'; +/** A queued filled-rectangle or outline-rectangle draw command. */ +type RectCommand = { + kind: 'rectFill' | 'rect'; + x0: number; + y0: number; + width: number; + height: number; + paletteIndex: number; + cameraX: number; + cameraY: number; +}; + +/** A queued line draw command between two endpoints. */ +type LineCommand = { + kind: 'line'; x0: number; y0: number; x1: number; @@ -22,6 +34,9 @@ type PrimitiveCommand = { cameraY: number; }; +/** A queued filled-rectangle, outline-rectangle, or line draw command. */ +type PrimitiveCommand = RectCommand | LineCommand; + /** A queued sprite blit command, storing the source sheet and destination position. */ type SpriteCommand = { kind: 'sprite'; @@ -232,8 +247,8 @@ export class SoftwareRenderer implements IRenderer { kind: 'rectFill', x0: rect.x, y0: rect.y, - x1: rect.width, - y1: rect.height, + width: rect.width, + height: rect.height, paletteIndex, cameraX: this.cameraOffset.x, cameraY: this.cameraOffset.y, @@ -281,8 +296,8 @@ export class SoftwareRenderer implements IRenderer { kind: 'rect', x0: rect.x, y0: rect.y, - x1: rect.width, - y1: rect.height, + width: rect.width, + height: rect.height, paletteIndex, cameraX: this.cameraOffset.x, cameraY: this.cameraOffset.y, @@ -470,8 +485,8 @@ export class SoftwareRenderer implements IRenderer { this.rasterRectFill( command.x0, command.y0, - command.x1, - command.y1, + command.width, + command.height, command.paletteIndex, command.cameraX, command.cameraY, @@ -481,8 +496,8 @@ export class SoftwareRenderer implements IRenderer { this.rasterRect( command.x0, command.y0, - command.x1, - command.y1, + command.width, + command.height, command.paletteIndex, command.cameraX, command.cameraY, diff --git a/src/utils/Bootstrap.test.ts b/src/utils/Bootstrap.test.ts index e6d915e..dd68b17 100644 --- a/src/utils/Bootstrap.test.ts +++ b/src/utils/Bootstrap.test.ts @@ -145,6 +145,25 @@ describe('bootstrap', () => { expect(result).toBe(false); expect(onError).toHaveBeenCalledOnce(); }); + + it('should skip WebGPU validation when ?renderer=software is set', async () => { + setupDOM(); + const originalLocation = window.location; + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + search: '?renderer=software', + }, + }); + + const onError = vi.fn(); + const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); + + expect(result).toBe(true); + expect(onError).not.toHaveBeenCalled(); + }); }); // #endregion diff --git a/src/utils/Bootstrap.ts b/src/utils/Bootstrap.ts index d7b7c9c..f3eb712 100644 --- a/src/utils/Bootstrap.ts +++ b/src/utils/Bootstrap.ts @@ -77,6 +77,26 @@ function buildWebGPUNotSupportedMessage(): string { return getWebGPUInstructions(detectBrowser()); } +/** + * Returns true when URL query requests software renderer override. + * + * Recognizes `?renderer=software`. + * + * @returns True if `?renderer=software` is present in the current URL. + */ +function isSoftwareRendererQueryOverrideEnabled(): boolean { + if (typeof window === 'undefined' || !window.location?.search) { + return false; + } + + try { + return new URLSearchParams(window.location.search).get('renderer') === 'software'; + } catch (error) { + console.warn('[BT] Failed to parse renderer query in bootstrap:', error); + return false; + } +} + // #endregion // #region Helper Functions @@ -295,10 +315,14 @@ export async function bootstrap(DemoClass: DemoConstructor, options: BootstrapOp } try { - // Validate WebGPU support. - const webGPUResult = validateWebGPU(containerId, onError); - - success = webGPUResult.success; + // Validate WebGPU support unless software backend is explicitly requested + // via `?renderer=software`. + if (isSoftwareRendererQueryOverrideEnabled()) { + success = true; + } else { + const webGPUResult = validateWebGPU(containerId, onError); + success = webGPUResult.success; + } // Validate canvas element. if (success) { -- 2.51.2 From ba87d91ffdfc0623b69c79bfa2e80cc3c9d2c40b Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 12:37:06 +0200 Subject: [PATCH 3/8] test(renderer): add software-mode visual regression specs and baselines Each existing visual suite (camera, fonts, mixed, primitives, sprites) gains a companion test that navigates to the same fixture page with `?renderer=software`, waits for render completion, and asserts a PNG snapshot. Five baseline images are committed alongside the specs. Also adds the software fallback smoke matrix to docs. Co-Authored-By: Claude Signed-off-by: Vaclav Vancura --- docs/software-fallback-smoke-matrix.md | 49 ++++++++++++++++++ .../chromium-webgpu.png | Bin 0 -> 1803 bytes .../chromium-webgpu.png | Bin 0 -> 1545 bytes .../chromium-webgpu.png | Bin 0 -> 1398 bytes .../chromium-webgpu.png | Bin 0 -> 1158 bytes .../chromium-webgpu.png | Bin 0 -> 939 bytes tests/visual/camera.spec.ts | 21 ++++++++ tests/visual/fonts.spec.ts | 21 ++++++++ tests/visual/mixed.spec.ts | 21 ++++++++ tests/visual/primitives.spec.ts | 21 ++++++++ tests/visual/sprites.spec.ts | 21 ++++++++ 11 files changed, 154 insertions(+) create mode 100644 docs/software-fallback-smoke-matrix.md create mode 100644 tests/visual/__snapshots__/camera.spec.ts/Camera-Rendering-should-render-matching-camera-offsets-in-software-mode/chromium-webgpu.png create mode 100644 tests/visual/__snapshots__/fonts.spec.ts/Font-Rendering-should-render-matching-text-output-in-software-mode/chromium-webgpu.png create mode 100644 tests/visual/__snapshots__/mixed.spec.ts/Mixed-Rendering-should-render-matching-primitives-and-sprites-layering-in-software-mode/chromium-webgpu.png create mode 100644 tests/visual/__snapshots__/primitives.spec.ts/Primitive-Rendering-should-render-matching-primitive-patterns-in-software-mode/chromium-webgpu.png create mode 100644 tests/visual/__snapshots__/sprites.spec.ts/Sprite-Rendering-should-render-matching-indexed-sprites-with-offsets-in-software-mode/chromium-webgpu.png diff --git a/docs/software-fallback-smoke-matrix.md b/docs/software-fallback-smoke-matrix.md new file mode 100644 index 0000000..a5a7df2 --- /dev/null +++ b/docs/software-fallback-smoke-matrix.md @@ -0,0 +1,49 @@ +# Software Fallback Smoke Matrix + +This checklist is for quick manual verification of the software renderer MVP in `VV-490`. + +## Scope + +- Backend under test: `software` (`?renderer=software` or `configure().renderer = 'software'`) +- Resolution target: low-res scenes (for example `320x240`) +- In scope: clear, clearRect, primitives, sprites, system text, bitmap text, camera offset, frame capture +- Out of scope: fullscreen shader/post-process effects (`effectAdd`, `effectRemove`, `effectClear`) in software mode + +## Environment + +- Browser: latest Chrome or Edge +- URL override for software mode: + - `?renderer=software` +- Optional baseline comparison: + - same page without override (WebGPU path) + +## Matrix + +| Scene | What to verify | Software expected result | Notes | +| --------------------------------------- | ------------------------------------------------ | ------------------------------------------------- | ------------------------------------- | +| `tests/visual/fixtures/primitives.html` | clear + clearRect + primitive rasterization | Matches expected primitive layout and colors | Check pixel edges are crisp | +| `tests/visual/fixtures/camera.html` | camera offset applied to all draw calls | Geometry is shifted consistently by camera offset | No partial drift between primitives | +| `tests/visual/fixtures/sprites.html` | indexed sprites + palette offsets + transparency | Sprite shapes/colors match expected output | Transparent pixels stay see-through | +| `tests/visual/fixtures/fonts.html` | system text + bitmap font rendering | Text positions and glyph colors are correct | No missing glyph blocks | +| `tests/visual/fixtures/mixed.html` | primitives + sprites + layering order | Same stacking as WebGPU for this fixture | Parity covered by Playwright snapshot | +| Frame capture via `BT.captureFrame()` | PNG export in software mode | Promise resolves with PNG blob | Repeat capture across multiple frames | + +## Automated regression + +Visual parity for software mode is exercised by Playwright under `tests/visual/` (`primitives`, `camera`, `sprites`, +`fonts`, `mixed` specs load fixtures with `?renderer=software`). Run `pnpm test:visual` after renderer changes that +affect pixel output. + +## Known exclusions (expected in MVP) + +- Calling fullscreen effects APIs in software mode should fail clearly: + - `BT.effectAdd(...)` + - `BT.effectRemove(...)` + - `BT.effectClear()` +- Expected message intent: software mode does not support fullscreen effects and suggests switching to WebGPU. + +## Pass criteria + +- All rows in the matrix pass in software mode. +- Software output is visually stable run-to-run for the same scene. +- Effect API failures are clear and actionable. diff --git a/tests/visual/__snapshots__/camera.spec.ts/Camera-Rendering-should-render-matching-camera-offsets-in-software-mode/chromium-webgpu.png b/tests/visual/__snapshots__/camera.spec.ts/Camera-Rendering-should-render-matching-camera-offsets-in-software-mode/chromium-webgpu.png new file mode 100644 index 0000000000000000000000000000000000000000..f6652221139bdffab982630f09ffc145f78affac GIT binary patch literal 1803 zcmeAS@N?(olHy`uVBq!ia0y~yV02($VEDkn#K6Gt&tJ=ifr0ISr;B4q#hf>Ht&3zr zCE6ZNlL&tD#6_gK;DA^J(}Nb<3Ray)Eum@7OwtLeA|hgsC$2J`_Uzi-=bz>m&r{!+ z*T`J|@BXV*7vH?A|MU6q;lnIjp5@Id`~7v_?{BB$_x*p>ecpa{gW8?_Gw=vJI z&wE|7P3+u_cSo-C)xZC%S=KHYu56xD{!Y3`>e21kANS@4^K85u|CiH$x#F6;PxI}0 z%kO=d6g=l+R{qcbE}GI z9N^W)=ESdwZT5g!x^2xQyCYUd^S?FJY~ru@G}~5T_8zb~ zMXGurbD~_?ZBAx-ygJoj-23LE$ol&m-X3;*$!#CHe#!VRPux07 zyh3ex;#>jdc~4B%1lcd_KbaY!a*B1{$;=(GJ3vbF#I!+zJG35w1$DkOs9W-iAN75a zTqNb!Ro9qaBo&vP2J&F|Ef0`jnr`fanRD9QefcUTT`sr~2l5Ngx~m}Dd)EA9^e>v# zxOMJ@<4;VOUp-_x|HNeR%d7*F=cF0@Rrz9Qc~+rn_W@(evmCo*Kd3C1beIqF*5!@! zK*4i)wIV2ZtV&A{L_adV9%3f)aW3!W$$=m*U!MFBEO+UD@QY%bxyzW9;~vbMv#iva zzhcs5le{}Ec52Hrue<=E2k_CW0}t1xM=VI;trz zU+L$RueyAthm*R(@|8y$E>E}|qPQ@4(q)lnOzLXOHBUBNo_twEd13Ly%OVe$(p8q9 zJlpVj(&ZG@h29e`r#xa3S6Y7ZV8iK&msQjkdQZNr@`Nc|VY%nghSw7=t0*qao^(0q z2~)V*@|%YmUQfQfMs4BZ376MAU}9HV9(k%EdE#XqrG?6qF5fx9%ID=*%+1{A?N>at zrO(f=xRLt~gXLLEwmWQ=XFY{>sTR!=k9lzHL}s64LG=?8W6rx;mS>+Znfv-BAIP12 zxq0#h+X#T4ZIx zdA793N=JOg>=Q3%@XUxl`LaR9B*P9Dkfh~ zzPuWf;56a#YYBtE6E8(oOg5f)=^B%8XyWDV#tBN3E}Kufa71z z^(S1|;^;U33DadB%fGW2FAG`zZE9I&TU7Oo>9UID-xCd)Q|HW6FY8rzY+$^V-t26Mfph^?3L77u4+;%r2-N$bpok*Z3j~G+G2TZLfeIGKWT%- zFN~I}nk2u}0f}4=_LDYHex9+g=}f_GKkw^?h@TryoVy zZ+%$JSbaOxLO;sSJ0n6Z#IKr*H=Cu>>Mhe@FTtW)4T*bSS?VrKT-{~)mdTlEYtgL+ z#a=B--Gz!v4}nCIREuvlDDF)HiA-k#iMZ_qiL75^sk?Atiw{Ud(X!}P!@@aFKq6aO zKq9OwK_Y!BAdzLgAd%E)kjTEPAalAzK_Uv?Aakanxq&JH4o3>*R z_t#ET!C&|3<^HPw|2F^m>i+Zld?uHQpE3JtX4n1x`}8>bSEp&-8>Z*z8-MsO-=MSp z<~|XHX$^`qu%R1%2QHhemHY1A*R%g=xTwaWHIeCUJ1x9oS0eOMU!=2`9E%g)A@HcO1xUyu(! z_lH;VMybVv%7o98?w`w_dvn*Sy+3O{JP4heelYnqXN6!&by0S~^H0(9wo3nhXHcQ? zPnxrdQ-T0`tCKcE|CMBl zjkn)P&-%Ayv#xNT}Z+a7j2u1+%5zxwscoS5zL=WcI*p_Cc1dAs_xTZM1=r1wPsPrfV17W31Uk3%hi z05aPkweoJm&Ww-UG6Fsa2%wx4|25xP9k>4b z^06u{<;<7QyY8Jn`R&ZB+GqU{vYUUb4m&MXp|ra?X|3uO3uB{YYrbx?*jzRL+M~`7 zN8W8&@c&g@>`P%bnab9@^D?K_?9F;@m;XHc{@%u!6IuV>-YZ)*^=@tJy!r31?0Zl; zOTFyXn;+0o_Z^(LK{lP;kx^tWUJ@@z$YyBtFA5W~0d=U7Sy=Kw9b2ZlQf5rvB zxK^H1QDMzkzHN7u&)Uz4%KE$B&L|ESy+5ZiDLw7Mx$QUqo!qqX^wovYym74O59k*; z7%)1IqQMpd8?HCR+lecj&fd13vHs_>M2{lQZFVdQ&MBb@0P%=n^8f$< literal 0 HcmV?d00001 diff --git a/tests/visual/__snapshots__/mixed.spec.ts/Mixed-Rendering-should-render-matching-primitives-and-sprites-layering-in-software-mode/chromium-webgpu.png b/tests/visual/__snapshots__/mixed.spec.ts/Mixed-Rendering-should-render-matching-primitives-and-sprites-layering-in-software-mode/chromium-webgpu.png new file mode 100644 index 0000000000000000000000000000000000000000..a08697f32c5aa431fcf85f07218986204f078f01 GIT binary patch literal 1398 zcmeAS@N?(olHy`uVBq!ia0y~yV02($VEDkn#K6Gt&tJ=ifq_-q)5S5QV$PepwnfUW z3~djut#dLGa?-r1JmHb;VYve`O$o-+0zzey^bbfHXg(F5a6<898jH#liRl`O*PJ+8m_s6DI428q3YKfOB~ zf4}DUvfF|BDKDGeZhC8W?DSkFm&ml=%NRDY@EnAoW4rlm&b;SZKHDdsE9P{?dBgWd zgMY3)KDGC|{J(z>*A>h;KY6yT`}5E7%P)Ut)jK8g!)sU7?l$h2DGlr0568@DSnqW> zW?I8~-@`Ez8sZzY2>rqBeQ)+xC91mtR~_ zuqE~Iwakw^ZVwy{_&(b($#L<1=3$Z(;4NOY`PqTZH`nax@mH_gFZJy3oFg1I7RM6% z)E_8bGH@6A;E*{Zd5$Ujzu%9Ke=@QYJ=1P^hQlV~SmHO~4;M0L*s{nt9DbwpfJZ7# zoALMsgIXrPMTuq{7Cmi|>JMzB(p(QrG&wV=Avkk}(t`{sw*xm#?pQD$zg%#dC5D;T z-21@P3k9=TV)%K@Jr0CkEXZbwk>)jbJs^6iz?vmSo!8vyz|~6y+$=H1yy5-_rd}?% z%MxSH8}4(!_d>y1mOEU$;hqP0FBa6Y+!5mqcRO(QVu3Hq9VOmy=L5Z$3S?RC=<$X- z9PqtdV8yuI@9;&Ip0;Mr8SYYistNxVKXhSwRxj13oG|Y~ff(av&%=%^XT+rZ6ce^x zE@&xil-4`*_BubEGU@yL^v5^3 zw>)3b!2c?|WMyUM|4V!A)too$2^Z8?>@O*#irn zHwR=Y%x0UGifodA2ebc^h7h}n%$qnRnnn@+5U^$2Be7T4VM^+@e#ZLB?~aEBmT+%7 zz3sL{FSI~=8qdJM@c;h{woP^n3=C|bVw0I+JJ+>Uw+uIYVPIfj@O1TaS?83{1OTNS BAi4km literal 0 HcmV?d00001 diff --git a/tests/visual/__snapshots__/primitives.spec.ts/Primitive-Rendering-should-render-matching-primitive-patterns-in-software-mode/chromium-webgpu.png b/tests/visual/__snapshots__/primitives.spec.ts/Primitive-Rendering-should-render-matching-primitive-patterns-in-software-mode/chromium-webgpu.png new file mode 100644 index 0000000000000000000000000000000000000000..c60be1049aa498af6c4dcdd9dca9a1a9e91f283e GIT binary patch literal 1158 zcmeAS@N?(olHy`uVBq!ia0y~yV02($VEDkn#K6Gt&tJ=ifq})^)5S5QV$Pepr+cN{ zMI0_ZN|@3)IY7wIu~38Q`D2wmfgIehY#9)CQ4e~0T#8ow;&LHW;ne9xsB zS5JR_Hth7A_nNPFw6qzZYUPwT#KM!fKDk%Ez+BfmddA6J4YFqow5DHx8V!+W!mbTf zIC}e1zV;vQ++J;zOb0s)LvNZQ`|ilP0}spHcME>lq$B>ip`z*VGXZnGh3miS-E&V2 z+iuUCEwQZh`t%5?Hp{!eb+@!7I`RMMS&}&8Z1R4P)Ta+le7uKk=0$ccO`LILvbUxw zk8wqv?v}QPC9Ct&E*Q+oetXwLs!cezz_DBKBy2BpQs^85M4!c9`6VKA``e z?ZT&NZ=(;qx6hLB0%c^`$3;IsWJ)@mPwm;yz`*eT|K<95ZVU_zY@lL|NarBenTeb?+Ep`*m}V;^&gn3-&~SX%Rcxhw->dX%SNhG{FEj1=7ofMD^XwW1FCwz#(kV!YOfx zg(s1zZNuB|_VcSevZc~@Ts-@^GkW>vf8t&~C`t?%oo6&C=A`o1TzvJ#&HVQFPxUhn z2&iFE;{y}Uz0Mc&B}w~1_)M4x%svA~=Nqr&ZtN5PrzZ?`0>pf{UYN=aQ_~louTs7? zmG7*2-;aCxbCQ@q?m$zgUHxEl*oJVcl>6NFsn`@Swc#*-R9F!$EQfkfy^;9vERR9= zcuqf~{TmY=RZzlD_PKnyPR`-H$uV^X28RFtB|DzoU|?Wi1LZYlhGXTb!RxY1PB1Vq OFnGH9xvX { maxDiffPixelRatio: 0.01, }); }); + + test('should render matching camera offsets in software mode', async ({ page }) => { + await page.goto('/camera.html?renderer=software'); + + await page.waitForFunction( + () => { + const w = window as unknown as Record; + return w.__RENDER_COMPLETE__ || w.__INIT_FAILED__; + }, + { timeout: 10_000 }, + ); + + const initFailed = await page.evaluate(() => (window as unknown as Record).__INIT_FAILED__); + expect(initFailed).toBeFalsy(); + + await page.waitForTimeout(GPU_PRESENT_DELAY); + + await expect(page.locator('canvas')).toHaveScreenshot('camera-software.png', { + maxDiffPixelRatio: 0.01, + }); + }); }); diff --git a/tests/visual/fonts.spec.ts b/tests/visual/fonts.spec.ts index bfe4685..9bf1163 100644 --- a/tests/visual/fonts.spec.ts +++ b/tests/visual/fonts.spec.ts @@ -28,4 +28,25 @@ test.describe('Font Rendering', () => { maxDiffPixelRatio: 0.01, }); }); + + test('should render matching text output in software mode', async ({ page }) => { + await page.goto('/fonts.html?renderer=software'); + + await page.waitForFunction( + () => { + const w = window as unknown as Record; + return w.__RENDER_COMPLETE__ || w.__INIT_FAILED__; + }, + { timeout: 10_000 }, + ); + + const initFailed = await page.evaluate(() => (window as unknown as Record).__INIT_FAILED__); + expect(initFailed).toBeFalsy(); + + await page.waitForTimeout(GPU_PRESENT_DELAY); + + await expect(page.locator('canvas')).toHaveScreenshot('fonts-software.png', { + maxDiffPixelRatio: 0.01, + }); + }); }); diff --git a/tests/visual/mixed.spec.ts b/tests/visual/mixed.spec.ts index d6530fe..4562f41 100644 --- a/tests/visual/mixed.spec.ts +++ b/tests/visual/mixed.spec.ts @@ -28,4 +28,25 @@ test.describe('Mixed Rendering', () => { maxDiffPixelRatio: 0.01, }); }); + + test('should render matching primitives and sprites layering in software mode', async ({ page }) => { + await page.goto('/mixed.html?renderer=software'); + + await page.waitForFunction( + () => { + const w = window as unknown as Record; + return w.__RENDER_COMPLETE__ || w.__INIT_FAILED__; + }, + { timeout: 10_000 }, + ); + + const initFailed = await page.evaluate(() => (window as unknown as Record).__INIT_FAILED__); + expect(initFailed).toBeFalsy(); + + await page.waitForTimeout(GPU_PRESENT_DELAY); + + await expect(page.locator('canvas')).toHaveScreenshot('mixed-software.png', { + maxDiffPixelRatio: 0.01, + }); + }); }); diff --git a/tests/visual/primitives.spec.ts b/tests/visual/primitives.spec.ts index 7b8ff7e..0a4df1b 100644 --- a/tests/visual/primitives.spec.ts +++ b/tests/visual/primitives.spec.ts @@ -33,4 +33,25 @@ test.describe('Primitive Rendering', () => { maxDiffPixelRatio: 0.01, }); }); + + test('should render matching primitive patterns in software mode', async ({ page }) => { + await page.goto('/primitives.html?renderer=software'); + + await page.waitForFunction( + () => { + const w = window as unknown as Record; + return w.__RENDER_COMPLETE__ || w.__INIT_FAILED__; + }, + { timeout: 10_000 }, + ); + + const initFailed = await page.evaluate(() => (window as unknown as Record).__INIT_FAILED__); + expect(initFailed).toBeFalsy(); + + await page.waitForTimeout(GPU_PRESENT_DELAY); + + await expect(page.locator('canvas')).toHaveScreenshot('primitives-software.png', { + maxDiffPixelRatio: 0.01, + }); + }); }); diff --git a/tests/visual/sprites.spec.ts b/tests/visual/sprites.spec.ts index 1cb4d95..4df12ec 100644 --- a/tests/visual/sprites.spec.ts +++ b/tests/visual/sprites.spec.ts @@ -28,4 +28,25 @@ test.describe('Sprite Rendering', () => { maxDiffPixelRatio: 0.01, }); }); + + test('should render matching indexed sprites with offsets in software mode', async ({ page }) => { + await page.goto('/sprites.html?renderer=software'); + + await page.waitForFunction( + () => { + const w = window as unknown as Record; + return w.__RENDER_COMPLETE__ || w.__INIT_FAILED__; + }, + { timeout: 10_000 }, + ); + + const initFailed = await page.evaluate(() => (window as unknown as Record).__INIT_FAILED__); + expect(initFailed).toBeFalsy(); + + await page.waitForTimeout(GPU_PRESENT_DELAY); + + await expect(page.locator('canvas')).toHaveScreenshot('sprites-software.png', { + maxDiffPixelRatio: 0.01, + }); + }); }); -- 2.51.2 From 6d41a554ac6eca5da1fafd115ac70ed09ab10aee Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 15:33:22 +0200 Subject: [PATCH 4/8] test(renderer): tighten 2d context mocks in software-related tests OffscreenCanvas stubs in BTAPI tests now return null unless context type is 2d. SoftwareRenderer tests use the same contract for canvas and offscreen mocks and add region markers. Bootstrap software-mode test restores window.location in a finally block. Co-Authored-By: Claude Signed-off-by: Vaclav Vancura --- src/core/BTAPI.test.ts | 71 +++++++++++------------------ src/render/SoftwareRenderer.test.ts | 52 ++++++++++++++++----- src/utils/Bootstrap.test.ts | 33 ++++++++------ 3 files changed, 86 insertions(+), 70 deletions(-) diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 456f196..27a6398 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -62,6 +62,26 @@ function makeMockCanvas(): HTMLCanvasElement { } as unknown as HTMLCanvasElement; } +/** Minimal 2D context shape for {@link OffscreenCanvas#getContext} mocks; rejects non-`2d` types. */ +type OffscreenCanvas2DMock = { + imageSmoothingEnabled: boolean; + createImageData: (w: number, h: number) => ImageData; + putImageData: ReturnType; +}; + +function makeOffscreenCanvas2dContext(): OffscreenCanvas2DMock { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + }; +} + // #endregion describe('BTAPI', () => { @@ -325,21 +345,8 @@ describe('BTAPI', () => { public width: number, public height: number, ) {} - getContext(): { - imageSmoothingEnabled: boolean; - createImageData: (w: number, h: number) => ImageData; - putImageData: ReturnType; - } { - return { - imageSmoothingEnabled: false, - createImageData: (w: number, h: number) => - ({ - data: new Uint8ClampedArray(w * h * 4), - width: w, - height: h, - }) as ImageData, - putImageData: vi.fn(), - }; + getContext(contextType?: string): OffscreenCanvas2DMock | null { + return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); @@ -393,21 +400,8 @@ describe('BTAPI', () => { public width: number, public height: number, ) {} - getContext(): { - imageSmoothingEnabled: boolean; - createImageData: (w: number, h: number) => ImageData; - putImageData: ReturnType; - } { - return { - imageSmoothingEnabled: false, - createImageData: (w: number, h: number) => - ({ - data: new Uint8ClampedArray(w * h * 4), - width: w, - height: h, - }) as ImageData, - putImageData: vi.fn(), - }; + getContext(contextType?: string): OffscreenCanvas2DMock | null { + return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); @@ -667,21 +661,8 @@ describe('BTAPI', () => { public width: number, public height: number, ) {} - getContext(): { - imageSmoothingEnabled: boolean; - createImageData: (w: number, h: number) => ImageData; - putImageData: ReturnType; - } { - return { - imageSmoothingEnabled: false, - createImageData: (w: number, h: number) => - ({ - data: new Uint8ClampedArray(w * h * 4), - width: w, - height: h, - }) as ImageData, - putImageData: vi.fn(), - }; + getContext(contextType?: string): OffscreenCanvas2DMock | null { + return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); diff --git a/src/render/SoftwareRenderer.test.ts b/src/render/SoftwareRenderer.test.ts index afcd051..6b56686 100644 --- a/src/render/SoftwareRenderer.test.ts +++ b/src/render/SoftwareRenderer.test.ts @@ -9,6 +9,8 @@ import { Vector2i } from '../utils/Vector2i'; import type { Effect } from './effects/Effect'; import { SoftwareRenderer } from './SoftwareRenderer'; +// #region Types and helpers + type MockContext = { imageSmoothingEnabled: boolean; createImageData: (w: number, h: number) => ImageData; @@ -45,11 +47,15 @@ class MockOffscreenCanvas { public width: number, public height: number, ) {} - getContext(): MockContext { - return logicalContext; + getContext(contextId?: string): MockContext | null { + return contextId === '2d' ? logicalContext : null; } } +function canvasGet2d(ctx: MockContext): (type?: string) => MockContext | null { + return (type?: string) => (type === '2d' ? ctx : null); +} + function getPixel(imageData: ImageData, width: number, x: number, y: number): [number, number, number, number] { const index = (y * width + x) * 4; /* eslint-disable security/detect-object-injection */ @@ -79,7 +85,11 @@ function hashPixels(imageData: ImageData): number { return hash >>> 0; } +// #endregion + describe('SoftwareRenderer', () => { + // #region Setup + beforeEach(() => { vi.clearAllMocks(); context.lastImageData = null; @@ -101,12 +111,16 @@ describe('SoftwareRenderer', () => { vi.unstubAllGlobals(); }); + // #endregion + + // #region Basic lifecycle + it('requires palette before beginFrame', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -115,12 +129,16 @@ describe('SoftwareRenderer', () => { expect(() => renderer.beginFrame()).toThrow('No palette set yet. Call BT.paletteSet'); }); + // #endregion + + // #region Rendering behaviors + it('renders primitives with camera offset applied', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -142,7 +160,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -169,7 +187,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -204,7 +222,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -217,6 +235,10 @@ describe('SoftwareRenderer', () => { expect(() => renderer.clearEffects()).toThrow("doesn't support fullscreen effects"); }); + // #endregion + + // #region Capture behavior + it('resolves captureFrame on next endFrame', async () => { const toBlob = vi.fn((callback: (blob: Blob | null) => void) => callback(new Blob(['png'], { type: 'image/png' })), @@ -225,7 +247,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -249,7 +271,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -271,7 +293,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: undefined, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -291,7 +313,7 @@ describe('SoftwareRenderer', () => { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); @@ -305,12 +327,16 @@ describe('SoftwareRenderer', () => { await expect(capture).rejects.toThrow('something went wrong exporting the canvas image'); }); + // #endregion + + // #region Determinism + it('produces deterministic output for the same command sequence', async () => { const canvas = { width: 0, height: 0, style: { width: '', height: '' }, - getContext: () => context, + getContext: canvasGet2d(context), toBlob: (_cb: (blob: Blob | null) => void) => {}, } as unknown as HTMLCanvasElement; const renderer = new SoftwareRenderer(canvas, new Vector2i(8, 8)); @@ -337,4 +363,6 @@ describe('SoftwareRenderer', () => { expect(second).toBe(first); }); + + // #endregion }); diff --git a/src/utils/Bootstrap.test.ts b/src/utils/Bootstrap.test.ts index dd68b17..cd32516 100644 --- a/src/utils/Bootstrap.test.ts +++ b/src/utils/Bootstrap.test.ts @@ -150,19 +150,26 @@ describe('bootstrap', () => { setupDOM(); const originalLocation = window.location; - Object.defineProperty(window, 'location', { - configurable: true, - value: { - ...originalLocation, - search: '?renderer=software', - }, - }); - - const onError = vi.fn(); - const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); - - expect(result).toBe(true); - expect(onError).not.toHaveBeenCalled(); + try { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + search: '?renderer=software', + }, + }); + + const onError = vi.fn(); + const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); + + expect(result).toBe(true); + expect(onError).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } }); }); -- 2.51.2 From cbcfa27d9ddd39872f8fc3b29331ef399c0135b2 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 15:39:41 +0200 Subject: [PATCH 5/8] test(api): extract makeMock2DCanvas helper to eliminate inline duplication Three software-renderer BTAPI tests each inlined the same 21-line 2D canvas mock (getContext/'2d' + toBlob). Extracted into a shared makeMock2DCanvas() helper in the helpers region of BTAPI.test.ts, replacing all three sites with a single call. Signed-off-by: Vaclav Vancura Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/core/BTAPI.test.ts | 90 +++++++++++++----------------------------- 1 file changed, 27 insertions(+), 63 deletions(-) diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 27a6398..2a46816 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -62,6 +62,30 @@ function makeMockCanvas(): HTMLCanvasElement { } as unknown as HTMLCanvasElement; } +function makeMock2DCanvas(): HTMLCanvasElement { + return { + ...makeMockCanvas(), + getContext: (type: string) => { + if (type === '2d') { + return { + imageSmoothingEnabled: false, + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn(), + }; + } + return null; + }, + toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), + } as unknown as HTMLCanvasElement; +} + /** Minimal 2D context shape for {@link OffscreenCanvas#getContext} mocks; rejects non-`2d` types. */ type OffscreenCanvas2DMock = { imageSmoothingEnabled: boolean; @@ -361,27 +385,7 @@ describe('BTAPI', () => { update: vi.fn(), render: vi.fn(), }; - const canvas = { - ...makeMockCanvas(), - getContext: (type: string) => { - if (type === '2d') { - return { - imageSmoothingEnabled: false, - createImageData: (w: number, h: number) => - ({ - data: new Uint8ClampedArray(w * h * 4), - width: w, - height: h, - }) as ImageData, - putImageData: vi.fn(), - clearRect: vi.fn(), - drawImage: vi.fn(), - }; - } - return null; - }, - toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), - } as unknown as HTMLCanvasElement; + const canvas = makeMock2DCanvas(); const result = await BTAPI.instance.init(demo, canvas); @@ -419,27 +423,7 @@ describe('BTAPI', () => { render: vi.fn(), }; - const canvas = { - ...makeMockCanvas(), - getContext: (type: string) => { - if (type === '2d') { - return { - imageSmoothingEnabled: false, - createImageData: (w: number, h: number) => - ({ - data: new Uint8ClampedArray(w * h * 4), - width: w, - height: h, - }) as ImageData, - putImageData: vi.fn(), - clearRect: vi.fn(), - drawImage: vi.fn(), - }; - } - return null; - }, - toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), - } as unknown as HTMLCanvasElement; + const canvas = makeMock2DCanvas(); const result = await BTAPI.instance.init(demo, canvas); @@ -680,27 +664,7 @@ describe('BTAPI', () => { render: vi.fn(), }; - const canvas = { - ...makeMockCanvas(), - getContext: (type: string) => { - if (type === '2d') { - return { - imageSmoothingEnabled: false, - createImageData: (w: number, h: number) => - ({ - data: new Uint8ClampedArray(w * h * 4), - width: w, - height: h, - }) as ImageData, - putImageData: vi.fn(), - clearRect: vi.fn(), - drawImage: vi.fn(), - }; - } - return null; - }, - toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), - } as unknown as HTMLCanvasElement; + const canvas = makeMock2DCanvas(); await BTAPI.instance.init(demo, canvas); BTAPI.instance.setPalette(new Palette(16)); -- 2.51.2 From fe14b92c7d6b85da7ac6e1a584d727010c1559e0 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 15:46:05 +0200 Subject: [PATCH 6/8] test(api): strengthen unknown-renderer test to assert configure() renderer is preserved Previously the test used makeMockDemo() which returns no renderer field, making the toBeUndefined assertion vacuous. The demo now configures renderer: 'webgpu' explicitly so the test proves that an unrecognized query param (?renderer=banana) leaves the configured backend untouched. Signed-off-by: Vaclav Vancura Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/core/BTAPI.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 2a46816..32e2a27 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -435,11 +435,21 @@ describe('BTAPI', () => { it('ignores unknown renderer query values and keeps configure renderer', async () => { vi.stubGlobal('location', { search: '?renderer=banana' }); - const demo = makeMockDemo(); + const demo: IBlitTechDemo = { + configure: () => ({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + targetFPS: 60, + renderer: 'webgpu', + }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; const result = await BTAPI.instance.init(demo, makeMockCanvas()); expect(result).toBe(true); - expect(BTAPI.instance.getHardwareSettings()?.renderer).toBeUndefined(); + expect(BTAPI.instance.getHardwareSettings()?.renderer).toBe('webgpu'); expect(BTAPI.instance.getDevice()).not.toBeNull(); }); -- 2.51.2 From 1532cb64241d7dca1817576a0ac1abb6b8ea5a9f Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 15:56:02 +0200 Subject: [PATCH 7/8] docs(renderer): document SoftwareRenderer backend in CLAUDE.md, README, and guides - Add SoftwareRenderer.ts to the src/render/ tree in CLAUDE.md and parent workspace CLAUDE.md - Update CLAUDE.md Rendering section: two backends ('webgpu' default, 'software' Canvas 2D fallback) with capabilities and URL override - Add SpriteSheet.getIndexedPixels() to CLAUDE.md API Conventions - README: fix stale Renderer.ts entry -> IRenderer.ts + WebGpuRenderer.ts + SoftwareRenderer.ts in Project Structure - README: expand WebGPU feature bullet to mention Canvas 2D fallback and ?renderer=software selection - README: note that software mode throws on effectAdd/Remove/Clear - docs/post-process-effects.md: add renderer field to HardwareSettings interface; note software-mode throws on all three effect APIs - docs/testing.md: expand Tier 2 list with SoftwareRenderer and Bootstrap software-mode entries; note visual specs cover both backends; add makeMock2DCanvas helper note to WebGPU mock section Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- CLAUDE.md | 15 +++++++++++---- README.md | 16 ++++++++++------ docs/post-process-effects.md | 8 +++++--- docs/testing.md | 18 ++++++++++++++---- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d034912..3314c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ src/ render/ IRenderer.ts # Backend-agnostic renderer contract (interface) WebGpuRenderer.ts # WebGPU concrete renderer implementing IRenderer + SoftwareRenderer.ts # Canvas 2D software fallback 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 @@ -68,11 +69,15 @@ src/ ### Rendering -Dual WebGPU pipeline architecture: +Two backends selectable via `HardwareSettings.renderer` (default `'webgpu'`): -1. **Primitives pipeline** - colored geometry (pixels, lines, rects). Max 50k vertices/frame. -2. **Sprites pipeline** - textured quads with tinting. Max 50k vertices (~8333 quads). Nearest-neighbor sampling. - Auto-batched by texture. +- **WebGPU** (`'webgpu'`): dual-pipeline hardware renderer. + 1. **Primitives pipeline** - colored geometry (pixels, lines, rects). Max 50k vertices/frame. + 2. **Sprites pipeline** - textured quads with tinting. Max 50k vertices (~8333 quads). Nearest-neighbor sampling. + Auto-batched by texture. +- **Software** (`'software'`): Canvas 2D fallback. Supports palette rendering, rects, Bresenham lines, indexed sprite + blits, and bitmap text. Post-process/fullscreen effects throw a clear error directing users to the WebGPU backend. + Force at runtime with the `?renderer=software` URL query parameter. ### Core Types @@ -102,6 +107,8 @@ Dual WebGPU pipeline architecture: - Prefer `SpriteSheet.loadIndexed(...)` for demo/game sprite setup; use manual `loadColorsIntoPalette` + `load` + `indexize` only for advanced flows +- Use `SpriteSheet.getIndexedPixels()` when the software renderer needs CPU-side pixel data; it returns a defensive copy + of the internal palette-indexed `Uint8Array` (throws if the sheet has not been indexized) - Prefer `Color32#luminance` for perceived brightness calculations instead of duplicating `0.299*r + 0.587*g + 0.114*b` at call sites - Prefer fixed-step helpers `BT.deltaSeconds()` / `BT.timeSeconds()` over hardcoded `1 / TARGET_FPS` in update loops diff --git a/README.md b/README.md index 9036062..4f47bbb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ primitives, and fonts. ## Features -- **WebGPU rendering** with dual-pipeline architecture (primitives + sprites) +- **WebGPU rendering** with dual-pipeline architecture (primitives + sprites); **Canvas 2D software fallback** for + environments without WebGPU — select via `HardwareSettings.renderer: 'software'` or `?renderer=software` URL param - **Palette system**: 256-entry indexed color palette with built-in presets (VGA, CGA, C64, Game Boy, PICO-8, NES) - **Palette effects**: cycling, fade, flash, swap with easing functions -- animated color manipulation each frame - **Post-process effects**: two-tier system — pixel-tier effects (chunky glitch, mosaic) at logical resolution, plus @@ -244,7 +245,9 @@ blit-tech/ │ │ ├── IBlitTechDemo.ts # Demo interface + HardwareSettings │ │ └── 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 +│ │ ├── SoftwareRenderer.ts # Canvas 2D software fallback implementing IRenderer │ │ ├── PrimitivePipeline.ts # Batched palette-indexed geometry │ │ ├── SpritePipeline.ts # Batched palette-indexed textured quads │ │ ├── PostProcessChain.ts # Tier-aware fullscreen effect chain @@ -397,10 +400,11 @@ organized into two chains by what they operate on: RGB shadow mask, vignette, chromatic aberration, bloom, etc. Operating at output resolution is what lets curved sampling (barrel) express smoothly without quantizing onto the source pixel grid. -Both chains add zero cost when empty. The display tier is already enabled when you omit `configure()` because -`defaultConfig()` sets `canvasDisplaySize` (and related fields). Implement `configure()` and set `canvasDisplaySize` -there only when you need to override those defaults (for example a different output or logical resolution than -`defaultConfig()` provides). +Both chains add zero cost when empty. Post-process effects are unsupported by the Canvas 2D software backend — calling +`BT.effectAdd` / `BT.effectRemove` / `BT.effectClear` in software mode throws a clear error directing you to the WebGPU +backend. The display tier is already enabled when you omit `configure()` because `defaultConfig()` sets +`canvasDisplaySize` (and related fields). Implement `configure()` and set `canvasDisplaySize` there only when you need +to override those defaults (for example a different output or logical resolution than `defaultConfig()` provides). ```ts import { BT, Vector2i, BarrelDistortion, Scanlines, Bloom, PixelGlitch } from 'blit-tech'; diff --git a/docs/post-process-effects.md b/docs/post-process-effects.md index 1a4cec1..7a657bb 100644 --- a/docs/post-process-effects.md +++ b/docs/post-process-effects.md @@ -85,17 +85,18 @@ Invariants: Appends an effect to the chain matching its declared `tier`. Effects can be added at any time; the first add allocates the chain's offscreen render targets, the second add allocates a second target for ping-pong. Throws if the engine has -not been initialized or if a `tier='display'` effect is added without `canvasDisplaySize`. +not been initialized, if a `tier='display'` effect is added without `canvasDisplaySize`, or if the active renderer +backend is `'software'` (Canvas 2D does not support post-process effects). ### `BT.effectRemove(effect: Effect): void` Removes a previously registered effect. Searches both tiers and disposes the effect from whichever chain holds it. Removing an effect that was never added is a no-op. When the last effect in either chain is removed, that chain's -offscreen textures are destroyed. +offscreen textures are destroyed. Throws in `'software'` mode. ### `BT.effectClear(): void` -Removes every effect in both tiers and destroys all offscreen GPU resources. +Removes every effect in both tiers and destroys all offscreen GPU resources. Throws in `'software'` mode. ### `Effect` interface @@ -138,6 +139,7 @@ interface HardwareSettings { outputUpscaleFilter?: 'nearest' | 'linear'; // default 'nearest' targetFPS: number; detectDroppedFrames?: boolean; + renderer?: 'webgpu' | 'software'; // default 'webgpu'; 'software' disables all post-process effects } ``` diff --git a/docs/testing.md b/docs/testing.md index cac9e17..4a9d308 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -24,22 +24,28 @@ and `vi` for browser API stubs. Tests that need a full DOM (Bootstrap, Bootstrap `// @vitest-environment happy-dom` directive. - **AssetLoader** - image caching and deduplication (Node + vi stubs) -- **SpriteSheet** - UV calculation, lazy texture creation, indexization, and `loadIndexed()` convenience flow (Node + - GPU mocks) +- **SpriteSheet** - UV calculation, lazy texture creation, indexization, `loadIndexed()` convenience flow, and + `getIndexedPixels()` defensive-copy semantics (Node + GPU mocks) - **BitmapFont** - glyph lookup, text measurement (Node + vi stubs) - **BootstrapHelpers** - WebGPU support detection, canvas lookup (happy-dom) -- **Bootstrap** - full bootstrap lifecycle (happy-dom) +- **Bootstrap** - full bootstrap lifecycle, including `?renderer=software` WebGPU-skip path (happy-dom) - **WebGpuRenderer** - frame lifecycle, camera, pipeline delegation (Node + GPU mocks) +- **SoftwareRenderer** - frame lifecycle, palette enforcement, camera offsets, indexed sprite blits, bitmap text, + `captureFrame()` semantics, and unsupported-effects assertions (Node + 2D canvas 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) -- **BTAPI** - singleton coordinator (Node + GPU mocks) +- **BTAPI** - singleton coordinator; includes software-mode init, `?renderer=software` URL override, and + `captureFrame()` in software mode (Node + GPU mocks + 2D canvas mocks) - **FrameCapture** - GPU readback, PNG conversion (Node + GPU mocks + browser stubs) ### Tier 3: Visual Regression (Playwright, Chromium) Actual GPU rendering verified via screenshot comparison. Requires Chrome with WebGPU flags enabled. +Each spec covers both the default WebGPU backend and the `?renderer=software` software backend, producing separate +baseline screenshots for each. + - **Primitives** - pixel, line, and rectangle rendering - **Sprites** - palette-indexed sprite rendering, palette offsets, batching - **Camera** - camera offset transforms applied to all geometry @@ -126,6 +132,10 @@ Available mocks: - `createMockPaletteBuffer()` - returns a 4096-byte stub GPUBuffer for palette uniform tests - `installMockNavigatorGPU()` / `uninstallMockNavigatorGPU()` - install/remove global navigator.gpu +For software-renderer tests that need a Canvas 2D context, use the `makeMock2DCanvas()` helper defined in +`src/core/BTAPI.test.ts`. It returns an `HTMLCanvasElement` stub whose `getContext('2d')` yields a minimal +`CanvasRenderingContext2D` mock (with `createImageData`, `putImageData`, `drawImage`, and `toBlob` stubs). + `src/__test__/setup.ts` also installs a global `OffscreenCanvas` stub in Node.js (returns zero-filled pixel data from `getImageData`). This is needed by `SpriteSheet.indexize()` tests that run outside a browser environment. -- 2.51.2 From 4d36b663dee5fabade0d929adc0b58d886e660b2 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 9 May 2026 16:01:29 +0200 Subject: [PATCH 8/8] ci(docs): ignore github actions urls in markdown link checker The CI badge link (github.com///actions/...) returns intermittent 502s from unauthenticated link checkers, causing false failures in the Check Documentation Links job. Ignoring the actions path pattern is consistent with the existing w3.org / angelcode.com / renderhjs.net exclusions added for the same reason. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- .github/markdown-link-check.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/markdown-link-check.json b/.github/markdown-link-check.json index 1b807ea..38b876f 100644 --- a/.github/markdown-link-check.json +++ b/.github/markdown-link-check.json @@ -23,6 +23,9 @@ }, { "pattern": "^https?://renderhjs\\.net(/|$)" + }, + { + "pattern": "^https?://github\\.com/[^/]+/[^/]+/actions(/|$)" } ], "replacementPatterns": [