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));
}
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import { isKindWith, plainKindOf } from '../properties/kinds';
import { type DeclaredPortKind, isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { GraphLookup, ValueTyping } from './lookup';
@@ -118,6 +118,8 @@ export interface NodeDef {
* input as well where it is marked `write`.
*/
declaresPorts?: 'inputs' | 'outputs' | 'both';
/** The kinds a declared port may have here; without it, every declarable kind. */
portKinds?: readonly DeclaredPortKind[];
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort, node: GraphNode, graph: Graph, lookup: GraphLookup): PortDef;
/**
+8
View File
@@ -12,6 +12,11 @@ export interface BehaviorProgram {
kept?: readonly string[];
}
export interface ShaderFunction {
name: string;
graph: Graph;
}
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
export interface MachineState {
id: string;
@@ -45,6 +50,8 @@ export interface StateMachine {
*/
export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null;
/** A shader function's graph, which a call node takes its ports from and the compiler sets in (#303). */
shaderFunction(assetId: string): ShaderFunction | null;
machine(assetId: string): StateMachine | null;
curve(assetId: string): readonly VectorPoint[] | null;
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
@@ -80,6 +87,7 @@ export interface ValueTyping {
export const NO_LOOKUP: GraphLookup = {
behavior: () => null,
shaderFunction: () => null,
machine: () => null,
curve: () => null,
structType: () => null,
+49 -1
View File
@@ -24,8 +24,13 @@ export const ON_BEHAVIOR = 'OnBehavior';
export const VARIABLES = 'Variables';
export const PROPERTIES = 'Properties';
const ON_START = 'OnStart';
/** The two ends of a shader function (#303) and the node calling one, see `core/shader/functions`. */
export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput';
export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput';
export const SHADER_CALL = 'ShaderCall';
export const BEHAVIOR_PARAM = 'behavior';
export const FUNCTION_PARAM = 'function';
export const VARIABLES_PARAM = 'variablesAsset';
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
@@ -116,8 +121,19 @@ export function callEntries(graph: Graph): GraphNode[] {
return start ? [start] : graph.nodes.filter((n) => n.type === ON_START);
}
/** A graph has one of each: its ports are those of every node calling the graph. */
export function isTerminalType(type: string): boolean {
return type === BEHAVIOR_START || type === BEHAVIOR_END;
return (
type === BEHAVIOR_START ||
type === BEHAVIOR_END ||
type === SHADER_FUNCTION_INPUT ||
type === SHADER_FUNCTION_OUTPUT
);
}
/** The terminal whose ports a caller passes in, rather than takes out. */
export function isEntryTerminal(type: string): boolean {
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT;
}
export function isTerminalNode(node: GraphNode): boolean {
@@ -336,6 +352,7 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
*/
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
@@ -357,6 +374,37 @@ function listenProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): s
return null;
}
/** Why a shader function's call has nothing to set in its place (#303). */
function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): string | null {
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
if (!id) return 'No function picked';
if (!lookup.shaderFunction(id)) return 'That function is gone';
if (ownerId && callsFunction(lookup, id, ownerId)) return 'A function cannot call itself';
return null;
}
/** The functions the calls in that graph name, picked or not found alike. */
export function calledFunctions(graph: Graph): string[] {
return graph.nodes
.filter((n) => n.type === SHADER_CALL)
.map((n) => textOf(n.params[FUNCTION_PARAM] ?? ''))
.filter((id) => id !== '');
}
/** Whether `from` is `target` or calls it, however many functions lie between. */
export function callsFunction(
lookup: GraphLookup,
from: string,
target: string,
seen: Set<string> = new Set(),
): boolean {
if (from === target) return true;
if (seen.has(from)) return false;
seen.add(from);
const called = lookup.shaderFunction(from);
return called ? calledFunctions(called.graph).some((id) => callsFunction(lookup, id, target, seen)) : false;
}
/** The behaviors the `On behavior` nodes of that graph name, picked or not found alike. */
export function heardBehaviors(graph: Graph): string[] {
return graph.nodes
+2
View File
@@ -140,6 +140,7 @@ import { SetStateByName } from './flow/SetStateByName';
import { StateBranch } from './flow/StateBranch';
import { StateTransition } from './flow/StateTransition';
import { PARTICLE_NODES } from '../../particles/nodes';
import { SHADER_FUNCTION_NODES } from '../../shader/functions';
import { SHADER_NODES } from '../../shader/nodes';
import { State } from '../stateMachine';
import { Variables } from './variable/Variables';
@@ -303,4 +304,5 @@ for (const def of BUILT_IN_NODES) registerNode(def);
// behavior graph offers them.
registerNode(State);
for (const def of SHADER_NODES) registerNode(def);
for (const def of SHADER_FUNCTION_NODES) registerNode(def);
for (const def of PARTICLE_NODES) registerNode(def);
+19 -2
View File
@@ -277,6 +277,15 @@ export interface ShaderAsset extends AssetBase {
graph: Graph;
}
/**
* A part of a shader graph used in others (#303): its `Function input` and `Function output` nodes
* are the ports of every node calling it, and the compiler sets the graph in at each call.
*/
export interface ShaderFunctionAsset extends AssetBase {
type: 'shaderfunction';
graph: Graph;
}
/**
* `file` names the typeface in `data/fonts/` the way a texture names its image: content-addressed
* `{sha256}.{ext}`, empty when the upload failed. A text object wears it through its `font`
@@ -545,6 +554,7 @@ export type Asset =
| TilemapAsset
| StateMachineAsset
| ShaderAsset
| ShaderFunctionAsset
| FontAsset
| SoundAsset
| ParticlesAsset;
@@ -557,9 +567,16 @@ export function isVariableOwner(asset: Asset | undefined): asset is VariableOwne
}
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
export type GraphAsset =
BehaviorAsset | StateMachineAsset | ShaderAsset | ShaderFunctionAsset | ParticlesAsset;
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set(['behavior', 'statemachine', 'shader', 'particles']);
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
'behavior',
'statemachine',
'shader',
'shaderfunction',
'particles',
]);
export function isGraphAsset(asset: Asset): asset is GraphAsset {
return GRAPH_TYPES.has(asset.type);
+5
View File
@@ -21,6 +21,7 @@ export const ASSET_TYPES = [
'tilemap',
'statemachine',
'shader',
'shaderfunction',
'font',
'sound',
'particles',
@@ -51,6 +52,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
tilemap: 'Tilemap',
statemachine: 'State machine',
shader: 'Shader',
shaderfunction: 'Shader function',
font: 'Font',
sound: 'Sound',
particles: 'Particles',
@@ -64,6 +66,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
* A shader function is part of other shaders (#303), worn by nothing itself.
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -77,6 +80,7 @@ export type PlaceableAssetType = Exclude<
| 'tile'
| 'statemachine'
| 'shader'
| 'shaderfunction'
| 'font'
| 'sound'
| 'variables'
@@ -94,6 +98,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'tile',
'statemachine',
'shader',
'shaderfunction',
'font',
'sound',
'variables',
+9
View File
@@ -9,6 +9,14 @@ import { fieldTypings, typeFields, typingOf } from './structTypes';
import { findAsset, findAssetOf, type Project } from './Project';
import { resolvePicture } from './texture';
/** The project's shader functions by id, as a call and the compiler see them (#303). */
export function shaderFunctionsOf(project: Project): GraphLookup['shaderFunction'] {
return (assetId) => {
const asset = findAssetOf(project, assetId, 'shaderfunction');
return asset ? { name: asset.name, graph: asset.graph } : null;
};
}
/**
* What a graph may see of the project around it. A behavior's `defaults` are its variable values;
* every call starts from a copy of them, except for the variables it keeps (#229).
@@ -25,6 +33,7 @@ export function graphLookup(project: Project): GraphLookup {
kept: asset.keptVariables ?? [],
};
},
shaderFunction: shaderFunctionsOf(project),
machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine');
if (!asset) return null;
+1
View File
@@ -630,6 +630,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [],
shaderfunction: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
// a pixel font a pixel per unit, magnified sharp.
font: [filterRow(true)],
+88 -8
View File
@@ -1,9 +1,18 @@
import { type GraphEmitter, graphEmitter } from '../graph/emitGraph';
import { edgesInto, type Graph, type GraphNode } from '../graph/Graph';
import { SHADER_CALL, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, terminalNode } from '../graph/nested';
import { nodeDef } from '../graph/NodeRegistry';
import { colorOf } from '../model/Properties';
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
import {
type FunctionLookup,
functionPortOf,
inlineFunctions,
misplacedProblem,
NO_FUNCTIONS,
problemsAtCalls,
} from './functions';
import {
asVec3,
BLUR_FN,
@@ -119,7 +128,20 @@ type PassCut = (node: GraphNode) => Record<string, Expr> | null;
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. Every
* Pass the Output reaches is a fragment of its own, drawn first into a picture (#301).
*/
export function compileShader(graph: Graph): ShaderBuild {
export function compileShader(source: Graph, functions: FunctionLookup = NO_FUNCTIONS): ShaderBuild {
const inlined = inlineFunctions(source, functions);
const build = compileGraph(inlined.graph, inlined.unresolved);
const problems = problemsAtCalls(build.problems, inlined);
for (const node of source.nodes) {
const misplaced = misplacedProblem(node.type, false);
if (misplaced && !(node.id in problems)) problems[node.id] = misplaced;
}
const failed = firstProblem(source, problems);
return failed === null ? build : { compiled: null, problems, hints: build.hints, reason: failed };
}
/** `compileShader` on a graph whose calls are set in already; `unresolved` says why the rest are not. */
function compileGraph(graph: Graph, unresolved: Record<string, string>): ShaderBuild {
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
const mode = output ? edgeOf(output.params) : edgeOf({});
const uniforms = shaderParamDefs(graph).map((row) => ({
@@ -130,8 +152,8 @@ export function compileShader(graph: Graph): ShaderBuild {
const lines: string[] = [];
const reads = new Set<number>();
const passes = passDrawer(graph, mode, uniforms);
const walk = walker(graph, lines, 'n', null, passes.cut(reads));
const passes = passDrawer(graph, mode, uniforms, unresolved);
const walk = walker(graph, lines, 'n', null, passes.cut(reads), unresolved);
const { problems } = walk;
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
@@ -156,7 +178,7 @@ export function compileShader(graph: Graph): ShaderBuild {
const perPixel: string[] = [];
const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
const cornerLines: string[] = [];
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }));
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }), unresolved);
let vertex: ShaderVertex | null = null;
if (corner) {
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
@@ -274,7 +296,12 @@ function fragmentOf(graph: Graph, p: FragmentParts): string {
* Compiles every Pass a walk meets, once, before the walk goes on: what feeds a Pass is a walk of
* its own, so a Pass it meets in turn is drawn earlier. `drawn` is the drawing order.
*/
function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
function passDrawer(
graph: Graph,
mode: EdgeMode,
uniforms: ShaderUniform[],
unresolved: Record<string, string>,
) {
const drawn: ShaderPass[] = [];
const walks: GraphEmitter<Expr>[] = [];
/** What keeps a Pass itself from being drawn, rather than something feeding it. */
@@ -303,7 +330,7 @@ function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
drawing.add(node.id);
const lines: string[] = [];
const reads = new Set<number>();
const walk = walker(graph, lines, 'n', null, cut(reads));
const walk = walker(graph, lines, 'n', null, cut(reads), unresolved);
const def = nodeDef(SHADER_PASS)!;
const color = walk.input(node, def.inputs[0]!);
const alpha = walk.input(node, def.inputs[1]!);
@@ -322,14 +349,35 @@ function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
/**
* One walk of the graph into `lines`, a local per node output. `perPixel` is the list a per-corner
* walk notes in what it met that only a pixel has; the fragment's walk hands in null. `pass` is
* what a Pass node is to this walk.
* what a Pass node is to this walk, `unresolved` why a call still standing was not set in.
*/
function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null, pass: PassCut) {
function walker(
graph: Graph,
lines: string[],
prefix: string,
perPixel: string[] | null,
pass: PassCut,
unresolved: Record<string, string>,
) {
let count = 0;
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
isDef: isShaderNodeDef,
noun: 'shader',
emit: (def, inputs, node) => {
if (node.type === SHADER_CALL) {
walk.problem(node, unresolved[node.id] ?? 'That function is gone');
return {};
}
// Only a function checked on its own is walked from its input: any value of the right type will do.
if (node.type === SHADER_FUNCTION_INPUT)
return Object.fromEntries(
(node.ports ?? []).map((port) => [
port.key,
functionPortOf(port).type === 'vec3'
? { code: 'vec3(1.0)', type: 'vec3' }
: { code: '0.0', type: 'float' },
]),
);
if (def.perPixel) perPixel?.push(def.label);
let prepared: string | undefined;
if (def.prepare) {
@@ -380,3 +428,35 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
});
return walk;
}
export interface FunctionCheck {
/** Per node id, what keeps the function from being set in anywhere. */
problems: Record<string, string>;
/** One line for the tab: empty when the function is sound. */
reason: string;
}
/**
* A shader function on its own (#303): walked from each port of its Function output as a call
* would, with any value of the right type at its input, so its problems show in its own tab and not
* only at the calls. `ownerId` is the function itself – a call of it inside is a call of itself.
*/
export function checkShaderFunction(graph: Graph, functions: FunctionLookup, ownerId: string): FunctionCheck {
const inlined = inlineFunctions(graph, functions, ownerId ? [ownerId] : []);
const flat = inlined.graph;
const passes = passDrawer(flat, edgeOf({}), [], inlined.unresolved);
const walk = walker(flat, [], 'n', null, passes.cut(new Set()), inlined.unresolved);
const output = terminalNode(flat, SHADER_FUNCTION_OUTPUT);
for (const port of output?.ports ?? []) walk.input(output!, functionPortOf(port));
const found: Record<string, string> = { ...walk.problems };
for (const [id, text] of Object.entries(passes.problems)) if (!(id in found)) found[id] = text;
for (const pass of passes.walks)
for (const [id, text] of Object.entries(pass.problems)) if (!(id in found)) found[id] = text;
const problems = problemsAtCalls(found, inlined);
for (const node of graph.nodes) {
const misplaced = misplacedProblem(node.type, true);
if (misplaced && !(node.id in problems)) problems[node.id] = misplaced;
}
const failed = firstProblem(graph, problems);
return { problems, reason: failed ?? (output ? '' : 'No Function output node') };
}
+297
View File
@@ -0,0 +1,297 @@
import {
type DeclaredPort,
edgesInto,
type Graph,
type GraphEdge,
graphFingerprint,
type GraphNode,
} from '../graph/Graph';
import type { GraphLookup } from '../graph/lookup';
import {
calledFunctions,
FUNCTION_PARAM,
SHADER_CALL,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
terminalNode,
} from '../graph/nested';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import { textOf } from '../model/Properties';
import { SHADER_COLOR_PROPERTY, SHADER_PROPERTY } from '../properties/shaders';
import {
NUMBER_STEP,
SHADER_COLOR,
SHADER_NUMBER,
SHADER_OUTPUT,
SHADER_VERTEX_OUTPUT,
type ShaderNodeDef,
} from './nodes';
/** The shader functions a compile may set in, by asset id (#303). */
export type FunctionLookup = GraphLookup['shaderFunction'];
export const NO_FUNCTIONS: FunctionLookup = () => null;
/** A function takes and gives values only: a picture stays the caller's. */
export const FUNCTION_PORT_KINDS = ['number', 'color'] as const;
const DEFAULT_COLOR = 0xffffff;
/** A declared port of a function as the nodes on either side of it see it. */
export function functionPortOf(port: DeclaredPort): PortDef {
const { key } = port;
if (port.kind === 'color')
return {
key,
label: key,
type: 'vec3',
default: DEFAULT_COLOR,
field: { key, label: key, kind: 'color', default: DEFAULT_COLOR },
};
return {
key,
label: key,
type: 'number',
default: 0,
field: { key, label: key, kind: 'number', default: 0, step: NUMBER_STEP },
};
}
function functionPorts(graph: Graph | undefined, type: string): PortDef[] {
const node = graph && terminalNode(graph, type);
return (node?.ports ?? []).map(functionPortOf);
}
export const ShaderFunctionInput: ShaderNodeDef = {
type: SHADER_FUNCTION_INPUT,
label: 'Function input',
category: 'shader',
description:
'The values a shader calling this function hands in. Every input declared here, a number or a color, becomes an input port on the node that calls the function.',
inputs: [],
outputs: [],
params: [],
declaresPorts: 'outputs',
portKinds: FUNCTION_PORT_KINDS,
declaredPort: (port) => functionPortOf(port),
emit: () => ({}),
};
export const ShaderFunctionOutput: ShaderNodeDef = {
type: SHADER_FUNCTION_OUTPUT,
label: 'Function output',
category: 'shader',
description:
'What this function hands back. Every output declared here becomes an output port on the node that calls the function; unconnected, it is the value typed in.',
inputs: [],
outputs: [],
params: [],
declaresPorts: 'inputs',
portKinds: FUNCTION_PORT_KINDS,
declaredPort: (port) => functionPortOf(port),
emit: () => ({}),
};
/** Never emitted in place: the compiler sets the function's graph in where it stands. */
export const ShaderCall: ShaderNodeDef = {
type: SHADER_CALL,
label: 'Shader function',
category: 'shader',
description:
"A shader function's graph, set in here. Its ports are the ones that function declares at its Function input and Function output, and the picture it reads is this shader's. Double-click opens the function.",
inputs: [],
outputs: [],
params: [
{ key: FUNCTION_PARAM, label: 'Function', kind: 'asset', default: '', assetTypes: ['shaderfunction'] },
],
derivePorts: ({ node, lookup }) => {
const called = lookup.shaderFunction(textOf(node.params[FUNCTION_PARAM] ?? ''));
return {
label: called?.name,
inputs: functionPorts(called?.graph, SHADER_FUNCTION_INPUT),
outputs: functionPorts(called?.graph, SHADER_FUNCTION_OUTPUT),
};
},
emit: () => ({}),
};
export const SHADER_FUNCTION_NODES: readonly ShaderNodeDef[] = [
ShaderFunctionInput,
ShaderFunctionOutput,
ShaderCall,
];
/**
* A function hands values back and nothing else, so what ends a shader or gives the wearer a row
* has no place in it; the ends of a function have none in a shader.
*/
const ONLY_IN_SHADER = new Set([SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_PROPERTY, SHADER_COLOR_PROPERTY]);
const ONLY_IN_FUNCTION = new Set([SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT]);
/** Why a node of that type cannot stand in a shader (or, `inFunction`, a function); null if it can. */
export function misplacedProblem(type: string, inFunction: boolean): string | null {
if (inFunction && ONLY_IN_SHADER.has(type)) return `A function cannot hold ${nodeDef(type)?.label ?? type}`;
if (!inFunction && ONLY_IN_FUNCTION.has(type)) return 'Only in a shader function';
return null;
}
/** Whether a node of that type belongs in a function's palette – or, with false, in a shader's. */
export function offeredIn(type: string, inFunction: boolean): boolean {
return misplacedProblem(type, inFunction) === null;
}
export interface Inlined {
/** The graph with every call that could be set in replaced by the function's nodes. */
graph: Graph;
/** Per call set in, the function's name: a problem inside it is reported at the call. */
names: Record<string, string>;
/** Per call left in place, why it could not be set in. */
unresolved: Record<string, string>;
}
/**
* Every call in `graph` replaced by a copy of the function it names (#303), its nodes renamed
* `<call id>/<node id>`: what fed a port of the call feeds what the Function input's port fed,
* and what read an output of the call reads what fed the Function output's port. A port nothing
* feeds becomes a constant with its typed-in value. `chain` is the functions already being set in
* – meeting one again is a function calling itself.
*/
export function inlineFunctions(
graph: Graph,
functions: FunctionLookup,
chain: readonly string[] = [],
): Inlined {
const names: Record<string, string> = {};
const unresolved: Record<string, string> = {};
let current = graph;
for (const call of graph.nodes) {
if (call.type !== SHADER_CALL) continue;
const id = textOf(call.params[FUNCTION_PARAM] ?? '');
const called = id ? functions(id) : null;
const set = !id
? 'No function picked'
: !called
? 'That function is gone'
: chain.includes(id)
? 'A function cannot call itself'
: setIn(current, call, called.name, called.graph, functions, [...chain, id]);
if (typeof set === 'string') unresolved[call.id] = set;
else {
current = set;
names[call.id] = called!.name;
}
}
return { graph: current, names, unresolved };
}
type End = { node: string; port: string };
function setIn(
graph: Graph,
call: GraphNode,
name: string,
body: Graph,
functions: FunctionLookup,
chain: readonly string[],
): Graph | string {
for (const node of body.nodes) {
const misplaced = misplacedProblem(node.type, true);
if (misplaced) return `In ${name}: ${misplaced}`;
}
const inner = inlineFunctions(body, functions, chain);
const stuck = Object.values(inner.unresolved)[0];
if (stuck) return `In ${name}: ${stuck}`;
const fi = terminalNode(inner.graph, SHADER_FUNCTION_INPUT);
const fo = terminalNode(inner.graph, SHADER_FUNCTION_OUTPUT);
const outs = new Map((fo?.ports ?? []).map((p) => [p.key, p]));
const gone = graph.edges.find((e) => e.from.node === call.id && !outs.has(e.from.port));
if (gone) return `"${gone.from.port}" is not an output of ${name}`;
const prefix = `${call.id}/`;
const constants: GraphNode[] = [];
const constant = (key: string, port: DeclaredPort, typed: unknown): End => {
const color = functionPortOf(port).type === 'vec3';
const value = typed ?? functionPortOf(port).default;
const id = `${prefix}${key}`;
constants.push({
id,
type: color ? SHADER_COLOR : SHADER_NUMBER,
x: 0,
y: 0,
params: { value: value as number },
});
return { node: id, port: color ? 'color' : 'value' };
};
const fed: Record<string, End> = {};
for (const port of fi?.ports ?? []) {
const edge = edgesInto(graph, call.id, port.key)[0];
fed[port.key] = edge ? { ...edge.from } : constant(`in:${port.key}`, port, call.params[port.key]);
}
/** Where an end inside the function comes from once it is set in; null for one that is gone. */
const outside = (end: End): End | null =>
end.node === fi?.id
? (fed[end.port] ?? null)
: end.node === fo?.id
? null
: { node: prefix + end.node, port: end.port };
const edges: GraphEdge[] = [];
const link = (from: End, to: End) => edges.push({ id: `${prefix}e${edges.length}`, from, to });
for (const edge of inner.graph.edges) {
if (edge.to.node === fi?.id || edge.to.node === fo?.id) continue;
const from = outside(edge.from);
if (from) link(from, { node: prefix + edge.to.node, port: edge.to.port });
}
const results: Record<string, End> = {};
for (const [key, port] of outs) {
const edge = edgesInto(inner.graph, fo!.id, key)[0];
results[key] = (edge && outside(edge.from)) ?? constant(`out:${key}`, port, fo!.params[key]);
}
const kept: GraphEdge[] = [];
for (const edge of graph.edges) {
if (edge.to.node === call.id) continue;
if (edge.from.node === call.id) link(results[edge.from.port]!, { ...edge.to });
else kept.push(edge);
}
const copies = inner.graph.nodes
.filter((n) => n !== fi && n !== fo)
.map((n) => ({ ...n, id: prefix + n.id }));
return {
...graph,
nodes: [...graph.nodes.filter((n) => n !== call), ...copies, ...constants],
edges: [...kept, ...edges],
};
}
/**
* Problems of the graph a compile saw, moved onto the graph its author sees: one inside a function
* goes to the call it was set in at, in that function's words.
*/
export function problemsAtCalls(problems: Record<string, string>, inlined: Inlined): Record<string, string> {
const lifted: Record<string, string> = {};
for (const [id, text] of Object.entries(problems)) {
const cut = id.indexOf('/');
const call = cut < 0 ? id : id.slice(0, cut);
const said = cut < 0 ? text : `In ${inlined.names[call] ?? 'the function'}: ${text}`;
if (!(call in lifted)) lifted[call] = said;
}
return lifted;
}
/**
* What a compile of `graph` depends on beyond its own nodes: every function it calls, however
* deep, by content. A shader recompiles when one of them changes.
*/
export function functionsFingerprint(graph: Graph, functions: FunctionLookup): string {
const parts: string[] = [];
const seen = new Set<string>();
const visit = (g: Graph) => {
for (const id of calledFunctions(g)) {
if (seen.has(id)) continue;
seen.add(id);
const called = functions(id);
parts.push(`${id}:${called ? `${called.name}:${graphFingerprint(called.graph)}` : 'gone'}`);
if (called) visit(called.graph);
}
};
visit(graph);
return parts.join('\n');
}
+1 -1
View File
@@ -155,7 +155,7 @@ export function texelAtFn(edge: EdgeMode): string {
: `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { return texture(picture, uv); }`;
}
const NUMBER_STEP = 0.05;
export const NUMBER_STEP = 0.05;
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' });
+2 -1
View File
@@ -140,6 +140,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
shaderGraphs.set(a.id, graph);
shaderRows.set(a.id, shaderParamDefs(graph));
}
if (a.type === 'shaderfunction') shaderGraphs.set(a.id, normalizeGraph(a.graph, isShaderNodeDef));
schemas.set(a.id, schemaFor(a.type));
}
}
@@ -225,7 +226,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
// Kept as written even where it names no state any more: the same as a deleted start.
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
assets.push(machine);
} else if (type === 'shader') {
} else if (type === 'shader' || type === 'shaderfunction') {
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'particles') {
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });