Files
feuerwerk/tools/perf-field-shaders.mjs
T
adminandClaude Opus 5.5 d6d38a6422
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m6s
Lay a shader over a particle source's whole field (#170)
A shader on a source runs per particle and stops at each quad, so a
glow or blur across the whole cloud was not possible. A source now has
a second shader row that is laid over everything it draws at once.

- New rows Field shader, Field width and Field height (256) on a
  particle source. The size rows show only while a field shader is
  picked (new `shownWhenSet` on a row definition).
- The particles are drawn into a RenderTexture of the frame, and a
  sprite mesh wearing the field shader draws that picture. The frame
  is centred on the source and turns and grows with it.
- UV run 0 to 1 across the frame. Texture color, Texture alpha and
  Sample texture read the picture of the particles.
- The picture has as many device pixels as the frame covers on
  screen, at most 2048 per side. A frame off screen draws no pass.
- The field shader's Padding and Vertex output do nothing: the frame
  is the edge, and its corners stay put as on a nine-sliced sprite.
- A name both shaders declare is one row, and both uniforms read it.
- The scene view draws the frame dashed round a selected source. The
  Particles tab preview wears the field shader too.
- `tools/perf-field-shaders.mjs` measures 1, 10 and 50 sources. On
  the HD 630 they ran at 60.0, 60.0 and 57.6 fps against 60.1, 60.0
  and 59.2 without, one run per row.

Tests:
- Gate: 223 files, 3460 tests (3452 + 8), CHECK EXIT 0.
- e2e 31: 43 checks, 3 of them new. Sum 1707 is computed (1704 + 3).

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

144 lines
6.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 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
*
* 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 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: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] } });
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, 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));
});