Files
feuerwerk/tools/perf-shaders.mjs
T
adminandClaude Opus 5.5 f03ca0d382
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Successful in 14m43s
Draw a shader in passes with a Pass node (#301)
A Blur stops at radius 8 in one pass, and bloom needs a softened copy
of the picture beside the original. A Pass node cuts the graph: what
feeds it is drawn into a picture of its own first, and the nodes that
read a picture read that one when it feeds them.

- Pass has color and alpha like the Output, a Resolution setting
  (Full, Half, Quarter) and a picture output. Texture color, Texture
  alpha, Sample texture, Blur and Texture size get a picture input.
  Unconnected it is the wearer's own picture, so the original stays
  readable beside any pass.
- picture is a port type of its own. It plugs only into a picture
  input, not even into an any port.
- The walk shared by the compilers can stop at a node (cut). The
  shader compiler draws each Pass it meets as a fragment of its own,
  earlier passes first. A ring through a picture says Feeds itself,
  and more than 8 passes say so at the node.
- A pass draws untinted. The tint comes on when the last part reads a
  picture, so a particle source draws its passes once for all of its
  particles (user decision).
- A pass's picture is the wearer's picture and its Padding, at the
  pixels of the picture times Resolution, at most 2048 per side. It is
  read with the picture's own filter and continues past its frame as
  the Output's Edge says.
- PassPictures keeps one render texture per pass and wearer. It draws
  again only when picture, size, Padding or a uniform changed. A
  field's picture is a render texture that changes under the same id,
  so a field draws its passes every time.
- Sprite, sliced sprite, text, particle source, field shader and the
  Shader tab's preview all draw passes. syncShader takes a fixed
  picture for a 9-slice, which holds its corners but keeps the margin.
- texelAt and blur take the sampler they read. Existing shaders
  compile to the same program apart from that argument.
- A field shader's Blur and Texture size count a pixel as a unit of
  the frame (uPixelScale), not of the screen. Before, a field's blur
  grew when zooming out and shrank when zooming in. This came with
  #170 and #300 and is fixed here because passes inherit it.
- tools/perf-shaders.mjs and tools/perf-field-shaders.mjs measure two
  passes with GM_PERF_PASSES=1 and GM_PERF_PASS_RESOLUTION. Below Full
  an open Pass shrinks the picture first, as the wiki's recipe does.

Found on the way:
- Destroying the pictures before the shader made Pixi warn that a
  texture was still bound. The shader goes first now, and a picture of
  a new size is resized rather than replaced.
- Two passes in a row could draw plain white the first time. Pixi
  builds the code that binds a program's resources once and finds them
  by position. A pass of one graph is often the same program as a pass
  of another with fewer slots, so it bound a placeholder. Every shader
  now declares all 8 pass slots and its uniform group.

Tests:
- Gate on melektaus-atx: 225 files, 3480 tests (3467 + 13), exit 0.
- e2e 28 and 31 on melektaus-atx: 97 checks (94 + 3) and 44 checks
  (43 + 1), all passed, no Pixi warning in the console.
- 28l-vii: a Blur across, a Pass and a Blur down draw the 5x5 spot of
  a Blur of Both (difference 0.000). On a grey sprite a pass's picture
  added to the original is 231/60/60, each tinted once. Two Passes in
  a row after a graph with one show red and green, not white.
- 31v-iii: a Blur of 8 fades a field's frame edge over 8.0 units at
  50 % and at 200 % zoom.
- Counter-checks: a tinted pass fails 1 unit test. Pictures never
  bound fail both e2e checks (spot 0.20 instead of 0.04, 243/158/158).
  Pass slots by the number of passes draw #ffffff, and a field without
  its pixel scale ramps over 16 and 4 units: exactly the two new
  checks fail.
- e2e sum 1716 is counted, not yet measured (1712 + 4).
- Measured on stein2 (HD 630, one run per row): 50 field shaders at
  radius 8 go from 11.4 to 41.3 fps in two passes, 44.4 at radius 16
  (three passes at Half). 800 sprites at radius 8 go from 13.9 to
  24.5 fps. At radius 4 one pass stays faster from 400 sprites on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:51:18 +02:00

190 lines
8.8 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
* GM_PERF_PASSES=1 makes that Blur two (#301): across, a Pass, then down. Its radius is tied to
* Time (times 0), so the Pass is drawn again every frame – the case that costs,
* where a still one is drawn once. GM_PERF_PASS_RESOLUTION=half|quarter puts
* an open Pass first, so the radius reaches twice or four times as far
*
* 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';
import { twoPassBlurGraph } from './lib/shaders.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 PASSES = process.env.GM_PERF_PASSES === '1';
const PASS_RESOLUTION = process.env.GM_PERF_PASS_RESOLUTION ?? 'full';
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: ${PASSES} ? ${JSON.stringify(twoPassBlurGraph(BLUR, PASS_RESOLUTION, true))} : { 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}${PASSES ? `, in two passes at ${PASS_RESOLUTION}` : ''}` : '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));
});