Lay a shader over a particle source's whole field (#170)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m6s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 12:24:51 +02:00
1 parent bf27e42e8a
commit d6d38a6422
14 files changed
+656 -28

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
#!/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));
});