From 978aaeda51a87098d0024bc976229a46922b4e99 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 14:24:34 +0100 Subject: [PATCH 1/2] Batch Bunnymark sprites with public raylib instancing --- .github/workflows/ci.yml | 1 + README.md | 14 +- benchmarks/bunnymark-instancing.md | 73 +++++++ examples/lib/bunny-instancing.ts | 107 ++++++++++ examples/lib/bunny-state.ts | 113 +++++++++++ examples/lib/run-bunnymark.ts | 198 ++++++++++++------- package.json | 1 + scripts/validate-package.mjs | 2 + tests/fixtures/bunnymark-benchmark.ts | 10 + tests/fixtures/bunnymark-direct-visual.ts | 3 + tests/fixtures/bunnymark-instanced-visual.ts | 3 + tests/fixtures/bunnymark-visual.ts | 19 ++ tests/integration/bunnymark-visual.test.mjs | 54 +++++ tests/linux/Dockerfile | 1 + tests/unit/bunny-state.test.ts | 80 ++++++++ tests/unit/syntax.test.ts | 8 + 16 files changed, 613 insertions(+), 74 deletions(-) create mode 100644 benchmarks/bunnymark-instancing.md create mode 100644 examples/lib/bunny-instancing.ts create mode 100644 examples/lib/bunny-state.ts create mode 100644 tests/fixtures/bunnymark-benchmark.ts create mode 100644 tests/fixtures/bunnymark-direct-visual.ts create mode 100644 tests/fixtures/bunnymark-instanced-visual.ts create mode 100644 tests/fixtures/bunnymark-visual.ts create mode 100644 tests/integration/bunnymark-visual.test.mjs create mode 100644 tests/unit/bunny-state.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57e5ac3..de155cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,7 @@ jobs: xvfb-run -a "$GOCCIA_LOADER" tests/fixtures/basic-input-smoke.ts xvfb-run -a "$GOCCIA_LOADER" tests/fixtures/image-loading-smoke.ts xvfb-run -a "$GOCCIA_LOADER" examples/smoke.ts + xvfb-run -a npm run test:bunnymark:visual xvfb-run -a "$GOCCIA_LOADER" tests/fixtures/doom-smoke.ts test -s .cache/doom-smoke.png diff --git a/README.md b/README.md index 81fd490..4183224 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,15 @@ performance case for compatibility loops: | indexed `while` | 4.02 | 6.3692 s | 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. +That loop experiment predates the application-side import aliases and the +public-raylib instanced drawing path. With the import-binding fast path held +constant, the 10,000-sprite workload now measures 30.35 FPS versus the +12.99-FPS `DrawTextureV` baseline. The renderer uses `DrawMeshInstanced` and a +custom shader through the generated bindings; it has no companion native +library. [Loop methodology](benchmarks/bunnymark-loop-syntax.md), +[the preceding application baseline](benchmarks/application-performance.md), +and [the instancing validation](benchmarks/bunnymark-instancing.md) are +recorded in the repository. ## Project scripts and tests diff --git a/benchmarks/bunnymark-instancing.md b/benchmarks/bunnymark-instancing.md new file mode 100644 index 0000000..25c8e2b --- /dev/null +++ b/benchmarks/bunnymark-instancing.md @@ -0,0 +1,73 @@ +# Bunnymark public-Raylib instancing experiment + +Measured on 2026-07-24 to test whether GocciaScript-Raylib can remove the +per-sprite FFI bottleneck without a companion native library. + +## Setup + +- Apple M1 Max, macOS 26.5.2 +- GocciaScript import-binding fast path at commit `3b19fdbe` +- production loader SHA-256 + `4d86950f941e4028e098090a7cb3e9a7bda1d713b78f59334fa92586f99da7d0` +- GocciaScript-Raylib baseline at commit `ff978c1` +- dynamically loaded raylib 6.0 +- 10,000 fixed-seed sprites (`1234`) +- uncapped target, 25 frames per run +- one unrecorded warm-up per variant +- five measured pairs with alternating run order +- overlays and normal Bunnymark update/collision work enabled + +The baseline is the import-fast-path `DrawTextureV` implementation on +`origin/main`. The candidate uses only existing public bindings: +`BeginShaderMode`, `DrawMeshInstanced`, and `EndShaderMode`. + +## Results + +| Pair | First path | Baseline frame time | Baseline FPS | Instanced frame time | Instanced FPS | +|---:|---|---:|---:|---:|---:| +| 1 | baseline | 1963 ms | 12.74 | 811 ms | 30.83 | +| 2 | instanced | 1906 ms | 13.12 | 828 ms | 30.19 | +| 3 | baseline | 1908 ms | 13.10 | 822 ms | 30.41 | +| 4 | instanced | 1908 ms | 13.10 | 810 ms | 30.86 | +| 5 | baseline | 1941 ms | 12.88 | 849 ms | 29.45 | +| **Mean** | | **1925.2 ms** | **12.99** | **824.0 ms** | **30.35** | + +The instanced path reduces the measured 25-frame portion by 57.2% and raises +observed FPS by 2.34x. Mean end-to-end loader execution falls from 2551.57 ms +to 1346.91 ms. Mean startup falls from 456.6 ms to 343.8 ms. + +## Rendering equivalence + +The graphical integration test renders frame 8 of the same fixed-seed, +1,000-sprite scene through both `DrawTextureV` and `DrawMeshInstanced`, with a +fixed update delta and the timing overlay disabled. The two 1280x720 PNG files +are byte-identical on both tested desktop paths: + +- macOS/OpenGL: + `982eb299ae1a855b3e4cbda832e4e34b49876999e1a3b710c392288f60bc1c3d` +- Linux/Xvfb/Mesa llvmpipe: + `e22537b001590b497bad6c8da318d38339bef0fb4cdb95a8c8f449584e47d1a0` + +The macOS image also byte-matches a test-instrumented capture from the +`origin/main` `DrawTextureV` implementation, not only the retained direct +fallback in the candidate. + +The custom fragment shader retains raylib's default +`texture0 * colDiffuse * fragColor` formula. Instance order is insertion order, +and `BeginShaderMode` flushes prior queued 2D work before the instanced draw. + +## Scope and tradeoffs + +- This is a Bunnymark-specific `DrawTextureV` fast path for one 32x32 texture, + not a new general Raylib API. +- Motion remains double precision. Exact byte colors and float instance + transforms live in compact typed arrays; a small numeric index array avoids + a costly custom iterator in the per-frame hot loop. +- Raylib still allocates, converts, uploads, and frees a 64-byte transform per + sprite for every `DrawMeshInstanced` call (640 KiB per 10,000 sprites). + A persistent rlgl VBO could have a higher ceiling but is outside this + public-Raylib-only experiment. +- The shader is GLSL 330, matching the repository's pinned macOS and Linux + desktop raylib builds. It is not a web/mobile shader path. +- The material owns the custom shader and attached bunny texture. If shader + creation fails, the example falls back to its direct `DrawTextureV` path. diff --git a/examples/lib/bunny-instancing.ts b/examples/lib/bunny-instancing.ts new file mode 100644 index 0000000..d4b7cfa --- /dev/null +++ b/examples/lib/bunny-instancing.ts @@ -0,0 +1,107 @@ +import { + BeginShaderMode, + DrawMeshInstanced, + EndShaderMode, + GenImageColor, + GenMeshHeightmap, + IsShaderValid, + LoadMaterialDefault, + LoadShaderFromMemory, + MATERIAL_MAP_DIFFUSE, + SetMaterialTexture, + UnloadImage, + UnloadMaterial, + UnloadMesh, + UnloadShader, + UpdateMeshBuffer, + Vector3, + WHITE, +} from "../../bindings/raylib.ts"; + +const vertexShaderSource = `#version 330 +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in mat4 instanceTransform; +out vec2 fragTexCoord; +out vec4 fragColor; +uniform mat4 mvp; +void main() { + fragTexCoord = vertexTexCoord; + fragColor = instanceTransform[0]; + vec2 position = vertexPosition.xy + instanceTransform[3].xy; + gl_Position = mvp*vec4(position, 0.0, 1.0); +}`; + +const fragmentShaderSource = `#version 330 +in vec2 fragTexCoord; +in vec4 fragColor; +out vec4 finalColor; +uniform sampler2D texture0; +uniform vec4 colDiffuse; +void main() { + finalColor = texture(texture0, fragTexCoord)*colDiffuse*fragColor; +}`; + +const quadVertices = new Float32Array([ + 0, 0, 0, + 0, 32, 0, + 32, 32, 0, + 0, 0, 0, + 32, 32, 0, + 32, 0, 0, +]); + +const quadTexcoords = new Float32Array([ + 0, 0, + 0, 1, + 1, 1, + 0, 0, + 1, 1, + 1, 0, +]); + +export const createBunnyInstancing = (texture) => { + const shader = LoadShaderFromMemory( + vertexShaderSource, + fragmentShaderSource, + ); + if (!IsShaderValid(shader)) { + UnloadShader(shader); + return null; + } + + const meshImage = GenImageColor(2, 2, WHITE); + const mesh = GenMeshHeightmap( + meshImage, + Vector3.create({ x: texture.width, y: 0, z: texture.height }), + ); + UnloadImage(meshImage); + UpdateMeshBuffer(mesh, 0, quadVertices, quadVertices.byteLength, 0); + UpdateMeshBuffer(mesh, 1, quadTexcoords, quadTexcoords.byteLength, 0); + + const material = LoadMaterialDefault(); + material.shader = shader; + SetMaterialTexture(material, MATERIAL_MAP_DIFFUSE, texture); + return { material, mesh }; +}; + +export const drawBunnyInstances = ( + instancing, + state, +): void => { + if (state.length === 0) return; + BeginShaderMode(instancing.material.shader); + DrawMeshInstanced( + instancing.mesh, + instancing.material, + state.transforms, + state.length, + ); + EndShaderMode(); +}; + +export const unloadBunnyInstancing = (instancing): void => { + UnloadMesh(instancing.mesh); + // The material owns its custom shader and attached bunny texture in raylib. + UnloadMaterial(instancing.material); +}; diff --git a/examples/lib/bunny-state.ts b/examples/lib/bunny-state.ts new file mode 100644 index 0000000..486a1a0 --- /dev/null +++ b/examples/lib/bunny-state.ts @@ -0,0 +1,113 @@ +import { frameNumbers } from "./frames.ts"; + +export const bunnyInstanceStride = 16; +export const bunnyRedOffset = 0; +export const bunnyGreenOffset = 4; +export const bunnyBlueOffset = 8; +export const bunnyAlphaOffset = 12; +export const bunnyXOffset = 3; +export const bunnyYOffset = 7; + +export type BunnyState = { + capacity: number; + length: number; + x: Float64Array; + y: Float64Array; + velocityX: Float64Array; + velocityY: Float64Array; + colors: Uint8Array; + transforms: Float32Array; + indices: number[]; +}; + +export const createBunnyState = (capacity: number): BunnyState => { + return { + capacity, + length: 0, + x: new Float64Array(capacity), + y: new Float64Array(capacity), + velocityX: new Float64Array(capacity), + velocityY: new Float64Array(capacity), + colors: new Uint8Array(capacity * 4), + transforms: new Float32Array(capacity * bunnyInstanceStride), + indices: [], + }; +}; + +export const addBunnies = ( + state: BunnyState, + count: number, + startX: number, + startY: number, + getRandomValue: (minimum: number, maximum: number) => number, +): void => { + const available = Math.min(count, state.capacity - state.length); + let length = state.length; + + for (const _index of frameNumbers(available)) { + const colorOffset = length * 4; + const transformOffset = length * bunnyInstanceStride; + const velocityX = getRandomValue(-250, 250) / 60; + const velocityY = getRandomValue(-250, 250) / 60; + const red = getRandomValue(50, 240); + const green = getRandomValue(50, 240); + const blue = getRandomValue(50, 240); + + state.x[length] = startX; + state.y[length] = startY; + state.velocityX[length] = velocityX; + state.velocityY[length] = velocityY; + state.colors[colorOffset] = red; + state.colors[colorOffset + 1] = green; + state.colors[colorOffset + 2] = blue; + state.colors[colorOffset + 3] = 255; + state.transforms[transformOffset + bunnyRedOffset] = red / 255; + state.transforms[transformOffset + bunnyGreenOffset] = green / 255; + state.transforms[transformOffset + bunnyBlueOffset] = blue / 255; + state.transforms[transformOffset + bunnyAlphaOffset] = 1; + state.transforms[transformOffset + bunnyXOffset] = startX; + state.transforms[transformOffset + bunnyYOffset] = startY; + state.indices.push(length); + length += 1; + } + + state.length = length; +}; + +export const updateBunnies = ( + state: BunnyState, + deltaScale: number, + textureWidth: number, + textureHeight: number, + screenWidth: number, + screenHeight: number, +): void => { + const halfWidth = textureWidth / 2; + const halfHeight = textureHeight / 2; + const xValues = state.x; + const yValues = state.y; + const velocityXValues = state.velocityX; + const velocityYValues = state.velocityY; + const transforms = state.transforms; + + for (const index of state.indices) { + const x = xValues[index] + velocityXValues[index] * deltaScale; + const y = yValues[index] + velocityYValues[index] * deltaScale; + + xValues[index] = x; + yValues[index] = y; + if (x + halfWidth > screenWidth || x + halfWidth < 0) { + velocityXValues[index] *= -1; + } + if ( + y + halfHeight > screenHeight || + y + halfHeight - 40 < 0 + ) { + velocityYValues[index] *= -1; + } + + const transformOffset = index * bunnyInstanceStride; + transforms[transformOffset + bunnyXOffset] = x; + transforms[transformOffset + bunnyYOffset] = y; + } +}; diff --git a/examples/lib/run-bunnymark.ts b/examples/lib/run-bunnymark.ts index 83fe516..2be9f8a 100644 --- a/examples/lib/run-bunnymark.ts +++ b/examples/lib/run-bunnymark.ts @@ -18,24 +18,27 @@ import { LoadTextureFromImage, MOUSE_BUTTON_LEFT, RAYWHITE, + SetRandomSeed, SetTargetFPS, + TakeScreenshot, UnloadImage, UnloadTexture, Vector2, WindowShouldClose, closeRaylib, } from "../../bindings/raylib.ts"; +import { + addBunnies, + createBunnyState, + updateBunnies, +} from "./bunny-state.ts"; +import { + createBunnyInstancing, + drawBunnyInstances, + unloadBunnyInstancing, +} from "./bunny-instancing.ts"; import { frameNumbers } from "./frames.ts"; -type Bunny = { - 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; @@ -57,7 +60,9 @@ const loadImageFromMemory = LoadImageFromMemory; const loadTextureFromImage = LoadTextureFromImage; const mouseButtonLeft = MOUSE_BUTTON_LEFT; const raywhite = RAYWHITE; +const setRandomSeed = SetRandomSeed; const setTargetFps = SetTargetFPS; +const takeScreenshot = TakeScreenshot; const unloadImage = UnloadImage; const unloadTexture = UnloadTexture; const vector2Type = Vector2; @@ -68,34 +73,6 @@ const screenWidth: number = 1280; const screenHeight: number = 720; const maxBunnies: number = 50000; -const randomColor = () => { - return colorType.create({ - r: getRandomValue(50, 240), - g: getRandomValue(50, 240), - b: getRandomValue(50, 240), - a: 255, - }); -}; - -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({ - x, - y, - velocityX: getRandomValue(-250, 250) / 60, - velocityY: getRandomValue(-250, 250) / 60, - drawPosition: vector2Type.create({ - x, - y, - }), - color: randomColor(), - }); - } -}; - export const runBunnymark = ( bunnyBytes: Uint8Array, maxFrames: number, @@ -109,62 +86,124 @@ export const runBunnymark = ( initWindow(screenWidth, screenHeight, "GocciaScript + raylib: Bunnymark"); const texture = loadTextureFromImage(image); unloadImage(image); - setTargetFps(60); - const bunnies: Bunny[] = []; - addBunnies(bunnies, 1000); + const configuredSeed = globalThis.BUNNYMARK_RANDOM_SEED; + const configuredInitialSprites = + globalThis.BUNNYMARK_INITIAL_SPRITES; + const configuredTargetFps = globalThis.BUNNYMARK_TARGET_FPS; + const configuredDeltaScale = globalThis.BUNNYMARK_FIXED_DELTA_SCALE; + const requestedDrawPath = globalThis.BUNNYMARK_DRAW_PATH === "direct" + ? "direct" + : "instanced"; + const initialSprites = typeof configuredInitialSprites === "number" + ? configuredInitialSprites + : 1000; + const targetFps = typeof configuredTargetFps === "number" + ? configuredTargetFps + : 60; + const fixedDeltaScale = typeof configuredDeltaScale === "number" + ? configuredDeltaScale + : null; + const showOverlay = globalThis.BUNNYMARK_SHOW_OVERLAY !== false; + const capturePath = globalThis.BUNNYMARK_CAPTURE_PATH; + const captureFrame = typeof globalThis.BUNNYMARK_CAPTURE_FRAME === "number" + ? globalThis.BUNNYMARK_CAPTURE_FRAME + : 0; + const reportFinal = globalThis.BUNNYMARK_REPORT_FINAL === true; + + if (typeof configuredSeed === "number") setRandomSeed(configuredSeed); + setTargetFps(targetFps); + + const bunnies = createBunnyState(maxBunnies); + addBunnies( + bunnies, + initialSprites, + screenWidth / 2, + screenHeight / 2, + getRandomValue, + ); + const instancing = requestedDrawPath === "instanced" + ? createBunnyInstancing(texture) + : null; + const drawPath = instancing === null ? "direct" : "instanced"; + const drawPosition = vector2Type.create({ x: 0, y: 0 }); + const drawColor = colorType.create({ r: 0, g: 0, b: 0, a: 255 }); const startupMilliseconds = Date.now() - startedAt; console.log( "Bunnymark startup_ms=" + startupMilliseconds + " initial_sprites=" + - bunnies.length, + bunnies.length + + " draw_path=" + + drawPath, ); + const framesStartedAt = Date.now(); + let framesRendered = 0; let framesSinceReport = 0; let reportStartedAt = Date.now(); try { - for (const _frame of frameNumbers(maxFrames)) { + for (const frame of frameNumbers(maxFrames)) { if (windowShouldClose()) break; - const deltaScale = getFrameTime() * 60; + const deltaScale = fixedDeltaScale === null + ? getFrameTime() * 60 + : fixedDeltaScale; if ( isMouseButtonDown(mouseButtonLeft) || isKeyDown(keySpace) ) { - addBunnies(bunnies, 100); + addBunnies( + bunnies, + 100, + screenWidth / 2, + screenHeight / 2, + getRandomValue, + ); } - for (const bunny of bunnies) { - bunny.x += bunny.velocityX * deltaScale; - bunny.y += bunny.velocityY * deltaScale; - - if ( - bunny.x + texture.width / 2 > screenWidth || - bunny.x + texture.width / 2 < 0 - ) { - bunny.velocityX *= -1; - } - if ( - bunny.y + texture.height / 2 > screenHeight || - bunny.y + texture.height / 2 - 40 < 0 - ) { - bunny.velocityY *= -1; - } - } + updateBunnies( + bunnies, + deltaScale, + texture.width, + texture.height, + screenWidth, + screenHeight, + ); beginDrawing(); clearBackground(black); - for (const bunny of bunnies) { - bunny.drawPosition.x = bunny.x; - bunny.drawPosition.y = bunny.y; - drawTextureV(texture, bunny.drawPosition, bunny.color); + if (instancing !== null) { + drawBunnyInstances(instancing, bunnies); + } else { + for (const index of bunnies.indices) { + const colorOffset = index * 4; + drawPosition.x = bunnies.x[index]; + drawPosition.y = bunnies.y[index]; + drawColor.r = bunnies.colors[colorOffset]; + drawColor.g = bunnies.colors[colorOffset + 1]; + drawColor.b = bunnies.colors[colorOffset + 2]; + drawColor.a = bunnies.colors[colorOffset + 3]; + drawTextureV(texture, drawPosition, drawColor); + } + } + if (showOverlay) { + 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); } - 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(); + if (typeof capturePath === "string" && frame === captureFrame) { + takeScreenshot(capturePath); + } + framesRendered += 1; framesSinceReport += 1; const now = Date.now(); if (now - reportStartedAt >= 5000) { @@ -175,6 +214,8 @@ export const runBunnymark = ( framesSinceReport + " sprites=" + bunnies.length + + " draw_path=" + + drawPath + " observed_fps=" + measuredFps.toFixed(1), ); @@ -183,7 +224,26 @@ export const runBunnymark = ( } } } finally { - unloadTexture(texture); + if (reportFinal) { + const frameMilliseconds = Date.now() - framesStartedAt; + const observedFps = frameMilliseconds === 0 + ? 0 + : (framesRendered * 1000) / frameMilliseconds; + console.log( + "Bunnymark summary frames=" + + framesRendered + + " sprites=" + + bunnies.length + + " draw_path=" + + drawPath + + " frame_ms=" + + frameMilliseconds + + " observed_fps=" + + observedFps.toFixed(2), + ); + } + if (instancing === null) unloadTexture(texture); + else unloadBunnyInstancing(instancing); closeWindow(); closeRaylibBinding(); } diff --git a/package.json b/package.json index eaa0674..5a5ee08 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:types": "tsc --noEmit --strict --skipLibCheck false --lib ES2022 bindings/raylib.d.ts", "test:abi": "node tests/integration/abi.test.mjs", "test:ffi": "node tests/integration/ffi.test.mjs", + "test:bunnymark:visual": "node tests/integration/bunnymark-visual.test.mjs", "pack:check": "node scripts/validate-package.mjs" }, "devDependencies": { diff --git a/scripts/validate-package.mjs b/scripts/validate-package.mjs index ff7feee..f8901ae 100644 --- a/scripts/validate-package.mjs +++ b/scripts/validate-package.mjs @@ -31,6 +31,8 @@ const expectedFiles = new Set([ "examples/image-loading.ts", "examples/bunnymark.ts", "examples/doom-clone.ts", + "examples/lib/bunny-instancing.ts", + "examples/lib/bunny-state.ts", "examples/lib/frames.ts", "examples/lib/run-bunnymark.ts", "examples/smoke.ts", diff --git a/tests/fixtures/bunnymark-benchmark.ts b/tests/fixtures/bunnymark-benchmark.ts new file mode 100644 index 0000000..96978ac --- /dev/null +++ b/tests/fixtures/bunnymark-benchmark.ts @@ -0,0 +1,10 @@ +import bunnyBytes from "../../examples/assets/raybunny.png" with { + type: "bytes", +}; +import { runBunnymark } from "../../examples/lib/run-bunnymark.ts"; + +globalThis.BUNNYMARK_INITIAL_SPRITES = 10000; +globalThis.BUNNYMARK_RANDOM_SEED = 1234; +globalThis.BUNNYMARK_TARGET_FPS = 0; +globalThis.BUNNYMARK_REPORT_FINAL = true; +runBunnymark(bunnyBytes, 25); diff --git a/tests/fixtures/bunnymark-direct-visual.ts b/tests/fixtures/bunnymark-direct-visual.ts new file mode 100644 index 0000000..0deed01 --- /dev/null +++ b/tests/fixtures/bunnymark-direct-visual.ts @@ -0,0 +1,3 @@ +import { runBunnymarkVisual } from "./bunnymark-visual.ts"; + +runBunnymarkVisual("direct", ".cache/bunnymark-direct.png"); diff --git a/tests/fixtures/bunnymark-instanced-visual.ts b/tests/fixtures/bunnymark-instanced-visual.ts new file mode 100644 index 0000000..2890646 --- /dev/null +++ b/tests/fixtures/bunnymark-instanced-visual.ts @@ -0,0 +1,3 @@ +import { runBunnymarkVisual } from "./bunnymark-visual.ts"; + +runBunnymarkVisual("instanced", ".cache/bunnymark-instanced.png"); diff --git a/tests/fixtures/bunnymark-visual.ts b/tests/fixtures/bunnymark-visual.ts new file mode 100644 index 0000000..1ad96bf --- /dev/null +++ b/tests/fixtures/bunnymark-visual.ts @@ -0,0 +1,19 @@ +import bunnyBytes from "../../examples/assets/raybunny.png" with { + type: "bytes", +}; +import { runBunnymark } from "../../examples/lib/run-bunnymark.ts"; + +export const runBunnymarkVisual = ( + drawPath: string, + capturePath: string, +): void => { + globalThis.BUNNYMARK_DRAW_PATH = drawPath; + globalThis.BUNNYMARK_INITIAL_SPRITES = 1000; + globalThis.BUNNYMARK_RANDOM_SEED = 1234; + globalThis.BUNNYMARK_TARGET_FPS = 0; + globalThis.BUNNYMARK_FIXED_DELTA_SCALE = 1; + globalThis.BUNNYMARK_SHOW_OVERLAY = false; + globalThis.BUNNYMARK_CAPTURE_PATH = capturePath; + globalThis.BUNNYMARK_CAPTURE_FRAME = 7; + runBunnymark(bunnyBytes, 8); +}; diff --git a/tests/integration/bunnymark-visual.test.mjs b/tests/integration/bunnymark-visual.test.mjs new file mode 100644 index 0000000..3f5c56e --- /dev/null +++ b/tests/integration/bunnymark-visual.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, unlink } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { fromRoot } from "../../scripts/lib/project.mjs"; +import { findGocciaLoader } from "./helpers.mjs"; + +const loader = await findGocciaLoader(); +const cacheDirectory = fromRoot(".cache"); +const directPath = fromRoot(".cache/bunnymark-direct.png"); +const instancedPath = fromRoot(".cache/bunnymark-instanced.png"); +await mkdir(cacheDirectory, { recursive: true }); + +for (const path of [directPath, instancedPath]) { + try { + await unlink(path); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +const runFixture = (fixture, expectedPath) => { + const result = spawnSync(loader, [fromRoot(fixture)], { + cwd: fromRoot(), + encoding: "utf8", + env: process.env, + }); + assert.equal( + result.status, + 0, + `${expectedPath} Bunnymark failed:\n${result.stdout}\n${result.stderr}`, + ); + assert.match( + result.stdout, + new RegExp(`draw_path=${expectedPath}`), + `${expectedPath} path was not active`, + ); +}; + +runFixture("tests/fixtures/bunnymark-direct-visual.ts", "direct"); +runFixture("tests/fixtures/bunnymark-instanced-visual.ts", "instanced"); + +const direct = await readFile(directPath); +const instanced = await readFile(instancedPath); +assert.deepEqual( + instanced, + direct, + "Instanced Bunnymark pixels differ from DrawTextureV reference pixels", +); + +const sha256 = createHash("sha256").update(instanced).digest("hex"); +console.log( + `Direct and instanced Bunnymark screenshots are byte-identical: ${sha256}`, +); diff --git a/tests/linux/Dockerfile b/tests/linux/Dockerfile index 9bf64d7..37fd1ea 100644 --- a/tests/linux/Dockerfile +++ b/tests/linux/Dockerfile @@ -72,6 +72,7 @@ RUN npm ci \ && xvfb-run -a GocciaScriptLoader tests/fixtures/basic-input-smoke.ts \ && xvfb-run -a GocciaScriptLoader tests/fixtures/image-loading-smoke.ts \ && xvfb-run -a GocciaScriptLoader examples/smoke.ts \ + && xvfb-run -a npm run test:bunnymark:visual \ && xvfb-run -a GocciaScriptLoader tests/fixtures/doom-smoke.ts \ && test -s .cache/doom-smoke.png \ && xvfb-run -a make -C examples/doom-gpl smoke diff --git a/tests/unit/bunny-state.test.ts b/tests/unit/bunny-state.test.ts new file mode 100644 index 0000000..a73f2f7 --- /dev/null +++ b/tests/unit/bunny-state.test.ts @@ -0,0 +1,80 @@ +import { + addBunnies, + bunnyAlphaOffset, + bunnyBlueOffset, + bunnyGreenOffset, + bunnyInstanceStride, + bunnyRedOffset, + bunnyXOffset, + bunnyYOffset, + createBunnyState, + updateBunnies, +} from "../../examples/lib/bunny-state.ts"; + +describe("Bunnymark compact sprite state", () => { + test("preserves random order and C Matrix instance layout", () => { + const state = createBunnyState(1); + const values = [-120, 180, 50, 100, 150]; + const ranges: number[][] = []; + let nextValue = 0; + + addBunnies(state, 2, 640, 360, (minimum, maximum) => { + ranges.push([minimum, maximum]); + const value = values[nextValue]; + nextValue += 1; + return value; + }); + + expect(state.length).toBe(1); + expect(ranges).toEqual([ + [-250, 250], + [-250, 250], + [50, 240], + [50, 240], + [50, 240], + ]); + expect(state.x[0]).toBe(640); + expect(state.y[0]).toBe(360); + expect(state.velocityX[0]).toBe(-2); + expect(state.velocityY[0]).toBe(3); + expect(Array.from(state.colors)).toEqual([50, 100, 150, 255]); + + expect( + Math.round( + state.transforms[bunnyRedOffset] * 255, + ), + ).toBe(50); + expect( + Math.round( + state.transforms[bunnyGreenOffset] * 255, + ), + ).toBe(100); + expect( + Math.round( + state.transforms[bunnyBlueOffset] * 255, + ), + ).toBe(150); + expect(state.transforms[bunnyAlphaOffset]).toBe(1); + expect(state.transforms[bunnyXOffset]).toBe(640); + expect(state.transforms[bunnyYOffset]).toBe(360); + expect(state.transforms[bunnyInstanceStride - 1]).toBe(0); + }); + + test("matches Bunnymark motion and edge reversal semantics", () => { + const state = createBunnyState(1); + addBunnies(state, 1, 0, 0, () => 50); + state.x[0] = -15; + state.y[0] = 20; + state.velocityX[0] = -2; + state.velocityY[0] = -1; + + updateBunnies(state, 1, 32, 32, 1280, 720); + + expect(state.x[0]).toBe(-17); + expect(state.y[0]).toBe(19); + expect(state.velocityX[0]).toBe(2); + expect(state.velocityY[0]).toBe(1); + expect(state.transforms[bunnyXOffset]).toBe(-17); + expect(state.transforms[bunnyYOffset]).toBe(19); + }); +}); diff --git a/tests/unit/syntax.test.ts b/tests/unit/syntax.test.ts index ddc0d57..73e924e 100644 --- a/tests/unit/syntax.test.ts +++ b/tests/unit/syntax.test.ts @@ -1,6 +1,12 @@ import abiProbe from "../abi/layout-probe.ts" with { type: "text" }; import basicInput from "../../examples/basic-input.ts" with { type: "text" }; import basicWindow from "../../examples/basic-window.ts" with { type: "text" }; +import bunnyInstancing from "../../examples/lib/bunny-instancing.ts" with { + type: "text", +}; +import bunnyState from "../../examples/lib/bunny-state.ts" with { + type: "text", +}; import bunnymark from "../../examples/lib/run-bunnymark.ts" with { type: "text", }; @@ -18,6 +24,8 @@ describe("default GocciaScript syntax", () => { abiProbe, basicInput, basicWindow, + bunnyInstancing, + bunnyState, bunnymark, doomClone, ffiSmoke, From 650cf2f2e8ab3bd2cf4bbaff4da5ba01e798f92a Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 20:09:29 +0100 Subject: [PATCH 2/2] test(bunnymark): validate visual captures --- examples/lib/bunny-instancing.ts | 2 ++ tests/integration/bunnymark-visual.test.mjs | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/examples/lib/bunny-instancing.ts b/examples/lib/bunny-instancing.ts index d4b7cfa..d05b855 100644 --- a/examples/lib/bunny-instancing.ts +++ b/examples/lib/bunny-instancing.ts @@ -71,6 +71,8 @@ export const createBunnyInstancing = (texture) => { } const meshImage = GenImageColor(2, 2, WHITE); + // GenMeshHeightmap supplies an uploaded 6-vertex mesh; both useful buffers + // are completely replaced below to form the sprite quad. const mesh = GenMeshHeightmap( meshImage, Vector3.create({ x: texture.width, y: 0, z: texture.height }), diff --git a/tests/integration/bunnymark-visual.test.mjs b/tests/integration/bunnymark-visual.test.mjs index 3f5c56e..1e6f332 100644 --- a/tests/integration/bunnymark-visual.test.mjs +++ b/tests/integration/bunnymark-visual.test.mjs @@ -42,6 +42,19 @@ runFixture("tests/fixtures/bunnymark-instanced-visual.ts", "instanced"); const direct = await readFile(directPath); const instanced = await readFile(instancedPath); +const pngSignature = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +for (const [name, image] of [ + ["direct", direct], + ["instanced", instanced], +]) { + assert.ok( + image.length > pngSignature.length && + image.subarray(0, pngSignature.length).equals(pngSignature), + `${name} capture is not a valid PNG`, + ); +} assert.deepEqual( instanced, direct,