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

+36 -11
View File
@@ -11,6 +11,7 @@ import {
drawnDef,
edgesInto,
edgesOutOf,
FUNCTION_PARAM,
ghostProblem,
type Graph,
type GraphNode,
@@ -19,6 +20,8 @@ import {
resolveDef,
namesBehavior,
RUN_BEHAVIOR,
SHADER_CALL,
callsFunction,
startStateOf,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
@@ -278,13 +281,16 @@ export class GraphCanvas extends Widget {
this.annotationLayer.sync(asset.graph);
const seen = new Set<string>();
const lookup = graphLookup(this.state.project);
// In a compiled graph a node's problem is the compiler's, not a call's.
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null;
// In a compiled graph a node's problem is the compiler's, not a call's – but a shader function's
// call says what is wrong with it even where nothing reaches it, as a behavior's does.
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset, this.state.project) : null;
const compilerProblems = shaderBuild
? { ...shaderBuild.hints, ...shaderBuild.problems }
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
: asset.type === 'shaderfunction'
? shaderLibrary.checkFor(asset, this.state.project).problems
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
@@ -299,7 +305,8 @@ export class GraphCanvas extends Widget {
this.nodeLayer.addChild(view);
}
const problem =
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
compilerProblems?.[node.id] ??
(!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ??
ghostProblem(node, asset.graph, resolved) ??
blockProblem(asset.graph, node);
view.sync(node, asset.graph, this.state.project, def, problem);
@@ -628,6 +635,12 @@ export class GraphCanvas extends Widget {
}
private openCalledBehavior(node: GraphNode): boolean {
if (node.type === SHADER_CALL) {
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
if (findAsset(this.state.project, id)?.type !== 'shaderfunction') return false;
this.state.openAsset(id);
return true;
}
if (!namesBehavior(node.type)) return false;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined;
@@ -683,23 +696,33 @@ export class GraphCanvas extends Widget {
}
/**
* Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so
* Follows a behavior or shader function dragged in from the asset panel. The node is centred under the pointer, so
* it lands where the ghost was rather than below and right of it.
*/
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
// Only a behavior graph runs other behaviors; a state machine has nothing to drop in.
if (!inside || this.asset()?.type !== 'behavior' || asset?.type !== 'behavior') {
// A behavior graph runs other behaviors and a shader calls functions (#303); a state machine
// has nothing to drop in.
const into = this.asset()?.type;
const calls = asset?.type === 'shaderfunction' && (into === 'shader' || into === 'shaderfunction');
if (!inside || !asset || !(calls || (into === 'behavior' && asset.type === 'behavior'))) {
this.setIncoming(null);
return;
}
const g = this.screenToGraph(local.x, local.y);
const rejection = calls
? callsFunction(graphLookup(this.state.project), asset.id, this.assetId)
? 'A function cannot call itself'
: ''
: asset.id === this.assetId
? 'A behavior cannot run itself'
: '';
this.setIncoming({
assetId: asset.id,
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) },
rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '',
rejection,
});
}
@@ -714,7 +737,9 @@ export class GraphCanvas extends Widget {
this.state.setNotice(drop.rejection);
return;
}
const entry = this.entries().find((it) => it.id === `${RUN_BEHAVIOR}:${drop.assetId}`);
const type =
findAsset(this.state.project, drop.assetId)?.type === 'shaderfunction' ? SHADER_CALL : RUN_BEHAVIOR;
const entry = this.entries().find((it) => it.id === `${type}:${drop.assetId}`);
if (entry) this.addNode(entry, drop.at);
}
+38 -15
View File
@@ -3,7 +3,9 @@ import {
allNodeDefs,
BEHAVIOR_END,
BEHAVIOR_PARAM,
callsFunction,
createEmptyGraph,
FUNCTION_PARAM,
isTerminalType,
namesBehavior,
nextStateName,
@@ -12,11 +14,14 @@ import {
ON_BEHAVIOR,
resolveDef,
RUN_BEHAVIOR,
SHADER_CALL,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
State,
STATE_NAME_PARAM,
terminalNode,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
@@ -34,6 +39,7 @@ import {
SHADER_PROPERTY,
} from '../../core/properties/shaders';
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
import { offeredIn, ShaderFunctionInput, ShaderFunctionOutput } from '../../core/shader/functions';
import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes';
/**
@@ -88,21 +94,17 @@ function filterEntry(find: NodeDef): PaletteEntry {
}
/**
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
* new one named by its number; a shader holds shader nodes, its one Output dropping out once
* placed. In a behavior, every other behavior appears under its own name instead of behind a
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
* A shader's nodes, its one Output dropping out once placed, and every shader function under its own
* name (#303). A function holds the same nodes but those that end a shader or give the wearer a row,
* and its own two ends instead; it calls every function but those that would come back to it.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader') {
const placed = new Set(asset.graph.nodes.map((n) => n.type));
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT];
return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => {
function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsset): PaletteEntry[] {
const inFunction = asset.type === 'shaderfunction';
const placed = new Set(asset.graph.nodes.map((n) => n.type));
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT];
const entries: PaletteEntry[] = [...SHADER_NODES, ShaderFunctionInput, ShaderFunctionOutput]
.filter((def) => offeredIn(def.type, inFunction) && (!once.includes(def.type) || !placed.has(def.type)))
.map((def) => {
const params: PropertyBag =
def.type === SHADER_PROPERTY
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) }
@@ -111,7 +113,28 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
: {};
return { id: def.type, hint: def.category, def, params };
});
const lookup = graphLookup(project);
for (const other of project.assets) {
if (other.type !== 'shaderfunction' || callsFunction(lookup, other.id, asset.id)) continue;
const params = { [FUNCTION_PARAM]: other.id };
const def = resolveDef({ id: '', type: SHADER_CALL, x: 0, y: 0, params }, createEmptyGraph(), lookup);
if (def) entries.push({ id: `${SHADER_CALL}:${other.id}`, hint: 'function', def, params });
}
return entries;
}
/**
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
* new one named by its number; a shader holds shader nodes (`shaderEntries`). In a behavior, every other behavior appears under its own name instead of behind a
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
if (asset.type === 'particles') {
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));