Files
feuerwerk/tools/perf-field-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

161 lines
6.7 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 field shader costs (#170): the same scene of N particle sources drawn with and without one,
* measured as frame time in the running game. A field shader draws the source's particles into a
* picture of its frame and lays the shader over that – one extra pass per source, whose price is
* the frame's area on screen, not the number of particles in it.
*
* The field shader is a graph with nothing but its Output, which hands the picture through: both
* runs put the very same pixels on screen, so what differs is the pass alone. A graph that samples
* the picture many times (a blur) adds its own cost per pixel on top.
*
* Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs
* GM_PERF_COUNTS=... source counts (default 1,10,50), GM_PERF_FRAME=... frame edge in units
* GM_PERF_BLUR=... puts a Blur (#300) with that radius between picture and Output
*
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
* Read the rate, not the median – see `perf-shaders.mjs` for why, and for the 60 Hz cap.
*/
import { readFileSync } from 'node:fs';
import { runChecks } from './lib/harness.mjs';
import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixture);
const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number);
const FRAME = Number(process.env.GM_PERF_FRAME ?? 256);
const BLUR = Number(process.env.GM_PERF_BLUR ?? 0);
const FIELD_GRAPH =
BLUR > 0
? {
nodes: [
{ id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } },
{ id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
],
edges: [
{ id: 'pf-c', from: { node: 'pf-blur', port: 'color' }, to: { node: 'pf-out', port: 'color' } },
{ id: 'pf-a', from: { node: 'pf-blur', port: 'alpha' }, to: { node: 'pf-out', port: 'alpha' } },
],
}
: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] };
const WARMUP_MS = 1500;
const SAMPLE_MS = 4000;
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: 9348, shotPrefix: 'gm-perf-field' }, async (t) => {
const { check, sleep, json, evaluate, mutate, play, stop, shot } = t;
await t.navigate();
// One source asset and one shader for all of them: flipping the asset's `fieldShader` switches
// every instance at once. 50 particles a second, a second each, 24 units – a modest spark.
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'perf-source' && a.id !== 'perf-field');
p.assets.push({ id: 'perf-field', type: 'shader', name: 'Perf field', props: {},
graph: ${JSON.stringify(FIELD_GRAPH)} });
p.assets.push({ id: 'perf-source', type: 'particles', name: 'Perf source',
props: { rate: 50, lifetime: 1, size: 24, color: 0x4080ff, fieldShader: '',
fieldWidth: ${FRAME}, fieldHeight: ${FRAME} },
graph: { nodes: [{ id: 'po-out', type: 'ParticleOutput', x: 0, y: 0, params: {} }], edges: [] } });
`);
/** `n` sources spread over the camera's view, so every frame is at least partly on screen. */
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-source', 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 setField = (on) =>
mutate(
`__gm.state.project.assets.find((a) => a.id === 'perf-source').props.fieldShader = ${on ? "'perf-field'" : "''"}`,
);
/** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */
const sample = async (n) => {
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 pictures = await json(
`Array.from({ length: ${n} }, (_, i) => __gm.fieldPixels('perf-' + i, 'game'))`,
);
const elapsed = frames.reduce((a, b) => a + b, 0);
return {
frames: frames.length,
mean: round(elapsed / frames.length),
fps: round((frames.length * 1000) / elapsed, 1),
pictures: pictures.filter(Boolean).length,
picture: pictures.find(Boolean) ?? null,
};
};
const rows = [];
for (const n of COUNTS) {
await buildScene(n);
const run = {};
for (const on of [false, true]) {
await setField(on);
const before = load();
await play();
run[on ? 'on' : 'off'] = { ...(await sample(n)), load: before };
await stop();
}
check(
`${n} sources: a field picture each with the field shader on, none without`,
run.on.pictures === n && run.off.pictures === 0,
{ on: run.on, off: run.off },
);
rows.push({ n, ...run });
console.log(
` n=${String(n).padStart(3)} off ${String(run.off.mean).padStart(6)} ms (${run.off.fps} fps)` +
` on ${String(run.on.mean).padStart(6)} ms (${run.on.fps} fps)` +
` picture ${JSON.stringify(run.on.picture)} load ${run.off.load} / ${run.on.load}`,
);
}
// The screenshot is of the last, heaviest scene with the field shader on – proof it really drew.
await setField(true);
await play();
await sleep(500);
await shot('heaviest');
await stop();
console.log(
`\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`,
);
console.log('| sources | no field shader | field shader | picture |');
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 |` +
` ${r.on.picture ? `${r.on.picture.w}x${r.on.picture.h}` : '-'} |`,
);
console.log(JSON.stringify(rows));
});