#!/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 * GM_PERF_PASSES=1 makes that Blur two (#301): across, a Pass, then down; a field's picture is * new every frame, so is the Pass. 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. * 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'; import { twoPassBlurGraph } from './lib/shaders.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 PASSES = process.env.GM_PERF_PASSES === '1'; const PASS_RESOLUTION = process.env.GM_PERF_PASS_RESOLUTION ?? 'full'; const FIELD_GRAPH = BLUR > 0 && PASSES ? twoPassBlurGraph(BLUR, PASS_RESOLUTION) : 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}${PASSES ? `, in two passes at ${PASS_RESOLUTION}` : ''}` : '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)); });