#!/usr/bin/env node /** * What a camera shader costs (#299): the same scene drawn in the running game without one, and with * one in either resolution. The camera's picture is drawn into a picture of its frame first and the * shader laid over that – one extra pass for the whole view, whose price is its pixels: the screen's * at *Screen pixels*, View width × View height at *Camera pixels*. * * The shader is a graph with nothing but its Output, which hands the picture through, so every run * puts the same scene on screen. `GM_PERF_BLUR` puts a Blur of that radius in between instead. * * Usage: node tools/seed-e2e.mjs && node tools/perf-camera-shader.mjs * GM_PERF_COUNTS=... sprite counts (default 100,400) GM_PERF_BLUR=... Blur radius (default none) * * In a 1920x1080 window, the Game tab at Fit – the large tab the issue asks for. 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-camera-shader.mjs', PROJECTS.fixture); const COUNTS = (process.env.GM_PERF_COUNTS ?? '100,400').split(',').map(Number); const BLUR = Number(process.env.GM_PERF_BLUR ?? 0); const GRAPH = BLUR > 0 ? { nodes: [ { id: 'pc-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } }, { id: 'pc-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, ], edges: [ { id: 'pc-c', from: { node: 'pc-blur', port: 'color' }, to: { node: 'pc-out', port: 'color' } }, { id: 'pc-a', from: { node: 'pc-blur', port: 'alpha' }, to: { node: 'pc-out', port: 'alpha' } }, ], } : { nodes: [{ id: 'pc-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] }; const MODES = ['none', 'screen', 'camera']; 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: 9349, shotPrefix: 'gm-perf-camera', width: 1920, height: 1080 }, async (t) => { const { check, sleep, json, evaluate, mutate, play, stop, shot, showTab } = t; await t.navigate(); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'perf-camera-fx'); p.assets.push({ id: 'perf-camera-fx', type: 'shader', name: 'Perf camera', props: {}, graph: ${JSON.stringify(GRAPH)} }); __gm.state.setGameScale('fit'); `); await showTab('game'); /** `n` plain sprites of 48x48 spread over the camera's view, the way `perf-shaders.mjs` lays them. */ const buildScene = async (n) => { await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const cam = scene.objects.find((o) => o.assetId === 'cam'); const sprite = p.assets.find((a) => a.type === 'sprite'); 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: sprite.id, 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 }, parentId: '', instanceEdits: {}, overrides: { width: 48, height: 48, texture: '', shader: '', color: 0x4080ff } }); } scene.objects = objects; `); }; /** The camera wears the shader in `mode`, or none. */ const wear = (mode) => mutate(` const cam = __gm.state.activeScene.objects.find((o) => o.assetId === 'cam'); cam.overrides = { ...cam.overrides, shader: ${mode === 'none' ? "''" : "'perf-camera-fx'"}, shaderResolution: '${mode === 'camera' ? 'camera' : 'screen'}' }; `); /** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */ const sample = async () => { 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 elapsed = frames.reduce((a, b) => a + b, 0); return { frames: frames.length, mean: round(elapsed / frames.length), fps: round((frames.length * 1000) / elapsed, 1), picture: await json('__gm.cameraShaderPixels()'), }; }; const scale = await json('__gm.gameScale()'); const rows = []; for (const n of COUNTS) { await buildScene(n); const run = {}; for (const mode of MODES) { await wear(mode); const before = load(); await play(); run[mode] = { ...(await sample()), load: before }; await stop(); } check( `${n} sprites: a camera picture with the shader on, in the size of its resolution, none without`, run.none.picture === null && run.camera.picture?.w === 640 && run.screen.picture !== null && run.screen.picture.w > 640, run, ); rows.push({ n, ...run }); console.log( ` n=${String(n).padStart(4)}` + MODES.map( (m) => ` ${m} ${run[m].fps} fps ${JSON.stringify(run[m].picture)} (load ${run[m].load})`, ).join(''), ); } await wear('screen'); await play(); await sleep(500); await shot('heaviest'); await stop(); console.log( `\nGame tab ${Math.round(scale.view.w)}x${Math.round(scale.view.h)} at Fit (factor ${round(scale.factor, 3)}),` + ` ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} camera shader, ${SAMPLE_MS} ms per point`, ); console.log('| sprites | no camera shader | Screen pixels | Camera pixels |'); console.log('| --- | --- | --- | --- |'); for (const r of rows) console.log( `| ${r.n} | ${r.none.fps} | ${r.screen.fps} (${r.screen.picture.w}x${r.screen.picture.h}) |` + ` ${r.camera.fps} (${r.camera.picture.w}x${r.camera.picture.h}) |`, ); console.log(JSON.stringify(rows)); }, );