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
@@ -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
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
@@ -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') };
|
||||||
|
}
|
||||||
@@ -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); }`;
|
: `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' });
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 |
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user