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
@@ -11,6 +11,7 @@ import {
|
||||
drawnDef,
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
FUNCTION_PARAM,
|
||||
ghostProblem,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
resolveDef,
|
||||
namesBehavior,
|
||||
RUN_BEHAVIOR,
|
||||
SHADER_CALL,
|
||||
callsFunction,
|
||||
startStateOf,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
@@ -278,13 +281,16 @@ export class GraphCanvas extends Widget {
|
||||
this.annotationLayer.sync(asset.graph);
|
||||
const seen = new Set<string>();
|
||||
const lookup = graphLookup(this.state.project);
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's.
|
||||
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null;
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's – but a shader function's
|
||||
// call says what is wrong with it even where nothing reaches it, as a behavior's does.
|
||||
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset, this.state.project) : null;
|
||||
const compilerProblems = shaderBuild
|
||||
? { ...shaderBuild.hints, ...shaderBuild.problems }
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
: asset.type === 'shaderfunction'
|
||||
? shaderLibrary.checkFor(asset, this.state.project).problems
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const resolved = resolveDef(node, asset.graph, lookup);
|
||||
if (!resolved) continue;
|
||||
@@ -299,7 +305,8 @@ export class GraphCanvas extends Widget {
|
||||
this.nodeLayer.addChild(view);
|
||||
}
|
||||
const problem =
|
||||
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
|
||||
compilerProblems?.[node.id] ??
|
||||
(!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ??
|
||||
ghostProblem(node, asset.graph, resolved) ??
|
||||
blockProblem(asset.graph, node);
|
||||
view.sync(node, asset.graph, this.state.project, def, problem);
|
||||
@@ -628,6 +635,12 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openCalledBehavior(node: GraphNode): boolean {
|
||||
if (node.type === SHADER_CALL) {
|
||||
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
|
||||
if (findAsset(this.state.project, id)?.type !== 'shaderfunction') return false;
|
||||
this.state.openAsset(id);
|
||||
return true;
|
||||
}
|
||||
if (!namesBehavior(node.type)) return false;
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||||
@@ -683,23 +696,33 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so
|
||||
* Follows a behavior or shader function dragged in from the asset panel. The node is centred under the pointer, so
|
||||
* it lands where the ghost was rather than below and right of it.
|
||||
*/
|
||||
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
|
||||
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
|
||||
// Only a behavior graph runs other behaviors; a state machine has nothing to drop in.
|
||||
if (!inside || this.asset()?.type !== 'behavior' || asset?.type !== 'behavior') {
|
||||
// A behavior graph runs other behaviors and a shader calls functions (#303); a state machine
|
||||
// has nothing to drop in.
|
||||
const into = this.asset()?.type;
|
||||
const calls = asset?.type === 'shaderfunction' && (into === 'shader' || into === 'shaderfunction');
|
||||
if (!inside || !asset || !(calls || (into === 'behavior' && asset.type === 'behavior'))) {
|
||||
this.setIncoming(null);
|
||||
return;
|
||||
}
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
const rejection = calls
|
||||
? callsFunction(graphLookup(this.state.project), asset.id, this.assetId)
|
||||
? 'A function cannot call itself'
|
||||
: ''
|
||||
: asset.id === this.assetId
|
||||
? 'A behavior cannot run itself'
|
||||
: '';
|
||||
this.setIncoming({
|
||||
assetId: asset.id,
|
||||
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) },
|
||||
rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '',
|
||||
rejection,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -714,7 +737,9 @@ export class GraphCanvas extends Widget {
|
||||
this.state.setNotice(drop.rejection);
|
||||
return;
|
||||
}
|
||||
const entry = this.entries().find((it) => it.id === `${RUN_BEHAVIOR}:${drop.assetId}`);
|
||||
const type =
|
||||
findAsset(this.state.project, drop.assetId)?.type === 'shaderfunction' ? SHADER_CALL : RUN_BEHAVIOR;
|
||||
const entry = this.entries().find((it) => it.id === `${type}:${drop.assetId}`);
|
||||
if (entry) this.addNode(entry, drop.at);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
allNodeDefs,
|
||||
BEHAVIOR_END,
|
||||
BEHAVIOR_PARAM,
|
||||
callsFunction,
|
||||
createEmptyGraph,
|
||||
FUNCTION_PARAM,
|
||||
isTerminalType,
|
||||
namesBehavior,
|
||||
nextStateName,
|
||||
@@ -12,11 +14,14 @@ import {
|
||||
ON_BEHAVIOR,
|
||||
resolveDef,
|
||||
RUN_BEHAVIOR,
|
||||
SHADER_CALL,
|
||||
SHADER_FUNCTION_INPUT,
|
||||
SHADER_FUNCTION_OUTPUT,
|
||||
State,
|
||||
STATE_NAME_PARAM,
|
||||
terminalNode,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
|
||||
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
SHADER_PROPERTY,
|
||||
} from '../../core/properties/shaders';
|
||||
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
|
||||
import { offeredIn, ShaderFunctionInput, ShaderFunctionOutput } from '../../core/shader/functions';
|
||||
import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes';
|
||||
|
||||
/**
|
||||
@@ -88,21 +94,17 @@ function filterEntry(find: NodeDef): PaletteEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
|
||||
* new one named by its number; a shader holds shader nodes, its one Output dropping out once
|
||||
* placed. In a behavior, every other behavior appears under its own name instead of behind a
|
||||
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
|
||||
* through (#294) –, and the two terminals drop out once the graph has them.
|
||||
* A shader's nodes, its one Output dropping out once placed, and every shader function under its own
|
||||
* name (#303). A function holds the same nodes but those that end a shader or give the wearer a row,
|
||||
* and its own two ends instead; it calls every function but those that would come back to it.
|
||||
*/
|
||||
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
|
||||
if (asset.type === 'statemachine') {
|
||||
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
if (asset.type === 'shader') {
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT];
|
||||
return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => {
|
||||
function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsset): PaletteEntry[] {
|
||||
const inFunction = asset.type === 'shaderfunction';
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT];
|
||||
const entries: PaletteEntry[] = [...SHADER_NODES, ShaderFunctionInput, ShaderFunctionOutput]
|
||||
.filter((def) => offeredIn(def.type, inFunction) && (!once.includes(def.type) || !placed.has(def.type)))
|
||||
.map((def) => {
|
||||
const params: PropertyBag =
|
||||
def.type === SHADER_PROPERTY
|
||||
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) }
|
||||
@@ -111,7 +113,28 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
||||
: {};
|
||||
return { id: def.type, hint: def.category, def, params };
|
||||
});
|
||||
const lookup = graphLookup(project);
|
||||
for (const other of project.assets) {
|
||||
if (other.type !== 'shaderfunction' || callsFunction(lookup, other.id, asset.id)) continue;
|
||||
const params = { [FUNCTION_PARAM]: other.id };
|
||||
const def = resolveDef({ id: '', type: SHADER_CALL, x: 0, y: 0, params }, createEmptyGraph(), lookup);
|
||||
if (def) entries.push({ id: `${SHADER_CALL}:${other.id}`, hint: 'function', def, params });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
|
||||
* new one named by its number; a shader holds shader nodes (`shaderEntries`). In a behavior, every other behavior appears under its own name instead of behind a
|
||||
* generic "Run behavior" – and once more as the event "On <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') {
|
||||
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
|
||||
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));
|
||||
|
||||
Reference in new issue
Block a user