From c6efd134b5dda6b1946493b43344ad5f457bd8d1 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 08:38:09 +0100 Subject: [PATCH 1/2] Optimize Bunnymark and DOOM hot paths --- README.md | 14 +-- benchmarks/application-performance.md | 51 +++++++++++ examples/doom-gpl/Makefile | 7 +- examples/doom-gpl/README.md | 6 ++ examples/doom-gpl/src/i_video.ts | 25 +++--- examples/doom-gpl/src/palette.ts | 28 ++++++ examples/doom-gpl/tests/palette.test.ts | 39 ++++++++ examples/lib/run-bunnymark.ts | 115 +++++++++++++++--------- 8 files changed, 229 insertions(+), 56 deletions(-) create mode 100644 benchmarks/application-performance.md create mode 100644 examples/doom-gpl/src/palette.ts create mode 100644 examples/doom-gpl/tests/palette.test.ts diff --git a/README.md b/README.md index f7cbdb4..146e8ba 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ directory is excluded from the MIT npm tarball. The full engine legitimately uses classic `for`, `while`, and `do...while` loops. Their compatibility flags are scoped to `examples/doom-gpl/goccia.json`; the bindings and smaller MIT examples do not enable them. The initial -GocciaScript 0.10.0 implementation renders at roughly 3 FPS on an Apple M1 Max, -so it is genuine but not yet real-time. +GocciaScript 0.10.0 implementation rendered at roughly 3 FPS on an Apple M1 +Max. Caching the framebuffer length and using the packed-palette display path +raised the profiled steady-state estimate to 4.71 FPS, so it is genuine but not +yet real-time. ## Bunnymark performance @@ -114,9 +116,11 @@ performance case for compatibility loops: | indexed classic `for` | 3.96 | 6.4842 s | | indexed `while` | 4.02 | 6.3692 s | -The result is dominated by rendering and the per-sprite `DrawTextureV` FFI -crossing. The current `for...of` loop stays, and both legacy loop flags remain -disabled. [Full methodology and raw results](benchmarks/bunnymark-loop-syntax.md) +The current `for...of` loop stays, and both legacy loop flags remain disabled. +That loop experiment predates the application-side import aliases and scalar +bunny state: the optimized 10,000-sprite workload now measures 12.13 FPS on the +same machine and runtime. [Loop methodology](benchmarks/bunnymark-loop-syntax.md) +and [current application-side validation](benchmarks/application-performance.md) are recorded in the repository. ## Project scripts and tests diff --git a/benchmarks/application-performance.md b/benchmarks/application-performance.md new file mode 100644 index 0000000..0466833 --- /dev/null +++ b/benchmarks/application-performance.md @@ -0,0 +1,51 @@ +# Application-side performance validation + +Measured on 2026-07-24 after applying the validated Bunnymark and DOOM +application-side improvements. + +## Setup + +- Apple M1 Max +- GocciaScript 0.10.0 in bytecode mode +- dynamically loaded raylib 6.0 +- the repository's pinned Bunnymark asset and Freedoom 0.13.0 data + +## Bunnymark + +The workload used 10,000 sprites, an uncapped target, 25 frames per run, and +five measured trials. Each trial included application startup; frame time below +subtracts the reported startup time from GocciaScript's execution time. + +| Trial | Startup | Execution | 25-frame time after startup | +|---:|---:|---:|---:| +| 1 | 451 ms | 2509.53 ms | 2058.53 ms | +| 2 | 451 ms | 2513.74 ms | 2062.74 ms | +| 3 | 444 ms | 2522.43 ms | 2078.43 ms | +| 4 | 452 ms | 2504.94 ms | 2052.94 ms | +| 5 | 444 ms | 2496.96 ms | 2052.96 ms | +| Mean | 448.4 ms | 2509.52 ms | 2061.12 ms | + +The mean measured frame portion is 12.13 FPS. The preceding profiling baseline +for the same workload was 4.00 FPS. The example's normal 1,000-sprite, +60-FPS-capped startup smoke also passes. + +## DOOM + +Three 30-frame runs measured the cached-length and packed-palette candidate: + +| Trial | Execution | +|---:|---:| +| 1 | 9000.02 ms | +| 2 | 9169.85 ms | +| 3 | 9016.46 ms | +| Mean | 9062.11 ms | + +The retained profiling baseline took 13.30-13.41 seconds for the same 30-frame +workload, so the reproduced candidate reduces end-to-end execution time by +about 32%. This includes startup and is separate from the profiler's 4.71 FPS +steady-state estimate. + +The deterministic frame-8 smoke screenshot has zero changed pixels below the +FPS overlay compared with the retained byte-wise conversion baseline. The +focused palette test also checks all 256 palette entries, with 1,024 byte-level +assertions in each of interpreter and bytecode modes. diff --git a/examples/doom-gpl/Makefile b/examples/doom-gpl/Makefile index fc37aa8..0e12a68 100644 --- a/examples/doom-gpl/Makefile +++ b/examples/doom-gpl/Makefile @@ -18,9 +18,10 @@ FREEDOOM_WAD := $(CACHE_DIR)/freedoom-$(FREEDOOM_VERSION)/freedoom1.wad CACHED_IWAD := $(CACHE_DIR)/iwad.wad GOCCIA_LOADER ?= GocciaScriptLoader +GOCCIA_TEST_RUNNER ?= GocciaTestRunner GOCCIA_CONFIG := examples/doom-gpl/goccia.json -.PHONY: all clean fetch freedoom prepare run run-freedoom smoke verify-source +.PHONY: all clean fetch freedoom prepare run run-freedoom smoke test-palette verify-source all: prepare @@ -85,6 +86,10 @@ smoke: prepare freedoom cd "$(PROJECT_ROOT)" && "$(GOCCIA_LOADER)" examples/doom-gpl/smoke.ts --config="$(GOCCIA_CONFIG)" @test -s "$(CACHE_DIR)/doom-goccia-smoke.png" +test-palette: + cd "$(PROJECT_ROOT)" && "$(GOCCIA_TEST_RUNNER)" examples/doom-gpl/tests --config="$(GOCCIA_CONFIG)" --mode=interpreted + cd "$(PROJECT_ROOT)" && "$(GOCCIA_TEST_RUNNER)" examples/doom-gpl/tests --config="$(GOCCIA_CONFIG)" --mode=bytecode + clean: @if [ "$(CACHE_DIR)" = "$(CURDIR)/.cache" ]; then \ rm -rf "$(CACHE_DIR)"; \ diff --git a/examples/doom-gpl/README.md b/examples/doom-gpl/README.md index 9823a6c..113b5b4 100644 --- a/examples/doom-gpl/README.md +++ b/examples/doom-gpl/README.md @@ -84,9 +84,15 @@ Doom 1. ```sh make verify-source make prepare +make test-palette make smoke ``` +`make test-palette` checks the packed palette conversion against the original +byte-wise RGBA result in both interpreter and bytecode modes. The palette and +framebuffer use paired byte and 32-bit views, so the copied bytes remain RGBA +on either little- or big-endian hosts. + `make smoke` runs deterministic engine frames against cached Freedoom data and writes `.cache/doom-goccia-smoke.png`. The current upstream port claims vanilla demo synchronization, but its published repository does not contain the diff --git a/examples/doom-gpl/src/i_video.ts b/examples/doom-gpl/src/i_video.ts index 8d60da6..17a9ace 100644 --- a/examples/doom-gpl/src/i_video.ts +++ b/examples/doom-gpl/src/i_video.ts @@ -26,11 +26,18 @@ import { Vector2, closeRaylib, } from "../../../bindings/raylib.ts"; +import { + expandIndexedFrame, + packPaletteBytes, +} from "./palette.ts"; const sourceWidth = 320; const sourceHeight = 200; const scale = 2; const rgba = new Uint8Array(sourceWidth * sourceHeight * 4); +const rgba32 = new Uint32Array(rgba.buffer); +const packedPaletteBytes = new Uint8Array(256 * 4); +const packedPalette = new Uint32Array(packedPaletteBytes.buffer); const camera = Camera2D.create({ offset: Vector2.create({ x: 0, y: 0 }), target: Vector2.create({ x: 0, y: 0 }), @@ -45,6 +52,11 @@ let image = null; let texture = null; let initialized = false; +const refreshPackedPalette = (): void => { + if (playpal === null) return; + packPaletteBytes(playpal, paletteIndex, packedPaletteBytes); +}; + const I_InitGraphics = (): void => { if (initialized) return; InitWindow( @@ -64,24 +76,17 @@ const I_InitGraphics = (): void => { const I_RegisterPlaypal = (paletteBytes: Uint8Array): void => { playpal = paletteBytes; + refreshPackedPalette(); }; const I_SetPalette = (nextPalette: number): void => { paletteIndex = nextPalette; + refreshPackedPalette(); }; const I_FinishUpdate = (): void => { const indexed = video.screens[0]; - const paletteOffset = paletteIndex * 768; - - for (let index = 0; index < indexed.length; index += 1) { - const colourOffset = paletteOffset + indexed[index] * 3; - const pixelOffset = index * 4; - rgba[pixelOffset] = playpal[colourOffset]; - rgba[pixelOffset + 1] = playpal[colourOffset + 1]; - rgba[pixelOffset + 2] = playpal[colourOffset + 2]; - rgba[pixelOffset + 3] = 255; - } + expandIndexedFrame(indexed, packedPalette, rgba32); UpdateTexture(texture, rgba); BeginDrawing(); diff --git a/examples/doom-gpl/src/palette.ts b/examples/doom-gpl/src/palette.ts new file mode 100644 index 0000000..51d1fdb --- /dev/null +++ b/examples/doom-gpl/src/palette.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-only + +export const packPaletteBytes = ( + playpal: Uint8Array, + paletteIndex: number, + packedPaletteBytes: Uint8Array, +): void => { + const paletteOffset = paletteIndex * 768; + for (let colourIndex = 0; colourIndex < 256; colourIndex += 1) { + const sourceOffset = paletteOffset + colourIndex * 3; + const targetOffset = colourIndex * 4; + packedPaletteBytes[targetOffset] = playpal[sourceOffset]; + packedPaletteBytes[targetOffset + 1] = playpal[sourceOffset + 1]; + packedPaletteBytes[targetOffset + 2] = playpal[sourceOffset + 2]; + packedPaletteBytes[targetOffset + 3] = 255; + } +}; + +export const expandIndexedFrame = ( + indexed: Uint8Array, + packedPalette: Uint32Array, + rgba32: Uint32Array, +): void => { + const pixelCount = indexed.length; + for (let index = 0; index < pixelCount; index += 1) { + rgba32[index] = packedPalette[indexed[index]]; + } +}; diff --git a/examples/doom-gpl/tests/palette.test.ts b/examples/doom-gpl/tests/palette.test.ts new file mode 100644 index 0000000..dbf63e9 --- /dev/null +++ b/examples/doom-gpl/tests/palette.test.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-2.0-only + +import { + expandIndexedFrame, + packPaletteBytes, +} from "../src/palette.ts"; + +describe("DOOM palette conversion", () => { + test("matches the byte-wise RGBA reference for every palette entry", () => { + const playpal = new Uint8Array(2 * 768); + for (let index = 0; index < playpal.length; index += 1) { + playpal[index] = (index * 37 + 11) % 256; + } + + const indexed = new Uint8Array(256); + for (let index = 0; index < indexed.length; index += 1) { + indexed[index] = index; + } + + const packedPaletteBytes = new Uint8Array(256 * 4); + const packedPalette = new Uint32Array(packedPaletteBytes.buffer); + const rgba = new Uint8Array(indexed.length * 4); + const rgba32 = new Uint32Array(rgba.buffer); + + packPaletteBytes(playpal, 1, packedPaletteBytes); + expandIndexedFrame(indexed, packedPalette, rgba32); + + const paletteOffset = 768; + const pixelCount = indexed.length; + for (let index = 0; index < pixelCount; index += 1) { + const colourOffset = paletteOffset + indexed[index] * 3; + const pixelOffset = index * 4; + expect(rgba[pixelOffset]).toBe(playpal[colourOffset]); + expect(rgba[pixelOffset + 1]).toBe(playpal[colourOffset + 1]); + expect(rgba[pixelOffset + 2]).toBe(playpal[colourOffset + 2]); + expect(rgba[pixelOffset + 3]).toBe(255); + } + }); +}); diff --git a/examples/lib/run-bunnymark.ts b/examples/lib/run-bunnymark.ts index eae735a..83fe516 100644 --- a/examples/lib/run-bunnymark.ts +++ b/examples/lib/run-bunnymark.ts @@ -28,20 +28,51 @@ import { import { frameNumbers } from "./frames.ts"; type Bunny = { - position: { x: number; y: number }; - speed: { x: number; y: number }; + x: number; + y: number; + velocityX: number; + velocityY: number; + drawPosition: { x: number; y: number }; color: unknown; }; +// Generated raylib bindings are immutable const exports. Reading them once +// avoids repeated import resolution while preserving the binding API. +const black = BLACK; +const beginDrawing = BeginDrawing; +const clearBackground = ClearBackground; +const closeWindow = CloseWindow; +const colorType = Color; +const drawFps = DrawFPS; +const drawText = DrawText; +const drawTextureV = DrawTextureV; +const endDrawing = EndDrawing; +const getFrameTime = GetFrameTime; +const getRandomValue = GetRandomValue; +const initWindow = InitWindow; +const isKeyDown = IsKeyDown; +const isMouseButtonDown = IsMouseButtonDown; +const keySpace = KEY_SPACE; +const loadImageFromMemory = LoadImageFromMemory; +const loadTextureFromImage = LoadTextureFromImage; +const mouseButtonLeft = MOUSE_BUTTON_LEFT; +const raywhite = RAYWHITE; +const setTargetFps = SetTargetFPS; +const unloadImage = UnloadImage; +const unloadTexture = UnloadTexture; +const vector2Type = Vector2; +const windowShouldClose = WindowShouldClose; +const closeRaylibBinding = closeRaylib; + const screenWidth: number = 1280; const screenHeight: number = 720; const maxBunnies: number = 50000; const randomColor = () => { - return Color.create({ - r: GetRandomValue(50, 240), - g: GetRandomValue(50, 240), - b: GetRandomValue(50, 240), + return colorType.create({ + r: getRandomValue(50, 240), + g: getRandomValue(50, 240), + b: getRandomValue(50, 240), a: 255, }); }; @@ -49,14 +80,16 @@ const randomColor = () => { const addBunnies = (bunnies: Bunny[], count: number): void => { for (const _index of frameNumbers(count)) { if (bunnies.length >= maxBunnies) break; + const x = screenWidth / 2; + const y = screenHeight / 2; bunnies.push({ - position: Vector2.create({ - x: screenWidth / 2, - y: screenHeight / 2, - }), - speed: Vector2.create({ - x: GetRandomValue(-250, 250) / 60, - y: GetRandomValue(-250, 250) / 60, + x, + y, + velocityX: getRandomValue(-250, 250) / 60, + velocityY: getRandomValue(-250, 250) / 60, + drawPosition: vector2Type.create({ + x, + y, }), color: randomColor(), }); @@ -68,15 +101,15 @@ export const runBunnymark = ( maxFrames: number, ): void => { const startedAt = Date.now(); - const image = LoadImageFromMemory(".png", bunnyBytes, bunnyBytes.length); + const image = loadImageFromMemory(".png", bunnyBytes, bunnyBytes.length); if (image.width !== 32 || image.height !== 32) { throw new Error("raybunny PNG failed to decode"); } - InitWindow(screenWidth, screenHeight, "GocciaScript + raylib: Bunnymark"); - const texture = LoadTextureFromImage(image); - UnloadImage(image); - SetTargetFPS(60); + initWindow(screenWidth, screenHeight, "GocciaScript + raylib: Bunnymark"); + const texture = loadTextureFromImage(image); + unloadImage(image); + setTargetFps(60); const bunnies: Bunny[] = []; addBunnies(bunnies, 1000); @@ -93,42 +126,44 @@ export const runBunnymark = ( try { for (const _frame of frameNumbers(maxFrames)) { - if (WindowShouldClose()) break; - const deltaScale = GetFrameTime() * 60; + if (windowShouldClose()) break; + const deltaScale = getFrameTime() * 60; if ( - IsMouseButtonDown(MOUSE_BUTTON_LEFT) || - IsKeyDown(KEY_SPACE) + isMouseButtonDown(mouseButtonLeft) || + isKeyDown(keySpace) ) { addBunnies(bunnies, 100); } for (const bunny of bunnies) { - bunny.position.x += bunny.speed.x * deltaScale; - bunny.position.y += bunny.speed.y * deltaScale; + bunny.x += bunny.velocityX * deltaScale; + bunny.y += bunny.velocityY * deltaScale; if ( - bunny.position.x + texture.width / 2 > screenWidth || - bunny.position.x + texture.width / 2 < 0 + bunny.x + texture.width / 2 > screenWidth || + bunny.x + texture.width / 2 < 0 ) { - bunny.speed.x *= -1; + bunny.velocityX *= -1; } if ( - bunny.position.y + texture.height / 2 > screenHeight || - bunny.position.y + texture.height / 2 - 40 < 0 + bunny.y + texture.height / 2 > screenHeight || + bunny.y + texture.height / 2 - 40 < 0 ) { - bunny.speed.y *= -1; + bunny.velocityY *= -1; } } - BeginDrawing(); - ClearBackground(BLACK); + beginDrawing(); + clearBackground(black); for (const bunny of bunnies) { - DrawTextureV(texture, bunny.position, bunny.color); + bunny.drawPosition.x = bunny.x; + bunny.drawPosition.y = bunny.y; + drawTextureV(texture, bunny.drawPosition, bunny.color); } - DrawText("bunnies: " + bunnies.length, 12, 10, 20, RAYWHITE); - DrawText("hold mouse-left or Space to add 100", 12, 34, 20, RAYWHITE); - DrawFPS(screenWidth - 100, 10); - EndDrawing(); + drawText("bunnies: " + bunnies.length, 12, 10, 20, raywhite); + drawText("hold mouse-left or Space to add 100", 12, 34, 20, raywhite); + drawFps(screenWidth - 100, 10); + endDrawing(); framesSinceReport += 1; const now = Date.now(); @@ -148,8 +183,8 @@ export const runBunnymark = ( } } } finally { - UnloadTexture(texture); - CloseWindow(); - closeRaylib(); + unloadTexture(texture); + closeWindow(); + closeRaylibBinding(); } }; From be19485e20792d08c27e15e5df9024c23e25fad3 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 12:31:43 +0100 Subject: [PATCH 2/2] Fix packed palette cache invalidation --- examples/doom-gpl/src/i_video.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/doom-gpl/src/i_video.ts b/examples/doom-gpl/src/i_video.ts index 17a9ace..5444622 100644 --- a/examples/doom-gpl/src/i_video.ts +++ b/examples/doom-gpl/src/i_video.ts @@ -48,13 +48,15 @@ const camera = Camera2D.create({ let video = null; let playpal = null; let paletteIndex = 0; +let packedPaletteIndex = -1; let image = null; let texture = null; let initialized = false; const refreshPackedPalette = (): void => { - if (playpal === null) return; + if (playpal === null || packedPaletteIndex === paletteIndex) return; packPaletteBytes(playpal, paletteIndex, packedPaletteBytes); + packedPaletteIndex = paletteIndex; }; const I_InitGraphics = (): void => { @@ -76,6 +78,7 @@ const I_InitGraphics = (): void => { const I_RegisterPlaypal = (paletteBytes: Uint8Array): void => { playpal = paletteBytes; + packedPaletteIndex = -1; refreshPackedPalette(); }; @@ -108,7 +111,7 @@ const I_ShutdownGraphics = (): void => { }; const I_RestoreDefaultPalette = (): void => { - paletteIndex = 0; + I_SetPalette(0); }; export default {