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

+2
View File
@@ -95,6 +95,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
case 'shader':
return { ...base, type: 'shader', graph: createEmptyGraph() };
case 'shaderfunction':
return { ...base, type: 'shaderfunction', graph: createEmptyGraph() };
case 'font':
return { ...base, type: 'font', file: '' };
case 'sound':
+24 -11
View File
@@ -1,28 +1,32 @@
import {
BEHAVIOR_PARAM,
BEHAVIOR_START,
declaresPropertyPorts,
declaresVariablePorts,
type DeclaredPort,
findNode,
fixedPortKeys,
FUNCTION_PARAM,
type Graph,
type GraphEdge,
type GraphNode,
isEntryTerminal,
isFlowPort,
isTerminalNode,
isValidPortName,
namesBehavior,
OBJECT_BEHAVIOR_PARAM,
propertyTargetOf,
SHADER_CALL,
variablesSourceOf,
} from '../graph';
import { isGraphAsset } from '../model/Asset';
import type { PropertyValue } from '../model/Properties';
import { kindLabel } from '../properties/kinds';
import type { Project } from '../model/Project';
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError, RenameCommand } from './Command';
import { nextFieldName } from './naming';
import { requireBehavior, requireNode, requireNodeDef } from './require';
import { requireGraph, requireNode, requireNodeDef } from './require';
interface PortSite {
graph: Graph;
@@ -33,18 +37,24 @@ interface PortSite {
/**
* Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node
* itself. A behavior's terminal is the exception: its ports are also ports of every node running or
* hearing the behavior – facing the other way, because what a start node hands out the caller passes in.
* itself. A terminal is the exception: its ports are also ports of every node running or hearing the
* behavior, or calling the shader function – facing the other way, because what a start node hands
* out the caller passes in.
* A node that has no such port (an `On behavior` and a flow port of the start) has no edges on it either.
*/
function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] {
const sites = ownSites(requireBehavior(project, assetId).graph, node);
const sites = ownSites(requireGraph(project, assetId), node);
if (!isTerminalNode(node)) return sites;
// A shader function's ends are the ports of its calls in shaders and in other functions (#303).
const callsIt = (caller: GraphNode) =>
caller.type === SHADER_CALL
? caller.params[FUNCTION_PARAM] === assetId
: namesBehavior(caller.type) && caller.params[BEHAVIOR_PARAM] === assetId;
for (const other of project.assets) {
if (other.type !== 'behavior') continue;
if (!isGraphAsset(other)) continue;
for (const caller of other.graph.nodes) {
if (!namesBehavior(caller.type) || caller.params[BEHAVIOR_PARAM] !== assetId) continue;
sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START });
if (!callsIt(caller)) continue;
sites.push({ graph: other.graph, nodeId: caller.id, asInput: isEntryTerminal(node.type) });
}
}
return sites;
@@ -135,8 +145,11 @@ export class AddPortsCommand implements Command {
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
const keys = new Set((node.ports ?? []).map((p) => p.key));
const kinds = requireNodeDef(node.type).portKinds;
for (const port of this.ports) {
checkPortName(node, port.key);
if (kinds && !kinds.includes(port.kind))
throw new CommandError(`A ${kindLabel(port.kind)} port has no place on this node`);
if (isFlowPort(port) && !isTerminalNode(node))
throw new CommandError('Only a start or an end node declares a flow port');
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
@@ -181,7 +194,7 @@ export class RemovePortCommand implements Command {
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
this.removed = removed;
node.ports = ports;
const graph = requireBehavior(project, this.assetId).graph;
const graph = requireGraph(project, this.assetId);
this.edges = dropEdges([
...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })),
...flowEdgesOnceSilent(graph, node),
@@ -240,7 +253,7 @@ export class SetPortWriteCommand implements Command {
this.edges = [];
this.value = undefined;
if (this.write) return;
const graph = requireBehavior(project, this.assetId).graph;
const graph = requireGraph(project, this.assetId);
const input: PortSite = { graph, nodeId: node.id, asInput: true };
this.edges = dropEdges([
{ graph, test: edgesOnPort(input, this.key) },
@@ -334,7 +347,7 @@ export class RepointPortCommand implements Command {
}
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
renamePortOn(requireGraph(project, this.assetId), node, from, to.key);
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
}
}