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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ website/dist
*.tsbuildinfo
tmp
.DS_Store

# labs bench results
.labs
50 changes: 50 additions & 0 deletions benches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# maath benches

Performance benchmarks powered by [`@pmndrs/labs`](https://github.com/pmndrs/labs) — statistically rigorous benchmarking (Mann-Whitney U, Cliff's delta, adaptive sampling) with baseline comparison.

Benches import directly from `../src`, so no build step is needed.

## Usage

Run from the repo root:

```sh
pnpm bench # run all benches, save results with auto timestamp
pnpm bench "vec3" # filter by file/bench name
pnpm bench "@core" # filter by tag (@core, @noise, @algo, ...)
pnpm bench -n "v1.0.0" -b # save with a name and set as baseline
pnpm bench compare # compare latest run against the baseline
pnpm bench run # run without saving
```

## Layout

- `core/`, `noise/` — micro benches of individual functions in tight loops (`@core`, `@noise`)
- `algorithms/` — composite benches (`@algo`) that implement a complete minimal feature from
navigation/collision-style libraries, exercising many maath functions together: funnel string
pulling (`@nav`), frustum culling (`@culling`), closest-hit raycasting (`@raycast`), transform
hierarchy propagation (`@scene`), and a full sphere physics step (`@physics`)

The composite benches run 100+ µs per iteration, which keeps them well above this machine's
micro-bench noise floor — prefer them for regression comparisons; use the micro benches to
localize a regression once one shows up.

Results are saved to `.labs/` (gitignored). Comparisons only report a change when it is statistically significant and the effect size is meaningful — see the labs README for details.

## Writing a bench

Benches use a generator: code before `yield` is setup, the yielded function is measured, code after is teardown. Chain `.gc('inner')` to force GC between samples.

```ts
import { bench, group } from '@pmndrs/labs';

group('my group @mytag', () => {
bench('my bench', function* () {
// setup
yield () => {
// measured
};
// teardown
}).gc('inner');
});
```
124 changes: 124 additions & 0 deletions benches/algorithms/frustum-culling.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { bench, group } from '@pmndrs/labs';
import * as mat4 from '../../src/core/mat4';
import * as vec3 from '../../src/core/vec3';
import type { Vec3 } from '../../src/core/vec3';
import * as mulberry32 from '../../src/random/mulberry32';
import * as box3 from '../../src/shapes/box3';
import type { Box3 } from '../../src/shapes/box3';
import * as plane3 from '../../src/shapes/plane3';
import type { Plane3 } from '../../src/shapes/plane3';
import type { Sphere } from '../../src/shapes/sphere';

// Camera frustum culling — build view + projection matrices, extract the six
// frustum planes (Gribb-Hartmann), then cull a field of bounding volumes.

const N = 4096;

const view = mat4.create();
const proj = mat4.create();
const viewProj = mat4.create();
const planeNormal = vec3.create();

function buildFrustum(out: Plane3[]): void {
mat4.lookAt(view, [30, 30, 30], [0, 0, 0], [0, 1, 0]);
mat4.perspectiveNO(proj, Math.PI / 3, 16 / 9, 0.1, 100);
mat4.multiply(viewProj, proj, view);

// extract the six frustum planes (Gribb-Hartmann) from the column-major
// view-projection matrix; rows: row3 + row_i (left/bottom/near), row3 - row_i (right/top/far)
const m = viewProj;
for (let i = 0; i < 3; i++) {
vec3.set(planeNormal, m[3] + m[i], m[7] + m[4 + i], m[11] + m[8 + i]);
plane3.fromNormalAndConstant(out[i * 2], planeNormal, m[15] + m[12 + i]);
plane3.normalize(out[i * 2], out[i * 2]);

vec3.set(planeNormal, m[3] - m[i], m[7] - m[4 + i], m[11] - m[8 + i]);
plane3.fromNormalAndConstant(out[i * 2 + 1], planeNormal, m[15] - m[12 + i]);
plane3.normalize(out[i * 2 + 1], out[i * 2 + 1]);
}
}

function makePlanes(): Plane3[] {
const planes: Plane3[] = [];
for (let i = 0; i < 6; i++) planes.push(plane3.create());
return planes;
}

function randPos(rand: ReturnType<typeof mulberry32.create>): Vec3 {
return [
(mulberry32.sample(rand) - 0.5) * 80,
(mulberry32.sample(rand) - 0.5) * 80,
(mulberry32.sample(rand) - 0.5) * 80,
];
}

let sink = 0;

group('frustum culling 4096 @algo @culling', () => {
bench('spheres', function* () {
const rand = mulberry32.create(42);
const spheres: Sphere[] = [];
for (let i = 0; i < N; i++) {
spheres.push({ center: randPos(rand), radius: 0.5 + mulberry32.sample(rand) * 1.5 });
}
const planes = makePlanes();

yield () => {
buildFrustum(planes);
let visible = 0;
for (let i = 0; i < N; i++) {
const sphere = spheres[i];
let inside = true;
for (let p = 0; p < 6; p++) {
if (plane3.distanceToPoint(planes[p], sphere.center) < -sphere.radius) {
inside = false;
break;
}
}
if (inside) visible++;
}
sink = visible;
};
}).gc('inner');

bench('aabbs', function* () {
const rand = mulberry32.create(42);
const boxes: Box3[] = [];
for (let i = 0; i < N; i++) {
const b = box3.create();
box3.setFromCenterAndSize(b, randPos(rand), [
1 + mulberry32.sample(rand) * 3,
1 + mulberry32.sample(rand) * 3,
1 + mulberry32.sample(rand) * 3,
]);
boxes.push(b);
}
const planes = makePlanes();
const center = vec3.create();
const extents = vec3.create();

yield () => {
buildFrustum(planes);
let visible = 0;
for (let i = 0; i < N; i++) {
const b = boxes[i];
box3.center(center, b);
box3.extents(extents, b);
let inside = true;
for (let p = 0; p < 6; p++) {
const n = planes[p].normal;
const effectiveRadius =
extents[0] * Math.abs(n[0]) + extents[1] * Math.abs(n[1]) + extents[2] * Math.abs(n[2]);
if (plane3.distanceToPoint(planes[p], center) < -effectiveRadius) {
inside = false;
break;
}
}
if (inside) visible++;
}
sink = visible;
};
}).gc('inner');
});

if (sink === Infinity) throw new Error('unreachable');
138 changes: 138 additions & 0 deletions benches/algorithms/funnel-path.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { bench, group } from '@pmndrs/labs';
import * as vec2 from '../../src/core/vec2';
import type { Vec2 } from '../../src/core/vec2';
import type { Vec3 } from '../../src/core/vec3';
import * as mulberry32 from '../../src/random/mulberry32';

// Simple stupid funnel (string pulling) over navmesh portal edges — the path
// smoothing step of a navigation pipeline. Composes vec2 subtract/cross/copy/
// exactEquals/distance.

const CORRIDORS = 16;
const PORTALS = 256; // per corridor, plus degenerate start/end portals

type Corridor = { left: Vec2[]; right: Vec2[]; count: number };

const edgeAB = vec2.create();
const edgeAC = vec2.create();
const crossOut: Vec3 = [0, 0, 0];

// twice the signed area of triangle (a, b, c); negative when c is left of ab
function triarea2(a: Vec2, b: Vec2, c: Vec2): number {
vec2.subtract(edgeAB, b, a);
vec2.subtract(edgeAC, c, a);
vec2.cross(crossOut, edgeAC, edgeAB);
return crossOut[2];
}

const portalApex = vec2.create();
const portalLeft = vec2.create();
const portalRight = vec2.create();

function stringPull(left: Vec2[], right: Vec2[], count: number, outCorners: Vec2[]): number {
let n = 0;
vec2.copy(portalApex, left[0]);
vec2.copy(portalLeft, left[0]);
vec2.copy(portalRight, right[0]);
let apexIndex = 0;
let leftIndex = 0;
let rightIndex = 0;
vec2.copy(outCorners[n++], portalApex);

for (let i = 1; i < count; i++) {
const pl = left[i];
const pr = right[i];

// update right vertex
if (triarea2(portalApex, portalRight, pr) <= 0) {
if (vec2.exactEquals(portalApex, portalRight) || triarea2(portalApex, portalLeft, pr) > 0) {
vec2.copy(portalRight, pr);
rightIndex = i;
} else {
// right crossed over left: left becomes the new apex
vec2.copy(outCorners[n++], portalLeft);
vec2.copy(portalApex, portalLeft);
apexIndex = leftIndex;
vec2.copy(portalLeft, portalApex);
vec2.copy(portalRight, portalApex);
leftIndex = apexIndex;
rightIndex = apexIndex;
i = apexIndex;
continue;
}
}

// update left vertex
if (triarea2(portalApex, portalLeft, pl) >= 0) {
if (vec2.exactEquals(portalApex, portalLeft) || triarea2(portalApex, portalRight, pl) < 0) {
vec2.copy(portalLeft, pl);
leftIndex = i;
} else {
// left crossed over right: right becomes the new apex
vec2.copy(outCorners[n++], portalRight);
vec2.copy(portalApex, portalRight);
apexIndex = rightIndex;
vec2.copy(portalLeft, portalApex);
vec2.copy(portalRight, portalApex);
leftIndex = apexIndex;
rightIndex = apexIndex;
i = apexIndex;
continue;
}
}
}

vec2.copy(outCorners[n++], left[count - 1]);
return n;
}

let sink = 0;

group('funnel string pull 16x256 @algo @nav', () => {
bench('string pull + path length', function* () {
const rand = mulberry32.create(42);
const corridors: Corridor[] = [];
for (let c = 0; c < CORRIDORS; c++) {
const left: Vec2[] = [];
const right: Vec2[] = [];
let centerX = 0;
// degenerate start portal
left.push([0, 0]);
right.push([0, 0]);
for (let i = 0; i < PORTALS; i++) {
const y = i + 1;
centerX += (mulberry32.sample(rand) - 0.5) * 1.5;
const halfWidth = 0.5 + mulberry32.sample(rand);
left.push([centerX - halfWidth, y]);
right.push([centerX + halfWidth, y]);
}
// degenerate end portal
left.push([centerX, PORTALS + 1]);
right.push([centerX, PORTALS + 1]);
corridors.push({ left, right, count: left.length });
}

const corners: Vec2[] = [];
for (let i = 0; i < PORTALS + 4; i++) corners.push(vec2.create());

// sanity: every corridor must pull to a valid multi-corner path
for (const c of corridors) {
const n = stringPull(c.left, c.right, c.count, corners);
if (n < 2 || !vec2.finite(corners[n - 1])) throw new Error('funnel produced a degenerate path');
}

yield () => {
let totalLength = 0;
for (let c = 0; c < corridors.length; c++) {
const corridor = corridors[c];
const n = stringPull(corridor.left, corridor.right, corridor.count, corners);
for (let i = 1; i < n; i++) {
totalLength += vec2.distance(corners[i - 1], corners[i]);
}
}
sink = totalLength;
};
}).gc('inner');
});

if (sink === Infinity) throw new Error('unreachable');
Loading