Skip to content
Draft
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
38 changes: 38 additions & 0 deletions .github/workflows/benchmark-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Benchmark PR
on:
pull_request:
branches: [dev]
types: [opened, synchronize, reopened]

concurrency:
group: ${{github.workflow}} - ${{github.ref}}
cancel-in-progress: true

jobs:
benchmark:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Project
uses: ./.github/actions/setup
with:
node-version: 24

- name: Global Playwright Install
run: npm install playwright -g

- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps

- name: Build
run: npm run build:rollup

- name: Run PR Benchmark and Compare
uses: pixijs/performance-benchmark-action@main
with:
benchmark-path: './scripts/benchmarks'
perf-change: 5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 4 additions & 0 deletions .knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"*.{mjs,js,json}",
"types/**/*.d.ts"
],
"ignoreBinaries": [
// Used in GitHub workflows only
"playwright"
],
"ignoreDependencies": [
// Not detected in jest setup
"http-server",
Expand Down
105 changes: 105 additions & 0 deletions scripts/benchmarks/Engine.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as PIXI from 'pixi.js';

class Engine
{
constructor(name, count)
{
this.count = count || 0;
this.name = name || 'Unnamed Benchmark';
}

async init()
{
this.width = 800;
this.height = 600;
this.maxFrames = 300;
this.app = new PIXI.Application();
await this.app.init({
width: this.width,
height: this.height,
backgroundColor: 0x1a1a1a,
antialias: false,
});

document.body.appendChild(this.app.canvas);
this.resetMetrics();
}

async render()
{
// abstract method to be implemented by subclasses
}

tick()
{
const currentTime = performance.now();
const deltaTime = currentTime - this.lastFrameTime;

// Update frame timing
this.frameCount++;
this.totalFrameTime += deltaTime;

// Calculate instantaneous FPS
const instantFps = deltaTime > 0 ? 1000 / deltaTime : 0;

// Track frame times for smoothed FPS calculation
this.frameTimes.push(deltaTime);
if (this.frameTimes.length > this.maxFrameTimeHistory)
{
this.frameTimes.shift();
}

// Calculate smoothed FPS (average over recent frames)
const avgFrameTime = this.frameTimes.reduce((sum, time) => sum + time, 0) / this.frameTimes.length;

this.fps = avgFrameTime > 0 ? 1000 / avgFrameTime : 0;

// Track min/max FPS
if (instantFps > 0)
{
this.minFps = Math.min(this.minFps, instantFps);
this.maxFps = Math.max(this.maxFps, instantFps);
}

// Update for next frame
this.lastFrameTime = currentTime;
}

/**
* Get current performance metrics
* @returns {object} Performance metrics object
*/
getPerformanceMetrics()
{
const currentTime = performance.now();
const totalBenchmarkTime = currentTime - this.benchmarkStartTime;
const avgFps = this.frameCount > 0 ? (this.frameCount * 1000) / totalBenchmarkTime : 0;
const avgFrameTime = this.frameCount > 0 ? this.totalFrameTime / this.frameCount : 0;

return {
fps: Math.round(this.fps * 100) / 100,
avgFps: Math.round(avgFps * 100) / 100,
minFps: this.minFps === Infinity ? 0 : Math.round(this.minFps * 100) / 100,
maxFps: Math.round(this.maxFps * 100) / 100,
frameCount: this.frameCount,
avgFrameTime: Math.round(avgFrameTime * 100) / 100,
totalTime: Math.round(totalBenchmarkTime * 100) / 100,
name: this.name || 'Unnamed Benchmark',
};
}

/** Reset performance metrics */
resetMetrics()
{
this.lastFrameTime = performance.now();
this.frameCount = 0;
this.fps = 0;
this.frameTimes = [];
this.minFps = Infinity;
this.maxFps = 0;
this.totalFrameTime = 0;
this.benchmarkStartTime = performance.now();
}
}

export default Engine;
87 changes: 87 additions & 0 deletions scripts/benchmarks/graphics/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as PIXI from 'pixi.js';
import Engine from '../Engine.mjs';

export class Test extends Engine
{
async init()
{
await super.init();

// load bunny texture
await PIXI.Assets.load({
alias: 'bunny',
src: 'https://pixijs.io/examples/examples/assets/bunny.png',
});

this.texture = PIXI.Assets.get('bunny');
const particles = new Array(this.count);
const rnd = [1, -1];

for (let i = 0; i < this.count; i++)
{
const size = 10 + (Math.random() * 80);
const x = Math.random() * this.width;
const y = Math.random() * (this.height - size);
const [dx, dy] = [
3 * Math.random() * rnd[Math.floor(Math.random() * 2)],
3 * Math.random() * rnd[Math.floor(Math.random() * 2)],
];

const particle = new PIXI.Graphics();

particle.circle(0, 0, size).fill(0xffffff).stroke(0x000000);
particle.position.set(x, y);
this.app.stage.addChild(particle);
particles[i] = { x, y, size, dx, dy, el: particle };
}
this.particles = particles;
}

async render()
{
return new Promise((resolve) =>
{
this.app.ticker.add(() =>
{
// Particle animation
const particles = this.particles;

for (let i = 0; i < this.count; i++)
{
const r = particles[i];

r.el.clear();
r.el.circle(0, 0, r.size).fill(0xffffff).stroke(0x000000);

r.x -= r.dx;
r.y -= r.dy;
if (r.x + r.size < 0) r.dx *= -1;
else if (r.y + r.size < 0) r.dy *= -1;
if (r.x > this.width) r.dx *= -1;
else if (r.y > this.height) r.dy *= -1;
r.el.position.x = r.x;
r.el.position.y = r.y;
}

this.tick();

if (this.frameCount >= this.maxFrames)
{
this.app.destroy(true, true);
resolve();
}
});
});
}
}

(async () =>
{
const spriteBenchmark = new Test('Graphics (100)', 100);

await spriteBenchmark.init();
spriteBenchmark.resetMetrics();
await spriteBenchmark.render();

window.benchmarkResult = spriteBenchmark.getPerformanceMetrics();
})();
94 changes: 94 additions & 0 deletions scripts/benchmarks/sprite-slow/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as PIXI from 'pixi.js';
import Engine from '../Engine.mjs';

export class Test extends Engine
{
async init()
{
await super.init();

// load bunny texture
await PIXI.Assets.load({
alias: 'bunny',
src: 'https://pixijs.io/examples/examples/assets/bunny.png'
});

this.texture = PIXI.Assets.get('bunny');
const particles = new Array(this.count);
const rnd = [1, -1];

for (let i = 0; i < this.count; i++)
{
const size = 10 + (Math.random() * 80);
const x = Math.random() * this.width;
const y = Math.random() * (this.height - size);
const [dx, dy] = [
3 * Math.random() * rnd[Math.floor(Math.random() * 2)],
3 * Math.random() * rnd[Math.floor(Math.random() * 2)]
];

const particle = new PIXI.Sprite(this.texture);

particle.position.set(x, y);
this.app.stage.addChild(particle);
particles[i] = { x, y, size, dx, dy, el: particle };
}
this.particles = particles;
}

async render()
{
return new Promise((resolve) =>
{
this.app.ticker.add(() =>
{
// Particle animation
const particles = this.particles;

for (let i = 0; i < this.count; i++)
{
const r = particles[i];

r.x -= r.dx;
r.y -= r.dy;
if (r.x + r.size < 0) r.dx *= -1;
else if (r.y + r.size < 0) r.dy *= -1;
if (r.x > this.width) r.dx *= -1;
else if (r.y > this.height) r.dy *= -1;
r.el.position.x = r.x;
r.el.position.y = r.y;
}

if (this.slowToggle)
{
this.app.stage.removeChild(this.particles[0].el);
this.slowToggle = false;
}
else
{
this.app.stage.addChild(this.particles[0].el);
this.slowToggle = true;
}

this.tick();

if (this.frameCount >= this.maxFrames)
{
this.app.destroy(true, true);
resolve();
}
});
});
}
}

(async () =>
{
const spriteBenchmark = new Test('Sprites Slow Path (25k)', 25_000);

await spriteBenchmark.init();
spriteBenchmark.resetMetrics();
await spriteBenchmark.render();

window.benchmarkResult = spriteBenchmark.getPerformanceMetrics();
})();
Loading
Loading