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>
161 lines
6.7 KiB
JavaScript
161 lines
6.7 KiB
JavaScript
#!/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));
|
||
});
|