Files
feuerwerk/tools/perf-shaders.mjs
T
adminandClaude Opus 5.5 df61b08bd1
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Successful in 14m26s
Soften a shader's picture in one pass with a Blur node (#300)
A blur already worked with Sample texture, but only by wiring every
sample by hand: 25 nodes for a 5x5. Averaging their colours also gave
a dark seam wherever a neighbour was transparent. The Blur node writes
the loop itself and averages the right way.

- Blur takes radius (pixels of the picture, 0 to 8, fractions allowed)
  and u/v like Sample texture, and gives color and alpha.
- Kind is Box (default) or Gaussian, Direction is Both, Horizontal or
  Vertical. Both are arguments of one prelude helper, blur().
- The samples are averaged premultiplied and only then made straight,
  so a transparent neighbour lowers the opacity and adds no black.
- The outermost ring counts by the part of the radius that reaches
  it, so a radius from a Property changes smoothly and compiles
  nothing again.
- A node may now declare prepare, a vec4 the compiler works out once
  into a local. Color and alpha read that one sum instead of blurring
  twice.
- A reached Blur counts as a shifted sample, so the picture comes
  with the Output's Edge. It cannot feed the Vertex output.
- Both perf tools take GM_PERF_BLUR=<radius>.

Wider blurs and bloom need several passes. That is #301.

Measured on stein2 (HD 630, one run per value, fps), Box blur:
- 48x48 sprites at 100/400/800: radius 2 59.8/58.4/55.6,
  radius 4 53.1/51.4/36.3, radius 8 46.6/24.4/14.4.
- 256x256 field shaders at 1/10/50: radius 2 60.2/56.0/49.0,
  radius 4 60.1/49.0/27.1, radius 8 52.4/34.9/11.4.

Tests:
- Gate: 224 files, 3467 tests, CHECK EXIT 0 (+7).
- e2e 28: ALL PASSED, 94 checks (+5 in 28l-vi). Pixels match the
  arithmetic to 1: the last red texel at radius 0.5/1/4 reads
  187/173/154 (expected 188/173/154).
- Counter-checks: Blur not counted as shifted fails 1 unit test.
  Blur output left premultiplied fails the seam and spot checks.
