Let a shader move the corners of the picture it wears (#79)

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 00:14:00 +02:00
1 parent 963f8a152a
commit 4716726b6b
14 files changed
+586 -75

No files matched your search

+71
View File
@@ -854,6 +854,77 @@ export async function shader(t) {
);
await shot('28-shader-padding-scene');
// 28l-iv. A Vertex output moves the corners of the sprite's grid (#79), in pixels of the picture:
// 16 of this 32-px one is half the sprite. A constant moves the whole picture; a cut on v
// bends it only where the grid has corners, and with one cell there is just a shear.
const cornered = (cornerNodes, cornerEdges, subdivisions) =>
graph(
[
node('texel', 'ShaderTextureColor'),
node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0),
...cornerNodes,
node('vout', 'ShaderVertexOutput', { subdivisions }, 780, 260),
],
[edge('texel', 'color', 'out', 'color'), ...cornerEdges],
);
await cornered(
[node('by', 'ShaderNumber', { value: 16 }, 260, 260)],
[edge('by', 'value', 'vout', 'x')],
8,
);
const moved = {
left: await pixel(-0.3, -0.3),
right: await pixel(0.3, -0.3),
past: await pixel(0.8, -0.3),
};
check(
'a Vertex output fed 16 moves the picture half a sprite to the right, past its rectangle',
colourDistance(moved.left, LEFT) > 60 &&
colourDistance(moved.right, LEFT) < 12 &&
colourDistance(moved.past, RIGHT) < 12 &&
colourDistance(bare.left, LEFT) < 12,
{ moved, bare },
);
const lowerHalf = async (subdivisions) => {
await cornered(
[
node('uv', 'ShaderUV', {}, 0, 260),
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 260),
node('far', 'ShaderMultiply', { b: 16 }, 520, 260),
],
[
edge('uv', 'v', 'cut', 'value'),
edge('cut', 'result', 'far', 'a'),
edge('far', 'result', 'vout', 'x'),
],
subdivisions,
);
return { upper: await pixel(0.6, -0.2), lower: await pixel(0.8, 0.35) };
};
const bent = await lowerHalf(8);
const sheared = await lowerHalf(1);
await shot('28-shader-vertex-scene');
check(
'with 8 subdivisions only the lower half moves; with 1 there are four corners, and the upper half leans along',
colourDistance(bent.lower, RIGHT) < 12 &&
colourDistance(bent.upper, RIGHT) > 60 &&
colourDistance(sheared.upper, RIGHT) < 12,
{ bent, sheared },
);
await cornered(
[node('opacity', 'ShaderTextureAlpha', {}, 260, 260)],
[edge('opacity', 'value', 'vout', 'x')],
8,
);
const perCorner = await json("__gm.shader.nodeProblem('vout')");
const perCornerReadout = await info();
check(
'what reads the picture cannot feed the Vertex output: the node says so, and the shader does not compile',
perCorner === 'Not available per corner: Texture alpha' &&
JSON.stringify(perCornerReadout).includes(perCorner),
{ perCorner, perCornerReadout },
);
// 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the
// asset's menu and from the inspector, whose Texture and Shader labels are links (#221).
const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props");
+17 -2
View File
@@ -321,10 +321,25 @@ export async function font(t) {
Math.abs(textRoomy.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textRoomy, reach }),
);
// 30i-iii. A shader's Vertex output moves a text's corners as it does a sprite's (#79), in the
// text's own units: six of them to the right, and the filled quad stands six further on.
await filled(0);
await mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
graph.nodes.push(${JSON.stringify(node('ft-red-by', 'ShaderNumber', 0, 320, { value: 6 }))});
graph.nodes.push(${JSON.stringify(node('ft-red-vout', 'ShaderVertexOutput', 260, 320, { subdivisions: 4 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e3', 'ft-red-by', 'value', 'ft-red-vout', 'x'))});`);
const textMoved = await redSpan();
check(
'a Vertex output fed 6 draws the text six units to the right of where it stands',
Math.abs(textMoved.first - textTight.first - reach) <= 2 &&
Math.abs(textMoved.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textMoved, reach }),
);
await mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => !['ft-red-full', 'ft-red-by', 'ft-red-vout'].includes(n.id));
graph.edges = graph.edges.filter((e) => !['ft-red-e2', 'ft-red-e3'].includes(e.id));
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`);
// 30j. The game draws a text through the same picture, shader included. In front of everything:
+13 -1
View File
@@ -10,6 +10,8 @@
*
* 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.
*
@@ -28,6 +30,7 @@ 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;
@@ -49,6 +52,14 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
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(`
@@ -145,7 +156,8 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
await stop();
console.log(
`\nsprite ${SIZE}x${SIZE}, ${SAMPLE_MS} ms per point, neutral values, ` +
`\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 |');