Use a shader graph as a node in other shaders (#303)
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Failing after 14m37s

A calculation needed in several shaders (a greyscale, a noise, an
outline) had to be built again in every one of them. A shader
function keeps it as an asset of its own, called like a behavior is
since #16.

- New asset type `shaderfunction` ("Shader function"), edited in the
  Shader tab. The panel beside the graph shows no picture, the
  readout counts its inputs and outputs.
- Two new nodes: `Function input` and `Function output`. Their
  declared ports, numbers or colours only, are the ports of every
  call. One of each per function, like Start and End.
- The call is node `ShaderCall`, named after the function. The
  palette lists every function, it can be dragged in from the Assets
  panel, and a double-click opens it.
- The compiler sets the function in where it is called
  (`inlineFunctions`). It renames the copied nodes `<call>/<node>`
  and turns an unconnected port into a constant. Passes, Time, the
  Edge cut and the corner walk then see a plain graph.
- A problem inside a function is reported at the call, in the
  function's words. The function's own tab checks it on its own
  (`checkShaderFunction`).
- `ShaderLibrary` keys a shader on its graph and on every function it
  calls, however deep, so a change reaches every caller.
- The port commands mirror a new, renamed or removed port into every
  call, as for a behavior's Start and End. `NodeDef.portKinds` holds
  the two ends to Number and Color.
- As agreed in the issue: no `Property`, `Color property`, Output or
  Vertex output in a function, and `Time` is the caller's clock.
- Own decision: a `Pass` may stand in a function. It becomes a pass
  of the calling shader, so a wide blur can be a function.
- A function calling itself, also through another, says so at the
  node and is not set in. The palette does not offer such a call.
- The Shader tab showed only the compiler's problems. A call nothing
  reaches was never compiled and looked healthy, so a call now also
  shows its own problem there, as in a behavior.

Tests:
- Gate: 228 files, 3510 tests, CHECK EXIT 0 (+13: 11 in
  tests/core/shader-functions.test.ts, 2 for the new icon)
- e2e 28: 100 checks, all passed (+3 in 28l-viii). Called and in
  place both #606060/#a2a2a2, halved in the function #303030/#515151.
- Counter-check: a fingerprint without the called functions fails 1
  unit test, and in the browser the change check (#606060 stays).
- The first e2e runs found two faults, both fixed: a double-click
  only selected the function, and an unreached call of itself
  showed no problem.
- e2e total 1728 is computed (1725 + 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 17:09:53 +02:00
1 parent 2e55071fa5
commit a526e1b40f
28 files changed
+1091 -83

No files matched your search

+83
View File
@@ -1140,6 +1140,89 @@ export async function shader(t) {
colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12,
{ chained },
);
// 28l-viii. A shader function (#303): Grayscale as an asset of its own, called from the shader,
// draws what the same nodes draw in place. A change to the function reaches the sprite
// without the shader being touched, and a double-click on the call opens the function.
const FN = 'fn28-gray';
const fnEdge = (id, from, fromPort, to, toPort) => ({
id,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
});
await mutate(`
__gm.state.project.assets.push({
id: '${FN}',
type: 'shaderfunction',
name: 'Grayscale 28',
props: {},
graph: {
nodes: ${JSON.stringify([
{ ...node('in', 'ShaderFunctionInput', {}, 0, 0), ports: [{ key: 'color', kind: 'color' }] },
node('lum', 'ShaderLuminance', {}, 260, 0),
{ ...node('out', 'ShaderFunctionOutput', {}, 520, 0), ports: [{ key: 'gray', kind: 'color' }] },
])},
edges: ${JSON.stringify([fnEdge('e1', 'in', 'color', 'lum', 'color'), fnEdge('e2', 'lum', 'value', 'out', 'gray')])},
},
})`);
const ends = [node('own', 'ShaderTextureColor', {}, 0, 0), node('out', 'ShaderOutput', {}, 520, 0)];
await graph(
[...ends, node('lum', 'ShaderLuminance', {}, 260, 0)],
[edge('own', 'color', 'lum', 'color'), edge('lum', 'value', 'out', 'color')],
);
const inPlace = [await texel(4, 6), await texel(27, 6)];
await graph(
[...ends, node('call', 'ShaderCall', { function: FN }, 260, 0)],
[edge('own', 'color', 'call', 'color'), edge('call', 'gray', 'out', 'color')],
);
const called = [await texel(4, 6), await texel(27, 6)];
const isGrey = (hex) => new Set(rgb(hex)).size === 1;
check(
'a function called from the shader draws what its nodes draw in place, byte for byte',
called.join() === inPlace.join() && called.every(isGrey) && called[0] !== called[1],
{ inPlace, called },
);
await mutate(`
const g = __gm.state.project.assets.find((a) => a.id === '${FN}').graph;
g.nodes.push(${JSON.stringify(node('half', 'ShaderMultiply', { b: 0.5 }, 390, 140))});
g.edges = g.edges.filter((e) => e.id !== 'e2');
g.edges.push(...${JSON.stringify([fnEdge('e3', 'lum', 'value', 'half', 'a'), fnEdge('e4', 'half', 'result', 'out', 'gray')])})`);
const fnHalved = [await texel(4, 6), await texel(27, 6)];
check(
'a change to the function reaches the sprite, its shader untouched',
fnHalved.every((hex, k) => isGrey(hex) && Math.abs(rgb(hex)[0] - rgb(called[k])[0] / 2) <= 2),
{ called, fnHalved },
);
await select({ kind: 'asset', assetId: made.id });
await showTab('shader');
await clickText('Fit');
const callNode = await json("__gm.shader.nodeAt('call')");
const callLabel = await json("__gm.shader.nodeLabel('call')");
if (callNode) await doubleClick(callNode.x, callNode.y);
await sleep(200);
const openedFunction = {
current: await json('__gm.shader.current()'),
info: await json('__gm.shader.info()'),
subject: (await json('__gm.shader.preview()')).subject,
};
await mutate(`
__gm.state.project.assets.find((a) => a.id === '${FN}').graph.nodes.push(
${JSON.stringify(node('self', 'ShaderCall', { function: FN }, 260, 260))})`);
const selfProblem = await json("__gm.shader.nodeProblem('self')");
check(
'the call is named after the function, a double-click opens it, and a call of itself has a problem',
callLabel === 'Grayscale 28' &&
openedFunction.current === FN &&
openedFunction.info === '1 input, 1 output' &&
openedFunction.subject === 'A function shows in the shaders calling it' &&
selfProblem === 'A function cannot call itself',
JSON.stringify({ callNode, callLabel, openedFunction, selfProblem }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== '${FN}')`);
await evaluate('__gm.state.select(null)');
await showTab('scene');
await assetProps({ texture: picture?.id ?? '' });
await filterOf(picture?.id, 'linear');