A shader could only say what colour a pixel has. Where the picture is drawn was fixed to the four corners of its rectangle, so nothing could wave, bend or wobble. - The new node Vertex output takes x and y, in pixels of the picture, and moves every corner of the wearer's grid by them. The UVs stay with the corners, so the picture bends along. - Subdivisions on that node says how fine the grid is: cells per axis, 1 to 64, 8 by default. 1 is the plain quad. A shader without the node keeps the quad every sprite shares. - A graph holds one Vertex output at most. UV, Time, Property, Number and the math nodes may feed it, and one node may feed both outputs. - Texture color, Texture alpha, Sample texture, Texture size and Color cannot feed it. The node says "Not available per corner" and the shader does not compile. - Sprites, texts and the preview in the shader tab wear the grid. A nine-slice sprite and particles draw as if the node were not there. - Picking, the selection frame, the size and colliders stay with the rectangle. A wearer with a Vertex output is never culled, as with Padding, and with Padding the grid covers the room around the picture too. - tools/perf-shaders.mjs takes GM_PERF_SUBDIVISIONS to measure a grid against the plain quad. Measured on stein2 (Intel HD 630), frames per second, one run per row: at 800 sprites 58 with a shader on the quad, 46 with 8 cells, 41 with 32. Up to 400 sprites a grid hardly shows. Every sprite holds its own grid. Tests: - Gate on melektaus-atx: 220 files, 3416 tests, CHECK EXIT 0. - 15 new unit tests for the compiler and the grid. - Four new e2e checks in modules 28 and 30: a fixed offset moves the picture, 8 cells bend only the lower half where 1 cell shears it all, Texture alpha is refused at the node, a text moves. - e2e 28,30 on melektaus-atx: 110 checks, all pass. - The e2e total of 1699 is counted (1695 + 4), the full suite did not run. - No counter-probe, no screenshot. Not looked at: the preview, Time in the browser, flipped sprites, Padding together with the grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
7.3 KiB
JavaScript
172 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* What a shader costs: the same scene of N sprites drawn with and without `Adjust colors`, measured
|
||
* as frame time in the running game. Up to #67 every sprite wearing a shader got its own Pixi
|
||
* filter, so its own render pass into an intermediate texture and back; since #77 the fragment
|
||
* runs in the sprite's own draw, which leaves the batch – one draw call per shaded sprite, no pass.
|
||
*
|
||
* The values are neutral (1), so both runs put the very same pixels on screen – the only
|
||
* difference between them is how the sprite is drawn.
|
||
*
|
||
* Usage: node tools/seed-e2e.mjs && node tools/perf-shaders.mjs
|
||
* GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite
|
||
* GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds
|
||
* it, so the pixels stay the same and only the corners are more
|
||
*
|
||
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
|
||
*
|
||
* Two things the numbers hang on. The rate is frames divided by sampling time, never the median
|
||
* frame time: the distribution is bimodal (most frames arrive on a vsync tick, some miss several),
|
||
* so at 800 sprites the median still reads 17 ms while only a third of the frames are there.
|
||
* And the 60 Hz cap cannot be lifted here – measured, `--disable-gpu-vsync` and
|
||
* `--disable-frame-rate-limit` change nothing in headless chromium (57.6 fps with and without).
|
||
* So this measures where the frame rate breaks, not what a pass costs inside an unfilled frame.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { runChecks } from './lib/harness.mjs';
|
||
import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
||
|
||
const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture);
|
||
|
||
const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number);
|
||
const SIZE = Number(process.env.GM_PERF_SIZE ?? 48);
|
||
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
|
||
const WARMUP_MS = 1500;
|
||
const SAMPLE_MS = 4000;
|
||
|
||
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
|
||
const percentile = (xs, p) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) * p)];
|
||
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: 9347, shotPrefix: 'gm-perf' }, async (t) => {
|
||
const { check, sleep, json, evaluate, mutate, play, stop, shot } = t;
|
||
|
||
await t.navigate();
|
||
|
||
// One sprite asset for all of them: flipping its `shader` prop switches every instance at once.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== 'perf-sprite');
|
||
p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite',
|
||
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
|
||
`);
|
||
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'adjust-colors').graph;
|
||
graph.nodes = graph.nodes.filter((n) => n.id !== 'perf-vout');
|
||
if (${SUBDIVISIONS} > 0)
|
||
graph.nodes.push({ id: 'perf-vout', type: 'ShaderVertexOutput', x: 0, y: 600,
|
||
params: { subdivisions: ${SUBDIVISIONS} } });
|
||
`);
|
||
|
||
/** Camera-sized grid of `n` instances, all inside the view so none of them is skipped. */
|
||
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-sprite', 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 setShader = (on) =>
|
||
mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? "'adjust-colors'" : "''"}`,
|
||
);
|
||
|
||
/** 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 reported = await json('__gm.fps()');
|
||
const shaders = await json('__gm.shaderCount()');
|
||
const filters = await json('__gm.filterCount()');
|
||
const elapsed = frames.reduce((a, b) => a + b, 0);
|
||
return {
|
||
frames: frames.length,
|
||
mean: round(elapsed / frames.length),
|
||
med: round(median(frames)),
|
||
p95: round(percentile(frames, 0.95)),
|
||
fps: round((frames.length * 1000) / elapsed, 1),
|
||
reported,
|
||
shaders,
|
||
filters,
|
||
};
|
||
};
|
||
|
||
const rows = [];
|
||
for (const n of COUNTS) {
|
||
await buildScene(n);
|
||
const run = {};
|
||
for (const on of [false, true]) {
|
||
await setShader(on);
|
||
const before = load();
|
||
await play();
|
||
run[on ? 'on' : 'off'] = { ...(await sample()), load: before };
|
||
await stop();
|
||
}
|
||
check(
|
||
`${n} sprites: a shader per sprite and no filter with the shader on, neither without`,
|
||
run.on.shaders === n && run.on.filters === 0 && run.off.shaders === 0 && run.off.filters === 0,
|
||
{ on: run.on, off: run.off },
|
||
);
|
||
rows.push({ n, ...run });
|
||
console.log(
|
||
` n=${String(n).padStart(4)} off ${String(run.off.mean).padStart(6)} ms (${run.off.fps} fps)` +
|
||
` on ${String(run.on.mean).padStart(6)} ms (${run.on.fps} fps)` +
|
||
` +${round(((run.on.mean - run.off.mean) * 1000) / n, 1)} us/sprite load ${run.off.load} / ${run.on.load}`,
|
||
);
|
||
}
|
||
|
||
// The comparison is only worth reading while the scene without shaders still holds the cap:
|
||
// once it misses ticks by itself, both columns measure something else.
|
||
check(
|
||
'the baseline holds the frame rate at every count',
|
||
rows.every((r) => r.off.fps > 55),
|
||
rows.map((r) => `${r.n}:${r.off.fps}`).join(' '),
|
||
);
|
||
|
||
// The screenshot is of the last, heaviest scene with the shader on – proof it really drew.
|
||
await setShader(true);
|
||
await play();
|
||
await sleep(500);
|
||
await shot('heaviest');
|
||
await stop();
|
||
|
||
console.log(
|
||
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
|
||
`${SAMPLE_MS} ms per point, neutral values, ` +
|
||
`60 Hz cap (cannot be lifted in headless)`,
|
||
);
|
||
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
|
||
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 |` +
|
||
` ${round(((r.on.mean - r.off.mean) * 1000) / r.n, 1)} us | ${r.on.shaders} | ${r.on.filters} |`,
|
||
);
|
||
console.log(JSON.stringify(rows));
|
||
});
|