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

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+2
View File
@@ -95,6 +95,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'statemachine', graph: createEmptyGraph() }; return { ...base, type: 'statemachine', graph: createEmptyGraph() };
case 'shader': case 'shader':
return { ...base, type: 'shader', graph: createEmptyGraph() }; return { ...base, type: 'shader', graph: createEmptyGraph() };
case 'shaderfunction':
return { ...base, type: 'shaderfunction', graph: createEmptyGraph() };
case 'font': case 'font':
return { ...base, type: 'font', file: '' }; return { ...base, type: 'font', file: '' };
case 'sound': case 'sound':
+24 -11
View File
@@ -1,28 +1,32 @@
import { import {
BEHAVIOR_PARAM, BEHAVIOR_PARAM,
BEHAVIOR_START,
declaresPropertyPorts, declaresPropertyPorts,
declaresVariablePorts, declaresVariablePorts,
type DeclaredPort, type DeclaredPort,
findNode, findNode,
fixedPortKeys, fixedPortKeys,
FUNCTION_PARAM,
type Graph, type Graph,
type GraphEdge, type GraphEdge,
type GraphNode, type GraphNode,
isEntryTerminal,
isFlowPort, isFlowPort,
isTerminalNode, isTerminalNode,
isValidPortName, isValidPortName,
namesBehavior, namesBehavior,
OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_PARAM,
propertyTargetOf, propertyTargetOf,
SHADER_CALL,
variablesSourceOf, variablesSourceOf,
} from '../graph'; } from '../graph';
import { isGraphAsset } from '../model/Asset';
import type { PropertyValue } from '../model/Properties'; import type { PropertyValue } from '../model/Properties';
import { kindLabel } from '../properties/kinds';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list'; import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError, RenameCommand } from './Command'; import { type Command, CommandError, RenameCommand } from './Command';
import { nextFieldName } from './naming'; import { nextFieldName } from './naming';
import { requireBehavior, requireNode, requireNodeDef } from './require'; import { requireGraph, requireNode, requireNodeDef } from './require';
interface PortSite { interface PortSite {
graph: Graph; 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 * 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 * itself. A terminal is the exception: its ports are also ports of every node running or hearing the
* hearing the behavior – facing the other way, because what a start node hands out the caller passes in. * 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. * 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[] { 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; 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) { for (const other of project.assets) {
if (other.type !== 'behavior') continue; if (!isGraphAsset(other)) continue;
for (const caller of other.graph.nodes) { for (const caller of other.graph.nodes) {
if (!namesBehavior(caller.type) || caller.params[BEHAVIOR_PARAM] !== assetId) continue; if (!callsIt(caller)) continue;
sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START }); sites.push({ graph: other.graph, nodeId: caller.id, asInput: isEntryTerminal(node.type) });
} }
} }
return sites; return sites;
@@ -135,8 +145,11 @@ export class AddPortsCommand implements Command {
execute(project: Project): void { execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const keys = new Set((node.ports ?? []).map((p) => p.key)); const keys = new Set((node.ports ?? []).map((p) => p.key));
const kinds = requireNodeDef(node.type).portKinds;
for (const port of this.ports) { for (const port of this.ports) {
checkPortName(node, port.key); 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)) if (isFlowPort(port) && !isTerminalNode(node))
throw new CommandError('Only a start or an end node declares a flow port'); 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`); 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`); if (!removed) throw new CommandError(`Port "${this.key}" not found`);
this.removed = removed; this.removed = removed;
node.ports = ports; node.ports = ports;
const graph = requireBehavior(project, this.assetId).graph; const graph = requireGraph(project, this.assetId);
this.edges = dropEdges([ this.edges = dropEdges([
...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })), ...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })),
...flowEdgesOnceSilent(graph, node), ...flowEdgesOnceSilent(graph, node),
@@ -240,7 +253,7 @@ export class SetPortWriteCommand implements Command {
this.edges = []; this.edges = [];
this.value = undefined; this.value = undefined;
if (this.write) return; 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 }; const input: PortSite = { graph, nodeId: node.id, asInput: true };
this.edges = dropEdges([ this.edges = dropEdges([
{ graph, test: edgesOnPort(input, this.key) }, { 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 { 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)); 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 { 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 { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph'; import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { GraphLookup, ValueTyping } from './lookup'; import type { GraphLookup, ValueTyping } from './lookup';
@@ -118,6 +118,8 @@ export interface NodeDef {
* input as well where it is marked `write`. * input as well where it is marked `write`.
*/ */
declaresPorts?: 'inputs' | 'outputs' | 'both'; 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. */ /** 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; declaredPort?(port: DeclaredPort, node: GraphNode, graph: Graph, lookup: GraphLookup): PortDef;
/** /**
+8
View File
@@ -12,6 +12,11 @@ export interface BehaviorProgram {
kept?: readonly string[]; 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. */ /** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
export interface MachineState { export interface MachineState {
id: string; id: string;
@@ -45,6 +50,8 @@ export interface StateMachine {
*/ */
export interface GraphLookup { export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null; 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; machine(assetId: string): StateMachine | null;
curve(assetId: string): readonly VectorPoint[] | null; curve(assetId: string): readonly VectorPoint[] | null;
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */ /** 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 = { export const NO_LOOKUP: GraphLookup = {
behavior: () => null, behavior: () => null,
shaderFunction: () => null,
machine: () => null, machine: () => null,
curve: () => null, curve: () => null,
structType: () => null, structType: () => null,
+49 -1
View File
@@ -24,8 +24,13 @@ export const ON_BEHAVIOR = 'OnBehavior';
export const VARIABLES = 'Variables'; export const VARIABLES = 'Variables';
export const PROPERTIES = 'Properties'; export const PROPERTIES = 'Properties';
const ON_START = 'OnStart'; 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 BEHAVIOR_PARAM = 'behavior';
export const FUNCTION_PARAM = 'function';
export const VARIABLES_PARAM = 'variablesAsset'; export const VARIABLES_PARAM = 'variablesAsset';
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */ /** 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); 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 { 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 { 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 { export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId); 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); if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked'; if (!id) return 'No behavior picked';
@@ -357,6 +374,37 @@ function listenProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): s
return null; 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. */ /** The behaviors the `On behavior` nodes of that graph name, picked or not found alike. */
export function heardBehaviors(graph: Graph): string[] { export function heardBehaviors(graph: Graph): string[] {
return graph.nodes return graph.nodes
+2
View File
@@ -140,6 +140,7 @@ import { SetStateByName } from './flow/SetStateByName';
import { StateBranch } from './flow/StateBranch'; import { StateBranch } from './flow/StateBranch';
import { StateTransition } from './flow/StateTransition'; import { StateTransition } from './flow/StateTransition';
import { PARTICLE_NODES } from '../../particles/nodes'; import { PARTICLE_NODES } from '../../particles/nodes';
import { SHADER_FUNCTION_NODES } from '../../shader/functions';
import { SHADER_NODES } from '../../shader/nodes'; import { SHADER_NODES } from '../../shader/nodes';
import { State } from '../stateMachine'; import { State } from '../stateMachine';
import { Variables } from './variable/Variables'; import { Variables } from './variable/Variables';
@@ -303,4 +304,5 @@ for (const def of BUILT_IN_NODES) registerNode(def);
// behavior graph offers them. // behavior graph offers them.
registerNode(State); registerNode(State);
for (const def of SHADER_NODES) registerNode(def); 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); for (const def of PARTICLE_NODES) registerNode(def);
+19 -2
View File
@@ -277,6 +277,15 @@ export interface ShaderAsset extends AssetBase {
graph: Graph; 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 * `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` * `{sha256}.{ext}`, empty when the upload failed. A text object wears it through its `font`
@@ -545,6 +554,7 @@ export type Asset =
| TilemapAsset | TilemapAsset
| StateMachineAsset | StateMachineAsset
| ShaderAsset | ShaderAsset
| ShaderFunctionAsset
| FontAsset | FontAsset
| SoundAsset | SoundAsset
| ParticlesAsset; | 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. */ /** 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 { export function isGraphAsset(asset: Asset): asset is GraphAsset {
return GRAPH_TYPES.has(asset.type); return GRAPH_TYPES.has(asset.type);
+5
View File
@@ -21,6 +21,7 @@ export const ASSET_TYPES = [
'tilemap', 'tilemap',
'statemachine', 'statemachine',
'shader', 'shader',
'shaderfunction',
'font', 'font',
'sound', 'sound',
'particles', 'particles',
@@ -51,6 +52,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
tilemap: 'Tilemap', tilemap: 'Tilemap',
statemachine: 'State machine', statemachine: 'State machine',
shader: 'Shader', shader: 'Shader',
shaderfunction: 'Shader function',
font: 'Font', font: 'Font',
sound: 'Sound', sound: 'Sound',
particles: 'Particles', 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 * 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. * 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 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< export type PlaceableAssetType = Exclude<
AssetType, AssetType,
@@ -77,6 +80,7 @@ export type PlaceableAssetType = Exclude<
| 'tile' | 'tile'
| 'statemachine' | 'statemachine'
| 'shader' | 'shader'
| 'shaderfunction'
| 'font' | 'font'
| 'sound' | 'sound'
| 'variables' | 'variables'
@@ -94,6 +98,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'tile', 'tile',
'statemachine', 'statemachine',
'shader', 'shader',
'shaderfunction',
'font', 'font',
'sound', 'sound',
'variables', 'variables',
+9
View File
@@ -9,6 +9,14 @@ import { fieldTypings, typeFields, typingOf } from './structTypes';
import { findAsset, findAssetOf, type Project } from './Project'; import { findAsset, findAssetOf, type Project } from './Project';
import { resolvePicture } from './texture'; 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; * 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). * 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 ?? [], kept: asset.keptVariables ?? [],
}; };
}, },
shaderFunction: shaderFunctionsOf(project),
machine: (assetId) => { machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine'); const asset = findAssetOf(project, assetId, 'statemachine');
if (!asset) return null; if (!asset) return null;
+1
View File
@@ -630,6 +630,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
statemachine: [], statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. // The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [], shader: [],
shaderfunction: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws // 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. // a pixel font a pixel per unit, magnified sharp.
font: [filterRow(true)], font: [filterRow(true)],
+88 -8
View File
@@ -1,9 +1,18 @@
import { type GraphEmitter, graphEmitter } from '../graph/emitGraph'; import { type GraphEmitter, graphEmitter } from '../graph/emitGraph';
import { edgesInto, type Graph, type GraphNode } from '../graph/Graph'; 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 { nodeDef } from '../graph/NodeRegistry';
import { colorOf } from '../model/Properties'; import { colorOf } from '../model/Properties';
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema'; import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders'; import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
import {
type FunctionLookup,
functionPortOf,
inlineFunctions,
misplacedProblem,
NO_FUNCTIONS,
problemsAtCalls,
} from './functions';
import { import {
asVec3, asVec3,
BLUR_FN, 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 * 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). * 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 output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
const mode = output ? edgeOf(output.params) : edgeOf({}); const mode = output ? edgeOf(output.params) : edgeOf({});
const uniforms = shaderParamDefs(graph).map((row) => ({ const uniforms = shaderParamDefs(graph).map((row) => ({
@@ -130,8 +152,8 @@ export function compileShader(graph: Graph): ShaderBuild {
const lines: string[] = []; const lines: string[] = [];
const reads = new Set<number>(); const reads = new Set<number>();
const passes = passDrawer(graph, mode, uniforms); const passes = passDrawer(graph, mode, uniforms, unresolved);
const walk = walker(graph, lines, 'n', null, passes.cut(reads)); const walk = walker(graph, lines, 'n', null, passes.cut(reads), unresolved);
const { problems } = walk; const { problems } = walk;
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) => walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
@@ -156,7 +178,7 @@ export function compileShader(graph: Graph): ShaderBuild {
const perPixel: string[] = []; const perPixel: string[] = [];
const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT); const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
const cornerLines: string[] = []; 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; let vertex: ShaderVertex | null = null;
if (corner) { if (corner) {
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!; 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 * 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. * 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 drawn: ShaderPass[] = [];
const walks: GraphEmitter<Expr>[] = []; const walks: GraphEmitter<Expr>[] = [];
/** What keeps a Pass itself from being drawn, rather than something feeding it. */ /** 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); drawing.add(node.id);
const lines: string[] = []; const lines: string[] = [];
const reads = new Set<number>(); 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 def = nodeDef(SHADER_PASS)!;
const color = walk.input(node, def.inputs[0]!); const color = walk.input(node, def.inputs[0]!);
const alpha = walk.input(node, def.inputs[1]!); 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 * 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 * 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; let count = 0;
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, { const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
isDef: isShaderNodeDef, isDef: isShaderNodeDef,
noun: 'shader', noun: 'shader',
emit: (def, inputs, node) => { 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); if (def.perPixel) perPixel?.push(def.label);
let prepared: string | undefined; let prepared: string | undefined;
if (def.prepare) { if (def.prepare) {
@@ -380,3 +428,35 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
}); });
return walk; 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); }`; : `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 vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' }); 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); shaderGraphs.set(a.id, graph);
shaderRows.set(a.id, shaderParamDefs(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)); 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. // 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; if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
assets.push(machine); assets.push(machine);
} else if (type === 'shader') { } else if (type === 'shader' || type === 'shaderfunction') {
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() }); assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'particles') { } else if (type === 'particles') {
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() }); assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
+1
View File
@@ -7,6 +7,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
behavior: 'graph', behavior: 'graph',
statemachine: 'statemachine', statemachine: 'statemachine',
shader: 'shader', shader: 'shader',
shaderfunction: 'shader',
particles: 'particles', particles: 'particles',
texture: 'texture', texture: 'texture',
vectorart: 'vectorart', vectorart: 'vectorart',
+4 -1
View File
@@ -7,9 +7,11 @@ import {
BEHAVIOR_PARAM, BEHAVIOR_PARAM,
findAnnotation, findAnnotation,
findNode, findNode,
FUNCTION_PARAM,
type GraphNode, type GraphNode,
nodeDef, nodeDef,
namesBehavior, namesBehavior,
SHADER_CALL,
} from '../core/graph'; } from '../core/graph';
import type { GraphAsset } from '../core/model/Asset'; import type { GraphAsset } from '../core/model/Asset';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; 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( return paletteEntries(project, asset).some(
(entry) => (entry) =>
entry.def.type === node.type && 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]),
); );
} }
+36 -11
View File
@@ -11,6 +11,7 @@ import {
drawnDef, drawnDef,
edgesInto, edgesInto,
edgesOutOf, edgesOutOf,
FUNCTION_PARAM,
ghostProblem, ghostProblem,
type Graph, type Graph,
type GraphNode, type GraphNode,
@@ -19,6 +20,8 @@ import {
resolveDef, resolveDef,
namesBehavior, namesBehavior,
RUN_BEHAVIOR, RUN_BEHAVIOR,
SHADER_CALL,
callsFunction,
startStateOf, startStateOf,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
@@ -278,13 +281,16 @@ export class GraphCanvas extends Widget {
this.annotationLayer.sync(asset.graph); this.annotationLayer.sync(asset.graph);
const seen = new Set<string>(); const seen = new Set<string>();
const lookup = graphLookup(this.state.project); const lookup = graphLookup(this.state.project);
// In a compiled graph a node's problem is the compiler's, not a call's. // In a compiled graph a node's problem is the compiler's, not a call's – but a shader function's
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null; // 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 const compilerProblems = shaderBuild
? { ...shaderBuild.hints, ...shaderBuild.problems } ? { ...shaderBuild.hints, ...shaderBuild.problems }
: asset.type === 'particles' : asset.type === 'shaderfunction'
? particleLibrary.buildFor(asset).problems ? shaderLibrary.checkFor(asset, this.state.project).problems
: null; : asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) { for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup); const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue; if (!resolved) continue;
@@ -299,7 +305,8 @@ export class GraphCanvas extends Widget {
this.nodeLayer.addChild(view); this.nodeLayer.addChild(view);
} }
const problem = 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) ?? ghostProblem(node, asset.graph, resolved) ??
blockProblem(asset.graph, node); blockProblem(asset.graph, node);
view.sync(node, asset.graph, this.state.project, def, problem); view.sync(node, asset.graph, this.state.project, def, problem);
@@ -628,6 +635,12 @@ export class GraphCanvas extends Widget {
} }
private openCalledBehavior(node: GraphNode): boolean { 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; if (!namesBehavior(node.type)) return false;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined; 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. * it lands where the ghost was rather than below and right of it.
*/ */
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void { private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h; 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; 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. // A behavior graph runs other behaviors and a shader calls functions (#303); a state machine
if (!inside || this.asset()?.type !== 'behavior' || asset?.type !== 'behavior') { // 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); this.setIncoming(null);
return; return;
} }
const g = this.screenToGraph(local.x, local.y); 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({ this.setIncoming({
assetId: asset.id, assetId: asset.id,
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) }, 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); this.state.setNotice(drop.rejection);
return; 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); if (entry) this.addNode(entry, drop.at);
} }
+38 -15
View File
@@ -3,7 +3,9 @@ import {
allNodeDefs, allNodeDefs,
BEHAVIOR_END, BEHAVIOR_END,
BEHAVIOR_PARAM, BEHAVIOR_PARAM,
callsFunction,
createEmptyGraph, createEmptyGraph,
FUNCTION_PARAM,
isTerminalType, isTerminalType,
namesBehavior, namesBehavior,
nextStateName, nextStateName,
@@ -12,11 +14,14 @@ import {
ON_BEHAVIOR, ON_BEHAVIOR,
resolveDef, resolveDef,
RUN_BEHAVIOR, RUN_BEHAVIOR,
SHADER_CALL,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
State, State,
STATE_NAME_PARAM, STATE_NAME_PARAM,
terminalNode, terminalNode,
} from '../../core/graph'; } 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 { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
@@ -34,6 +39,7 @@ import {
SHADER_PROPERTY, SHADER_PROPERTY,
} from '../../core/properties/shaders'; } from '../../core/properties/shaders';
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes'; 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'; 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 * A shader's nodes, its one Output dropping out once placed, and every shader function under its own
* new one named by its number; a shader holds shader nodes, its one Output dropping out once * name (#303). A function holds the same nodes but those that end a shader or give the wearer a row,
* placed. In a behavior, every other behavior appears under its own name instead of behind a * and its own two ends instead; it calls every function but those that would come back to it.
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
*/ */
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] { function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsset): PaletteEntry[] {
if (asset.type === 'statemachine') { const inFunction = asset.type === 'shaderfunction';
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) }; const placed = new Set(asset.graph.nodes.map((n) => n.type));
return [{ id: State.type, hint: State.category, def: State, params }]; const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT];
} const entries: PaletteEntry[] = [...SHADER_NODES, ShaderFunctionInput, ShaderFunctionOutput]
if (asset.type === 'shader') { .filter((def) => offeredIn(def.type, inFunction) && (!once.includes(def.type) || !placed.has(def.type)))
const placed = new Set(asset.graph.nodes.map((n) => n.type)); .map((def) => {
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT];
return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => {
const params: PropertyBag = const params: PropertyBag =
def.type === SHADER_PROPERTY def.type === SHADER_PROPERTY
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) } ? { [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 }; return { id: def.type, hint: def.category, def, params };
}); });
const lookup = graphLookup(project);
for (const other of project.assets) {
if (other.type !== 'shaderfunction' || callsFunction(lookup, other.id, asset.id)) continue;
const params = { [FUNCTION_PARAM]: other.id };
const def = resolveDef({ id: '', type: SHADER_CALL, x: 0, y: 0, params }, createEmptyGraph(), lookup);
if (def) entries.push({ id: `${SHADER_CALL}:${other.id}`, hint: 'function', def, params });
} }
return entries;
}
/**
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
* new one named by its number; a shader holds shader nodes (`shaderEntries`). In a behavior, every other behavior appears under its own name instead of behind a
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
if (asset.type === 'particles') { if (asset.type === 'particles') {
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT); const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf)); const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));
+16 -6
View File
@@ -8,16 +8,19 @@ import {
SetPortWriteCommand, SetPortWriteCommand,
} from '../../core/commands/portCommands'; } from '../../core/commands/portCommands';
import { import {
BEHAVIOR_START,
type DeclaredPort, type DeclaredPort,
type GraphNode, type GraphNode,
isEntryTerminal,
isPortOf, isPortOf,
isTerminalNode, isTerminalNode,
missingFieldPorts, missingFieldPorts,
nodeDef,
OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_PARAM,
OBJECT_BEHAVIOR_SETTING, OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING, OBJECT_TYPE_SETTING,
portKindOf, portKindOf,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
VARIABLES_SETTING, VARIABLES_SETTING,
variablesSourceOf, variablesSourceOf,
propertyTargetOf, propertyTargetOf,
@@ -211,8 +214,9 @@ export function buildPortSection(
variables: readonly PropertyDef[] = [], variables: readonly PropertyDef[] = [],
typeName: string | null = null, typeName: string | null = null,
): void { ): void {
const onStart = node.type === BEHAVIOR_START; const onStart = isEntryTerminal(node.type);
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; 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 base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
const header = sectionHeader(host, `${noun.toUpperCase()}S`); const header = sectionHeader(host, `${noun.toUpperCase()}S`);
@@ -233,7 +237,9 @@ export function buildPortSection(
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' }); const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => { add.onClick = () => {
// A further way in or out is a start or end node's to declare; a struct has fields only. // 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) => const items = kindMenu(kinds, (kind) =>
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })), host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
); );
@@ -248,14 +254,18 @@ export function buildPortSection(
host, host,
noun === 'Field' noun === 'Field'
? 'No fields yet. Every field added here becomes a port of the struct.' ? 'No fields yet. Every field added here becomes a port of the struct.'
: onStart : ofFunction
? '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 ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.', : 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)); 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 { function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, { return new TextField(port.key, {
width: NAME_WIDTH, width: NAME_WIDTH,
+1
View File
@@ -31,6 +31,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
tilemap: 'Tilemaps', tilemap: 'Tilemaps',
statemachine: 'State machines', statemachine: 'State machines',
shader: 'Shaders', shader: 'Shaders',
shaderfunction: 'Shader functions',
font: 'Fonts', font: 'Fonts',
sound: 'Sounds', sound: 'Sounds',
particles: 'Particles', particles: 'Particles',
+2
View File
@@ -22,6 +22,7 @@ import plugins from './icons/plugins.svg?raw';
import rigidbody from './icons/rigidbody.svg?raw'; import rigidbody from './icons/rigidbody.svg?raw';
import scene from './icons/scene.svg?raw'; import scene from './icons/scene.svg?raw';
import shader from './icons/shader.svg?raw'; import shader from './icons/shader.svg?raw';
import shaderfunction from './icons/shaderfunction.svg?raw';
import sound from './icons/sound.svg?raw'; import sound from './icons/sound.svg?raw';
import sprite from './icons/sprite.svg?raw'; import sprite from './icons/sprite.svg?raw';
import statemachine from './icons/statemachine.svg?raw'; import statemachine from './icons/statemachine.svg?raw';
@@ -50,6 +51,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
rigidbody, rigidbody,
scene, scene,
shader, shader,
shaderfunction,
sound, sound,
sprite, sprite,
statemachine, statemachine,
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- The shader's half-lit disc between the brackets of a call: a part used in other shaders. -->
<circle cx="0" cy="0" r="3.6" fill="none" stroke="currentColor" stroke-width="1.25" />
<path d="M0 -3.6 A3.6 3.6 0 0 1 0 3.6 Z" fill="currentColor" />
<path d="M-4.6 -6.8 Q-7.6 0 -4.6 6.8 M4.6 -6.8 Q7.6 0 4.6 6.8" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

+39 -12
View File
@@ -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 { FlatDef } from '../../core/properties/PropertySchema';
import { type ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders'; import { type ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders';
import { import {
@@ -37,12 +38,18 @@ const HOLD_WIDTH = 52;
const PARAM_NAME_WIDTH = 72; const PARAM_NAME_WIDTH = 72;
const PARAM_VALUE_WIDTH = 44; 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 * 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 * 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. * 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<ShaderAsset, GraphCanvas> { export class ShaderEditor extends AssetTabEditor<ShaderTabAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
readonly preview: ShaderPreview; readonly preview: ShaderPreview;
/** Assigned by `body`; public for the check scripts, which drag it. */ /** Assigned by `body`; public for the check scripts, which drag it. */
@@ -108,8 +115,10 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
/** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */ /** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */
get animates(): boolean { get animates(): boolean {
const asset = this.asset(); const asset = this.shader();
return Boolean(asset && !this.preview.holding && shaderLibrary.buildFor(asset).compiled?.time); return Boolean(
asset && !this.preview.holding && shaderLibrary.buildFor(asset, this.state.project).compiled?.time,
);
} }
/** Called every frame while the tab is showing. */ /** Called every frame while the tab is showing. */
@@ -150,21 +159,37 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
return this.state.selectedShaderId ?? ''; return this.state.selectedShaderId ?? '';
} }
override isMine(asset: Asset): asset is ShaderAsset { override isMine(asset: Asset): asset is ShaderTabAsset {
return asset.type === 'shader'; return asset.type === 'shader' || asset.type === 'shaderfunction';
} }
protected override infoText(asset: ShaderAsset): string { /** The open asset while it is a shader rather than a function. */
const build = shaderLibrary.buildFor(asset); 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; if (!build.compiled) return build.reason;
const count = shaderParamDefs(asset.graph).length; const count = shaderParamDefs(asset.graph).length;
return `${count} ${count === 1 ? 'property' : 'properties'}`; return `${count} ${count === 1 ? 'property' : 'properties'}`;
} }
protected override syncExtras(asset: ShaderAsset | null): void { protected override syncExtras(open: ShaderTabAsset | null): void {
this.snap.setEnabled(Boolean(asset)); this.snap.setEnabled(Boolean(open));
const asset = open?.type === 'shader' ? open : null;
const picture = this.subject && previewPicture(this.state.project, this.subject); 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) : []; const defs = asset ? shaderParamDefs(asset.graph) : [];
// A new picture or another shader starts over from what that sprite wears; an edit of the 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. // keeps what the sliders were moved to.
@@ -180,7 +205,9 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
} }
this.syncParams(defs); this.syncParams(defs);
this.preview.show(asset, picture, this.values); 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.restartButton.setEnabled(this.holdButton.enabled);
this.syncControls(); this.syncControls();
} }
+4 -4
View File
@@ -4,7 +4,7 @@ import type { ObjectPick, PickEnd } from './objectPick';
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
import { type Command, CommandError } from '../core/commands/Command'; import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack'; 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 { findAnnotation, type Graph } from '../core/graph';
import { findObject } from '../core/model/hierarchy'; import { findObject } from '../core/model/hierarchy';
import { allShapes } from '../core/model/vectorTree'; import { allShapes } from '../core/model/vectorTree';
@@ -800,9 +800,9 @@ export class EditorState {
return this.selectedGraphId('statemachine'); 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 { 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. */ /** 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'); return this.selectedGraphId('particles');
} }
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null { private selectedGraphId(type: GraphAsset['type']): string | null {
const s = this.selection; const s = this.selection;
if (!s) return null; if (!s) return null;
if (s.kind === 'frame') return null; if (s.kind === 'frame') return null;
+29 -8
View File
@@ -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 { findAssetOf, type Project } from '../core/model/Project';
import { graphFingerprint } from '../core/graph/Graph'; 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 { gameClock } from '../core/time';
import type { ShaderSource } from './shaders'; import type { ShaderSource } from './shaders';
/** /**
* Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is * Compiled shader graphs, one per asset, recompiled when the graph's content changes – or that of
* edited in place, so the cache keys on a fingerprint of what the fragment depends on. * 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 { class ShaderLibrary {
private readonly builds = new Map<string, { fingerprint: string; build: ShaderBuild }>(); private readonly builds = new Map<string, { fingerprint: string; build: ShaderBuild }>();
private readonly checks = new Map<string, { fingerprint: string; check: FunctionCheck }>();
/** How often a graph went through the compiler – what the checks read to prove a move did not. */ /** How often a graph went through the compiler – what the checks read to prove a move did not. */
compiles = 0; compiles = 0;
/** Set from the test hook: the clock stands at this second until released. */ /** Set from the test hook: the clock stands at this second until released. */
@@ -25,16 +34,28 @@ class ShaderLibrary {
this.frozenAt = at; this.frozenAt = at;
} }
buildFor(asset: ShaderAsset): ShaderBuild { buildFor(asset: ShaderAsset, project: Project): ShaderBuild {
const fingerprint = graphFingerprint(asset.graph); const functions = shaderFunctionsOf(project);
const fingerprint = `${graphFingerprint(asset.graph)}\n${functionsFingerprint(asset.graph, functions)}`;
const cached = this.builds.get(asset.id); const cached = this.builds.get(asset.id);
if (cached && cached.fingerprint === fingerprint) return cached.build; if (cached && cached.fingerprint === fingerprint) return cached.build;
this.compiles += 1; this.compiles += 1;
const build = compileShader(asset.graph); const build = compileShader(asset.graph, functions);
this.builds.set(asset.id, { fingerprint, build }); this.builds.set(asset.id, { fingerprint, build });
return 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. */ /** For one redraw: each asset is looked up and fingerprinted once, however many sprites wear it. */
source(project: Project): ShaderSource { source(project: Project): ShaderSource {
const seen = new Map<string, ShaderBuild | null>(); const seen = new Map<string, ShaderBuild | null>();
@@ -44,7 +65,7 @@ class ShaderLibrary {
let build = seen.get(assetId); let build = seen.get(assetId);
if (build === undefined) { if (build === undefined) {
const asset = findAssetOf(project, assetId, 'shader'); const asset = findAssetOf(project, assetId, 'shader');
build = asset ? this.buildFor(asset) : null; build = asset ? this.buildFor(asset, project) : null;
seen.set(assetId, build); seen.set(assetId, build);
} }
return build?.compiled ?? null; return build?.compiled ?? null;
+321
View File
@@ -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<T extends 'shader' | 'shaderfunction'>(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<typeof paletteEntries>[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);
});
});
+83
View File
@@ -1140,6 +1140,89 @@ export async function shader(t) {
colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12, colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12,
{ chained }, { 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 assetProps({ texture: picture?.id ?? '' });
await filterOf(picture?.id, 'linear'); await filterOf(picture?.id, 'linear');