game-maker / suite (push) Successful in 8m23s
The wiki carried an estimate ("fine for dozens, not for thousands"). Measured
on the Intel HD 630 that CI uses, 48x48 sprites in a 640x360 camera, neutral
values, three runs agreeing to 1 fps:
sprites 25 50 100 200 400 800
fps 58 56 54 48 34 20
The same scene without shaders holds 60 fps at every one of those counts, 800
included. Quadrupling the sprite area (96x96) measures the same, so the cost is
the render target switch, not the pixels: roughly 20 us per pass up to 200,
40 us at 800.
tools/perf-shaders.mjs drives it (not run in CI, frame times are machine
dependent). New hook __gm.filterCount() counts visible nodes carrying a filter
and proves one pass per sprite; e2e 28 checks it.
Two traps the tool guards against, both cost a wrong answer first: the median
frame time is useless here (the distribution is bimodal under vsync, so the
rate comes from frames/duration), and --disable-gpu-vsync has no effect in
headless, so what is measured is the point where the frame rate breaks down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
17 KiB
JavaScript
406 lines
17 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
|
||
/**
|
||
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through
|
||
* its Shader dropdown – whose Property nodes are the rows below it – and visible in the pixels, in
|
||
* the scene view and in the game. The fixture ships "Adjust colors"; the step removes what it made.
|
||
*/
|
||
|
||
/** (64, 128, 255): three distinct channels, so brightness, contrast and saturation each read differently. */
|
||
const BASE = '#4080ff';
|
||
const ADJUST = 'adjust-colors';
|
||
|
||
export async function shader(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
click,
|
||
clickText,
|
||
locate,
|
||
press,
|
||
select,
|
||
undo,
|
||
play,
|
||
stop,
|
||
shot,
|
||
} = t;
|
||
const { doubleClick, typeText } = t;
|
||
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
|
||
const info = () => json('__gm.shader.info()');
|
||
const shaders = () =>
|
||
json(`__gm.state.project.assets
|
||
.filter((a) => a.type === 'shader')
|
||
.map((a) => ({ id: a.id, name: a.name, nodes: a.graph.nodes.map((n) => ({ id: n.id, type: n.type, name: n.params.name })) }))`);
|
||
/** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
await sleep(200);
|
||
};
|
||
const assetProps = (patch) =>
|
||
mutate(
|
||
`Object.assign(__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props, ${JSON.stringify(patch)})`,
|
||
);
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
await sleep(200);
|
||
|
||
// 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it.
|
||
const seeded = (await shaders()).find((s) => s.id === ADJUST);
|
||
check(
|
||
'the fixture carries the Adjust colors shader as an asset of twelve nodes',
|
||
Boolean(seeded) && seeded.nodes.length === 12,
|
||
JSON.stringify(seeded),
|
||
);
|
||
await clickText('+');
|
||
await sleep(200);
|
||
await clickLast('Shader');
|
||
await sleep(400);
|
||
const made = (await shaders()).find((s) => s.id !== ADJUST);
|
||
const selection = await json('__gm.state.selection');
|
||
check(
|
||
'the + menu creates a shader asset, selected, and its tab opens on it',
|
||
Boolean(made) &&
|
||
selection?.kind === 'asset' &&
|
||
selection.assetId === made.id &&
|
||
(await json('__gm.tab()')) === 'shader' &&
|
||
(await json('__gm.shader.current()')) === made.id,
|
||
JSON.stringify({ made, selection, tab: await json('__gm.tab()') }),
|
||
);
|
||
if (!made) return;
|
||
check('the tab button has room for its label', (await json("__gm.shownLabel('Shader')")) === 'Shader');
|
||
// Named after its type, the asset's row in the Assets panel would say "Shader" like the inspector row.
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).name = 'Glow'`);
|
||
made.name = 'Glow';
|
||
check('an empty shader says what it lacks', (await info()) === 'No Output node', await info());
|
||
|
||
// 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette.
|
||
const canvas = await json('__gm.shader.canvasRect()');
|
||
await click(canvas.x + 80, canvas.y + 60, 'right');
|
||
await sleep(300);
|
||
const offered = await json('__gm.overlayTexts()');
|
||
check(
|
||
'right-click offers shader nodes and no behavior node',
|
||
['Texture color', 'Output', 'Property', 'Number', 'Mix', 'Luminance'].every((text) =>
|
||
offered.includes(text),
|
||
) && !offered.some((text) => /On start|Boolean|Branch|Set state/.test(text)),
|
||
JSON.stringify(offered),
|
||
);
|
||
// A dozen entries scroll; searched, the row is the only one and in view.
|
||
await typeText('Texture color');
|
||
await sleep(150);
|
||
await clickLast('Texture color');
|
||
await sleep(300);
|
||
await click(canvas.x + 420, canvas.y + 60, 'right');
|
||
await sleep(300);
|
||
await clickLast('Output');
|
||
await sleep(300);
|
||
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
|
||
check(
|
||
'both nodes are in the graph, the Output where its right-click was',
|
||
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
|
||
JSON.stringify(await madeNodes()),
|
||
);
|
||
await click(canvas.x + 420, canvas.y + 320, 'right');
|
||
await sleep(300);
|
||
const again = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the palette offers the Output only once',
|
||
!again.includes('Output') && again.includes('Property'),
|
||
JSON.stringify(again),
|
||
);
|
||
await press('Escape');
|
||
await sleep(200);
|
||
check(
|
||
'an Output with nothing connected compiles: the texel passes through, no property declared',
|
||
(await info()) === '0 properties',
|
||
await info(),
|
||
);
|
||
|
||
// 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node.
|
||
await click(canvas.x + 80, canvas.y + 320, 'right');
|
||
await sleep(300);
|
||
await clickLast('Property');
|
||
await sleep(300);
|
||
const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty');
|
||
check(
|
||
'the placed property is named "value" and counted',
|
||
propertyNode?.name === 'value' && (await info()) === '1 property',
|
||
JSON.stringify({ propertyNode, info: await info() }),
|
||
);
|
||
const nameField = await json("__gm.inspectorField('Name', true)");
|
||
check('the inspector shows a Name field for the property', Boolean(nameField), JSON.stringify(nameField));
|
||
await doubleClick(nameField.x, nameField.y);
|
||
await typeText('glow');
|
||
await press('Enter');
|
||
await sleep(300);
|
||
check(
|
||
'typing in the Name field renames the property, on the node too',
|
||
(await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' &&
|
||
(await json(`__gm.shader.nodeLabel(${JSON.stringify(propertyNode?.id)})`)) === 'glow',
|
||
JSON.stringify(await madeNodes()),
|
||
);
|
||
await shot('28-shader-tab');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'no good';`);
|
||
await sleep(300);
|
||
const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`);
|
||
check(
|
||
'a name that is not one word is a problem on the node and the reason in the readout',
|
||
typeof problem === 'string' && problem.startsWith('Name must be') && (await info()) === problem,
|
||
JSON.stringify({ problem, info: await info() }),
|
||
);
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`);
|
||
await sleep(300);
|
||
|
||
// 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = 'shade-sprite';
|
||
sprite.name = 'Shaded';
|
||
sprite.props = { ...sprite.props, width: 160, height: 120, color: 0x4080ff, texture: '', shader: '' };
|
||
project.assets.push(sprite);
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x, y: cam.y, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });
|
||
__gm.state.select(null)`);
|
||
await sleep(600);
|
||
// The pixels are the scene view's; the tab is still the shader's.
|
||
await evaluate("__gm.showTab('scene')");
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
|
||
await sleep(400);
|
||
const box = await json("__gm.objectRect('oshade')");
|
||
check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
|
||
if (!box) return;
|
||
// Off centre: the camera symbol sits on the very same spot, and symbols draw above objects.
|
||
const centre = () =>
|
||
json(`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`);
|
||
const base = await centre();
|
||
check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base);
|
||
// The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and
|
||
// Texture say it above). The label itself is no anchor: the tab button says "Shader" too.
|
||
const shaderField = await locate('None', true);
|
||
check(
|
||
'a sprite has a Shader row reading None, and no shader brings no parameter rows',
|
||
shaderField.x > box.x + box.w && (await field('brightness')) === null,
|
||
JSON.stringify(shaderField),
|
||
);
|
||
await click(shaderField.x, shaderField.y);
|
||
await sleep(300);
|
||
const choices = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the dropdown offers None and every shader asset',
|
||
choices.some((text) => text.includes('None')) &&
|
||
choices.some((text) => text.includes('Adjust colors')) &&
|
||
choices.some((text) => text.includes(made.name)),
|
||
JSON.stringify({ choices, shaderField }),
|
||
);
|
||
await clickLast(choices.find((text) => text.includes('Adjust colors')));
|
||
await sleep(400);
|
||
const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]);
|
||
check(
|
||
'picking Adjust colors adds its three property rows below the dropdown',
|
||
rows.every((r) => r !== null) &&
|
||
(await json("__gm.state.activeScene.objects.find((o) => o.id === 'oshade').overrides.shader")) ===
|
||
ADJUST,
|
||
JSON.stringify(rows),
|
||
);
|
||
await shot('28-shader-rows');
|
||
await undo();
|
||
await sleep(300);
|
||
check('undoing the choice takes the rows away again', (await field('brightness')) === null);
|
||
await select(null);
|
||
await sleep(200);
|
||
|
||
// 28e. Each property is visible in the pixels, with 1 neutral.
|
||
await assetProps({ shader: ADJUST });
|
||
await sleep(300);
|
||
const neutral = await centre();
|
||
check(
|
||
'Adjust colors at its defaults draws exactly what no shader draws',
|
||
colourDistance(neutral, base) < 6,
|
||
JSON.stringify({ base, neutral }),
|
||
);
|
||
// What the cost is made of (#67): one filter per sprite wearing a shader, so one render pass.
|
||
const passesOn = await json('__gm.filterCount()');
|
||
await assetProps({ shader: '' });
|
||
await sleep(300);
|
||
const passesOff = await json('__gm.filterCount()');
|
||
await assetProps({ shader: ADJUST });
|
||
await sleep(300);
|
||
check(
|
||
'a shaded sprite carries one filter – one render pass – and none without',
|
||
passesOn === 1 && passesOff === 0,
|
||
{
|
||
on: passesOn,
|
||
off: passesOff,
|
||
},
|
||
);
|
||
await assetProps({ brightness: 1.25 });
|
||
await sleep(300);
|
||
const lighter = await centre();
|
||
check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter);
|
||
await assetProps({ brightness: 1, contrast: 0 });
|
||
await sleep(300);
|
||
const flat = await centre();
|
||
check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat);
|
||
await assetProps({ contrast: 1, saturation: 0 });
|
||
await sleep(300);
|
||
const grey = await centre();
|
||
check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey);
|
||
await shot('28-shader-desaturated');
|
||
|
||
// 28f. The game draws through the same path, so play shows the shader too.
|
||
await assetProps({ saturation: 1, brightness: 1.25 });
|
||
await play();
|
||
await sleep(500);
|
||
const at = await json("__gm.gameScreen('oshade')");
|
||
const played = at ? await json(`__gm.pixelAt(${Math.round(at.x + 20)}, ${Math.round(at.y + 20)})`) : null;
|
||
check(
|
||
'during play the sprite is drawn with its shader',
|
||
played !== null && colourDistance(played, '#80c0ff') < 24,
|
||
JSON.stringify({ at, played }),
|
||
);
|
||
await stop();
|
||
await sleep(300);
|
||
|
||
// 28g. Editing a shader's graph reaches every sprite wearing it: a Color wired into the new one's Output.
|
||
await assetProps({ brightness: 1, shader: made.id });
|
||
await sleep(300);
|
||
const through = await centre();
|
||
check(
|
||
'the new shader passes the texel through',
|
||
colourDistance(through, base) < 6,
|
||
JSON.stringify({ base, through }),
|
||
);
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push({ id: 'sh-red', type: 'ShaderColor', x: 0, y: 200, params: { value: 0xff0000 } });
|
||
a.graph.edges.push({ id: 'sh-red-edge', from: { node: 'sh-red', port: 'color' }, to: { node: out.id, port: 'color' } });`);
|
||
await sleep(400);
|
||
const red = await centre();
|
||
check(
|
||
'wiring a red Color into the Output recompiles: the sprite turns red',
|
||
colourDistance(red, '#ff0000') < 24,
|
||
red,
|
||
);
|
||
|
||
// 28h. Alpha: Texture alpha times a Property into the Output's alpha blends the sprite with what
|
||
// is behind it; at 1 the pixel is the one from before, byte for byte.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push(
|
||
{ id: 'sh-alpha', type: 'ShaderTextureAlpha', x: 0, y: 300, params: {} },
|
||
{ id: 'sh-opacity', type: 'ShaderProperty', x: 0, y: 400, params: { name: 'opacity', default: 1, min: 0, max: 1 } },
|
||
{ id: 'sh-fade', type: 'ShaderMultiply', x: 200, y: 350, params: {} });
|
||
a.graph.edges.push(
|
||
{ id: 'sh-fade-a', from: { node: 'sh-alpha', port: 'value' }, to: { node: 'sh-fade', port: 'a' } },
|
||
{ id: 'sh-fade-b', from: { node: 'sh-opacity', port: 'value' }, to: { node: 'sh-fade', port: 'b' } },
|
||
{ id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`);
|
||
await sleep(400);
|
||
const opaque = await centre();
|
||
check(
|
||
'an opacity property at its default 1 changes nothing',
|
||
opaque === red,
|
||
JSON.stringify({ red, opaque, info: await info() }),
|
||
);
|
||
await assetProps({ opacity: 0 });
|
||
await sleep(300);
|
||
const behind = await centre();
|
||
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
|
||
await assetProps({ opacity: 0.5 });
|
||
await sleep(300);
|
||
const half = await centre();
|
||
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
|
||
const expected =
|
||
'#' +
|
||
[1, 3, 5]
|
||
.map((i) =>
|
||
Math.round((channel(red, i) + channel(behind, i)) / 2)
|
||
.toString(16)
|
||
.padStart(2, '0'),
|
||
)
|
||
.join('');
|
||
check('opacity 0.5 blends the sprite half with what is behind', colourDistance(half, expected) < 24, {
|
||
red,
|
||
behind,
|
||
half,
|
||
expected,
|
||
});
|
||
await assetProps({ opacity: 1 });
|
||
await sleep(300);
|
||
check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red);
|
||
|
||
// 28i. A Number node is a constant in the fragment: red times 0.5 is half red; typed back to 1,
|
||
// the pixel is the red from before. Its value is a setting row on the node.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push(
|
||
{ id: 'sh-half', type: 'ShaderNumber', x: 200, y: 200, params: { value: 0.5 } },
|
||
{ id: 'sh-dim', type: 'ShaderMultiply', x: 400, y: 200, params: {} });
|
||
a.graph.edges = a.graph.edges.filter((e) => e.id !== 'sh-red-edge');
|
||
a.graph.edges.push(
|
||
{ id: 'sh-dim-a', from: { node: 'sh-red', port: 'color' }, to: { node: 'sh-dim', port: 'a' } },
|
||
{ id: 'sh-dim-b', from: { node: 'sh-half', port: 'value' }, to: { node: 'sh-dim', port: 'b' } },
|
||
{ id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`);
|
||
await sleep(400);
|
||
const dimmed = await centre();
|
||
check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, {
|
||
red,
|
||
dimmed,
|
||
});
|
||
await evaluate("__gm.showTab('shader')");
|
||
await sleep(300);
|
||
check(
|
||
'the Number node draws its value as a setting row',
|
||
(await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.500',
|
||
JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")),
|
||
);
|
||
await shot('28-number-node');
|
||
await evaluate("__gm.showTab('scene')");
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === 'sh-half').params.value = 1`);
|
||
await sleep(400);
|
||
check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, {
|
||
red,
|
||
now: await centre(),
|
||
});
|
||
|
||
// 28j. Back to None: no filter at all, the plain colour returns bit for bit.
|
||
await assetProps({ shader: '' });
|
||
await sleep(300);
|
||
const back = await centre();
|
||
check(
|
||
'switching back to None restores the plain colour',
|
||
colourDistance(back, base) < 6,
|
||
JSON.stringify({ base, back }),
|
||
);
|
||
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => a.id !== 'shade-sprite' && a.id !== ${JSON.stringify(made.id)});
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`);
|
||
await evaluate('__gm.state.select(null)');
|
||
await sleep(200);
|
||
check(
|
||
'the step leaves nothing behind but the fixture’s shader',
|
||
(await json("__gm.state.project.assets.some((a) => a.id === 'shade-sprite')")) === false &&
|
||
(await json("__gm.state.activeScene.objects.some((o) => o.id === 'oshade')")) === false &&
|
||
(await shaders()).map((s) => s.id).join(',') === ADJUST,
|
||
);
|
||
}
|