From a526e1b40f27c892e79b2e72a730874fb71b084a Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 17:09:53 +0200 Subject: [PATCH] Use a shader graph as a node in other shaders (#303) 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 `/` 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 --- README.md | 2 +- src/core/commands/assetCommands.ts | 2 + src/core/commands/portCommands.ts | 35 ++- src/core/graph/NodeRegistry.ts | 4 +- src/core/graph/lookup.ts | 8 + src/core/graph/nested.ts | 50 +++- src/core/graph/nodes/index.ts | 2 + src/core/model/Asset.ts | 21 +- src/core/model/AssetTypes.ts | 5 + src/core/model/graphLookup.ts | 9 + src/core/properties/PropertySchema.ts | 1 + src/core/shader/compile.ts | 96 +++++- src/core/shader/functions.ts | 297 +++++++++++++++++++ src/core/shader/nodes.ts | 2 +- src/core/store/serialize.ts | 3 +- src/editor/assetTabs.ts | 1 + src/editor/clipboard.ts | 5 +- src/editor/graph/GraphCanvas.ts | 47 ++- src/editor/graph/paletteEntries.ts | 53 +++- src/editor/inspector/portSection.ts | 22 +- src/editor/panels/assetGroups.ts | 1 + src/editor/panels/assetIcons.ts | 2 + src/editor/panels/icons/shaderfunction.svg | 6 + src/editor/shader/ShaderEditor.ts | 51 +++- src/editor/state.ts | 8 +- src/viewport/ShaderLibrary.ts | 37 ++- tests/core/shader-functions.test.ts | 321 +++++++++++++++++++++ tools/e2e/28-shader.mjs | 83 ++++++ 28 files changed, 1091 insertions(+), 83 deletions(-) create mode 100644 src/core/shader/functions.ts create mode 100644 src/editor/panels/icons/shaderfunction.svg create mode 100644 tests/core/shader-functions.test.ts diff --git a/README.md b/README.md index f2521cd4..b7fac6aa 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1725 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1728 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index ef348748..a0fc9a1f 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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': diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index 84695405..f714e404 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -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)); } } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 6ad44387..93e5a4b6 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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; /** diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index 5b2ed3aa..31e5f218 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -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, diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index ed9d1a0c..a0cba02b 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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 = 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 diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 5003f443..1147aaae 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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); diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index e3b1fef5..e33c7bc0 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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 = new Set(['behavior', 'statemachine', 'shader', 'particles']); +const GRAPH_TYPES: ReadonlySet = new Set([ + 'behavior', + 'statemachine', + 'shader', + 'shaderfunction', + 'particles', +]); export function isGraphAsset(asset: Asset): asset is GraphAsset { return GRAPH_TYPES.has(asset.type); diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index e21d049b..77082a1a 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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 = { * 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 = new Set([ 'tile', 'statemachine', 'shader', + 'shaderfunction', 'font', 'sound', 'variables', diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index ab21cc6c..e9945e4f 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -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; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index b0ada01f..cb01fda8 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -630,6 +630,7 @@ const ASSET_SCHEMAS: Record = { 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)], diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 713861ae..7a9e0c19 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -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 | 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): 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(); - 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, +) { const drawn: ShaderPass[] = []; const walks: GraphEmitter[] = []; /** 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(); - 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, +) { let count = 0; const walk = graphEmitter(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; + /** 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 = { ...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') }; +} diff --git a/src/core/shader/functions.ts b/src/core/shader/functions.ts new file mode 100644 index 00000000..29c2c56d --- /dev/null +++ b/src/core/shader/functions.ts @@ -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; + /** Per call left in place, why it could not be set in. */ + unresolved: Record; +} + +/** + * Every call in `graph` replaced by a copy of the function it names (#303), its nodes renamed + * `/`: 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 = {}; + const unresolved: Record = {}; + 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 = {}; + 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 = {}; + 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, inlined: Inlined): Record { + const lifted: Record = {}; + 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(); + 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'); +} diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 354fd005..be2973a2 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -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' }); diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 41f6b544..3e2c4cde 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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() }); diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index daeda244..ce770451 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -7,6 +7,7 @@ export const TAB_OF_ASSET: Partial> = { behavior: 'graph', statemachine: 'statemachine', shader: 'shader', + shaderfunction: 'shader', particles: 'particles', texture: 'texture', vectorart: 'vectorart', diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index b833a12b..fea6e476 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -7,9 +7,11 @@ import { BEHAVIOR_PARAM, findAnnotation, findNode, + FUNCTION_PARAM, type GraphNode, nodeDef, namesBehavior, + SHADER_CALL, } from '../core/graph'; import type { GraphAsset } from '../core/model/Asset'; import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; @@ -160,7 +162,8 @@ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolea return paletteEntries(project, asset).some( (entry) => entry.def.type === node.type && - (!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]), + (!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) && + (node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]), ); } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index e910149d..43b7ef3a 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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(); 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); } diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 3e10d4be..934fa413 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -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 " 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 " 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)); diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 1f6644ba..c272ed32 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -8,16 +8,19 @@ import { SetPortWriteCommand, } from '../../core/commands/portCommands'; import { - BEHAVIOR_START, type DeclaredPort, type GraphNode, + isEntryTerminal, isPortOf, isTerminalNode, missingFieldPorts, + nodeDef, OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_SETTING, OBJECT_TYPE_SETTING, portKindOf, + SHADER_FUNCTION_INPUT, + SHADER_FUNCTION_OUTPUT, VARIABLES_SETTING, variablesSourceOf, propertyTargetOf, @@ -211,8 +214,9 @@ export function buildPortSection( variables: readonly PropertyDef[] = [], typeName: string | null = null, ): void { - const onStart = node.type === BEHAVIOR_START; + const onStart = isEntryTerminal(node.type); const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; + const ofFunction = node.type === SHADER_FUNCTION_INPUT || node.type === SHADER_FUNCTION_OUTPUT; const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out'; const header = sectionHeader(host, `${noun.toUpperCase()}S`); @@ -233,7 +237,9 @@ export function buildPortSection( const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' }); add.onClick = () => { // A further way in or out is a start or end node's to declare; a struct has fields only. - const kinds = DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field'); + const kinds = + nodeDef(node.type)?.portKinds ?? + DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field'); const items = kindMenu(kinds, (kind) => host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })), ); @@ -248,14 +254,18 @@ export function buildPortSection( host, noun === 'Field' ? 'No fields yet. Every field added here becomes a port of the struct.' - : onStart - ? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.' - : 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.', + : ofFunction + ? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.` + : onStart + ? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.' + : 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.', ); for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port)); } +const plural = (noun: string) => `${noun.toLowerCase()}s`; + function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField { return new TextField(port.key, { width: NAME_WIDTH, diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index b23a5104..901e961d 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -31,6 +31,7 @@ export const ASSET_GROUP_LABELS: Record = { tilemap: 'Tilemaps', statemachine: 'State machines', shader: 'Shaders', + shaderfunction: 'Shader functions', font: 'Fonts', sound: 'Sounds', particles: 'Particles', diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 94d484a4..7df18c38 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -22,6 +22,7 @@ import plugins from './icons/plugins.svg?raw'; import rigidbody from './icons/rigidbody.svg?raw'; import scene from './icons/scene.svg?raw'; import shader from './icons/shader.svg?raw'; +import shaderfunction from './icons/shaderfunction.svg?raw'; import sound from './icons/sound.svg?raw'; import sprite from './icons/sprite.svg?raw'; import statemachine from './icons/statemachine.svg?raw'; @@ -50,6 +51,7 @@ export const ASSET_ICON_SVG: Record = { rigidbody, scene, shader, + shaderfunction, sound, sprite, statemachine, diff --git a/src/editor/panels/icons/shaderfunction.svg b/src/editor/panels/icons/shaderfunction.svg new file mode 100644 index 00000000..d953117e --- /dev/null +++ b/src/editor/panels/icons/shaderfunction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index b68e18ff..809011f7 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -1,4 +1,5 @@ -import type { Asset, ShaderAsset } from '../../core/model/Asset'; +import type { Asset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset'; +import { SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, terminalNode } from '../../core/graph'; import type { FlatDef } from '../../core/properties/PropertySchema'; import { type ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders'; import { @@ -37,12 +38,18 @@ const HOLD_WIDTH = 52; const PARAM_NAME_WIDTH = 72; const PARAM_VALUE_WIDTH = 44; +/** What the tab edits: a shader, or a function used in shaders (#303), which has no picture of its own. */ +type ShaderTabAsset = ShaderAsset | ShaderFunctionAsset; + +/** The panel's line while a function is open: it is seen in the shaders that call it. */ +export const FUNCTION_SUBJECT = 'A function shows in the shaders calling it'; + /** * The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture * wearing it (#168). The picture is whatever was last selected that has one – a sprite, a texture, a * frame – so the shader is judged on the image it is meant for; before that, a built-in test image. */ -export class ShaderEditor extends AssetTabEditor { +export class ShaderEditor extends AssetTabEditor { readonly snap = new SnapToggle(() => this.canvas); readonly preview: ShaderPreview; /** Assigned by `body`; public for the check scripts, which drag it. */ @@ -108,8 +115,10 @@ export class ShaderEditor extends AssetTabEditor { /** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */ get animates(): boolean { - const asset = this.asset(); - return Boolean(asset && !this.preview.holding && shaderLibrary.buildFor(asset).compiled?.time); + const asset = this.shader(); + return Boolean( + asset && !this.preview.holding && shaderLibrary.buildFor(asset, this.state.project).compiled?.time, + ); } /** Called every frame while the tab is showing. */ @@ -150,21 +159,37 @@ export class ShaderEditor extends AssetTabEditor { return this.state.selectedShaderId ?? ''; } - override isMine(asset: Asset): asset is ShaderAsset { - return asset.type === 'shader'; + override isMine(asset: Asset): asset is ShaderTabAsset { + return asset.type === 'shader' || asset.type === 'shaderfunction'; } - protected override infoText(asset: ShaderAsset): string { - const build = shaderLibrary.buildFor(asset); + /** The open asset while it is a shader rather than a function. */ + private shader(): ShaderAsset | null { + const asset = this.asset(); + return asset?.type === 'shader' ? asset : null; + } + + protected override infoText(asset: ShaderTabAsset): string { + if (asset.type === 'shaderfunction') { + const check = shaderLibrary.checkFor(asset, this.state.project); + if (check.reason) return check.reason; + const count = (type: string, noun: string) => { + const n = terminalNode(asset.graph, type)?.ports?.length ?? 0; + return `${n} ${noun}${n === 1 ? '' : 's'}`; + }; + return `${count(SHADER_FUNCTION_INPUT, 'input')}, ${count(SHADER_FUNCTION_OUTPUT, 'output')}`; + } + const build = shaderLibrary.buildFor(asset, this.state.project); if (!build.compiled) return build.reason; const count = shaderParamDefs(asset.graph).length; return `${count} ${count === 1 ? 'property' : 'properties'}`; } - protected override syncExtras(asset: ShaderAsset | null): void { - this.snap.setEnabled(Boolean(asset)); + protected override syncExtras(open: ShaderTabAsset | null): void { + this.snap.setEnabled(Boolean(open)); + const asset = open?.type === 'shader' ? open : null; const picture = this.subject && previewPicture(this.state.project, this.subject); - this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : ''); + this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : open ? FUNCTION_SUBJECT : ''); const defs = asset ? shaderParamDefs(asset.graph) : []; // A new picture or another shader starts over from what that sprite wears; an edit of the graph // keeps what the sliders were moved to. @@ -180,7 +205,9 @@ export class ShaderEditor extends AssetTabEditor { } this.syncParams(defs); this.preview.show(asset, picture, this.values); - this.holdButton.setEnabled(Boolean(asset && shaderLibrary.buildFor(asset).compiled?.time)); + this.holdButton.setEnabled( + Boolean(asset && shaderLibrary.buildFor(asset, this.state.project).compiled?.time), + ); this.restartButton.setEnabled(this.holdButton.enabled); this.syncControls(); } diff --git a/src/editor/state.ts b/src/editor/state.ts index f1d6437b..e2b793dc 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -4,7 +4,7 @@ import type { ObjectPick, PickEnd } from './objectPick'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; -import type { Asset, SceneAsset } from '../core/model/Asset'; +import type { Asset, GraphAsset, SceneAsset } from '../core/model/Asset'; import { findAnnotation, type Graph } from '../core/graph'; import { findObject } from '../core/model/hierarchy'; import { allShapes } from '../core/model/vectorTree'; @@ -800,9 +800,9 @@ export class EditorState { return this.selectedGraphId('statemachine'); } - /** The shader the current selection refers to: the asset, or one of its nodes. */ + /** The shader or shader function the current selection refers to: the asset, or one of its nodes. */ get selectedShaderId(): string | null { - return this.selectedGraphId('shader'); + return this.selectedGraphId('shader') ?? this.selectedGraphId('shaderfunction'); } /** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */ @@ -810,7 +810,7 @@ export class EditorState { return this.selectedGraphId('particles'); } - private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null { + private selectedGraphId(type: GraphAsset['type']): string | null { const s = this.selection; if (!s) return null; if (s.kind === 'frame') return null; diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts index 487ee1fb..ce92f8fd 100644 --- a/src/viewport/ShaderLibrary.ts +++ b/src/viewport/ShaderLibrary.ts @@ -1,16 +1,25 @@ -import type { ShaderAsset } from '../core/model/Asset'; +import type { ShaderAsset, ShaderFunctionAsset } from '../core/model/Asset'; +import { shaderFunctionsOf } from '../core/model/graphLookup'; import { findAssetOf, type Project } from '../core/model/Project'; import { graphFingerprint } from '../core/graph/Graph'; -import { compileShader, type ShaderBuild } from '../core/shader/compile'; +import { + checkShaderFunction, + compileShader, + type FunctionCheck, + type ShaderBuild, +} from '../core/shader/compile'; +import { functionsFingerprint } from '../core/shader/functions'; import { gameClock } from '../core/time'; import type { ShaderSource } from './shaders'; /** - * Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is - * edited in place, so the cache keys on a fingerprint of what the fragment depends on. + * Compiled shader graphs, one per asset, recompiled when the graph's content changes – or that of + * a function it calls (#303). A graph is edited in place, so the cache keys on a fingerprint of what + * the fragment depends on. */ class ShaderLibrary { private readonly builds = new Map(); + private readonly checks = new Map(); /** How often a graph went through the compiler – what the checks read to prove a move did not. */ compiles = 0; /** Set from the test hook: the clock stands at this second until released. */ @@ -25,16 +34,28 @@ class ShaderLibrary { this.frozenAt = at; } - buildFor(asset: ShaderAsset): ShaderBuild { - const fingerprint = graphFingerprint(asset.graph); + buildFor(asset: ShaderAsset, project: Project): ShaderBuild { + const functions = shaderFunctionsOf(project); + const fingerprint = `${graphFingerprint(asset.graph)}\n${functionsFingerprint(asset.graph, functions)}`; const cached = this.builds.get(asset.id); if (cached && cached.fingerprint === fingerprint) return cached.build; this.compiles += 1; - const build = compileShader(asset.graph); + const build = compileShader(asset.graph, functions); this.builds.set(asset.id, { fingerprint, build }); return build; } + /** A function on its own: what its tab and its nodes report. */ + checkFor(asset: ShaderFunctionAsset, project: Project): FunctionCheck { + const functions = shaderFunctionsOf(project); + const fingerprint = `${graphFingerprint(asset.graph)}\n${functionsFingerprint(asset.graph, functions)}`; + const cached = this.checks.get(asset.id); + if (cached && cached.fingerprint === fingerprint) return cached.check; + const check = checkShaderFunction(asset.graph, functions, asset.id); + this.checks.set(asset.id, { fingerprint, check }); + return check; + } + /** For one redraw: each asset is looked up and fingerprinted once, however many sprites wear it. */ source(project: Project): ShaderSource { const seen = new Map(); @@ -44,7 +65,7 @@ class ShaderLibrary { let build = seen.get(assetId); if (build === undefined) { const asset = findAssetOf(project, assetId, 'shader'); - build = asset ? this.buildFor(asset) : null; + build = asset ? this.buildFor(asset, project) : null; seen.set(assetId, build); } return build?.compiled ?? null; diff --git a/tests/core/shader-functions.test.ts b/tests/core/shader-functions.test.ts new file mode 100644 index 00000000..1d7b8535 --- /dev/null +++ b/tests/core/shader-functions.test.ts @@ -0,0 +1,321 @@ +/** + * Shader functions (#303): a graph of its own, used as a node in other shaders and set in by the + * compiler where it is called. + */ +import { describe, expect, it } from 'vitest'; +import { CommandError } from '../../src/core/commands/Command'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands'; +import { callProblem, FUNCTION_PARAM, resolveDef, SHADER_CALL } from '../../src/core/graph'; +import { SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT } from '../../src/core/graph/nested'; +import { graphLookup, shaderFunctionsOf } from '../../src/core/model/graphLookup'; +import type { ShaderAsset, ShaderFunctionAsset } from '../../src/core/model/Asset'; +import { findAssetOf, findGraphAsset } from '../../src/core/model/Project'; +import { SHADER_PROPERTY } from '../../src/core/properties/shaders'; +import { checkShaderFunction, compileShader } from '../../src/core/shader/compile'; +import { + SHADER_BLUR, + SHADER_OUTPUT, + SHADER_PASS, + SHADER_TEXTURE_COLOR, + SHADER_TIME, + SHADER_VERTEX_OUTPUT, +} from '../../src/core/shader/nodes'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { paletteEntries } from '../../src/editor/graph/paletteEntries'; +import { shaderLibrary } from '../../src/viewport/ShaderLibrary'; +import { makeSpriteScene, type SceneFixture } from '../helpers'; + +type Fixture = SceneFixture; + +/** A graph asset with the helpers every test here builds with. */ +function graphOf(f: Fixture, type: T, name: string) { + const create = new CreateAssetCommand(type, name); + f.stack.execute(create); + const id = create.asset.id; + const asset = () => + findAssetOf(f.project, id, type)! as T extends 'shader' ? ShaderAsset : ShaderFunctionAsset; + const graph = () => findGraphAsset(f.project, id)!.graph; + const add = (nodeType: string, params = {}) => { + const command = new AddNodeCommand(id, nodeType, 0, 0, params); + f.stack.execute(command); + return command.node.id; + }; + const link = (from: string, fromPort: string, to: string, toPort: string) => + f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort })); + /** An edge no command would lay, for what a hand-edited file may hold. */ + const wire = (from: string, fromPort: string, to: string, toPort: string) => + graph().edges.push({ + id: `w${graph().edges.length}`, + from: { node: from, port: fromPort }, + to: { node: to, port: toPort }, + }); + const port = (node: string, key: string, kind: 'number' | 'color') => + f.stack.execute(new AddPortCommand(id, node, { key, kind })); + return { id, asset, add, link, wire, port }; +} + +/** "Grayscale": a color in, its luminance out as a color. */ +function grayscale(f: Fixture, name = 'Grayscale') { + const fn = graphOf(f, 'shaderfunction', name); + const input = fn.add(SHADER_FUNCTION_INPUT); + const output = fn.add(SHADER_FUNCTION_OUTPUT); + fn.port(input, 'color', 'color'); + fn.port(output, 'gray', 'color'); + const luminance = fn.add('ShaderLuminance'); + fn.link(input, 'color', luminance, 'color'); + fn.link(luminance, 'value', output, 'gray'); + return { ...fn, input, output, luminance }; +} + +/** A shader handing its texel through a call of `fnId` to the Output. */ +function calling(f: Fixture, fnId: string, name = 'Uses') { + const s = graphOf(f, 'shader', name); + const out = s.add(SHADER_OUTPUT); + const texel = s.add(SHADER_TEXTURE_COLOR); + const call = s.add(SHADER_CALL, { [FUNCTION_PARAM]: fnId }); + const build = () => compileShader(s.asset().graph, shaderFunctionsOf(f.project)); + return { ...s, out, texel, call, build }; +} + +describe('shader functions (#303)', () => { + it('draws through a call in two shaders exactly what the same nodes draw in place', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const direct = graphOf(f, 'shader', 'Direct'); + const out = direct.add(SHADER_OUTPUT); + const luminance = direct.add('ShaderLuminance'); + direct.link(direct.add(SHADER_TEXTURE_COLOR), 'color', luminance, 'color'); + direct.link(luminance, 'value', out, 'color'); + const expected = compileShader(direct.asset().graph).compiled!.fragment; + + for (const name of ['First', 'Second']) { + const s = calling(f, fn.id, name); + s.link(s.texel, 'color', s.call, 'color'); + s.link(s.call, 'gray', s.out, 'color'); + const build = s.build(); + expect(build.problems).toEqual({}); + expect(build.compiled!.fragment).toBe(expected); + } + }); + + it('takes a change to the function into every shader calling it', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const shaders = ['First', 'Second'].map((name) => { + const s = calling(f, fn.id, name); + s.link(s.texel, 'color', s.call, 'color'); + s.link(s.call, 'gray', s.out, 'color'); + return s; + }); + const before = shaders.map((s) => shaderLibrary.buildFor(s.asset(), f.project).compiled!.fragment); + const compiles = shaderLibrary.compiles; + expect(shaders.map((s) => shaderLibrary.buildFor(s.asset(), f.project))).toBeTruthy(); + expect(shaderLibrary.compiles).toBe(compiles); + + const sin = fn.add('ShaderSin'); + fn.link(fn.luminance, 'value', sin, 'value'); + fn.link(sin, 'result', fn.output, 'gray'); + const after = shaders.map((s) => shaderLibrary.buildFor(s.asset(), f.project).compiled!.fragment); + expect(shaderLibrary.compiles).toBe(compiles + 2); + for (const [i, fragment] of after.entries()) { + expect(fragment).not.toBe(before[i]); + expect(fragment).toMatch(/= sin\(n\d+_value\);/); + } + }); + + it('grows the call in another shader with a new input, and an undo takes it back in both', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const s = calling(f, fn.id); + const lookup = graphLookup(f.project); + const inputsOfCall = () => { + const node = s.asset().graph.nodes.find((n) => n.id === s.call)!; + return resolveDef(node, s.asset().graph, lookup)!.inputs.map((p) => `${p.key}:${p.type}`); + }; + expect(inputsOfCall()).toEqual(['color:vec3']); + fn.port(fn.input, 'amount', 'number'); + expect(inputsOfCall()).toEqual(['color:vec3', 'amount:number']); + f.stack.undo(); + expect(inputsOfCall()).toEqual(['color:vec3']); + expect( + fn + .asset() + .graph.nodes.find((n) => n.id === fn.input)! + .ports!.map((p) => p.key), + ).toEqual(['color']); + }); + + it('carries the edges at every call along when a port is renamed or removed', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const s = calling(f, fn.id); + s.link(s.texel, 'color', s.call, 'color'); + s.link(s.call, 'gray', s.out, 'color'); + const ports = () => s.asset().graph.edges.map((e) => (e.to.node === s.call ? e.to.port : e.from.port)); + + f.stack.execute(new RenamePortCommand(fn.id, fn.input, 'color', 'tone')); + expect(ports()).toEqual(['tone', 'gray']); + expect(s.build().problems).toEqual({}); + + f.stack.execute(new RemovePortCommand(fn.id, fn.output, 'gray')); + expect(ports()).toEqual(['tone']); + f.stack.undo(); + expect(ports()).toEqual(['tone', 'gray']); + expect(s.build().compiled).not.toBeNull(); + }); + + it('declares numbers and colors only', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + expect(() => f.stack.execute(new AddPortCommand(fn.id, fn.input, { key: 'name', kind: 'text' }))).toThrow( + CommandError, + ); + expect(() => + f.stack.execute(new AddPortCommand(fn.id, fn.output, { key: 'more', kind: 'flow' })), + ).toThrow(CommandError); + }); + + it('reports a function calling itself, directly or through another, and compiles nothing endlessly', () => { + const f = makeSpriteScene(); + const a = grayscale(f, 'A'); + const b = grayscale(f, 'B'); + const lookup = graphLookup(f.project); + const self = a.add(SHADER_CALL, { [FUNCTION_PARAM]: a.id }); + expect( + callProblem( + a.asset().graph.nodes.find((n) => n.id === self)!, + lookup, + a.id, + ), + ).toBe('A function cannot call itself'); + a.asset().graph.nodes.splice( + a.asset().graph.nodes.findIndex((n) => n.id === self), + 1, + ); + const toB = a.add(SHADER_CALL, { [FUNCTION_PARAM]: b.id }); + const toA = b.add(SHADER_CALL, { [FUNCTION_PARAM]: a.id }); + a.port(a.output, 'twice', 'color'); + a.wire(a.input, 'color', toB, 'color'); + a.wire(toB, 'gray', a.output, 'twice'); + expect( + callProblem( + b.asset().graph.nodes.find((n) => n.id === toA)!, + lookup, + b.id, + ), + ).toBe('A function cannot call itself'); + expect(checkShaderFunction(a.asset().graph, shaderFunctionsOf(f.project), a.id).problems[toB]).toBe( + 'In B: A function cannot call itself', + ); + + const s = calling(f, a.id); + s.link(s.texel, 'color', s.call, 'color'); + s.link(s.call, 'gray', s.out, 'color'); + const build = s.build(); + expect(build.compiled).toBeNull(); + expect(build.problems[s.call]).toBe('In A: In B: A function cannot call itself'); + expect(paletteEntries(f.project, b.asset()).some((e) => e.id === `${SHADER_CALL}:${a.id}`)).toBe(false); + }); + + it('feeds an unconnected input of the call, and an unconnected output, with the value typed in', () => { + const f = makeSpriteScene(); + const fn = graphOf(f, 'shaderfunction', 'Scale'); + const input = fn.add(SHADER_FUNCTION_INPUT); + const output = fn.add(SHADER_FUNCTION_OUTPUT, { spare: 0.25 }); + fn.port(input, 'amount', 'number'); + fn.port(output, 'result', 'number'); + fn.port(output, 'spare', 'number'); + fn.link(input, 'amount', output, 'result'); + const s = calling(f, fn.id); + s.asset().graph.nodes.find((n) => n.id === s.call)!.params.amount = 0.75; + const add = s.add('ShaderAdd'); + s.link(s.call, 'result', add, 'a'); + s.link(s.call, 'spare', add, 'b'); + s.link(add, 'result', s.out, 'alpha'); + const fragment = s.build().compiled!.fragment; + expect(fragment).toMatch(/float n\d+_value = 0\.75;/); + expect(fragment).toMatch(/float n\d+_value = 0\.25;/); + }); + + it('sets in two calls of one function apart, and a Time or a Pass inside counts for the caller', () => { + const f = makeSpriteScene(); + const fn = graphOf(f, 'shaderfunction', 'Wide blur'); + const output = fn.add(SHADER_FUNCTION_OUTPUT); + fn.port(output, 'color', 'color'); + const pass = fn.add(SHADER_PASS); + const blur = fn.add(SHADER_BLUR); + const time = fn.add(SHADER_TIME); + fn.link(pass, 'picture', blur, 'picture'); + fn.link(time, 'value', blur, 'radius'); + fn.link(blur, 'color', output, 'color'); + const s = calling(f, fn.id); + const second = s.add(SHADER_CALL, { [FUNCTION_PARAM]: fn.id }); + const mix = s.add('ShaderMix'); + s.link(s.call, 'color', mix, 'a'); + s.link(second, 'color', mix, 'b'); + s.link(mix, 'result', s.out, 'color'); + const compiled = s.build().compiled!; + expect(compiled.passes).toHaveLength(2); + expect(compiled.time).toBe(true); + expect(compiled.fragment.match(/= blur\(/g)).toHaveLength(2); + }); + + it('reports what inside a function goes wrong at the call, and what has no place in it at the node', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const property = fn.add(SHADER_PROPERTY); + const own = checkShaderFunction(fn.asset().graph, shaderFunctionsOf(f.project), fn.id); + expect(own.problems[property]).toBe('A function cannot hold Property'); + const s = calling(f, fn.id); + s.link(s.call, 'gray', s.out, 'color'); + expect(s.build().problems[s.call]).toBe('In Grayscale: A function cannot hold Property'); + + const lone = graphOf(f, 'shaderfunction', 'Lone'); + expect(checkShaderFunction(lone.asset().graph, shaderFunctionsOf(f.project), lone.id).reason).toBe( + 'No Function output node', + ); + const typed = grayscale(f, 'Typed'); + typed.port(typed.output, 'level', 'number'); + typed.wire(typed.input, 'color', typed.output, 'level'); + expect(checkShaderFunction(typed.asset().graph, shaderFunctionsOf(f.project), typed.id).problems).toEqual( + { + [typed.output]: '"level" needs a number, not a color', + }, + ); + }); + + it('offers a function its own ends and no Output, Property or Vertex output, and a shader every function', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const fresh = graphOf(f, 'shaderfunction', 'Fresh'); + const types = (asset: Parameters[1]) => + paletteEntries(f.project, asset).map((e) => e.id); + const inFunction = types(fresh.asset()); + expect(inFunction).toEqual( + expect.arrayContaining([SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, SHADER_TIME]), + ); + for (const absent of [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_PROPERTY, `${SHADER_CALL}:${fresh.id}`]) + expect(inFunction).not.toContain(absent); + expect(inFunction).toContain(`${SHADER_CALL}:${fn.id}`); + expect(types(fn.asset())).not.toContain(SHADER_FUNCTION_INPUT); + + const s = calling(f, fn.id); + const inShader = types(s.asset()); + expect(inShader).not.toContain(SHADER_FUNCTION_INPUT); + const entry = paletteEntries(f.project, s.asset()).find((e) => e.id === `${SHADER_CALL}:${fn.id}`)!; + expect(entry.def.label).toBe('Grayscale'); + }); + + it('keeps a function and its declared ports through saving and loading', () => { + const f = makeSpriteScene(); + const fn = grayscale(f); + const loaded = deserializeProject(serializeProject(f.project)); + const back = findAssetOf(loaded, fn.id, 'shaderfunction')!; + expect(back.graph.nodes.map((n) => [n.type, n.ports?.map((p) => `${p.key}:${p.kind}`) ?? []])).toEqual( + fn.asset().graph.nodes.map((n) => [n.type, n.ports?.map((p) => `${p.key}:${p.kind}`) ?? []]), + ); + expect(back.graph.edges).toHaveLength(2); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index ddf10428..1d538acf 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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');