Use a shader graph as a node in other shaders (#303)
A calculation needed in several shaders (a greyscale, a noise, an outline) had to be built again in every one of them. A shader function keeps it as an asset of its own, called like a behavior is since #16. - New asset type `shaderfunction` ("Shader function"), edited in the Shader tab. The panel beside the graph shows no picture, the readout counts its inputs and outputs. - Two new nodes: `Function input` and `Function output`. Their declared ports, numbers or colours only, are the ports of every call. One of each per function, like Start and End. - The call is node `ShaderCall`, named after the function. The palette lists every function, it can be dragged in from the Assets panel, and a double-click opens it. - The compiler sets the function in where it is called (`inlineFunctions`). It renames the copied nodes `<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:
1 parent
2e55071fa5
commit
a526e1b40f
28 files changed
+1091
-83
No files matched your search
@@ -95,6 +95,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
|
||||
case 'shader':
|
||||
return { ...base, type: 'shader', graph: createEmptyGraph() };
|
||||
case 'shaderfunction':
|
||||
return { ...base, type: 'shaderfunction', graph: createEmptyGraph() };
|
||||
case 'font':
|
||||
return { ...base, type: 'font', file: '' };
|
||||
case 'sound':
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
declaresPropertyPorts,
|
||||
declaresVariablePorts,
|
||||
type DeclaredPort,
|
||||
findNode,
|
||||
fixedPortKeys,
|
||||
FUNCTION_PARAM,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
isEntryTerminal,
|
||||
isFlowPort,
|
||||
isTerminalNode,
|
||||
isValidPortName,
|
||||
namesBehavior,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
propertyTargetOf,
|
||||
SHADER_CALL,
|
||||
variablesSourceOf,
|
||||
} from '../graph';
|
||||
import { isGraphAsset } from '../model/Asset';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { kindLabel } from '../properties/kinds';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError, RenameCommand } from './Command';
|
||||
import { nextFieldName } from './naming';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
import { requireGraph, requireNode, requireNodeDef } from './require';
|
||||
|
||||
interface PortSite {
|
||||
graph: Graph;
|
||||
@@ -33,18 +37,24 @@ interface PortSite {
|
||||
|
||||
/**
|
||||
* Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node
|
||||
* itself. A behavior's terminal is the exception: its ports are also ports of every node running or
|
||||
* hearing the behavior – facing the other way, because what a start node hands out the caller passes in.
|
||||
* itself. A terminal is the exception: its ports are also ports of every node running or hearing the
|
||||
* behavior, or calling the shader function – facing the other way, because what a start node hands
|
||||
* out the caller passes in.
|
||||
* A node that has no such port (an `On behavior` and a flow port of the start) has no edges on it either.
|
||||
*/
|
||||
function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] {
|
||||
const sites = ownSites(requireBehavior(project, assetId).graph, node);
|
||||
const sites = ownSites(requireGraph(project, assetId), node);
|
||||
if (!isTerminalNode(node)) return sites;
|
||||
// A shader function's ends are the ports of its calls in shaders and in other functions (#303).
|
||||
const callsIt = (caller: GraphNode) =>
|
||||
caller.type === SHADER_CALL
|
||||
? caller.params[FUNCTION_PARAM] === assetId
|
||||
: namesBehavior(caller.type) && caller.params[BEHAVIOR_PARAM] === assetId;
|
||||
for (const other of project.assets) {
|
||||
if (other.type !== 'behavior') continue;
|
||||
if (!isGraphAsset(other)) continue;
|
||||
for (const caller of other.graph.nodes) {
|
||||
if (!namesBehavior(caller.type) || caller.params[BEHAVIOR_PARAM] !== assetId) continue;
|
||||
sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START });
|
||||
if (!callsIt(caller)) continue;
|
||||
sites.push({ graph: other.graph, nodeId: caller.id, asInput: isEntryTerminal(node.type) });
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
@@ -135,8 +145,11 @@ export class AddPortsCommand implements Command {
|
||||
execute(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const keys = new Set((node.ports ?? []).map((p) => p.key));
|
||||
const kinds = requireNodeDef(node.type).portKinds;
|
||||
for (const port of this.ports) {
|
||||
checkPortName(node, port.key);
|
||||
if (kinds && !kinds.includes(port.kind))
|
||||
throw new CommandError(`A ${kindLabel(port.kind)} port has no place on this node`);
|
||||
if (isFlowPort(port) && !isTerminalNode(node))
|
||||
throw new CommandError('Only a start or an end node declares a flow port');
|
||||
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
|
||||
@@ -181,7 +194,7 @@ export class RemovePortCommand implements Command {
|
||||
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
|
||||
this.removed = removed;
|
||||
node.ports = ports;
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
this.edges = dropEdges([
|
||||
...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })),
|
||||
...flowEdgesOnceSilent(graph, node),
|
||||
@@ -240,7 +253,7 @@ export class SetPortWriteCommand implements Command {
|
||||
this.edges = [];
|
||||
this.value = undefined;
|
||||
if (this.write) return;
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
const input: PortSite = { graph, nodeId: node.id, asInput: true };
|
||||
this.edges = dropEdges([
|
||||
{ graph, test: edgesOnPort(input, this.key) },
|
||||
@@ -334,7 +347,7 @@ export class RepointPortCommand implements Command {
|
||||
}
|
||||
|
||||
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
|
||||
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
|
||||
renamePortOn(requireGraph(project, this.assetId), node, from, to.key);
|
||||
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
|
||||
import { isKindWith, plainKindOf } from '../properties/kinds';
|
||||
import { type DeclaredPortKind, isKindWith, plainKindOf } from '../properties/kinds';
|
||||
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
|
||||
import type { GraphLookup, ValueTyping } from './lookup';
|
||||
@@ -118,6 +118,8 @@ export interface NodeDef {
|
||||
* input as well where it is marked `write`.
|
||||
*/
|
||||
declaresPorts?: 'inputs' | 'outputs' | 'both';
|
||||
/** The kinds a declared port may have here; without it, every declarable kind. */
|
||||
portKinds?: readonly DeclaredPortKind[];
|
||||
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
|
||||
declaredPort?(port: DeclaredPort, node: GraphNode, graph: Graph, lookup: GraphLookup): PortDef;
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface BehaviorProgram {
|
||||
kept?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ShaderFunction {
|
||||
name: string;
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
|
||||
export interface MachineState {
|
||||
id: string;
|
||||
@@ -45,6 +50,8 @@ export interface StateMachine {
|
||||
*/
|
||||
export interface GraphLookup {
|
||||
behavior(assetId: string): BehaviorProgram | null;
|
||||
/** A shader function's graph, which a call node takes its ports from and the compiler sets in (#303). */
|
||||
shaderFunction(assetId: string): ShaderFunction | null;
|
||||
machine(assetId: string): StateMachine | null;
|
||||
curve(assetId: string): readonly VectorPoint[] | null;
|
||||
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
|
||||
@@ -80,6 +87,7 @@ export interface ValueTyping {
|
||||
|
||||
export const NO_LOOKUP: GraphLookup = {
|
||||
behavior: () => null,
|
||||
shaderFunction: () => null,
|
||||
machine: () => null,
|
||||
curve: () => null,
|
||||
structType: () => null,
|
||||
|
||||
@@ -24,8 +24,13 @@ export const ON_BEHAVIOR = 'OnBehavior';
|
||||
export const VARIABLES = 'Variables';
|
||||
export const PROPERTIES = 'Properties';
|
||||
const ON_START = 'OnStart';
|
||||
/** The two ends of a shader function (#303) and the node calling one, see `core/shader/functions`. */
|
||||
export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput';
|
||||
export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput';
|
||||
export const SHADER_CALL = 'ShaderCall';
|
||||
|
||||
export const BEHAVIOR_PARAM = 'behavior';
|
||||
export const FUNCTION_PARAM = 'function';
|
||||
export const VARIABLES_PARAM = 'variablesAsset';
|
||||
|
||||
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
|
||||
@@ -116,8 +121,19 @@ export function callEntries(graph: Graph): GraphNode[] {
|
||||
return start ? [start] : graph.nodes.filter((n) => n.type === ON_START);
|
||||
}
|
||||
|
||||
/** A graph has one of each: its ports are those of every node calling the graph. */
|
||||
export function isTerminalType(type: string): boolean {
|
||||
return type === BEHAVIOR_START || type === BEHAVIOR_END;
|
||||
return (
|
||||
type === BEHAVIOR_START ||
|
||||
type === BEHAVIOR_END ||
|
||||
type === SHADER_FUNCTION_INPUT ||
|
||||
type === SHADER_FUNCTION_OUTPUT
|
||||
);
|
||||
}
|
||||
|
||||
/** The terminal whose ports a caller passes in, rather than takes out. */
|
||||
export function isEntryTerminal(type: string): boolean {
|
||||
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT;
|
||||
}
|
||||
|
||||
export function isTerminalNode(node: GraphNode): boolean {
|
||||
@@ -336,6 +352,7 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
|
||||
*/
|
||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
|
||||
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
|
||||
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
if (!id) return 'No behavior picked';
|
||||
@@ -357,6 +374,37 @@ function listenProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): s
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why a shader function's call has nothing to set in its place (#303). */
|
||||
function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): string | null {
|
||||
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
|
||||
if (!id) return 'No function picked';
|
||||
if (!lookup.shaderFunction(id)) return 'That function is gone';
|
||||
if (ownerId && callsFunction(lookup, id, ownerId)) return 'A function cannot call itself';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The functions the calls in that graph name, picked or not found alike. */
|
||||
export function calledFunctions(graph: Graph): string[] {
|
||||
return graph.nodes
|
||||
.filter((n) => n.type === SHADER_CALL)
|
||||
.map((n) => textOf(n.params[FUNCTION_PARAM] ?? ''))
|
||||
.filter((id) => id !== '');
|
||||
}
|
||||
|
||||
/** Whether `from` is `target` or calls it, however many functions lie between. */
|
||||
export function callsFunction(
|
||||
lookup: GraphLookup,
|
||||
from: string,
|
||||
target: string,
|
||||
seen: Set<string> = new Set(),
|
||||
): boolean {
|
||||
if (from === target) return true;
|
||||
if (seen.has(from)) return false;
|
||||
seen.add(from);
|
||||
const called = lookup.shaderFunction(from);
|
||||
return called ? calledFunctions(called.graph).some((id) => callsFunction(lookup, id, target, seen)) : false;
|
||||
}
|
||||
|
||||
/** The behaviors the `On behavior` nodes of that graph name, picked or not found alike. */
|
||||
export function heardBehaviors(graph: Graph): string[] {
|
||||
return graph.nodes
|
||||
|
||||
@@ -140,6 +140,7 @@ import { SetStateByName } from './flow/SetStateByName';
|
||||
import { StateBranch } from './flow/StateBranch';
|
||||
import { StateTransition } from './flow/StateTransition';
|
||||
import { PARTICLE_NODES } from '../../particles/nodes';
|
||||
import { SHADER_FUNCTION_NODES } from '../../shader/functions';
|
||||
import { SHADER_NODES } from '../../shader/nodes';
|
||||
import { State } from '../stateMachine';
|
||||
import { Variables } from './variable/Variables';
|
||||
@@ -303,4 +304,5 @@ for (const def of BUILT_IN_NODES) registerNode(def);
|
||||
// behavior graph offers them.
|
||||
registerNode(State);
|
||||
for (const def of SHADER_NODES) registerNode(def);
|
||||
for (const def of SHADER_FUNCTION_NODES) registerNode(def);
|
||||
for (const def of PARTICLE_NODES) registerNode(def);
|
||||
+19
-2
@@ -277,6 +277,15 @@ export interface ShaderAsset extends AssetBase {
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* A part of a shader graph used in others (#303): its `Function input` and `Function output` nodes
|
||||
* are the ports of every node calling it, and the compiler sets the graph in at each call.
|
||||
*/
|
||||
export interface ShaderFunctionAsset extends AssetBase {
|
||||
type: 'shaderfunction';
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* `file` names the typeface in `data/fonts/` the way a texture names its image: content-addressed
|
||||
* `{sha256}.{ext}`, empty when the upload failed. A text object wears it through its `font`
|
||||
@@ -545,6 +554,7 @@ export type Asset =
|
||||
| TilemapAsset
|
||||
| StateMachineAsset
|
||||
| ShaderAsset
|
||||
| ShaderFunctionAsset
|
||||
| FontAsset
|
||||
| SoundAsset
|
||||
| ParticlesAsset;
|
||||
@@ -557,9 +567,16 @@ export function isVariableOwner(asset: Asset | undefined): asset is VariableOwne
|
||||
}
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
|
||||
export type GraphAsset =
|
||||
BehaviorAsset | StateMachineAsset | ShaderAsset | ShaderFunctionAsset | ParticlesAsset;
|
||||
|
||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set(['behavior', 'statemachine', 'shader', 'particles']);
|
||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'behavior',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'particles',
|
||||
]);
|
||||
|
||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||
return GRAPH_TYPES.has(asset.type);
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ASSET_TYPES = [
|
||||
'tilemap',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'font',
|
||||
'sound',
|
||||
'particles',
|
||||
@@ -51,6 +52,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
tilemap: 'Tilemap',
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
shaderfunction: 'Shader function',
|
||||
font: 'Font',
|
||||
sound: 'Sound',
|
||||
particles: 'Particles',
|
||||
@@ -64,6 +66,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
|
||||
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||
* A shader function is part of other shaders (#303), worn by nothing itself.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -77,6 +80,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'tile'
|
||||
| 'statemachine'
|
||||
| 'shader'
|
||||
| 'shaderfunction'
|
||||
| 'font'
|
||||
| 'sound'
|
||||
| 'variables'
|
||||
@@ -94,6 +98,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'tile',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'font',
|
||||
'sound',
|
||||
'variables',
|
||||
|
||||
@@ -9,6 +9,14 @@ import { fieldTypings, typeFields, typingOf } from './structTypes';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { resolvePicture } from './texture';
|
||||
|
||||
/** The project's shader functions by id, as a call and the compiler see them (#303). */
|
||||
export function shaderFunctionsOf(project: Project): GraphLookup['shaderFunction'] {
|
||||
return (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'shaderfunction');
|
||||
return asset ? { name: asset.name, graph: asset.graph } : null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a graph may see of the project around it. A behavior's `defaults` are its variable values;
|
||||
* every call starts from a copy of them, except for the variables it keeps (#229).
|
||||
@@ -25,6 +33,7 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
kept: asset.keptVariables ?? [],
|
||||
};
|
||||
},
|
||||
shaderFunction: shaderFunctionsOf(project),
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) return null;
|
||||
|
||||
@@ -630,6 +630,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
statemachine: [],
|
||||
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
|
||||
shader: [],
|
||||
shaderfunction: [],
|
||||
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
|
||||
// a pixel font a pixel per unit, magnified sharp.
|
||||
font: [filterRow(true)],
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { type GraphEmitter, graphEmitter } from '../graph/emitGraph';
|
||||
import { edgesInto, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { SHADER_CALL, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, terminalNode } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { colorOf } from '../model/Properties';
|
||||
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
|
||||
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
|
||||
import {
|
||||
type FunctionLookup,
|
||||
functionPortOf,
|
||||
inlineFunctions,
|
||||
misplacedProblem,
|
||||
NO_FUNCTIONS,
|
||||
problemsAtCalls,
|
||||
} from './functions';
|
||||
import {
|
||||
asVec3,
|
||||
BLUR_FN,
|
||||
@@ -119,7 +128,20 @@ type PassCut = (node: GraphNode) => Record<string, Expr> | null;
|
||||
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. Every
|
||||
* Pass the Output reaches is a fragment of its own, drawn first into a picture (#301).
|
||||
*/
|
||||
export function compileShader(graph: Graph): ShaderBuild {
|
||||
export function compileShader(source: Graph, functions: FunctionLookup = NO_FUNCTIONS): ShaderBuild {
|
||||
const inlined = inlineFunctions(source, functions);
|
||||
const build = compileGraph(inlined.graph, inlined.unresolved);
|
||||
const problems = problemsAtCalls(build.problems, inlined);
|
||||
for (const node of source.nodes) {
|
||||
const misplaced = misplacedProblem(node.type, false);
|
||||
if (misplaced && !(node.id in problems)) problems[node.id] = misplaced;
|
||||
}
|
||||
const failed = firstProblem(source, problems);
|
||||
return failed === null ? build : { compiled: null, problems, hints: build.hints, reason: failed };
|
||||
}
|
||||
|
||||
/** `compileShader` on a graph whose calls are set in already; `unresolved` says why the rest are not. */
|
||||
function compileGraph(graph: Graph, unresolved: Record<string, string>): ShaderBuild {
|
||||
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
|
||||
const mode = output ? edgeOf(output.params) : edgeOf({});
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({
|
||||
@@ -130,8 +152,8 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
|
||||
const lines: string[] = [];
|
||||
const reads = new Set<number>();
|
||||
const passes = passDrawer(graph, mode, uniforms);
|
||||
const walk = walker(graph, lines, 'n', null, passes.cut(reads));
|
||||
const passes = passDrawer(graph, mode, uniforms, unresolved);
|
||||
const walk = walker(graph, lines, 'n', null, passes.cut(reads), unresolved);
|
||||
const { problems } = walk;
|
||||
|
||||
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
|
||||
@@ -156,7 +178,7 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
const perPixel: string[] = [];
|
||||
const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
|
||||
const cornerLines: string[] = [];
|
||||
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }));
|
||||
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }), unresolved);
|
||||
let vertex: ShaderVertex | null = null;
|
||||
if (corner) {
|
||||
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
|
||||
@@ -274,7 +296,12 @@ function fragmentOf(graph: Graph, p: FragmentParts): string {
|
||||
* Compiles every Pass a walk meets, once, before the walk goes on: what feeds a Pass is a walk of
|
||||
* its own, so a Pass it meets in turn is drawn earlier. `drawn` is the drawing order.
|
||||
*/
|
||||
function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
|
||||
function passDrawer(
|
||||
graph: Graph,
|
||||
mode: EdgeMode,
|
||||
uniforms: ShaderUniform[],
|
||||
unresolved: Record<string, string>,
|
||||
) {
|
||||
const drawn: ShaderPass[] = [];
|
||||
const walks: GraphEmitter<Expr>[] = [];
|
||||
/** What keeps a Pass itself from being drawn, rather than something feeding it. */
|
||||
@@ -303,7 +330,7 @@ function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
|
||||
drawing.add(node.id);
|
||||
const lines: string[] = [];
|
||||
const reads = new Set<number>();
|
||||
const walk = walker(graph, lines, 'n', null, cut(reads));
|
||||
const walk = walker(graph, lines, 'n', null, cut(reads), unresolved);
|
||||
const def = nodeDef(SHADER_PASS)!;
|
||||
const color = walk.input(node, def.inputs[0]!);
|
||||
const alpha = walk.input(node, def.inputs[1]!);
|
||||
@@ -322,14 +349,35 @@ function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
|
||||
/**
|
||||
* One walk of the graph into `lines`, a local per node output. `perPixel` is the list a per-corner
|
||||
* walk notes in what it met that only a pixel has; the fragment's walk hands in null. `pass` is
|
||||
* what a Pass node is to this walk.
|
||||
* what a Pass node is to this walk, `unresolved` why a call still standing was not set in.
|
||||
*/
|
||||
function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null, pass: PassCut) {
|
||||
function walker(
|
||||
graph: Graph,
|
||||
lines: string[],
|
||||
prefix: string,
|
||||
perPixel: string[] | null,
|
||||
pass: PassCut,
|
||||
unresolved: Record<string, string>,
|
||||
) {
|
||||
let count = 0;
|
||||
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
|
||||
isDef: isShaderNodeDef,
|
||||
noun: 'shader',
|
||||
emit: (def, inputs, node) => {
|
||||
if (node.type === SHADER_CALL) {
|
||||
walk.problem(node, unresolved[node.id] ?? 'That function is gone');
|
||||
return {};
|
||||
}
|
||||
// Only a function checked on its own is walked from its input: any value of the right type will do.
|
||||
if (node.type === SHADER_FUNCTION_INPUT)
|
||||
return Object.fromEntries(
|
||||
(node.ports ?? []).map((port) => [
|
||||
port.key,
|
||||
functionPortOf(port).type === 'vec3'
|
||||
? { code: 'vec3(1.0)', type: 'vec3' }
|
||||
: { code: '0.0', type: 'float' },
|
||||
]),
|
||||
);
|
||||
if (def.perPixel) perPixel?.push(def.label);
|
||||
let prepared: string | undefined;
|
||||
if (def.prepare) {
|
||||
@@ -380,3 +428,35 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
|
||||
});
|
||||
return walk;
|
||||
}
|
||||
|
||||
export interface FunctionCheck {
|
||||
/** Per node id, what keeps the function from being set in anywhere. */
|
||||
problems: Record<string, string>;
|
||||
/** One line for the tab: empty when the function is sound. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shader function on its own (#303): walked from each port of its Function output as a call
|
||||
* would, with any value of the right type at its input, so its problems show in its own tab and not
|
||||
* only at the calls. `ownerId` is the function itself – a call of it inside is a call of itself.
|
||||
*/
|
||||
export function checkShaderFunction(graph: Graph, functions: FunctionLookup, ownerId: string): FunctionCheck {
|
||||
const inlined = inlineFunctions(graph, functions, ownerId ? [ownerId] : []);
|
||||
const flat = inlined.graph;
|
||||
const passes = passDrawer(flat, edgeOf({}), [], inlined.unresolved);
|
||||
const walk = walker(flat, [], 'n', null, passes.cut(new Set()), inlined.unresolved);
|
||||
const output = terminalNode(flat, SHADER_FUNCTION_OUTPUT);
|
||||
for (const port of output?.ports ?? []) walk.input(output!, functionPortOf(port));
|
||||
const found: Record<string, string> = { ...walk.problems };
|
||||
for (const [id, text] of Object.entries(passes.problems)) if (!(id in found)) found[id] = text;
|
||||
for (const pass of passes.walks)
|
||||
for (const [id, text] of Object.entries(pass.problems)) if (!(id in found)) found[id] = text;
|
||||
const problems = problemsAtCalls(found, inlined);
|
||||
for (const node of graph.nodes) {
|
||||
const misplaced = misplacedProblem(node.type, true);
|
||||
if (misplaced && !(node.id in problems)) problems[node.id] = misplaced;
|
||||
}
|
||||
const failed = firstProblem(graph, problems);
|
||||
return { problems, reason: failed ?? (output ? '' : 'No Function output node') };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function texelAtFn(edge: EdgeMode): string {
|
||||
: `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { return texture(picture, uv); }`;
|
||||
}
|
||||
|
||||
const NUMBER_STEP = 0.05;
|
||||
export const NUMBER_STEP = 0.05;
|
||||
|
||||
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
|
||||
const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' });
|
||||
|
||||
@@ -140,6 +140,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
shaderGraphs.set(a.id, graph);
|
||||
shaderRows.set(a.id, shaderParamDefs(graph));
|
||||
}
|
||||
if (a.type === 'shaderfunction') shaderGraphs.set(a.id, normalizeGraph(a.graph, isShaderNodeDef));
|
||||
schemas.set(a.id, schemaFor(a.type));
|
||||
}
|
||||
}
|
||||
@@ -225,7 +226,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
// Kept as written even where it names no state any more: the same as a deleted start.
|
||||
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
|
||||
assets.push(machine);
|
||||
} else if (type === 'shader') {
|
||||
} else if (type === 'shader' || type === 'shaderfunction') {
|
||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'particles') {
|
||||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||||
|
||||
Reference in new issue
Block a user