- e2e total 1712 is computed (1707 + 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:11:31 +02:00

183 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* What a shader costs: the same scene of N sprites drawn with and without `Adjust colors`, measured
* as frame time in the running game. Up to #67 every sprite wearing a shader got its own Pixi
* filter, so its own render pass into an intermediate texture and back; since #77 the fragment
* runs in the sprite's own draw, which leaves the batch – one draw call per shaded sprite, no pass.
*
* The values are neutral (1), so both runs put the very same pixels on screen – the only
* difference between them is how the sprite is drawn.
*
* Usage: node tools/seed-e2e.mjs && node tools/perf-shaders.mjs
* GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite
* GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds
* it, so the pixels stay the same and only the corners are more
* GM_PERF_BLUR=... wears a shader of one Blur (#300) with that radius instead of Adjust colors
*
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
*
* Two things the numbers hang on. The rate is frames divided by sampling time, never the median
* frame time: the distribution is bimodal (most frames arrive on a vsync tick, some miss several),
* so at 800 sprites the median still reads 17 ms while only a third of the frames are there.
* And the 60 Hz cap cannot be lifted here – measured, `--disable-gpu-vsync` and
* `--disable-frame-rate-limit` change nothing in headless chromium (57.6 fps with and without).
* So this measures where the frame rate breaks, not what a pass costs inside an unfilled frame.
*/
import { readFileSync } from 'node:fs';
import { runChecks } from './lib/harness.mjs';
import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture);
const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number);
const SIZE = Number(process.env.GM_PERF_SIZE ?? 48);
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
const BLUR = Number(process.env.GM_PERF_BLUR ?? 0);
const SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors';
const WARMUP_MS = 1500;
const SAMPLE_MS = 4000;
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const percentile = (xs, p) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) * p)];
const round = (x, digits = 2) => Number(x.toFixed(digits));
const load = () => readFileSync('/proc/loadavg', 'utf8').split(' ').slice(0, 2).join(' ');
await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async (t) => {
const { check, sleep, json, evaluate, mutate, play, stop, shot } = t;
await t.navigate();
// One sprite asset for all of them: flipping its `shader` prop switches every instance at once.
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'perf-sprite' && a.id !== 'perf-blur');
p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite',
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
p.assets.push({ id: 'perf-blur', type: 'shader', name: 'Perf blur', props: {},
graph: { nodes: [
{ id: 'pb-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } },
{ id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
], edges: [
{ id: 'pb-c', from: { node: 'pb-blur', port: 'color' }, to: { node: 'pb-out', port: 'color' } },
{ id: 'pb-a', from: { node: 'pb-blur', port: 'alpha' }, to: { node: 'pb-out', port: 'alpha' } },
] } });
`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'adjust-colors').graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'perf-vout');
if (${SUBDIVISIONS} > 0)
graph.nodes.push({ id: 'perf-vout', type: 'ShaderVertexOutput', x: 0, y: 600,
params: { subdivisions: ${SUBDIVISIONS} } });
`);
/** Camera-sized grid of `n` instances, all inside the view so none of them is skipped. */
const buildScene = async (n) => {
await mutate(`
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.assetId === 'cam');
const objects = [cam];
const cols = Math.ceil(Math.sqrt(${n}));
for (let i = 0; i < ${n}; i++) {
const col = i % cols, row = Math.floor(i / cols);
objects.push({ id: 'perf-' + i, assetId: 'perf-sprite', name: '',
position: { x: cam.position.x - 300 + (col + 0.5) * (600 / cols),
y: cam.position.y - 160 + (row + 0.5) * (320 / cols), z: i },
editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });
}
scene.objects = objects;
`);
};
const setShader = (on) =>
mutate(
`__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? `'${SHADER}'` : "''"}`,
);
/** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */
const sample = async () => {
await sleep(WARMUP_MS);
await evaluate(`(() => {
window.__perfSamples = [];
window.__perfOn = true;
let last = performance.now();
const tick = () => {
const now = performance.now();
window.__perfSamples.push(now - last);
last = now;
if (window.__perfOn) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await sleep(SAMPLE_MS);
const frames = await json('(window.__perfOn = false, window.__perfSamples.slice(1))');
const reported = await json('__gm.fps()');
const shaders = await json('__gm.shaderCount()');
const filters = await json('__gm.filterCount()');
const elapsed = frames.reduce((a, b) => a + b, 0);
return {
frames: frames.length,
mean: round(elapsed / frames.length),
med: round(median(frames)),
p95: round(percentile(frames, 0.95)),
fps: round((frames.length * 1000) / elapsed, 1),
reported,
shaders,
filters,
};
};
const rows = [];
for (const n of COUNTS) {
await buildScene(n);
const run = {};
for (const on of [false, true]) {
await setShader(on);
const before = load();
await play();
run[on ? 'on' : 'off'] = { ...(await sample()), load: before };
await stop();
}
check(
`${n} sprites: a shader per sprite and no filter with the shader on, neither without`,
run.on.shaders === n && run.on.filters === 0 && run.off.shaders === 0 && run.off.filters === 0,
{ on: run.on, off: run.off },
);
rows.push({ n, ...run });
console.log(
` n=${String(n).padStart(4)} off ${String(run.off.mean).padStart(6)} ms (${run.off.fps} fps)` +
` on ${String(run.on.mean).padStart(6)} ms (${run.on.fps} fps)` +
` +${round(((run.on.mean - run.off.mean) * 1000) / n, 1)} us/sprite load ${run.off.load} / ${run.on.load}`,
);
}
// The comparison is only worth reading while the scene without shaders still holds the cap:
// once it misses ticks by itself, both columns measure something else.
check(
'the baseline holds the frame rate at every count',
rows.every((r) => r.off.fps > 55),
rows.map((r) => `${r.n}:${r.off.fps}`).join(' '),
);
// The screenshot is of the last, heaviest scene with the shader on – proof it really drew.
await setShader(true);
await play();
await sleep(500);
await shot('heaviest');
await stop();
console.log(
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
`${BLUR > 0 ? `Blur radius ${BLUR}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` +
`60 Hz cap (cannot be lifted in headless)`,
);
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
console.log('| --- | --- | --- | --- | --- | --- |');
for (const r of rows)
console.log(
`| ${r.n} | ${r.off.mean} ms / ${r.off.fps} fps | ${r.on.mean} ms / ${r.on.fps} fps |` +
` ${round(((r.on.mean - r.off.mean) * 1000) / r.n, 1)} us | ${r.on.shaders} | ${r.on.filters} |`,
);
console.log(JSON.stringify(rows));
});