Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 73 additions & 0 deletions benchmarks/bunnymark-instancing.md
Original file line number Diff line number Diff line change
@@ -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.
109 changes: 109 additions & 0 deletions examples/lib/bunny-instancing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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);
}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
// 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 }),
);
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);
};
113 changes: 113 additions & 0 deletions examples/lib/bunny-state.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
Loading
Loading