diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 325e019..b8bd095 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -177,13 +177,19 @@ describe('BT.paletteGet', () => { vi.restoreAllMocks(); }); - it('delegates to BTAPI.instance.getPalette', () => { + it('returns palette when one is set', () => { const palette = new Palette(16); vi.spyOn(BTAPI.instance, 'getPalette').mockReturnValue(palette); expect(BT.paletteGet()).toBe(palette); }); + + it('throws when no palette is set', () => { + vi.spyOn(BTAPI.instance, 'getPalette').mockReturnValue(null); + + expect(() => BT.paletteGet()).toThrow('No active palette. Call BT.paletteSet() first.'); + }); }); // #endregion @@ -197,11 +203,10 @@ describe('BT.clear', () => { it('delegates to BTAPI.instance.setClearColor', () => { const spy = vi.spyOn(BTAPI.instance, 'setClearColor').mockReturnValue(undefined); - const color = Color32.black(); - BT.clear(color); + BT.clear(1); - expect(spy).toHaveBeenCalledWith(color); + expect(spy).toHaveBeenCalledWith(1); }); }); @@ -212,12 +217,11 @@ describe('BT.clearRect', () => { it('delegates to BTAPI.instance.clearRect', () => { const spy = vi.spyOn(BTAPI.instance, 'clearRect').mockReturnValue(undefined); - const color = Color32.black(); const rect = new Rect2i(0, 0, 100, 100); - BT.clearRect(color, rect); + BT.clearRect(1, rect); - expect(spy).toHaveBeenCalledWith(color, rect); + expect(spy).toHaveBeenCalledWith(1, rect); }); }); @@ -233,11 +237,10 @@ describe('BT.drawPixel', () => { it('delegates to BTAPI.instance.drawPixel', () => { const spy = vi.spyOn(BTAPI.instance, 'drawPixel').mockReturnValue(undefined); const pos = new Vector2i(5, 10); - const color = Color32.red(); - BT.drawPixel(pos, color); + BT.drawPixel(pos, 2); - expect(spy).toHaveBeenCalledWith(pos, color); + expect(spy).toHaveBeenCalledWith(pos, 2); }); }); @@ -250,11 +253,10 @@ describe('BT.drawLine', () => { const spy = vi.spyOn(BTAPI.instance, 'drawLine').mockReturnValue(undefined); const p0 = new Vector2i(0, 0); const p1 = new Vector2i(50, 50); - const color = Color32.green(); - BT.drawLine(p0, p1, color); + BT.drawLine(p0, p1, 3); - expect(spy).toHaveBeenCalledWith(p0, p1, color); + expect(spy).toHaveBeenCalledWith(p0, p1, 3); }); }); @@ -266,11 +268,10 @@ describe('BT.drawRect', () => { it('delegates to BTAPI.instance.drawRect', () => { const spy = vi.spyOn(BTAPI.instance, 'drawRect').mockReturnValue(undefined); const rect = new Rect2i(10, 10, 40, 30); - const color = Color32.blue(); - BT.drawRect(rect, color); + BT.drawRect(rect, 4); - expect(spy).toHaveBeenCalledWith(rect, color); + expect(spy).toHaveBeenCalledWith(rect, 4); }); }); @@ -282,11 +283,10 @@ describe('BT.drawRectFill', () => { it('delegates to BTAPI.instance.drawRectFill', () => { const spy = vi.spyOn(BTAPI.instance, 'drawRectFill').mockReturnValue(undefined); const rect = new Rect2i(0, 0, 20, 20); - const color = Color32.white(); - BT.drawRectFill(rect, color); + BT.drawRectFill(rect, 8); - expect(spy).toHaveBeenCalledWith(rect, color); + expect(spy).toHaveBeenCalledWith(rect, 8); }); }); @@ -406,11 +406,10 @@ describe('BT.print', () => { it('delegates to BTAPI.instance.drawText', () => { const spy = vi.spyOn(BTAPI.instance, 'drawText').mockReturnValue(undefined); const pos = new Vector2i(10, 10); - const color = Color32.white(); - BT.print(pos, color, 'Hello'); + BT.print(pos, 8, 'Hello'); - expect(spy).toHaveBeenCalledWith(pos, color, 'Hello'); + expect(spy).toHaveBeenCalledWith(pos, 8, 'Hello'); }); }); diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 750946c..53891e9 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -206,12 +206,17 @@ export const BT = { /** * Gets the active engine palette. * - * Lazily creates a default VGA palette if none has been set yet. - * * @returns Active palette. + * @throws Error if no palette has been set. */ paletteGet: (): Palette => { - return BTAPI.instance.getPalette(); + const palette = BTAPI.instance.getPalette(); + + if (!palette) { + throw new Error('No active palette. Call BT.paletteSet() first.'); + } + + return palette; }, // #endregion @@ -219,25 +224,25 @@ export const BT = { // #region Rendering - Clear Operations /** - * Sets the frame clear color. + * Sets the frame clear color using a palette index. * * The renderer uses this color when clearing the full display at the start * of the next frame. * - * @param color - Color used for the full-screen clear pass. + * @param paletteIndex - Palette index for the full-screen clear pass. */ - clear: (color: Color32): void => { - BTAPI.instance.setClearColor(color); + clear: (paletteIndex: number): void => { + BTAPI.instance.setClearColor(paletteIndex); }, /** - * Fills a rectangular display region with a solid color. + * Fills a rectangular display region with a palette-indexed color. * - * @param color - Fill color applied to the region. + * @param paletteIndex - Palette color index. * @param rect - Rectangle in display pixel coordinates. */ - clearRect: (color: Color32, rect: Rect2i): void => { - BTAPI.instance.clearRect(color, rect); + clearRect: (paletteIndex: number, rect: Rect2i): void => { + BTAPI.instance.clearRect(paletteIndex, rect); }, // #endregion @@ -248,10 +253,10 @@ export const BT = { * Draws a single pixel. * * @param pos - Target pixel in display coordinates. - * @param color - Pixel color. + * @param paletteIndex - Palette color index. */ - drawPixel: (pos: Vector2i, color: Color32): void => { - BTAPI.instance.drawPixel(pos, color); + drawPixel: (pos: Vector2i, paletteIndex: number): void => { + BTAPI.instance.drawPixel(pos, paletteIndex); }, /** @@ -261,30 +266,30 @@ export const BT = { * * @param p0 - Start position in display coordinates. * @param p1 - End position in display coordinates. - * @param color - Line color. + * @param paletteIndex - Palette color index. */ - drawLine: (p0: Vector2i, p1: Vector2i, color: Color32): void => { - BTAPI.instance.drawLine(p0, p1, color); + drawLine: (p0: Vector2i, p1: Vector2i, paletteIndex: number): void => { + BTAPI.instance.drawLine(p0, p1, paletteIndex); }, /** * Draws an unfilled rectangle outline. * * @param rect - Rectangle bounds in display coordinates. - * @param color - Outline color. + * @param paletteIndex - Palette color index. */ - drawRect: (rect: Rect2i, color: Color32): void => { - BTAPI.instance.drawRect(rect, color); + drawRect: (rect: Rect2i, paletteIndex: number): void => { + BTAPI.instance.drawRect(rect, paletteIndex); }, /** * Draws a filled rectangle. * * @param rect - Rectangle bounds in display coordinates. - * @param color - Fill color. + * @param paletteIndex - Palette color index. */ - drawRectFill: (rect: Rect2i, color: Color32): void => { - BTAPI.instance.drawRectFill(rect, color); + drawRectFill: (rect: Rect2i, paletteIndex: number): void => { + BTAPI.instance.drawRectFill(rect, paletteIndex); }, // #endregion @@ -416,11 +421,11 @@ export const BT = { * prefer {@link BT.printFont}. * * @param pos - Text origin in display coordinates. - * @param color - Text color. + * @param paletteIndex - Palette color index. * @param text - String to render. */ - print: (pos: Vector2i, color: Color32, text: string): void => { - BTAPI.instance.drawText(pos, color, text); + print: (pos: Vector2i, paletteIndex: number, text: string): void => { + BTAPI.instance.drawText(pos, paletteIndex, text); }, /** diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index cb01f6b..f95065c 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -22,7 +22,6 @@ import { import type { BitmapFont } from '../assets/BitmapFont'; import { Palette } from '../assets/Palette'; import type { SpriteSheet } from '../assets/SpriteSheet'; -import { Color32 } from '../utils/Color32'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { BTAPI } from './BTAPI'; @@ -151,11 +150,8 @@ describe('BTAPI', () => { expect(BTAPI.instance.getHardwareSettings()).toBeNull(); }); - it('getPalette should lazily create a VGA palette before init', () => { - const palette = BTAPI.instance.getPalette(); - - expect(palette).toBeInstanceOf(Palette); - expect(palette.size).toBe(256); + it('getPalette should return null before palette is set', () => { + expect(BTAPI.instance.getPalette()).toBeNull(); }); it('getCameraOffset should return a zero vector before init', () => { @@ -176,33 +172,31 @@ describe('BTAPI', () => { }); it('setClearColor should not throw before init', () => { - expect(() => BTAPI.instance.setClearColor(Color32.red())).not.toThrow(); + expect(() => BTAPI.instance.setClearColor(1)).not.toThrow(); }); it('clearRect should not throw before init', () => { - expect(() => BTAPI.instance.clearRect(Color32.red(), new Rect2i(0, 0, 10, 10))).not.toThrow(); + expect(() => BTAPI.instance.clearRect(1, new Rect2i(0, 0, 10, 10))).not.toThrow(); }); it('drawPixel should not throw before init', () => { - expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), Color32.red())).not.toThrow(); + expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), 2)).not.toThrow(); }); it('drawLine should not throw before init', () => { - expect(() => - BTAPI.instance.drawLine(new Vector2i(0, 0), new Vector2i(10, 10), Color32.red()), - ).not.toThrow(); + expect(() => BTAPI.instance.drawLine(new Vector2i(0, 0), new Vector2i(10, 10), 3)).not.toThrow(); }); it('drawRect should not throw before init', () => { - expect(() => BTAPI.instance.drawRect(new Rect2i(0, 0, 10, 10), Color32.red())).not.toThrow(); + expect(() => BTAPI.instance.drawRect(new Rect2i(0, 0, 10, 10), 4)).not.toThrow(); }); it('drawRectFill should not throw before init', () => { - expect(() => BTAPI.instance.drawRectFill(new Rect2i(0, 0, 10, 10), Color32.red())).not.toThrow(); + expect(() => BTAPI.instance.drawRectFill(new Rect2i(0, 0, 10, 10), 5)).not.toThrow(); }); it('drawText should not throw before init', () => { - expect(() => BTAPI.instance.drawText(new Vector2i(0, 0), Color32.white(), 'test')).not.toThrow(); + expect(() => BTAPI.instance.drawText(new Vector2i(0, 0), 8, 'test')).not.toThrow(); }); it('drawSprite should not throw before init', () => { diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 83bd9eb..351f3bc 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -1,5 +1,5 @@ import type { BitmapFont } from '../assets/BitmapFont'; -import { Palette } from '../assets/Palette'; +import type { Palette } from '../assets/Palette'; import type { SpriteSheet } from '../assets/SpriteSheet'; import { Renderer } from '../render/Renderer'; import type { Color32 } from '../utils/Color32'; @@ -285,25 +285,22 @@ export class BTAPI { } /** - * Gets the active engine palette, creating the default VGA palette lazily. + * Gets the active engine palette. * - * @returns Active palette instance. + * @returns Active palette, or null if none has been set. */ - public getPalette(): Palette { - if (!this.palette) { - this.palette = Palette.vga(); - } - + public getPalette(): Palette | null { return this.palette; } /** - * Sets the active engine palette. + * Sets the active engine palette and propagates it to the renderer. * * @param palette - Palette to store as the active engine palette. */ public setPalette(palette: Palette): void { this.palette = palette; + this.renderer?.setPalette(palette); } // #endregion @@ -311,22 +308,24 @@ export class BTAPI { // #region Rendering API - Clear Operations /** - * Sets the background clear color for each frame. + * Sets the background clear color for each frame using a palette index. * - * @param color - Color to clear the screen with. + * @param paletteIndex - Palette index for the clear color. */ - public setClearColor(color: Color32): void { - this.renderer?.setClearColor(color); + public setClearColor(paletteIndex: number): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.setClearColor(paletteIndex); } /** - * Fills a rectangular region with a solid color. + * Fills a rectangular region with a palette-indexed color. * - * @param color - Fill color. + * @param paletteIndex - Palette color index. * @param rect - Region to fill in pixel coordinates. */ - public clearRect(color: Color32, rect: Rect2i): void { - this.renderer?.clearRect(color, rect); + public clearRect(paletteIndex: number, rect: Rect2i): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.clearRect(paletteIndex, rect); } // #endregion @@ -337,10 +336,11 @@ export class BTAPI { * Draws a single pixel at the specified position. * * @param pos - Pixel coordinates. - * @param color - Pixel color. + * @param paletteIndex - Palette color index. */ - public drawPixel(pos: Vector2i, color: Color32): void { - this.renderer?.drawPixel(pos, color); + public drawPixel(pos: Vector2i, paletteIndex: number): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.drawPixel(pos, paletteIndex); } /** @@ -349,30 +349,33 @@ export class BTAPI { * * @param p0 - Start point. * @param p1 - End point. - * @param color - Line color. + * @param paletteIndex - Palette color index. */ - public drawLine(p0: Vector2i, p1: Vector2i, color: Color32): void { - this.renderer?.drawLine(p0, p1, color); + public drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.drawLine(p0, p1, paletteIndex); } /** * Draws a rectangle outline (unfilled). * * @param rect - Rectangle bounds. - * @param color - Outline color. + * @param paletteIndex - Palette color index. */ - public drawRect(rect: Rect2i, color: Color32): void { - this.renderer?.drawRect(rect, color); + public drawRect(rect: Rect2i, paletteIndex: number): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.drawRect(rect, paletteIndex); } /** * Draws a filled rectangle. * * @param rect - Rectangle bounds. - * @param color - Fill color. + * @param paletteIndex - Palette color index. */ - public drawRectFill(rect: Rect2i, color: Color32): void { - this.renderer?.drawRectFill(rect, color); + public drawRectFill(rect: Rect2i, paletteIndex: number): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.drawRectFill(rect, paletteIndex); } /** @@ -380,11 +383,12 @@ export class BTAPI { * For proper text rendering, use drawBitmapText() instead. * * @param pos - Text position (top-left corner). - * @param color - Text color. + * @param paletteIndex - Palette color index. * @param text - String to display. */ - public drawText(pos: Vector2i, color: Color32, text: string): void { - this.renderer?.drawText(pos, color, text); + public drawText(pos: Vector2i, paletteIndex: number, text: string): void { + this.assertPaletteIndex(paletteIndex); + this.renderer?.drawText(pos, paletteIndex, text); } // #endregion @@ -467,4 +471,24 @@ export class BTAPI { } // #endregion + + // #region Private Helpers + + /** + * Validates that a palette index is a non-negative integer within the active palette. + * + * @param index - Palette index to validate. + * @throws Error if no palette is set or the index is out of range. + */ + private assertPaletteIndex(index: number): void { + if (!this.palette) { + return; + } + + if (!Number.isInteger(index) || index < 0 || index >= this.palette.size) { + throw new Error(`Palette index ${index} out of range for palette of size ${this.palette.size}.`); + } + } + + // #endregion } diff --git a/src/render/PrimitivePipeline.test.ts b/src/render/PrimitivePipeline.test.ts index 0fd8d2d..3cf6f61 100644 --- a/src/render/PrimitivePipeline.test.ts +++ b/src/render/PrimitivePipeline.test.ts @@ -20,11 +20,23 @@ import { installMockNavigatorGPU, uninstallMockNavigatorGPU, } from '../__test__/webgpu-mock'; -import { Color32 } from '../utils/Color32'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { PrimitivePipeline } from './PrimitivePipeline'; +// #region Test Helpers + +/** Creates a mock palette buffer matching the real 4096-byte uniform layout. */ +function createMockPaletteBuffer(device: GPUDevice): GPUBuffer { + return device.createBuffer({ + label: 'Test Palette Buffer', + size: 256 * 4 * 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); +} + +// #endregion + // #region Constructor describe('PrimitivePipeline constructor', () => { @@ -70,29 +82,26 @@ describe('pre-initialization safety', () => { it('drawRectFill() does not throw before initialize', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(0, 0, 10, 10); - const color = Color32.red(); expect(() => { - pipeline.drawRectFill(rect, color); + pipeline.drawRectFill(rect, 1); }).not.toThrow(); }); it('drawPixel() does not throw before initialize', () => { const pipeline = new PrimitivePipeline(); const pos = new Vector2i(5, 5); - const color = Color32.green(); expect(() => { - pipeline.drawPixel(pos, color); + pipeline.drawPixel(pos, 2); }).not.toThrow(); }); it('drawPixelXY() does not throw before initialize', () => { const pipeline = new PrimitivePipeline(); - const color = Color32.blue(); expect(() => { - pipeline.drawPixelXY(3, 7, color); + pipeline.drawPixelXY(3, 7, 3); }).not.toThrow(); }); @@ -100,10 +109,9 @@ describe('pre-initialization safety', () => { const pipeline = new PrimitivePipeline(); const p0 = new Vector2i(0, 10); const p1 = new Vector2i(100, 10); - const color = Color32.white(); expect(() => { - pipeline.drawLine(p0, p1, color); + pipeline.drawLine(p0, p1, 4); }).not.toThrow(); }); @@ -111,10 +119,9 @@ describe('pre-initialization safety', () => { const pipeline = new PrimitivePipeline(); const p0 = new Vector2i(10, 0); const p1 = new Vector2i(10, 100); - const color = Color32.yellow(); expect(() => { - pipeline.drawLine(p0, p1, color); + pipeline.drawLine(p0, p1, 5); }).not.toThrow(); }); @@ -122,40 +129,36 @@ describe('pre-initialization safety', () => { const pipeline = new PrimitivePipeline(); const p0 = new Vector2i(0, 0); const p1 = new Vector2i(50, 30); - const color = Color32.cyan(); expect(() => { - pipeline.drawLine(p0, p1, color); + pipeline.drawLine(p0, p1, 6); }).not.toThrow(); }); it('drawRect() does not throw before initialize', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(5, 5, 20, 20); - const color = Color32.magenta(); expect(() => { - pipeline.drawRect(rect, color); + pipeline.drawRect(rect, 7); }).not.toThrow(); }); it('clearRect() does not throw before initialize', () => { const pipeline = new PrimitivePipeline(); const rect = new Rect2i(0, 0, 100, 100); - const color = Color32.black(); expect(() => { - pipeline.clearRect(color, rect); + pipeline.clearRect(1, rect); }).not.toThrow(); }); it('drawText() does not throw before initialize', () => { const pipeline = new PrimitivePipeline(); const pos = new Vector2i(10, 10); - const color = Color32.white(); expect(() => { - pipeline.drawText(pos, color, 'Hello'); + pipeline.drawText(pos, 8, 'Hello'); }).not.toThrow(); }); }); @@ -171,7 +174,9 @@ describe('with initialized pipeline', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240)); + const paletteBuffer = createMockPaletteBuffer(device); + + await pipeline.initialize(device, new Vector2i(320, 240), paletteBuffer); }); afterAll(() => { @@ -193,9 +198,9 @@ describe('with initialized pipeline', () => { const rect = new Rect2i(0, 0, 10, 10); - pipeline.drawRectFill(rect, Color32.red()); - pipeline.drawPixel(new Vector2i(5, 5), Color32.green()); - pipeline.drawLine(new Vector2i(0, 0), new Vector2i(20, 0), Color32.blue()); + pipeline.drawRectFill(rect, 1); + pipeline.drawPixel(new Vector2i(5, 5), 2); + pipeline.drawLine(new Vector2i(0, 0), new Vector2i(20, 0), 3); const renderPass = createMockRenderPassEncoder(); @@ -207,7 +212,7 @@ describe('with initialized pipeline', () => { it('reset followed by encodePass produces no draw calls', () => { pipeline.reset(); - pipeline.drawRectFill(new Rect2i(0, 0, 5, 5), Color32.white()); + pipeline.drawRectFill(new Rect2i(0, 0, 5, 5), 1); pipeline.reset(); @@ -227,7 +232,7 @@ describe('with initialized pipeline', () => { it('encodePass calls draw on render pass when vertices exist', () => { pipeline.reset(); - pipeline.drawRectFill(new Rect2i(10, 10, 20, 20), Color32.red()); + pipeline.drawRectFill(new Rect2i(10, 10, 20, 20), 1); let drawCalled = false; @@ -248,7 +253,7 @@ describe('with initialized pipeline', () => { for (let i = 0; i < 5; i++) { pipeline.reset(); - pipeline.drawPixel(new Vector2i(i, i), Color32.white()); + pipeline.drawPixel(new Vector2i(i, i), 8); pipeline.encodePass(createMockRenderPassEncoder()); pipeline.reset(); @@ -262,7 +267,7 @@ describe('with initialized pipeline', () => { pipeline.setCameraOffset(new Vector2i(100, 50)); expect(() => { - pipeline.drawRectFill(new Rect2i(0, 0, 10, 10), Color32.red()); + pipeline.drawRectFill(new Rect2i(0, 0, 10, 10), 1); pipeline.encodePass(createMockRenderPassEncoder()); }).not.toThrow(); @@ -281,7 +286,9 @@ describe('vertex count verification', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240)); + const paletteBuffer = createMockPaletteBuffer(device); + + await pipeline.initialize(device, new Vector2i(320, 240), paletteBuffer); }); afterAll(() => { @@ -291,7 +298,7 @@ describe('vertex count verification', () => { it('drawRectFill produces six vertices (two triangles)', () => { pipeline.reset(); - pipeline.drawRectFill(new Rect2i(0, 0, 10, 10), Color32.red()); + pipeline.drawRectFill(new Rect2i(0, 0, 10, 10), 1); let totalVertices = 0; const renderPass = { @@ -309,7 +316,7 @@ describe('vertex count verification', () => { it('drawPixel produces six vertices (1x1 filled rect)', () => { pipeline.reset(); - pipeline.drawPixel(new Vector2i(5, 5), Color32.green()); + pipeline.drawPixel(new Vector2i(5, 5), 2); let totalVertices = 0; @@ -328,7 +335,7 @@ describe('vertex count verification', () => { it('horizontal line uses a single quad (six vertices)', () => { pipeline.reset(); - pipeline.drawLine(new Vector2i(0, 10), new Vector2i(100, 10), Color32.white()); + pipeline.drawLine(new Vector2i(0, 10), new Vector2i(100, 10), 4); let totalVertices = 0; const renderPass = { @@ -345,7 +352,7 @@ describe('vertex count verification', () => { it('vertical line uses a single quad (six vertices)', () => { pipeline.reset(); - pipeline.drawLine(new Vector2i(10, 0), new Vector2i(10, 50), Color32.white()); + pipeline.drawLine(new Vector2i(10, 0), new Vector2i(10, 50), 4); let totalVertices = 0; @@ -365,7 +372,7 @@ describe('vertex count verification', () => { pipeline.reset(); // A 45-degree diagonal from (0,0) to (4,4) = 5 pixels - pipeline.drawLine(new Vector2i(0, 0), new Vector2i(4, 4), Color32.white()); + pipeline.drawLine(new Vector2i(0, 0), new Vector2i(4, 4), 8); let totalVertices = 0; @@ -385,7 +392,7 @@ describe('vertex count verification', () => { pipeline.reset(); // height=2 means y1-y0 = 1, which is NOT > 1, so no vertical sides - pipeline.drawRect(new Rect2i(0, 0, 10, 2), Color32.red()); + pipeline.drawRect(new Rect2i(0, 0, 10, 2), 1); let totalVertices = 0; @@ -405,7 +412,7 @@ describe('vertex count verification', () => { it('drawRect with height > two includes all four sides', () => { pipeline.reset(); - pipeline.drawRect(new Rect2i(0, 0, 10, 10), Color32.red()); + pipeline.drawRect(new Rect2i(0, 0, 10, 10), 1); let totalVertices = 0; @@ -425,7 +432,7 @@ describe('vertex count verification', () => { it('drawText produces six vertices per character', () => { pipeline.reset(); - pipeline.drawText(new Vector2i(0, 0), Color32.white(), 'Hi'); + pipeline.drawText(new Vector2i(0, 0), 8, 'Hi'); let totalVertices = 0; @@ -450,7 +457,7 @@ describe('vertex count verification', () => { // Fill buffer: 50,000 max vertices, each drawRectFill uses 6 vertices // ~8333 rects fill the buffer; drawing more should trigger a warning for (let i = 0; i < 8400; i++) { - pipeline.drawRectFill(new Rect2i(0, 0, 1, 1), Color32.white()); + pipeline.drawRectFill(new Rect2i(0, 0, 1, 1), 1); } expect(warnSpy).toHaveBeenCalled(); diff --git a/src/render/PrimitivePipeline.ts b/src/render/PrimitivePipeline.ts index eb6593c..8b6cc94 100644 --- a/src/render/PrimitivePipeline.ts +++ b/src/render/PrimitivePipeline.ts @@ -1,4 +1,3 @@ -import type { Color32 } from '../utils/Color32'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; @@ -12,13 +11,26 @@ import { Vector2i } from '../utils/Vector2i'; */ const MAX_PRIMITIVE_VERTICES = 50000; +/** + * Number of 4-byte values per vertex: x (f32), y (f32), paletteIndex (u32). + */ +const VALUES_PER_VERTEX = 3; + +/** + * Byte stride per vertex (3 values x 4 bytes each = 12 bytes). + */ +const VERTEX_STRIDE = VALUES_PER_VERTEX * 4; + // #endregion /** - * Batched WebGPU pipeline for solid-color primitives. + * Batched WebGPU pipeline for palette-indexed primitives. * * The pipeline collects CPU-side vertices for pixels, lines, rectangles, and * placeholder text during a frame, then uploads and draws them in `encodePass()`. + * + * Each vertex stores a palette index instead of an RGBA color. The fragment + * shader performs a flat lookup into a 256-entry palette uniform buffer. */ export class PrimitivePipeline { // #region State @@ -26,20 +38,29 @@ export class PrimitivePipeline { /** WebGPU device, set during initialize(). */ private device: GPUDevice | null = null; - /** Render pipeline for colored geometry. */ + /** Render pipeline for palette-indexed geometry. */ private pipeline: GPURenderPipeline | null = null; /** Uniform buffer containing screen resolution. */ private uniformBuffer: GPUBuffer | null = null; - /** Bind group for the uniform buffer. */ + /** Bind group for the uniform and palette buffers. */ private bindGroup: GPUBindGroup | null = null; /** GPU vertex buffer. */ private vertexBuffer: GPUBuffer | null = null; - /** CPU-side vertex data (6 floats per vertex: x, y, r, g, b, a). */ - private readonly vertices: Float32Array; + /** + * CPU-side vertex data backing buffer. + * Shared between {@link vertexFloats} and {@link vertexIndices} views. + */ + private readonly vertexArrayBuffer: ArrayBuffer; + + /** Float view for writing position values (x, y). */ + private readonly vertexFloats: Float32Array; + + /** Uint32 view for writing palette index values. */ + private readonly vertexIndices: Uint32Array; /** Number of vertices in the current (unflushed) batch. */ private vertexCount: number = 0; @@ -73,7 +94,9 @@ export class PrimitivePipeline { * Call `initialize()` before encoding GPU work. */ constructor() { - this.vertices = new Float32Array(MAX_PRIMITIVE_VERTICES * 6); + this.vertexArrayBuffer = new ArrayBuffer(MAX_PRIMITIVE_VERTICES * VERTEX_STRIDE); + this.vertexFloats = new Float32Array(this.vertexArrayBuffer); + this.vertexIndices = new Uint32Array(this.vertexArrayBuffer); } // #endregion @@ -85,10 +108,11 @@ export class PrimitivePipeline { * * @param device - WebGPU device for GPU operations. * @param displaySize - Render target resolution in pixels. + * @param paletteBuffer - Shared palette uniform buffer (256 x vec4f = 4096 bytes). */ - async initialize(device: GPUDevice, displaySize: Vector2i): Promise { + async initialize(device: GPUDevice, displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { this.device = device; - await this.createPipeline(displaySize); + await this.createPipeline(displaySize, paletteBuffer); } // #endregion @@ -112,26 +136,21 @@ export class PrimitivePipeline { * Draws a filled rectangle using two triangles. * * @param rect - Rectangle bounds in pixel coordinates. - * @param color - Fill color. + * @param paletteIndex - Palette color index. */ - drawRectFill(rect: Rect2i, color: Color32): void { + drawRectFill(rect: Rect2i, paletteIndex: number): void { const x0 = rect.x; const y0 = rect.y; const x1 = rect.x + rect.width; const y1 = rect.y + rect.height; - const r = color.r / 255; - const g = color.g / 255; - const b = color.b / 255; - const a = color.a / 255; - - this.addVertex(x0, y0, r, g, b, a); - this.addVertex(x1, y0, r, g, b, a); - this.addVertex(x0, y1, r, g, b, a); + this.addVertex(x0, y0, paletteIndex); + this.addVertex(x1, y0, paletteIndex); + this.addVertex(x0, y1, paletteIndex); - this.addVertex(x1, y0, r, g, b, a); - this.addVertex(x1, y1, r, g, b, a); - this.addVertex(x0, y1, r, g, b, a); + this.addVertex(x1, y0, paletteIndex); + this.addVertex(x1, y1, paletteIndex); + this.addVertex(x0, y1, paletteIndex); } /** @@ -139,19 +158,13 @@ export class PrimitivePipeline { * Each character is rendered as a small filled rectangle. * * @param pos - Text position (top-left corner). - * @param color - Text color. + * @param paletteIndex - Palette color index. * @param text - String to display. */ - drawText(pos: Vector2i, color: Color32, text: string): void { + drawText(pos: Vector2i, paletteIndex: number, text: string): void { const charWidth = 6; const charHeight = 8; - // Pre-compute color values once for all characters. - const r = color.r / 255; - const g = color.g / 255; - const b = color.b / 255; - const a = color.a / 255; - for (let i = 0; i < text.length; i++) { const x0 = pos.x + i * charWidth; const x1 = x0 + charWidth - 1; @@ -159,13 +172,13 @@ export class PrimitivePipeline { const y1 = y0 + charHeight; // Draw directly without allocating Rect2i. - this.addVertex(x0, y0, r, g, b, a); - this.addVertex(x1, y0, r, g, b, a); - this.addVertex(x0, y1, r, g, b, a); + this.addVertex(x0, y0, paletteIndex); + this.addVertex(x1, y0, paletteIndex); + this.addVertex(x0, y1, paletteIndex); - this.addVertex(x1, y0, r, g, b, a); - this.addVertex(x1, y1, r, g, b, a); - this.addVertex(x0, y1, r, g, b, a); + this.addVertex(x1, y0, paletteIndex); + this.addVertex(x1, y1, paletteIndex); + this.addVertex(x0, y1, paletteIndex); } } @@ -173,12 +186,12 @@ export class PrimitivePipeline { * Draws a single pixel as a 1x1 filled rectangle. * * @param pos - Pixel position. - * @param color - Pixel color. + * @param paletteIndex - Palette color index. */ - drawPixel(pos: Vector2i, color: Color32): void { + drawPixel(pos: Vector2i, paletteIndex: number): void { // Use pre-allocated rect to avoid allocation per pixel. this.tempRect.set(pos.x, pos.y, 1, 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); } /** @@ -187,26 +200,20 @@ export class PrimitivePipeline { * * @param x - X position. * @param y - Y position. - * @param color - Pixel color. + * @param paletteIndex - Palette color index. */ - drawPixelXY(x: number, y: number, color: Color32): void { - // Direct vertex addition without any object allocation. - const r = color.r / 255; - const g = color.g / 255; - const b = color.b / 255; - const a = color.a / 255; - + drawPixelXY(x: number, y: number, paletteIndex: number): void { // Draw 1x1 rectangle (2 triangles = 6 vertices). const x1 = x + 1; const y1 = y + 1; - this.addVertex(x, y, r, g, b, a); - this.addVertex(x1, y, r, g, b, a); - this.addVertex(x, y1, r, g, b, a); + this.addVertex(x, y, paletteIndex); + this.addVertex(x1, y, paletteIndex); + this.addVertex(x, y1, paletteIndex); - this.addVertex(x1, y, r, g, b, a); - this.addVertex(x1, y1, r, g, b, a); - this.addVertex(x, y1, r, g, b, a); + this.addVertex(x1, y, paletteIndex); + this.addVertex(x1, y1, paletteIndex); + this.addVertex(x, y1, paletteIndex); } /** @@ -218,9 +225,9 @@ export class PrimitivePipeline { * * @param p0 - Start point. * @param p1 - End point. - * @param color - Line color. + * @param paletteIndex - Palette color index. */ - drawLine(p0: Vector2i, p1: Vector2i, color: Color32): void { + drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void { // Vector2i already guarantees integers, but |0 ensures the 32-bit int for bitwise ops. const x0 = p0.x | 0; const y0 = p0.y | 0; @@ -235,7 +242,7 @@ export class PrimitivePipeline { const maxX = Math.max(x0, x1); this.tempRect.set(minX, y0, maxX - minX + 1, 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); return; } @@ -246,13 +253,13 @@ export class PrimitivePipeline { const maxY = Math.max(y0, y1); this.tempRect.set(x0, minY, 1, maxY - minY + 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); return; } // Diagonal lines: fall back to Bresenham for pixel-perfect rendering. - this.drawLineBresenham(x0, y0, x1, y1, color); + this.drawLineBresenham(x0, y0, x1, y1, paletteIndex); } /** @@ -260,9 +267,9 @@ export class PrimitivePipeline { * Emits quads directly rather than delegating to `drawLine()`. * * @param rect - Rectangle bounds. - * @param color - Outline color. + * @param paletteIndex - Palette color index. */ - drawRect(rect: Rect2i, color: Color32): void { + drawRect(rect: Rect2i, paletteIndex: number): void { const x0 = rect.x; const y0 = rect.y; const x1 = rect.x + rect.width - 1; @@ -273,36 +280,36 @@ export class PrimitivePipeline { // Top line (horizontal): from (x0, y0) to (x1, y0), 1px tall. this.tempRect.set(x0, y0, x1 - x0 + 1, 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); // Bottom line (horizontal): from (x0, y1) to (x1, y1), 1px tall. this.tempRect.set(x0, y1, x1 - x0 + 1, 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); // Left line (vertical): from (x0, y0+1) to (x0, y1-1), 1px wide. // Shortened to avoid corner overlap with top/bottom lines. if (y1 - y0 > 1) { this.tempRect.set(x0, y0 + 1, 1, y1 - y0 - 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); } // Right line (vertical): from (x1, y0+1) to (x1, y1-1), 1px wide. // Shortened to avoid corner overlap with top/bottom lines. if (y1 - y0 > 1) { this.tempRect.set(x1, y0 + 1, 1, y1 - y0 - 1); - this.drawRectFill(this.tempRect, color); + this.drawRectFill(this.tempRect, paletteIndex); } } /** - * Fills a rectangular region with a solid color. + * Fills a rectangular region with a palette-indexed color. * Alias for `drawRectFill()` kept for renderer API consistency. * - * @param color - Fill color. + * @param paletteIndex - Palette color index. * @param rect - Region to fill. */ - clearRect(color: Color32, rect: Rect2i): void { - this.drawRectFill(rect, color); + clearRect(paletteIndex: number, rect: Rect2i): void { + this.drawRectFill(rect, paletteIndex); } /** @@ -324,9 +331,9 @@ export class PrimitivePipeline { (this.device as GPUDevice).queue.writeBuffer( this.vertexBuffer as GPUBuffer, 0, - this.vertices.buffer, + this.vertexArrayBuffer, 0, - this.totalVertices * 6 * 4, + this.totalVertices * VERTEX_STRIDE, ); renderPass.setPipeline(this.pipeline as GPURenderPipeline); @@ -355,70 +362,53 @@ export class PrimitivePipeline { * Creates shader modules, pipeline state, and GPU buffers for primitive draws. * * @param displaySize - Render target resolution in pixels. + * @param paletteBuffer - Shared palette uniform buffer. */ - private async createPipeline(displaySize: Vector2i): Promise { + private async createPipeline(displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { const device = this.device as GPUDevice; const shaderModule = device.createShaderModule({ label: 'Primitive Shader', code: ` - /** - * Vertex input structure for primitive rendering. - */ - struct VertexInput { - // Position in clip space. - @location(0) position: vec2, + struct Uniforms { + resolution: vec2, + } - // Color in RGBA. - @location(1) color: vec4, + struct Palette { + colors: array, 256>, } - /** - * Vertex output structure for primitive rendering. - */ - struct VertexOutput { - // Position in clip space. - @builtin(position) position: vec4, + @group(0) @binding(0) var uniforms: Uniforms; + @group(0) @binding(1) var palette: Palette; - // Color in RGBA. - @location(0) color: vec4, + struct VertexInput { + @location(0) position: vec2, + @location(1) paletteIndex: u32, } - /** - * Uniforms for primitive rendering. - */ - struct Uniforms { - // Resolution in pixels. - resolution: vec2, + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) @interpolate(flat) paletteIndex: u32, } - /** - * Uniforms for primitive rendering. - */ - @group(0) @binding(0) var uniforms: Uniforms; - - /** - * Vertex shader main function. - */ @vertex fn vs_main(input: VertexInput) -> VertexOutput { - // Output vertex. var output: VertexOutput; - // Convert from pixel coordinates to clip space (-1 to 1). let clipX = (input.position.x / uniforms.resolution.x) * 2.0 - 1.0; let clipY = 1.0 - (input.position.y / uniforms.resolution.y) * 2.0; - // Set the position of the vertex in clip space. output.position = vec4(clipX, clipY, 0.0, 1.0); - output.color = input.color; + output.paletteIndex = input.paletteIndex; return output; } @fragment fn fs_main(input: VertexOutput) -> @location(0) vec4 { - return input.color; + let color = palette.colors[input.paletteIndex]; + if (color.a == 0.0) { discard; } + return vec4(color.rgb, 1.0); } `, }); @@ -431,10 +421,10 @@ export class PrimitivePipeline { entryPoint: 'vs_main', buffers: [ { - arrayStride: 6 * 4, // 6 floats * 4 bytes + arrayStride: VERTEX_STRIDE, attributes: [ { shaderLocation: 0, offset: 0, format: 'float32x2' }, // position - { shaderLocation: 1, offset: 2 * 4, format: 'float32x4' }, // color + { shaderLocation: 1, offset: 2 * 4, format: 'uint32' }, // paletteIndex ], }, ], @@ -445,10 +435,6 @@ export class PrimitivePipeline { targets: [ { format: navigator.gpu.getPreferredCanvasFormat(), - blend: { - color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, - alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }, - }, }, ], }, @@ -467,12 +453,15 @@ export class PrimitivePipeline { this.bindGroup = device.createBindGroup({ label: 'Primitive Bind Group', layout: (this.pipeline as GPURenderPipeline).getBindGroupLayout(0), - entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }], + entries: [ + { binding: 0, resource: { buffer: this.uniformBuffer } }, + { binding: 1, resource: { buffer: paletteBuffer } }, + ], }); this.vertexBuffer = device.createBuffer({ label: 'Primitive Vertex Buffer', - size: this.vertices.byteLength, + size: this.vertexArrayBuffer.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); } @@ -490,9 +479,9 @@ export class PrimitivePipeline { * @param y0 - Start Y coordinate. * @param x1 - End X coordinate. * @param y1 - End Y coordinate. - * @param color - Line color. + * @param paletteIndex - Palette color index. */ - private drawLineBresenham(x0: number, y0: number, x1: number, y1: number, color: Color32): void { + private drawLineBresenham(x0: number, y0: number, x1: number, y1: number, paletteIndex: number): void { const dx = Math.abs(x1 - x0); const dy = Math.abs(y1 - y0); const sx = x0 < x1 ? 1 : -1; @@ -501,15 +490,9 @@ export class PrimitivePipeline { let cx = x0; let cy = y0; - // Pre-compute color values once for the entire line. - const r = color.r / 255; - const g = color.g / 255; - const b = color.b / 255; - const a = color.a / 255; - while (true) { // Draw pixel directly without creating Vector2i. - this.addPixelVertices(cx, cy, r, g, b, a); + this.addPixelVertices(cx, cy, paletteIndex); if (cx === x1 && cy === y1) { break; @@ -533,23 +516,20 @@ export class PrimitivePipeline { * * @param x - X position. * @param y - Y position. - * @param r - Red component (0-1). - * @param g - Green component (0-1). - * @param b - Blue component (0-1). - * @param a - Alpha component (0-1). + * @param paletteIndex - Palette color index. */ - private addPixelVertices(x: number, y: number, r: number, g: number, b: number, a: number): void { - // Ensure space for complete pixel (6 vertices) to prevent partial geometry - const index = (this.totalVertices + this.vertexCount) * 6; + private addPixelVertices(x: number, y: number, paletteIndex: number): void { + // Ensure space for complete pixel (6 vertices) to prevent partial geometry. + const index = (this.totalVertices + this.vertexCount) * VALUES_PER_VERTEX; + const pixelValues = 6 * VALUES_PER_VERTEX; // 6 vertices * 3 values - if (index + 36 > this.vertices.length) { - // 6 vertices * 6 floats + if (index + pixelValues > this.vertexFloats.length) { if (this.vertexCount > 0) { this.earlyFlush(); } - // Check again after flush - if still no space, buffer is exhausted - if ((this.totalVertices + this.vertexCount) * 6 + 36 > this.vertices.length) { + // Check again after flush - if still no space, buffer is exhausted. + if ((this.totalVertices + this.vertexCount) * VALUES_PER_VERTEX + pixelValues > this.vertexFloats.length) { console.warn('[PrimitivePipeline] Buffer exhausted, pixel dropped'); return; @@ -559,13 +539,13 @@ export class PrimitivePipeline { const x1 = x + 1; const y1 = y + 1; - this.addVertex(x, y, r, g, b, a); - this.addVertex(x1, y, r, g, b, a); - this.addVertex(x, y1, r, g, b, a); + this.addVertex(x, y, paletteIndex); + this.addVertex(x1, y, paletteIndex); + this.addVertex(x, y1, paletteIndex); - this.addVertex(x1, y, r, g, b, a); - this.addVertex(x1, y1, r, g, b, a); - this.addVertex(x, y1, r, g, b, a); + this.addVertex(x1, y, paletteIndex); + this.addVertex(x1, y1, paletteIndex); + this.addVertex(x, y1, paletteIndex); } /** @@ -574,21 +554,18 @@ export class PrimitivePipeline { * * @param x - X position in pixels. * @param y - Y position in pixels. - * @param r - Red component (0-1). - * @param g - Green component (0-1). - * @param b - Blue component (0-1). - * @param a - Alpha component (0-1). + * @param paletteIndex - Palette color index (written as uint32). */ - private addVertex(x: number, y: number, r: number, g: number, b: number, a: number): void { - const index = (this.totalVertices + this.vertexCount) * 6; + private addVertex(x: number, y: number, paletteIndex: number): void { + const index = (this.totalVertices + this.vertexCount) * VALUES_PER_VERTEX; - if (index + 6 > this.vertices.length) { + if (index + VALUES_PER_VERTEX > this.vertexFloats.length) { this.earlyFlush(); // Re-check after flush - if still no space, buffer is exhausted for this frame. - const newIndex = (this.totalVertices + this.vertexCount) * 6; + const newIndex = (this.totalVertices + this.vertexCount) * VALUES_PER_VERTEX; - if (newIndex + 6 > this.vertices.length) { + if (newIndex + VALUES_PER_VERTEX > this.vertexFloats.length) { console.warn('[PrimitivePipeline] Primitive buffer capacity exceeded for this frame, vertex dropped'); return; } @@ -596,13 +573,13 @@ export class PrimitivePipeline { // Space available after flush, continue with vertex addition below. } + // Position stored as float32 via the float view. // eslint-disable-next-line security/detect-object-injection - this.vertices[index] = x - this.cameraOffset.x; - this.vertices[index + 1] = y - this.cameraOffset.y; - this.vertices[index + 2] = r; - this.vertices[index + 3] = g; - this.vertices[index + 4] = b; - this.vertices[index + 5] = a; + this.vertexFloats[index] = x - this.cameraOffset.x; + this.vertexFloats[index + 1] = y - this.cameraOffset.y; + + // Palette index stored as uint32 via the uint view. + this.vertexIndices[index + 2] = paletteIndex; this.vertexCount++; } @@ -610,7 +587,7 @@ export class PrimitivePipeline { /** * Records the current vertex batch and resets the vertex count. * Used for early flush when the buffer is full mid-frame. - * Does not write to GPU — encodePass() uploads all batches at once. + * Does not write to GPU -- encodePass() uploads all batches at once. */ private earlyFlush(): void { if (this.vertexCount === 0) { diff --git a/src/render/Renderer.test.ts b/src/render/Renderer.test.ts index 7f09db6..0b79467 100644 --- a/src/render/Renderer.test.ts +++ b/src/render/Renderer.test.ts @@ -5,7 +5,8 @@ * - constructor behavior and pre-initialization safety * - camera state ownership and copy semantics * - successful renderer initialization and repeated frame lifecycles - * - delegation of primitive drawing calls during active frames + * - delegation of palette-indexed primitive drawing calls during active frames + * - palette enforcement (beginFrame throws without active palette) * - frame capture flow and error handling during presentation * * The suite uses mocked WebGPU devices, contexts, and browser image APIs so @@ -20,11 +21,32 @@ import { installMockNavigatorGPU, uninstallMockNavigatorGPU, } from '../__test__/webgpu-mock'; +import { Palette } from '../assets/Palette'; import { Color32 } from '../utils/Color32'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { Renderer } from './Renderer'; +// #region Test Helpers + +/** Creates a small 16-color test palette with known color assignments. */ +function createTestPalette(): Palette { + const palette = new Palette(16); + + palette.set(1, Color32.black()); + palette.set(2, Color32.red()); + palette.set(3, Color32.green()); + palette.set(4, Color32.blue()); + palette.set(5, Color32.yellow()); + palette.set(6, Color32.cyan()); + palette.set(7, Color32.magenta()); + palette.set(8, Color32.white()); + + return palette; +} + +// #endregion + // #region Constructor describe('Renderer constructor', () => { @@ -48,7 +70,7 @@ describe('pre-initialization methods', () => { const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); expect(() => { - renderer.setClearColor(Color32.blue()); + renderer.setClearColor(1); }).not.toThrow(); }); @@ -80,9 +102,19 @@ describe('pre-initialization methods', () => { expect(offset.y).toBe(0); }); - it('beginFrame does not throw before initialize', () => { + it('beginFrame throws without active palette', () => { + const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + + expect(() => { + renderer.beginFrame(); + }).toThrow('Cannot begin frame: no active palette. Call BT.paletteSet() first.'); + }); + + it('beginFrame succeeds with active palette', () => { const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + renderer.setPalette(createTestPalette()); + expect(() => { renderer.beginFrame(); }).not.toThrow(); @@ -142,6 +174,27 @@ describe('camera operations', () => { // #endregion +// #region Palette Enforcement + +describe('palette enforcement', () => { + it('setPalette stores and returns palette', () => { + const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + const palette = createTestPalette(); + + renderer.setPalette(palette); + + expect(renderer.getPalette()).toBe(palette); + }); + + it('getPalette returns null when no palette is set', () => { + const renderer = new Renderer(createMockGPUDevice(), createMockGPUCanvasContext(), new Vector2i(320, 240)); + + expect(renderer.getPalette()).toBeNull(); + }); +}); + +// #endregion + // #region Initialized Renderer describe('with initialized renderer', () => { @@ -156,6 +209,8 @@ describe('with initialized renderer', () => { const result = await renderer.initialize(); expect(result).toBe(true); + + renderer.setPalette(createTestPalette()); }); afterAll(() => { @@ -197,7 +252,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.drawPixel(new Vector2i(10, 10), Color32.red()); + renderer.drawPixel(new Vector2i(10, 10), 2); }).not.toThrow(); renderer.endFrame(); @@ -207,7 +262,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.drawPixelXY(15, 25, Color32.green()); + renderer.drawPixelXY(15, 25, 3); }).not.toThrow(); renderer.endFrame(); @@ -217,7 +272,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.drawRectFill(new Rect2i(5, 5, 20, 20), Color32.blue()); + renderer.drawRectFill(new Rect2i(5, 5, 20, 20), 4); }).not.toThrow(); renderer.endFrame(); @@ -227,7 +282,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.drawLine(new Vector2i(0, 0), new Vector2i(50, 50), Color32.yellow()); + renderer.drawLine(new Vector2i(0, 0), new Vector2i(50, 50), 5); }).not.toThrow(); renderer.endFrame(); @@ -237,7 +292,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.drawRect(new Rect2i(10, 10, 30, 30), Color32.cyan()); + renderer.drawRect(new Rect2i(10, 10, 30, 30), 6); }).not.toThrow(); renderer.endFrame(); @@ -247,7 +302,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.clearRect(Color32.black(), new Rect2i(0, 0, 320, 240)); + renderer.clearRect(1, new Rect2i(0, 0, 320, 240)); }).not.toThrow(); renderer.endFrame(); @@ -257,7 +312,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.drawText(new Vector2i(10, 10), Color32.white(), 'Test'); + renderer.drawText(new Vector2i(10, 10), 8, 'Test'); }).not.toThrow(); renderer.endFrame(); @@ -267,7 +322,7 @@ describe('with initialized renderer', () => { renderer.beginFrame(); expect(() => { - renderer.setClearColor(new Color32(64, 128, 192, 255)); + renderer.setClearColor(4); }).not.toThrow(); renderer.endFrame(); @@ -276,7 +331,7 @@ describe('with initialized renderer', () => { it('camera operations work within a frame cycle', () => { renderer.beginFrame(); renderer.setCameraOffset(new Vector2i(50, 50)); - renderer.drawRectFill(new Rect2i(0, 0, 10, 10), Color32.red()); + renderer.drawRectFill(new Rect2i(0, 0, 10, 10), 2); renderer.resetCamera(); expect(() => { @@ -332,6 +387,7 @@ describe('frame capture', () => { ); await renderer.initialize(); + renderer.setPalette(createTestPalette()); const promise = renderer.captureFrame(); @@ -367,6 +423,7 @@ describe('frame capture', () => { installMockNavigatorGPU(); await renderer.initialize(); + renderer.setPalette(createTestPalette()); // Stub browser APIs for PNG conversion. vi.stubGlobal( @@ -427,6 +484,7 @@ describe('frame capture', () => { installMockNavigatorGPU(); await renderer.initialize(); + renderer.setPalette(createTestPalette()); renderer.beginFrame(); renderer.endFrame(); @@ -457,12 +515,13 @@ describe('endFrame error paths', () => { installMockNavigatorGPU(); await renderer.initialize(); + renderer.setPalette(createTestPalette()); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { renderer.beginFrame(); - renderer.drawRectFill(new Rect2i(0, 0, 10, 10), Color32.red()); + renderer.drawRectFill(new Rect2i(0, 0, 10, 10), 2); expect(() => { renderer.endFrame(); @@ -500,6 +559,7 @@ describe('endFrame error paths', () => { installMockNavigatorGPU(); await renderer.initialize(); + renderer.setPalette(createTestPalette()); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts index 81ae632..8c45838 100644 --- a/src/render/Renderer.ts +++ b/src/render/Renderer.ts @@ -1,4 +1,5 @@ import type { BitmapFont } from '../assets/BitmapFont'; +import type { Palette } from '../assets/Palette'; import type { SpriteSheet } from '../assets/SpriteSheet'; import { Color32 } from '../utils/Color32'; import { FrameCapture } from '../utils/FrameCapture'; @@ -7,12 +8,21 @@ import { Vector2i } from '../utils/Vector2i'; import { PrimitivePipeline } from './PrimitivePipeline'; import { SpritePipeline } from './SpritePipeline'; +// #region Configuration + +/** + * GPU palette uniform buffer size: 256 entries x 4 floats x 4 bytes = 4096 bytes. + */ +const PALETTE_BUFFER_SIZE = 256 * 4 * 4; + +// #endregion + /** * High-level renderer that coordinates primitive and sprite pipelines. * - * `Renderer` owns frame begin/end, clear color, camera state, and frame capture. - * Actual draw batching is delegated to {@link PrimitivePipeline} and - * {@link SpritePipeline}. + * `Renderer` owns frame begin/end, clear color, camera state, palette buffer, + * and frame capture. Actual draw batching is delegated to + * {@link PrimitivePipeline} and {@link SpritePipeline}. */ export class Renderer { // #region State @@ -26,8 +36,8 @@ export class Renderer { /** Render target resolution in pixels. */ private readonly displaySize: Vector2i; - /** Current clear color for the background. */ - private currentClearColor: Color32 = Color32.black(); + /** Palette index used for the frame clear color. Defaults to 0 (transparent). */ + private clearPaletteIndex: number = 0; /** Camera offset for scrolling effects. */ private cameraOffset: Vector2i = Vector2i.zero(); @@ -37,9 +47,19 @@ export class Renderer { // #endregion + // #region Palette State + + /** Active palette for color lookups and GPU upload. */ + private palette: Palette | null = null; + + /** GPU uniform buffer for the 256-entry palette. */ + private paletteBuffer: GPUBuffer | null = null; + + // #endregion + // #region Pipelines - /** Pipeline for colored geometry (pixels, lines, rectangles). */ + /** Pipeline for palette-indexed geometry (pixels, lines, rectangles). */ private readonly primitives: PrimitivePipeline; /** Pipeline for textured quads (sprites, bitmap text). */ @@ -75,8 +95,15 @@ export class Renderer { */ async initialize(): Promise { try { - await this.primitives.initialize(this.device, this.displaySize); - await this.sprites.initialize(this.device, this.displaySize); + // Create shared palette uniform buffer (256 entries x vec4f). + this.paletteBuffer = this.device.createBuffer({ + label: 'Palette Uniform Buffer', + size: PALETTE_BUFFER_SIZE, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + await this.primitives.initialize(this.device, this.displaySize, this.paletteBuffer); + await this.sprites.initialize(this.device, this.displaySize, this.paletteBuffer); return true; } catch (error) { @@ -88,28 +115,57 @@ export class Renderer { // #endregion + // #region Palette + + /** + * Sets the active palette used for rendering. + * + * @param palette - Palette to use for color lookups and GPU upload. + */ + setPalette(palette: Palette): void { + this.palette = palette; + } + + /** + * Returns the active palette, or null if none has been set. + * + * @returns Active palette instance. + */ + getPalette(): Palette | null { + return this.palette; + } + + // #endregion + // #region Frame Management /** * Begins a new frame by clearing all per-frame batching state. + * + * @throws Error if no palette has been set via {@link setPalette}. */ beginFrame(): void { + if (!this.palette) { + throw new Error('Cannot begin frame: no active palette. Call BT.paletteSet() first.'); + } + this.primitives.reset(); this.sprites.reset(); } /** - * Sets the background clear color for this frame. + * Sets the background clear color for this frame using a palette index. * - * @param color - Color to clear the screen with. + * @param paletteIndex - Palette index for the clear color. */ - setClearColor(color: Color32): void { - this.currentClearColor = color.clone(); + setClearColor(paletteIndex: number): void { + this.clearPaletteIndex = paletteIndex; } /** * Ends the current frame and presents to the screen. - * Encodes both pipelines into a render pass and submits the command buffer. + * Uploads the palette uniform buffer, encodes both pipelines into a render + * pass, and submits the command buffer. */ endFrame(): void { // Get the current texture to render to. @@ -136,6 +192,16 @@ export class Renderer { return; } + // Upload palette to GPU before encoding the render pass. + if (this.palette && this.paletteBuffer) { + const paletteData = this.palette.toFloat32Array(); + + this.device.queue.writeBuffer(this.paletteBuffer, 0, paletteData.buffer); + } + + // Resolve clear color from palette. + const clearColor = this.resolveClearColor(); + const textureView = texture.createView(); const commandEncoder = this.device.createCommandEncoder({ label: 'Render Commands' }); @@ -145,10 +211,10 @@ export class Renderer { { view: textureView, clearValue: { - r: this.currentClearColor.r / 255, - g: this.currentClearColor.g / 255, - b: this.currentClearColor.b / 255, - a: this.currentClearColor.a / 255, + r: clearColor.r / 255, + g: clearColor.g / 255, + b: clearColor.b / 255, + a: clearColor.a / 255, }, loadOp: 'clear', storeOp: 'store', @@ -189,10 +255,10 @@ export class Renderer { * Draws a filled rectangle using two triangles. * * @param rect - Rectangle bounds in pixel coordinates. - * @param color - Fill color. + * @param paletteIndex - Palette color index. */ - drawRectFill(rect: Rect2i, color: Color32): void { - this.primitives.drawRectFill(rect, color); + drawRectFill(rect: Rect2i, paletteIndex: number): void { + this.primitives.drawRectFill(rect, paletteIndex); } /** @@ -200,21 +266,21 @@ export class Renderer { * Each character is rendered as a small filled rectangle. * * @param pos - Text position (top-left corner). - * @param color - Text color. + * @param paletteIndex - Palette color index. * @param text - String to display. */ - drawText(pos: Vector2i, color: Color32, text: string): void { - this.primitives.drawText(pos, color, text); + drawText(pos: Vector2i, paletteIndex: number, text: string): void { + this.primitives.drawText(pos, paletteIndex, text); } /** * Draws a single pixel as a 1x1 filled rectangle. * * @param pos - Pixel position. - * @param color - Pixel color. + * @param paletteIndex - Palette color index. */ - drawPixel(pos: Vector2i, color: Color32): void { - this.primitives.drawPixel(pos, color); + drawPixel(pos: Vector2i, paletteIndex: number): void { + this.primitives.drawPixel(pos, paletteIndex); } /** @@ -223,10 +289,10 @@ export class Renderer { * * @param x - X position. * @param y - Y position. - * @param color - Pixel color. + * @param paletteIndex - Palette color index. */ - drawPixelXY(x: number, y: number, color: Color32): void { - this.primitives.drawPixelXY(x, y, color); + drawPixelXY(x: number, y: number, paletteIndex: number): void { + this.primitives.drawPixelXY(x, y, paletteIndex); } /** @@ -235,30 +301,30 @@ export class Renderer { * * @param p0 - Start point. * @param p1 - End point. - * @param color - Line color. + * @param paletteIndex - Palette color index. */ - drawLine(p0: Vector2i, p1: Vector2i, color: Color32): void { - this.primitives.drawLine(p0, p1, color); + drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void { + this.primitives.drawLine(p0, p1, paletteIndex); } /** * Draws a rectangle outline using four 1-pixel quads. * * @param rect - Rectangle bounds. - * @param color - Outline color. + * @param paletteIndex - Palette color index. */ - drawRect(rect: Rect2i, color: Color32): void { - this.primitives.drawRect(rect, color); + drawRect(rect: Rect2i, paletteIndex: number): void { + this.primitives.drawRect(rect, paletteIndex); } /** - * Fills a rectangular region with a solid color. + * Fills a rectangular region with a palette-indexed color. * - * @param color - Fill color. - * @param rect - Region to fill. + * @param paletteIndex - Palette color index. + * @param rect - Region to fill in pixel coordinates. */ - clearRect(color: Color32, rect: Rect2i): void { - this.primitives.clearRect(color, rect); + clearRect(paletteIndex: number, rect: Rect2i): void { + this.primitives.clearRect(paletteIndex, rect); } // #endregion @@ -340,4 +406,26 @@ export class Renderer { } // #endregion + + // #region Private Helpers + + /** + * Resolves the clear palette index into a Color32 for the render pass. + * Falls back to black if no palette is available. + * + * @returns Resolved clear color. + */ + private resolveClearColor(): Color32 { + if (!this.palette) { + return Color32.black(); + } + + try { + return this.palette.get(this.clearPaletteIndex); + } catch { + return Color32.black(); + } + } + + // #endregion } diff --git a/src/render/SpritePipeline.test.ts b/src/render/SpritePipeline.test.ts index 9bc3273..eb01d5c 100644 --- a/src/render/SpritePipeline.test.ts +++ b/src/render/SpritePipeline.test.ts @@ -27,6 +27,19 @@ import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { SpritePipeline } from './SpritePipeline'; +// #region Test Helpers + +/** Creates a mock palette buffer matching the real 4096-byte uniform layout. */ +function createMockPaletteBuffer(device: GPUDevice): GPUBuffer { + return device.createBuffer({ + label: 'Test Palette Buffer', + size: 256 * 4 * 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); +} + +// #endregion + // #region Constructor describe('SpritePipeline constructor', () => { @@ -81,7 +94,7 @@ describe('with initialized pipeline', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240)); + await pipeline.initialize(device, new Vector2i(320, 240), createMockPaletteBuffer(device)); }); afterAll(() => { @@ -149,7 +162,7 @@ describe('drawSprite', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240)); + await pipeline.initialize(device, new Vector2i(320, 240), createMockPaletteBuffer(device)); }); afterAll(() => { @@ -395,7 +408,7 @@ describe('drawBitmapText', () => { beforeAll(async () => { installMockNavigatorGPU(); - await pipeline.initialize(device, new Vector2i(320, 240)); + await pipeline.initialize(device, new Vector2i(320, 240), createMockPaletteBuffer(device)); }); afterAll(() => { diff --git a/src/render/SpritePipeline.ts b/src/render/SpritePipeline.ts index a8e26cf..acb31d0 100644 --- a/src/render/SpritePipeline.ts +++ b/src/render/SpritePipeline.ts @@ -34,6 +34,10 @@ export class SpritePipeline { /** Nearest-neighbor sampler for pixel-perfect rendering. */ private sampler: GPUSampler | null = null; + /** Shared palette uniform buffer (stored for VV-426 indexed sprite pipeline). */ + // @ts-expect-error Stored for VV-426 indexed sprite pipeline, not yet consumed. + private paletteBuffer: GPUBuffer | null = null; + /** GPU vertex buffer. */ private vertexBuffer: GPUBuffer | null = null; @@ -96,9 +100,11 @@ export class SpritePipeline { * * @param device - WebGPU device for GPU operations. * @param displaySize - Render target resolution in pixels. + * @param paletteBuffer - Shared palette uniform buffer (stored for VV-426). */ - async initialize(device: GPUDevice, displaySize: Vector2i): Promise { + async initialize(device: GPUDevice, displaySize: Vector2i, paletteBuffer: GPUBuffer): Promise { this.device = device; + this.paletteBuffer = paletteBuffer; await this.createPipeline(displaySize); } diff --git a/tests/visual/fixtures/camera.html b/tests/visual/fixtures/camera.html index 3af081c..cfcace8 100644 --- a/tests/visual/fixtures/camera.html +++ b/tests/visual/fixtures/camera.html @@ -16,11 +16,20 @@