Lay a shader over a particle source's whole field (#170)
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:
1 parent
bf27e42e8a
commit
d6d38a6422
14 files changed
+656
-28
No files matched your search
@@ -655,6 +655,80 @@ export async function particles(t) {
|
||||
__gm.state.project.assets = __gm.state.project.assets.filter(
|
||||
(a) => a.id !== ${JSON.stringify(SHADER)});`);
|
||||
|
||||
// 31v. (#170) A field shader draws the particles into a picture of a 256-unit frame round the
|
||||
// source and lays the shader over that. A graph with nothing in it hands the picture through:
|
||||
// at 0.3 s the burst has fallen ½·800·0.3² = 36 units, so it has to stand below the source and
|
||||
// not above – a picture upside down would show it mirrored about the source.
|
||||
const FIELD = 'pt-field';
|
||||
const setField = (graph) =>
|
||||
mutate(`
|
||||
const project = __gm.state.project;
|
||||
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(FIELD)});
|
||||
if (${JSON.stringify(graph)}) project.assets.push({
|
||||
id: ${JSON.stringify(FIELD)}, type: 'shader', name: 'Field', props: {}, graph: ${JSON.stringify(graph)} });
|
||||
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
|
||||
if (${JSON.stringify(graph)}) o.overrides.fieldShader = ${JSON.stringify(FIELD)};
|
||||
else delete o.overrides.fieldShader;`);
|
||||
const fallen = async () => {
|
||||
await at(0.3);
|
||||
const pick = (dy) => json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y + dy * unit)})`);
|
||||
return {
|
||||
below: await pick(36),
|
||||
above: await pick(-36),
|
||||
pixels: await json(`__gm.fieldPixels(${JSON.stringify(SOURCE)})`),
|
||||
};
|
||||
};
|
||||
await rewind();
|
||||
const unshaded = await fallen();
|
||||
await setField({ nodes: [{ id: 'fd-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] });
|
||||
const handedThrough = await fallen();
|
||||
const resolution = (await json('__gm.pixelGrid()')).resolution;
|
||||
check(
|
||||
'a field shader that changes nothing draws the field where it was, the right way up',
|
||||
unshaded.pixels === null &&
|
||||
colourDistance(unshaded.below, '#3cc85a') < 12 &&
|
||||
colourDistance(handedThrough.below, unshaded.below) < 12 &&
|
||||
colourDistance(handedThrough.above, unshaded.above) < 12 &&
|
||||
Math.abs((handedThrough.pixels?.w ?? 0) - 256 * unit * resolution) <= 2,
|
||||
JSON.stringify({ unshaded, handedThrough, unit, resolution }),
|
||||
);
|
||||
|
||||
// 31v-ii. (#170) The shader covers the frame, not the particles: a flat red fills it 100 units off
|
||||
// the source, where no particle ever is, and stops at its edge 128 units out.
|
||||
await setField({
|
||||
nodes: [
|
||||
{ id: 'fd-red', type: 'ShaderColor', x: 0, y: 0, params: { value: 0xe63c3c } },
|
||||
{ id: 'fd-one', type: 'ShaderNumber', x: 0, y: 80, params: { value: 1 } },
|
||||
{ id: 'fd-out', type: 'ShaderOutput', x: 200, y: 0, params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ from: { node: 'fd-red', port: 'color' }, to: { node: 'fd-out', port: 'color' } },
|
||||
{ from: { node: 'fd-one', port: 'value' }, to: { node: 'fd-out', port: 'alpha' } },
|
||||
],
|
||||
});
|
||||
await at(0.3);
|
||||
const offAt = (dx, dy) =>
|
||||
json(`__gm.pixelAt(${Math.round(spotOn.x + dx * unit)}, ${Math.round(spotOn.y + dy * unit)})`);
|
||||
const inFrame = await offAt(100, -100);
|
||||
const pastFrame = await offAt(140, 0);
|
||||
await shot('31-particles-field');
|
||||
check(
|
||||
'the field shader fills its whole frame, past every particle, and nothing beyond it',
|
||||
colourDistance(inFrame, '#e63c3c') < 12 && colourDistance(pastFrame, '#e63c3c') > 60,
|
||||
JSON.stringify({ inFrame, pastFrame }),
|
||||
);
|
||||
|
||||
// 31v-iii. Taken off again, the source draws its particles itself and no picture is left.
|
||||
await setField(null);
|
||||
const back = await fallen();
|
||||
check(
|
||||
'without a field shader the particles are drawn as before',
|
||||
back.pixels === null &&
|
||||
colourDistance(back.below, unshaded.below) < 12 &&
|
||||
colourDistance(back.above, unshaded.above) < 12,
|
||||
JSON.stringify({ back, unshaded }),
|
||||
);
|
||||
|
||||
// 31p. The graph tab draws no Pixi complaint of its own.
|
||||
const logged = await json('__gm.console.lines()');
|
||||
check(
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user