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:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1725 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1728 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -95,6 +95,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
|
||||
case 'shader':
|
||||
return { ...base, type: 'shader', graph: createEmptyGraph() };
|
||||
case 'shaderfunction':
|
||||
return { ...base, type: 'shaderfunction', graph: createEmptyGraph() };
|
||||
case 'font':
|
||||
return { ...base, type: 'font', file: '' };
|
||||
case 'sound':
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
declaresPropertyPorts,
|
||||
declaresVariablePorts,
|
||||
type DeclaredPort,
|
||||
findNode,
|
||||
fixedPortKeys,
|
||||
FUNCTION_PARAM,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
isEntryTerminal,
|
||||
isFlowPort,
|
||||
isTerminalNode,
|
||||
isValidPortName,
|
||||
namesBehavior,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
propertyTargetOf,
|
||||
SHADER_CALL,
|
||||
variablesSourceOf,
|
||||
} from '../graph';
|
||||
import { isGraphAsset } from '../model/Asset';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { kindLabel } from '../properties/kinds';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError, RenameCommand } from './Command';
|
||||
import { nextFieldName } from './naming';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
import { requireGraph, requireNode, requireNodeDef } from './require';
|
||||
|
||||
interface PortSite {
|
||||
graph: Graph;
|
||||
@@ -33,18 +37,24 @@ interface PortSite {
|
||||
|
||||
/**
|
||||
* Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node
|
||||
* itself. A behavior's terminal is the exception: its ports are also ports of every node running or
|
||||
* hearing the behavior – facing the other way, because what a start node hands out the caller passes in.
|
||||
* itself. A terminal is the exception: its ports are also ports of every node running or hearing the
|
||||
* behavior, or calling the shader function – facing the other way, because what a start node hands
|
||||
* out the caller passes in.
|
||||
* A node that has no such port (an `On behavior` and a flow port of the start) has no edges on it either.
|
||||
*/
|
||||
function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] {
|
||||
const sites = ownSites(requireBehavior(project, assetId).graph, node);
|
||||
const sites = ownSites(requireGraph(project, assetId), node);
|
||||
if (!isTerminalNode(node)) return sites;
|
||||
// A shader function's ends are the ports of its calls in shaders and in other functions (#303).
|
||||
const callsIt = (caller: GraphNode) =>
|
||||
caller.type === SHADER_CALL
|
||||
? caller.params[FUNCTION_PARAM] === assetId
|
||||
: namesBehavior(caller.type) && caller.params[BEHAVIOR_PARAM] === assetId;
|
||||
for (const other of project.assets) {
|
||||
if (other.type !== 'behavior') continue;
|
||||
if (!isGraphAsset(other)) continue;
|
||||
for (const caller of other.graph.nodes) {
|
||||
if (!namesBehavior(caller.type) || caller.params[BEHAVIOR_PARAM] !== assetId) continue;
|
||||
sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START });
|
||||
if (!callsIt(caller)) continue;
|
||||
sites.push({ graph: other.graph, nodeId: caller.id, asInput: isEntryTerminal(node.type) });
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
@@ -135,8 +145,11 @@ export class AddPortsCommand implements Command {
|
||||
execute(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const keys = new Set((node.ports ?? []).map((p) => p.key));
|
||||
const kinds = requireNodeDef(node.type).portKinds;
|
||||
for (const port of this.ports) {
|
||||
checkPortName(node, port.key);
|
||||
if (kinds && !kinds.includes(port.kind))
|
||||
throw new CommandError(`A ${kindLabel(port.kind)} port has no place on this node`);
|
||||
if (isFlowPort(port) && !isTerminalNode(node))
|
||||
throw new CommandError('Only a start or an end node declares a flow port');
|
||||
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
|
||||
@@ -181,7 +194,7 @@ export class RemovePortCommand implements Command {
|
||||
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
|
||||
this.removed = removed;
|
||||
node.ports = ports;
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
this.edges = dropEdges([
|
||||
...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })),
|
||||
...flowEdgesOnceSilent(graph, node),
|
||||
@@ -240,7 +253,7 @@ export class SetPortWriteCommand implements Command {
|
||||
this.edges = [];
|
||||
this.value = undefined;
|
||||
if (this.write) return;
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
const input: PortSite = { graph, nodeId: node.id, asInput: true };
|
||||
this.edges = dropEdges([
|
||||
{ graph, test: edgesOnPort(input, this.key) },
|
||||
@@ -334,7 +347,7 @@ export class RepointPortCommand implements Command {
|
||||
}
|
||||
|
||||
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
|
||||
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
|
||||
renamePortOn(requireGraph(project, this.assetId), node, from, to.key);
|
||||
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
|
||||
import { isKindWith, plainKindOf } from '../properties/kinds';
|
||||
import { type DeclaredPortKind, isKindWith, plainKindOf } from '../properties/kinds';
|
||||
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
|
||||
import type { GraphLookup, ValueTyping } from './lookup';
|
||||
@@ -118,6 +118,8 @@ export interface NodeDef {
|
||||
* input as well where it is marked `write`.
|
||||
*/
|
||||
declaresPorts?: 'inputs' | 'outputs' | 'both';
|
||||
/** The kinds a declared port may have here; without it, every declarable kind. */
|
||||
portKinds?: readonly DeclaredPortKind[];
|
||||
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
|
||||
declaredPort?(port: DeclaredPort, node: GraphNode, graph: Graph, lookup: GraphLookup): PortDef;
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface BehaviorProgram {
|
||||
kept?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ShaderFunction {
|
||||
name: string;
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
|
||||
export interface MachineState {
|
||||
id: string;
|
||||
@@ -45,6 +50,8 @@ export interface StateMachine {
|
||||
*/
|
||||
export interface GraphLookup {
|
||||
behavior(assetId: string): BehaviorProgram | null;
|
||||
/** A shader function's graph, which a call node takes its ports from and the compiler sets in (#303). */
|
||||
shaderFunction(assetId: string): ShaderFunction | null;
|
||||
machine(assetId: string): StateMachine | null;
|
||||
curve(assetId: string): readonly VectorPoint[] | null;
|
||||
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
|
||||
@@ -80,6 +87,7 @@ export interface ValueTyping {
|
||||
|
||||
export const NO_LOOKUP: GraphLookup = {
|
||||
behavior: () => null,
|
||||
shaderFunction: () => null,
|
||||
machine: () => null,
|
||||
curve: () => null,
|
||||
structType: () => null,
|
||||
|
||||
@@ -24,8 +24,13 @@ export const ON_BEHAVIOR = 'OnBehavior';
|
||||
export const VARIABLES = 'Variables';
|
||||
export const PROPERTIES = 'Properties';
|
||||
const ON_START = 'OnStart';
|
||||
/** The two ends of a shader function (#303) and the node calling one, see `core/shader/functions`. */
|
||||
export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput';
|
||||
export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput';
|
||||
export const SHADER_CALL = 'ShaderCall';
|
||||
|
||||
export const BEHAVIOR_PARAM = 'behavior';
|
||||
export const FUNCTION_PARAM = 'function';
|
||||
export const VARIABLES_PARAM = 'variablesAsset';
|
||||
|
||||
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
|
||||
@@ -116,8 +121,19 @@ export function callEntries(graph: Graph): GraphNode[] {
|
||||
return start ? [start] : graph.nodes.filter((n) => n.type === ON_START);
|
||||
}
|
||||
|
||||
/** A graph has one of each: its ports are those of every node calling the graph. */
|
||||
export function isTerminalType(type: string): boolean {
|
||||
return type === BEHAVIOR_START || type === BEHAVIOR_END;
|
||||
return (
|
||||
type === BEHAVIOR_START ||
|
||||
type === BEHAVIOR_END ||
|
||||
type === SHADER_FUNCTION_INPUT ||
|
||||
type === SHADER_FUNCTION_OUTPUT
|
||||
);
|
||||
}
|
||||
|
||||
/** The terminal whose ports a caller passes in, rather than takes out. */
|
||||
export function isEntryTerminal(type: string): boolean {
|
||||
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT;
|
||||
}
|
||||
|
||||
export function isTerminalNode(node: GraphNode): boolean {
|
||||
@@ -336,6 +352,7 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
|
||||
*/
|
||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
|
||||
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
|
||||
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
if (!id) return 'No behavior picked';
|
||||
@@ -357,6 +374,37 @@ function listenProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): s
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why a shader function's call has nothing to set in its place (#303). */
|
||||
function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): string | null {
|
||||
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
|
||||
if (!id) return 'No function picked';
|
||||
if (!lookup.shaderFunction(id)) return 'That function is gone';
|
||||
if (ownerId && callsFunction(lookup, id, ownerId)) return 'A function cannot call itself';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The functions the calls in that graph name, picked or not found alike. */
|
||||
export function calledFunctions(graph: Graph): string[] {
|
||||
return graph.nodes
|
||||
.filter((n) => n.type === SHADER_CALL)
|
||||
.map((n) => textOf(n.params[FUNCTION_PARAM] ?? ''))
|
||||
.filter((id) => id !== '');
|
||||
}
|
||||
|
||||
/** Whether `from` is `target` or calls it, however many functions lie between. */
|
||||
export function callsFunction(
|
||||
lookup: GraphLookup,
|
||||
from: string,
|
||||
target: string,
|
||||
seen: Set<string> = new Set(),
|
||||
): boolean {
|
||||
if (from === target) return true;
|
||||
if (seen.has(from)) return false;
|
||||
seen.add(from);
|
||||
const called = lookup.shaderFunction(from);
|
||||
return called ? calledFunctions(called.graph).some((id) => callsFunction(lookup, id, target, seen)) : false;
|
||||
}
|
||||
|
||||
/** The behaviors the `On behavior` nodes of that graph name, picked or not found alike. */
|
||||
export function heardBehaviors(graph: Graph): string[] {
|
||||
return graph.nodes
|
||||
|
||||
@@ -140,6 +140,7 @@ import { SetStateByName } from './flow/SetStateByName';
|
||||
import { StateBranch } from './flow/StateBranch';
|
||||
import { StateTransition } from './flow/StateTransition';
|
||||
import { PARTICLE_NODES } from '../../particles/nodes';
|
||||
import { SHADER_FUNCTION_NODES } from '../../shader/functions';
|
||||
import { SHADER_NODES } from '../../shader/nodes';
|
||||
import { State } from '../stateMachine';
|
||||
import { Variables } from './variable/Variables';
|
||||
@@ -303,4 +304,5 @@ for (const def of BUILT_IN_NODES) registerNode(def);
|
||||
// behavior graph offers them.
|
||||
registerNode(State);
|
||||
for (const def of SHADER_NODES) registerNode(def);
|
||||
for (const def of SHADER_FUNCTION_NODES) registerNode(def);
|
||||
for (const def of PARTICLE_NODES) registerNode(def);
|
||||
+19
-2
@@ -277,6 +277,15 @@ export interface ShaderAsset extends AssetBase {
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* A part of a shader graph used in others (#303): its `Function input` and `Function output` nodes
|
||||
* are the ports of every node calling it, and the compiler sets the graph in at each call.
|
||||
*/
|
||||
export interface ShaderFunctionAsset extends AssetBase {
|
||||
type: 'shaderfunction';
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* `file` names the typeface in `data/fonts/` the way a texture names its image: content-addressed
|
||||
* `{sha256}.{ext}`, empty when the upload failed. A text object wears it through its `font`
|
||||
@@ -545,6 +554,7 @@ export type Asset =
|
||||
| TilemapAsset
|
||||
| StateMachineAsset
|
||||
| ShaderAsset
|
||||
| ShaderFunctionAsset
|
||||
| FontAsset
|
||||
| SoundAsset
|
||||
| ParticlesAsset;
|
||||
@@ -557,9 +567,16 @@ export function isVariableOwner(asset: Asset | undefined): asset is VariableOwne
|
||||
}
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
|
||||
export type GraphAsset =
|
||||
BehaviorAsset | StateMachineAsset | ShaderAsset | ShaderFunctionAsset | ParticlesAsset;
|
||||
|
||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set(['behavior', 'statemachine', 'shader', 'particles']);
|
||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'behavior',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'particles',
|
||||
]);
|
||||
|
||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||
return GRAPH_TYPES.has(asset.type);
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ASSET_TYPES = [
|
||||
'tilemap',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'font',
|
||||
'sound',
|
||||
'particles',
|
||||
@@ -51,6 +52,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
tilemap: 'Tilemap',
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
shaderfunction: 'Shader function',
|
||||
font: 'Font',
|
||||
sound: 'Sound',
|
||||
particles: 'Particles',
|
||||
@@ -64,6 +66,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
|
||||
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||
* A shader function is part of other shaders (#303), worn by nothing itself.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -77,6 +80,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'tile'
|
||||
| 'statemachine'
|
||||
| 'shader'
|
||||
| 'shaderfunction'
|
||||
| 'font'
|
||||
| 'sound'
|
||||
| 'variables'
|
||||
@@ -94,6 +98,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'tile',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'font',
|
||||
'sound',
|
||||
'variables',
|
||||
|
||||
@@ -9,6 +9,14 @@ import { fieldTypings, typeFields, typingOf } from './structTypes';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { resolvePicture } from './texture';
|
||||
|
||||
/** The project's shader functions by id, as a call and the compiler see them (#303). */
|
||||
export function shaderFunctionsOf(project: Project): GraphLookup['shaderFunction'] {
|
||||
return (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'shaderfunction');
|
||||
return asset ? { name: asset.name, graph: asset.graph } : null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a graph may see of the project around it. A behavior's `defaults` are its variable values;
|
||||
* every call starts from a copy of them, except for the variables it keeps (#229).
|
||||
@@ -25,6 +33,7 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
kept: asset.keptVariables ?? [],
|
||||
};
|
||||
},
|
||||
shaderFunction: shaderFunctionsOf(project),
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) return null;
|
||||
|
||||
@@ -630,6 +630,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
statemachine: [],
|
||||
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
|
||||
shader: [],
|
||||
shaderfunction: [],
|
||||
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
|
||||
// a pixel font a pixel per unit, magnified sharp.
|
||||
font: [filterRow(true)],
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { type GraphEmitter, graphEmitter } from '../graph/emitGraph';
|
||||
import { edgesInto, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { SHADER_CALL, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, terminalNode } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { colorOf } from '../model/Properties';
|
||||
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
|
||||
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
|
||||
import {
|
||||
type FunctionLookup,
|
||||
functionPortOf,
|
||||
inlineFunctions,
|
||||
misplacedProblem,
|
||||
NO_FUNCTIONS,
|
||||
problemsAtCalls,
|
||||
} from './functions';
|
||||
import {
|
||||
asVec3,
|
||||
BLUR_FN,
|
||||
@@ -119,7 +128,20 @@ type PassCut = (node: GraphNode) => Record<string, Expr> | null;
|
||||
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. Every
|
||||
* Pass the Output reaches is a fragment of its own, drawn first into a picture (#301).
|
||||
*/
|
||||
export function compileShader(graph: Graph): ShaderBuild {
|
||||
export function compileShader(source: Graph, functions: FunctionLookup = NO_FUNCTIONS): ShaderBuild {
|
||||
const inlined = inlineFunctions(source, functions);
|
||||
const build = compileGraph(inlined.graph, inlined.unresolved);
|
||||
const problems = problemsAtCalls(build.problems, inlined);
|
||||
for (const node of source.nodes) {
|
||||
const misplaced = misplacedProblem(node.type, false);
|
||||
if (misplaced && !(node.id in problems)) problems[node.id] = misplaced;
|
||||
}
|
||||
const failed = firstProblem(source, problems);
|
||||
return failed === null ? build : { compiled: null, problems, hints: build.hints, reason: failed };
|
||||
}
|
||||
|
||||
/** `compileShader` on a graph whose calls are set in already; `unresolved` says why the rest are not. */
|
||||
function compileGraph(graph: Graph, unresolved: Record<string, string>): ShaderBuild {
|
||||
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
|
||||
const mode = output ? edgeOf(output.params) : edgeOf({});
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({
|
||||
@@ -130,8 +152,8 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
|
||||
const lines: string[] = [];
|
||||
const reads = new Set<number>();
|
||||
const passes = passDrawer(graph, mode, uniforms);
|
||||
const walk = walker(graph, lines, 'n', null, passes.cut(reads));
|
||||
const passes = passDrawer(graph, mode, uniforms, unresolved);
|
||||
const walk = walker(graph, lines, 'n', null, passes.cut(reads), unresolved);
|
||||
const { problems } = walk;
|
||||
|
||||
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
|
||||
@@ -156,7 +178,7 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
const perPixel: string[] = [];
|
||||
const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
|
||||
const cornerLines: string[] = [];
|
||||
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }));
|
||||
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }), unresolved);
|
||||
let vertex: ShaderVertex | null = null;
|
||||
if (corner) {
|
||||
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
|
||||
@@ -274,7 +296,12 @@ function fragmentOf(graph: Graph, p: FragmentParts): string {
|
||||
* Compiles every Pass a walk meets, once, before the walk goes on: what feeds a Pass is a walk of
|
||||
* its own, so a Pass it meets in turn is drawn earlier. `drawn` is the drawing order.
|
||||
*/
|
||||
function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
|
||||
function passDrawer(
|
||||
graph: Graph,
|
||||
mode: EdgeMode,
|
||||
uniforms: ShaderUniform[],
|
||||
unresolved: Record<string, string>,
|
||||
) {
|
||||
const drawn: ShaderPass[] = [];
|
||||
const walks: GraphEmitter<Expr>[] = [];
|
||||
/** What keeps a Pass itself from being drawn, rather than something feeding it. */
|
||||
@@ -303,7 +330,7 @@ function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
|
||||
drawing.add(node.id);
|
||||
const lines: string[] = [];
|
||||
const reads = new Set<number>();
|
||||
const walk = walker(graph, lines, 'n', null, cut(reads));
|
||||
const walk = walker(graph, lines, 'n', null, cut(reads), unresolved);
|
||||
const def = nodeDef(SHADER_PASS)!;
|
||||
const color = walk.input(node, def.inputs[0]!);
|
||||
const alpha = walk.input(node, def.inputs[1]!);
|
||||
@@ -322,14 +349,35 @@ function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
|
||||
/**
|
||||
* One walk of the graph into `lines`, a local per node output. `perPixel` is the list a per-corner
|
||||
* walk notes in what it met that only a pixel has; the fragment's walk hands in null. `pass` is
|
||||
* what a Pass node is to this walk.
|
||||
* what a Pass node is to this walk, `unresolved` why a call still standing was not set in.
|
||||
*/
|
||||
function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null, pass: PassCut) {
|
||||
function walker(
|
||||
graph: Graph,
|
||||
lines: string[],
|
||||
prefix: string,
|
||||
perPixel: string[] | null,
|
||||
pass: PassCut,
|
||||
unresolved: Record<string, string>,
|
||||
) {
|
||||
let count = 0;
|
||||
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
|
||||
isDef: isShaderNodeDef,
|
||||
noun: 'shader',
|
||||
emit: (def, inputs, node) => {
|
||||
if (node.type === SHADER_CALL) {
|
||||
walk.problem(node, unresolved[node.id] ?? 'That function is gone');
|
||||
return {};
|
||||
}
|
||||
// Only a function checked on its own is walked from its input: any value of the right type will do.
|
||||
if (node.type === SHADER_FUNCTION_INPUT)
|
||||
return Object.fromEntries(
|
||||
(node.ports ?? []).map((port) => [
|
||||
port.key,
|
||||
functionPortOf(port).type === 'vec3'
|
||||
? { code: 'vec3(1.0)', type: 'vec3' }
|
||||
: { code: '0.0', type: 'float' },
|
||||
]),
|
||||
);
|
||||
if (def.perPixel) perPixel?.push(def.label);
|
||||
let prepared: string | undefined;
|
||||
if (def.prepare) {
|
||||
@@ -380,3 +428,35 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
|
||||
});
|
||||
return walk;
|
||||
}
|
||||
|
||||
export interface FunctionCheck {
|
||||
/** Per node id, what keeps the function from being set in anywhere. */
|
||||
problems: Record<string, string>;
|
||||
/** One line for the tab: empty when the function is sound. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shader function on its own (#303): walked from each port of its Function output as a call
|
||||
* would, with any value of the right type at its input, so its problems show in its own tab and not
|
||||
* only at the calls. `ownerId` is the function itself – a call of it inside is a call of itself.
|
||||
*/
|
||||
export function checkShaderFunction(graph: Graph, functions: FunctionLookup, ownerId: string): FunctionCheck {
|
||||
const inlined = inlineFunctions(graph, functions, ownerId ? [ownerId] : []);
|
||||
const flat = inlined.graph;
|
||||
const passes = passDrawer(flat, edgeOf({}), [], inlined.unresolved);
|
||||
const walk = walker(flat, [], 'n', null, passes.cut(new Set()), inlined.unresolved);
|
||||
const output = terminalNode(flat, SHADER_FUNCTION_OUTPUT);
|
||||
for (const port of output?.ports ?? []) walk.input(output!, functionPortOf(port));
|
||||
const found: Record<string, string> = { ...walk.problems };
|
||||
for (const [id, text] of Object.entries(passes.problems)) if (!(id in found)) found[id] = text;
|
||||
for (const pass of passes.walks)
|
||||
for (const [id, text] of Object.entries(pass.problems)) if (!(id in found)) found[id] = text;
|
||||
const problems = problemsAtCalls(found, inlined);
|
||||
for (const node of graph.nodes) {
|
||||
const misplaced = misplacedProblem(node.type, true);
|
||||
if (misplaced && !(node.id in problems)) problems[node.id] = misplaced;
|
||||
}
|
||||
const failed = firstProblem(graph, problems);
|
||||
return { problems, reason: failed ?? (output ? '' : 'No Function output node') };
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
type DeclaredPort,
|
||||
edgesInto,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
graphFingerprint,
|
||||
type GraphNode,
|
||||
} from '../graph/Graph';
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import {
|
||||
calledFunctions,
|
||||
FUNCTION_PARAM,
|
||||
SHADER_CALL,
|
||||
SHADER_FUNCTION_INPUT,
|
||||
SHADER_FUNCTION_OUTPUT,
|
||||
terminalNode,
|
||||
} from '../graph/nested';
|
||||
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
|
||||
import { textOf } from '../model/Properties';
|
||||
import { SHADER_COLOR_PROPERTY, SHADER_PROPERTY } from '../properties/shaders';
|
||||
import {
|
||||
NUMBER_STEP,
|
||||
SHADER_COLOR,
|
||||
SHADER_NUMBER,
|
||||
SHADER_OUTPUT,
|
||||
SHADER_VERTEX_OUTPUT,
|
||||
type ShaderNodeDef,
|
||||
} from './nodes';
|
||||
|
||||
/** The shader functions a compile may set in, by asset id (#303). */
|
||||
export type FunctionLookup = GraphLookup['shaderFunction'];
|
||||
export const NO_FUNCTIONS: FunctionLookup = () => null;
|
||||
|
||||
/** A function takes and gives values only: a picture stays the caller's. */
|
||||
export const FUNCTION_PORT_KINDS = ['number', 'color'] as const;
|
||||
const DEFAULT_COLOR = 0xffffff;
|
||||
|
||||
/** A declared port of a function as the nodes on either side of it see it. */
|
||||
export function functionPortOf(port: DeclaredPort): PortDef {
|
||||
const { key } = port;
|
||||
if (port.kind === 'color')
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'vec3',
|
||||
default: DEFAULT_COLOR,
|
||||
field: { key, label: key, kind: 'color', default: DEFAULT_COLOR },
|
||||
};
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'number',
|
||||
default: 0,
|
||||
field: { key, label: key, kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
};
|
||||
}
|
||||
|
||||
function functionPorts(graph: Graph | undefined, type: string): PortDef[] {
|
||||
const node = graph && terminalNode(graph, type);
|
||||
return (node?.ports ?? []).map(functionPortOf);
|
||||
}
|
||||
|
||||
export const ShaderFunctionInput: ShaderNodeDef = {
|
||||
type: SHADER_FUNCTION_INPUT,
|
||||
label: 'Function input',
|
||||
category: 'shader',
|
||||
description:
|
||||
'The values a shader calling this function hands in. Every input declared here, a number or a color, becomes an input port on the node that calls the function.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [],
|
||||
declaresPorts: 'outputs',
|
||||
portKinds: FUNCTION_PORT_KINDS,
|
||||
declaredPort: (port) => functionPortOf(port),
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
export const ShaderFunctionOutput: ShaderNodeDef = {
|
||||
type: SHADER_FUNCTION_OUTPUT,
|
||||
label: 'Function output',
|
||||
category: 'shader',
|
||||
description:
|
||||
'What this function hands back. Every output declared here becomes an output port on the node that calls the function; unconnected, it is the value typed in.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [],
|
||||
declaresPorts: 'inputs',
|
||||
portKinds: FUNCTION_PORT_KINDS,
|
||||
declaredPort: (port) => functionPortOf(port),
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
/** Never emitted in place: the compiler sets the function's graph in where it stands. */
|
||||
export const ShaderCall: ShaderNodeDef = {
|
||||
type: SHADER_CALL,
|
||||
label: 'Shader function',
|
||||
category: 'shader',
|
||||
description:
|
||||
"A shader function's graph, set in here. Its ports are the ones that function declares at its Function input and Function output, and the picture it reads is this shader's. Double-click opens the function.",
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [
|
||||
{ key: FUNCTION_PARAM, label: 'Function', kind: 'asset', default: '', assetTypes: ['shaderfunction'] },
|
||||
],
|
||||
derivePorts: ({ node, lookup }) => {
|
||||
const called = lookup.shaderFunction(textOf(node.params[FUNCTION_PARAM] ?? ''));
|
||||
return {
|
||||
label: called?.name,
|
||||
inputs: functionPorts(called?.graph, SHADER_FUNCTION_INPUT),
|
||||
outputs: functionPorts(called?.graph, SHADER_FUNCTION_OUTPUT),
|
||||
};
|
||||
},
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
export const SHADER_FUNCTION_NODES: readonly ShaderNodeDef[] = [
|
||||
ShaderFunctionInput,
|
||||
ShaderFunctionOutput,
|
||||
ShaderCall,
|
||||
];
|
||||
|
||||
/**
|
||||
* A function hands values back and nothing else, so what ends a shader or gives the wearer a row
|
||||
* has no place in it; the ends of a function have none in a shader.
|
||||
*/
|
||||
const ONLY_IN_SHADER = new Set([SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_PROPERTY, SHADER_COLOR_PROPERTY]);
|
||||
const ONLY_IN_FUNCTION = new Set([SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT]);
|
||||
|
||||
/** Why a node of that type cannot stand in a shader (or, `inFunction`, a function); null if it can. */
|
||||
export function misplacedProblem(type: string, inFunction: boolean): string | null {
|
||||
if (inFunction && ONLY_IN_SHADER.has(type)) return `A function cannot hold ${nodeDef(type)?.label ?? type}`;
|
||||
if (!inFunction && ONLY_IN_FUNCTION.has(type)) return 'Only in a shader function';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a node of that type belongs in a function's palette – or, with false, in a shader's. */
|
||||
export function offeredIn(type: string, inFunction: boolean): boolean {
|
||||
return misplacedProblem(type, inFunction) === null;
|
||||
}
|
||||
|
||||
export interface Inlined {
|
||||
/** The graph with every call that could be set in replaced by the function's nodes. */
|
||||
graph: Graph;
|
||||
/** Per call set in, the function's name: a problem inside it is reported at the call. */
|
||||
names: Record<string, string>;
|
||||
/** Per call left in place, why it could not be set in. */
|
||||
unresolved: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every call in `graph` replaced by a copy of the function it names (#303), its nodes renamed
|
||||
* `<call id>/<node id>`: what fed a port of the call feeds what the Function input's port fed,
|
||||
* and what read an output of the call reads what fed the Function output's port. A port nothing
|
||||
* feeds becomes a constant with its typed-in value. `chain` is the functions already being set in
|
||||
* – meeting one again is a function calling itself.
|
||||
*/
|
||||
export function inlineFunctions(
|
||||
graph: Graph,
|
||||
functions: FunctionLookup,
|
||||
chain: readonly string[] = [],
|
||||
): Inlined {
|
||||
const names: Record<string, string> = {};
|
||||
const unresolved: Record<string, string> = {};
|
||||
let current = graph;
|
||||
for (const call of graph.nodes) {
|
||||
if (call.type !== SHADER_CALL) continue;
|
||||
const id = textOf(call.params[FUNCTION_PARAM] ?? '');
|
||||
const called = id ? functions(id) : null;
|
||||
const set = !id
|
||||
? 'No function picked'
|
||||
: !called
|
||||
? 'That function is gone'
|
||||
: chain.includes(id)
|
||||
? 'A function cannot call itself'
|
||||
: setIn(current, call, called.name, called.graph, functions, [...chain, id]);
|
||||
if (typeof set === 'string') unresolved[call.id] = set;
|
||||
else {
|
||||
current = set;
|
||||
names[call.id] = called!.name;
|
||||
}
|
||||
}
|
||||
return { graph: current, names, unresolved };
|
||||
}
|
||||
|
||||
type End = { node: string; port: string };
|
||||
|
||||
function setIn(
|
||||
graph: Graph,
|
||||
call: GraphNode,
|
||||
name: string,
|
||||
body: Graph,
|
||||
functions: FunctionLookup,
|
||||
chain: readonly string[],
|
||||
): Graph | string {
|
||||
for (const node of body.nodes) {
|
||||
const misplaced = misplacedProblem(node.type, true);
|
||||
if (misplaced) return `In ${name}: ${misplaced}`;
|
||||
}
|
||||
const inner = inlineFunctions(body, functions, chain);
|
||||
const stuck = Object.values(inner.unresolved)[0];
|
||||
if (stuck) return `In ${name}: ${stuck}`;
|
||||
const fi = terminalNode(inner.graph, SHADER_FUNCTION_INPUT);
|
||||
const fo = terminalNode(inner.graph, SHADER_FUNCTION_OUTPUT);
|
||||
const outs = new Map((fo?.ports ?? []).map((p) => [p.key, p]));
|
||||
const gone = graph.edges.find((e) => e.from.node === call.id && !outs.has(e.from.port));
|
||||
if (gone) return `"${gone.from.port}" is not an output of ${name}`;
|
||||
|
||||
const prefix = `${call.id}/`;
|
||||
const constants: GraphNode[] = [];
|
||||
const constant = (key: string, port: DeclaredPort, typed: unknown): End => {
|
||||
const color = functionPortOf(port).type === 'vec3';
|
||||
const value = typed ?? functionPortOf(port).default;
|
||||
const id = `${prefix}${key}`;
|
||||
constants.push({
|
||||
id,
|
||||
type: color ? SHADER_COLOR : SHADER_NUMBER,
|
||||
x: 0,
|
||||
y: 0,
|
||||
params: { value: value as number },
|
||||
});
|
||||
return { node: id, port: color ? 'color' : 'value' };
|
||||
};
|
||||
const fed: Record<string, End> = {};
|
||||
for (const port of fi?.ports ?? []) {
|
||||
const edge = edgesInto(graph, call.id, port.key)[0];
|
||||
fed[port.key] = edge ? { ...edge.from } : constant(`in:${port.key}`, port, call.params[port.key]);
|
||||
}
|
||||
/** Where an end inside the function comes from once it is set in; null for one that is gone. */
|
||||
const outside = (end: End): End | null =>
|
||||
end.node === fi?.id
|
||||
? (fed[end.port] ?? null)
|
||||
: end.node === fo?.id
|
||||
? null
|
||||
: { node: prefix + end.node, port: end.port };
|
||||
|
||||
const edges: GraphEdge[] = [];
|
||||
const link = (from: End, to: End) => edges.push({ id: `${prefix}e${edges.length}`, from, to });
|
||||
for (const edge of inner.graph.edges) {
|
||||
if (edge.to.node === fi?.id || edge.to.node === fo?.id) continue;
|
||||
const from = outside(edge.from);
|
||||
if (from) link(from, { node: prefix + edge.to.node, port: edge.to.port });
|
||||
}
|
||||
const results: Record<string, End> = {};
|
||||
for (const [key, port] of outs) {
|
||||
const edge = edgesInto(inner.graph, fo!.id, key)[0];
|
||||
results[key] = (edge && outside(edge.from)) ?? constant(`out:${key}`, port, fo!.params[key]);
|
||||
}
|
||||
const kept: GraphEdge[] = [];
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.to.node === call.id) continue;
|
||||
if (edge.from.node === call.id) link(results[edge.from.port]!, { ...edge.to });
|
||||
else kept.push(edge);
|
||||
}
|
||||
const copies = inner.graph.nodes
|
||||
.filter((n) => n !== fi && n !== fo)
|
||||
.map((n) => ({ ...n, id: prefix + n.id }));
|
||||
return {
|
||||
...graph,
|
||||
nodes: [...graph.nodes.filter((n) => n !== call), ...copies, ...constants],
|
||||
edges: [...kept, ...edges],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Problems of the graph a compile saw, moved onto the graph its author sees: one inside a function
|
||||
* goes to the call it was set in at, in that function's words.
|
||||
*/
|
||||
export function problemsAtCalls(problems: Record<string, string>, inlined: Inlined): Record<string, string> {
|
||||
const lifted: Record<string, string> = {};
|
||||
for (const [id, text] of Object.entries(problems)) {
|
||||
const cut = id.indexOf('/');
|
||||
const call = cut < 0 ? id : id.slice(0, cut);
|
||||
const said = cut < 0 ? text : `In ${inlined.names[call] ?? 'the function'}: ${text}`;
|
||||
if (!(call in lifted)) lifted[call] = said;
|
||||
}
|
||||
return lifted;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a compile of `graph` depends on beyond its own nodes: every function it calls, however
|
||||
* deep, by content. A shader recompiles when one of them changes.
|
||||
*/
|
||||
export function functionsFingerprint(graph: Graph, functions: FunctionLookup): string {
|
||||
const parts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (g: Graph) => {
|
||||
for (const id of calledFunctions(g)) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const called = functions(id);
|
||||
parts.push(`${id}:${called ? `${called.name}:${graphFingerprint(called.graph)}` : 'gone'}`);
|
||||
if (called) visit(called.graph);
|
||||
}
|
||||
};
|
||||
visit(graph);
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function texelAtFn(edge: EdgeMode): string {
|
||||
: `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { return texture(picture, uv); }`;
|
||||
}
|
||||
|
||||
const NUMBER_STEP = 0.05;
|
||||
export const NUMBER_STEP = 0.05;
|
||||
|
||||
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
|
||||
const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' });
|
||||
|
||||
@@ -140,6 +140,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
shaderGraphs.set(a.id, graph);
|
||||
shaderRows.set(a.id, shaderParamDefs(graph));
|
||||
}
|
||||
if (a.type === 'shaderfunction') shaderGraphs.set(a.id, normalizeGraph(a.graph, isShaderNodeDef));
|
||||
schemas.set(a.id, schemaFor(a.type));
|
||||
}
|
||||
}
|
||||
@@ -225,7 +226,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
// Kept as written even where it names no state any more: the same as a deleted start.
|
||||
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
|
||||
assets.push(machine);
|
||||
} else if (type === 'shader') {
|
||||
} else if (type === 'shader' || type === 'shaderfunction') {
|
||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'particles') {
|
||||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||||
|
||||
@@ -7,6 +7,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
behavior: 'graph',
|
||||
statemachine: 'statemachine',
|
||||
shader: 'shader',
|
||||
shaderfunction: 'shader',
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
vectorart: 'vectorart',
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
BEHAVIOR_PARAM,
|
||||
findAnnotation,
|
||||
findNode,
|
||||
FUNCTION_PARAM,
|
||||
type GraphNode,
|
||||
nodeDef,
|
||||
namesBehavior,
|
||||
SHADER_CALL,
|
||||
} from '../core/graph';
|
||||
import type { GraphAsset } from '../core/model/Asset';
|
||||
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
|
||||
@@ -160,7 +162,8 @@ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolea
|
||||
return paletteEntries(project, asset).some(
|
||||
(entry) =>
|
||||
entry.def.type === node.type &&
|
||||
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]),
|
||||
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
|
||||
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
drawnDef,
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
FUNCTION_PARAM,
|
||||
ghostProblem,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
resolveDef,
|
||||
namesBehavior,
|
||||
RUN_BEHAVIOR,
|
||||
SHADER_CALL,
|
||||
callsFunction,
|
||||
startStateOf,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
@@ -278,13 +281,16 @@ export class GraphCanvas extends Widget {
|
||||
this.annotationLayer.sync(asset.graph);
|
||||
const seen = new Set<string>();
|
||||
const lookup = graphLookup(this.state.project);
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's.
|
||||
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null;
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's – but a shader function's
|
||||
// call says what is wrong with it even where nothing reaches it, as a behavior's does.
|
||||
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset, this.state.project) : null;
|
||||
const compilerProblems = shaderBuild
|
||||
? { ...shaderBuild.hints, ...shaderBuild.problems }
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
: asset.type === 'shaderfunction'
|
||||
? shaderLibrary.checkFor(asset, this.state.project).problems
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const resolved = resolveDef(node, asset.graph, lookup);
|
||||
if (!resolved) continue;
|
||||
@@ -299,7 +305,8 @@ export class GraphCanvas extends Widget {
|
||||
this.nodeLayer.addChild(view);
|
||||
}
|
||||
const problem =
|
||||
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
|
||||
compilerProblems?.[node.id] ??
|
||||
(!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ??
|
||||
ghostProblem(node, asset.graph, resolved) ??
|
||||
blockProblem(asset.graph, node);
|
||||
view.sync(node, asset.graph, this.state.project, def, problem);
|
||||
@@ -628,6 +635,12 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openCalledBehavior(node: GraphNode): boolean {
|
||||
if (node.type === SHADER_CALL) {
|
||||
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
|
||||
if (findAsset(this.state.project, id)?.type !== 'shaderfunction') return false;
|
||||
this.state.openAsset(id);
|
||||
return true;
|
||||
}
|
||||
if (!namesBehavior(node.type)) return false;
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||||
@@ -683,23 +696,33 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so
|
||||
* Follows a behavior or shader function dragged in from the asset panel. The node is centred under the pointer, so
|
||||
* it lands where the ghost was rather than below and right of it.
|
||||
*/
|
||||
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
|
||||
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
|
||||
// Only a behavior graph runs other behaviors; a state machine has nothing to drop in.
|
||||
if (!inside || this.asset()?.type !== 'behavior' || asset?.type !== 'behavior') {
|
||||
// A behavior graph runs other behaviors and a shader calls functions (#303); a state machine
|
||||
// has nothing to drop in.
|
||||
const into = this.asset()?.type;
|
||||
const calls = asset?.type === 'shaderfunction' && (into === 'shader' || into === 'shaderfunction');
|
||||
if (!inside || !asset || !(calls || (into === 'behavior' && asset.type === 'behavior'))) {
|
||||
this.setIncoming(null);
|
||||
return;
|
||||
}
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
const rejection = calls
|
||||
? callsFunction(graphLookup(this.state.project), asset.id, this.assetId)
|
||||
? 'A function cannot call itself'
|
||||
: ''
|
||||
: asset.id === this.assetId
|
||||
? 'A behavior cannot run itself'
|
||||
: '';
|
||||
this.setIncoming({
|
||||
assetId: asset.id,
|
||||
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) },
|
||||
rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '',
|
||||
rejection,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -714,7 +737,9 @@ export class GraphCanvas extends Widget {
|
||||
this.state.setNotice(drop.rejection);
|
||||
return;
|
||||
}
|
||||
const entry = this.entries().find((it) => it.id === `${RUN_BEHAVIOR}:${drop.assetId}`);
|
||||
const type =
|
||||
findAsset(this.state.project, drop.assetId)?.type === 'shaderfunction' ? SHADER_CALL : RUN_BEHAVIOR;
|
||||
const entry = this.entries().find((it) => it.id === `${type}:${drop.assetId}`);
|
||||
if (entry) this.addNode(entry, drop.at);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
allNodeDefs,
|
||||
BEHAVIOR_END,
|
||||
BEHAVIOR_PARAM,
|
||||
callsFunction,
|
||||
createEmptyGraph,
|
||||
FUNCTION_PARAM,
|
||||
isTerminalType,
|
||||
namesBehavior,
|
||||
nextStateName,
|
||||
@@ -12,11 +14,14 @@ import {
|
||||
ON_BEHAVIOR,
|
||||
resolveDef,
|
||||
RUN_BEHAVIOR,
|
||||
SHADER_CALL,
|
||||
SHADER_FUNCTION_INPUT,
|
||||
SHADER_FUNCTION_OUTPUT,
|
||||
State,
|
||||
STATE_NAME_PARAM,
|
||||
terminalNode,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
|
||||
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
SHADER_PROPERTY,
|
||||
} from '../../core/properties/shaders';
|
||||
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
|
||||
import { offeredIn, ShaderFunctionInput, ShaderFunctionOutput } from '../../core/shader/functions';
|
||||
import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes';
|
||||
|
||||
/**
|
||||
@@ -88,21 +94,17 @@ function filterEntry(find: NodeDef): PaletteEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
|
||||
* new one named by its number; a shader holds shader nodes, its one Output dropping out once
|
||||
* placed. In a behavior, every other behavior appears under its own name instead of behind a
|
||||
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
|
||||
* through (#294) –, and the two terminals drop out once the graph has them.
|
||||
* A shader's nodes, its one Output dropping out once placed, and every shader function under its own
|
||||
* name (#303). A function holds the same nodes but those that end a shader or give the wearer a row,
|
||||
* and its own two ends instead; it calls every function but those that would come back to it.
|
||||
*/
|
||||
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
|
||||
if (asset.type === 'statemachine') {
|
||||
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
if (asset.type === 'shader') {
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT];
|
||||
return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => {
|
||||
function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsset): PaletteEntry[] {
|
||||
const inFunction = asset.type === 'shaderfunction';
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT];
|
||||
const entries: PaletteEntry[] = [...SHADER_NODES, ShaderFunctionInput, ShaderFunctionOutput]
|
||||
.filter((def) => offeredIn(def.type, inFunction) && (!once.includes(def.type) || !placed.has(def.type)))
|
||||
.map((def) => {
|
||||
const params: PropertyBag =
|
||||
def.type === SHADER_PROPERTY
|
||||
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) }
|
||||
@@ -111,7 +113,28 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
||||
: {};
|
||||
return { id: def.type, hint: def.category, def, params };
|
||||
});
|
||||
const lookup = graphLookup(project);
|
||||
for (const other of project.assets) {
|
||||
if (other.type !== 'shaderfunction' || callsFunction(lookup, other.id, asset.id)) continue;
|
||||
const params = { [FUNCTION_PARAM]: other.id };
|
||||
const def = resolveDef({ id: '', type: SHADER_CALL, x: 0, y: 0, params }, createEmptyGraph(), lookup);
|
||||
if (def) entries.push({ id: `${SHADER_CALL}:${other.id}`, hint: 'function', def, params });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
|
||||
* new one named by its number; a shader holds shader nodes (`shaderEntries`). In a behavior, every other behavior appears under its own name instead of behind a
|
||||
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
|
||||
* through (#294) –, and the two terminals drop out once the graph has them.
|
||||
*/
|
||||
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
|
||||
if (asset.type === 'statemachine') {
|
||||
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
|
||||
if (asset.type === 'particles') {
|
||||
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
|
||||
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
SetPortWriteCommand,
|
||||
} from '../../core/commands/portCommands';
|
||||
import {
|
||||
BEHAVIOR_START,
|
||||
type DeclaredPort,
|
||||
type GraphNode,
|
||||
isEntryTerminal,
|
||||
isPortOf,
|
||||
isTerminalNode,
|
||||
missingFieldPorts,
|
||||
nodeDef,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
OBJECT_BEHAVIOR_SETTING,
|
||||
OBJECT_TYPE_SETTING,
|
||||
portKindOf,
|
||||
SHADER_FUNCTION_INPUT,
|
||||
SHADER_FUNCTION_OUTPUT,
|
||||
VARIABLES_SETTING,
|
||||
variablesSourceOf,
|
||||
propertyTargetOf,
|
||||
@@ -211,8 +214,9 @@ export function buildPortSection(
|
||||
variables: readonly PropertyDef[] = [],
|
||||
typeName: string | null = null,
|
||||
): void {
|
||||
const onStart = node.type === BEHAVIOR_START;
|
||||
const onStart = isEntryTerminal(node.type);
|
||||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||||
const ofFunction = node.type === SHADER_FUNCTION_INPUT || node.type === SHADER_FUNCTION_OUTPUT;
|
||||
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
|
||||
|
||||
const header = sectionHeader(host, `${noun.toUpperCase()}S`);
|
||||
@@ -233,7 +237,9 @@ export function buildPortSection(
|
||||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
// A further way in or out is a start or end node's to declare; a struct has fields only.
|
||||
const kinds = DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field');
|
||||
const kinds =
|
||||
nodeDef(node.type)?.portKinds ??
|
||||
DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field');
|
||||
const items = kindMenu(kinds, (kind) =>
|
||||
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
|
||||
);
|
||||
@@ -248,14 +254,18 @@ export function buildPortSection(
|
||||
host,
|
||||
noun === 'Field'
|
||||
? 'No fields yet. Every field added here becomes a port of the struct.'
|
||||
: onStart
|
||||
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
|
||||
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
|
||||
: ofFunction
|
||||
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
|
||||
: onStart
|
||||
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
|
||||
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
|
||||
);
|
||||
|
||||
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
|
||||
}
|
||||
|
||||
const plural = (noun: string) => `${noun.toLowerCase()}s`;
|
||||
|
||||
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
||||
return new TextField(port.key, {
|
||||
width: NAME_WIDTH,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
||||
tilemap: 'Tilemaps',
|
||||
statemachine: 'State machines',
|
||||
shader: 'Shaders',
|
||||
shaderfunction: 'Shader functions',
|
||||
font: 'Fonts',
|
||||
sound: 'Sounds',
|
||||
particles: 'Particles',
|
||||
|
||||
@@ -22,6 +22,7 @@ import plugins from './icons/plugins.svg?raw';
|
||||
import rigidbody from './icons/rigidbody.svg?raw';
|
||||
import scene from './icons/scene.svg?raw';
|
||||
import shader from './icons/shader.svg?raw';
|
||||
import shaderfunction from './icons/shaderfunction.svg?raw';
|
||||
import sound from './icons/sound.svg?raw';
|
||||
import sprite from './icons/sprite.svg?raw';
|
||||
import statemachine from './icons/statemachine.svg?raw';
|
||||
@@ -50,6 +51,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
rigidbody,
|
||||
scene,
|
||||
shader,
|
||||
shaderfunction,
|
||||
sound,
|
||||
sprite,
|
||||
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 ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders';
|
||||
import {
|
||||
@@ -37,12 +38,18 @@ const HOLD_WIDTH = 52;
|
||||
const PARAM_NAME_WIDTH = 72;
|
||||
const PARAM_VALUE_WIDTH = 44;
|
||||
|
||||
/** What the tab edits: a shader, or a function used in shaders (#303), which has no picture of its own. */
|
||||
type ShaderTabAsset = ShaderAsset | ShaderFunctionAsset;
|
||||
|
||||
/** The panel's line while a function is open: it is seen in the shaders that call it. */
|
||||
export const FUNCTION_SUBJECT = 'A function shows in the shaders calling it';
|
||||
|
||||
/**
|
||||
* The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture
|
||||
* wearing it (#168). The picture is whatever was last selected that has one – a sprite, a texture, a
|
||||
* frame – so the shader is judged on the image it is meant for; before that, a built-in test image.
|
||||
*/
|
||||
export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
export class ShaderEditor extends AssetTabEditor<ShaderTabAsset, GraphCanvas> {
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly preview: ShaderPreview;
|
||||
/** Assigned by `body`; public for the check scripts, which drag it. */
|
||||
@@ -108,8 +115,10 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
|
||||
/** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */
|
||||
get animates(): boolean {
|
||||
const asset = this.asset();
|
||||
return Boolean(asset && !this.preview.holding && shaderLibrary.buildFor(asset).compiled?.time);
|
||||
const asset = this.shader();
|
||||
return Boolean(
|
||||
asset && !this.preview.holding && shaderLibrary.buildFor(asset, this.state.project).compiled?.time,
|
||||
);
|
||||
}
|
||||
|
||||
/** Called every frame while the tab is showing. */
|
||||
@@ -150,21 +159,37 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
return this.state.selectedShaderId ?? '';
|
||||
}
|
||||
|
||||
override isMine(asset: Asset): asset is ShaderAsset {
|
||||
return asset.type === 'shader';
|
||||
override isMine(asset: Asset): asset is ShaderTabAsset {
|
||||
return asset.type === 'shader' || asset.type === 'shaderfunction';
|
||||
}
|
||||
|
||||
protected override infoText(asset: ShaderAsset): string {
|
||||
const build = shaderLibrary.buildFor(asset);
|
||||
/** The open asset while it is a shader rather than a function. */
|
||||
private shader(): ShaderAsset | null {
|
||||
const asset = this.asset();
|
||||
return asset?.type === 'shader' ? asset : null;
|
||||
}
|
||||
|
||||
protected override infoText(asset: ShaderTabAsset): string {
|
||||
if (asset.type === 'shaderfunction') {
|
||||
const check = shaderLibrary.checkFor(asset, this.state.project);
|
||||
if (check.reason) return check.reason;
|
||||
const count = (type: string, noun: string) => {
|
||||
const n = terminalNode(asset.graph, type)?.ports?.length ?? 0;
|
||||
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
||||
};
|
||||
return `${count(SHADER_FUNCTION_INPUT, 'input')}, ${count(SHADER_FUNCTION_OUTPUT, 'output')}`;
|
||||
}
|
||||
const build = shaderLibrary.buildFor(asset, this.state.project);
|
||||
if (!build.compiled) return build.reason;
|
||||
const count = shaderParamDefs(asset.graph).length;
|
||||
return `${count} ${count === 1 ? 'property' : 'properties'}`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: ShaderAsset | null): void {
|
||||
this.snap.setEnabled(Boolean(asset));
|
||||
protected override syncExtras(open: ShaderTabAsset | null): void {
|
||||
this.snap.setEnabled(Boolean(open));
|
||||
const asset = open?.type === 'shader' ? open : null;
|
||||
const picture = this.subject && previewPicture(this.state.project, this.subject);
|
||||
this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : '');
|
||||
this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : open ? FUNCTION_SUBJECT : '');
|
||||
const defs = asset ? shaderParamDefs(asset.graph) : [];
|
||||
// A new picture or another shader starts over from what that sprite wears; an edit of the graph
|
||||
// keeps what the sliders were moved to.
|
||||
@@ -180,7 +205,9 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
}
|
||||
this.syncParams(defs);
|
||||
this.preview.show(asset, picture, this.values);
|
||||
this.holdButton.setEnabled(Boolean(asset && shaderLibrary.buildFor(asset).compiled?.time));
|
||||
this.holdButton.setEnabled(
|
||||
Boolean(asset && shaderLibrary.buildFor(asset, this.state.project).compiled?.time),
|
||||
);
|
||||
this.restartButton.setEnabled(this.holdButton.enabled);
|
||||
this.syncControls();
|
||||
}
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ import type { ObjectPick, PickEnd } from './objectPick';
|
||||
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
|
||||
import { type Command, CommandError } from '../core/commands/Command';
|
||||
import { CommandStack } from '../core/commands/CommandStack';
|
||||
import type { Asset, SceneAsset } from '../core/model/Asset';
|
||||
import type { Asset, GraphAsset, SceneAsset } from '../core/model/Asset';
|
||||
import { findAnnotation, type Graph } from '../core/graph';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { allShapes } from '../core/model/vectorTree';
|
||||
@@ -800,9 +800,9 @@ export class EditorState {
|
||||
return this.selectedGraphId('statemachine');
|
||||
}
|
||||
|
||||
/** The shader the current selection refers to: the asset, or one of its nodes. */
|
||||
/** The shader or shader function the current selection refers to: the asset, or one of its nodes. */
|
||||
get selectedShaderId(): string | null {
|
||||
return this.selectedGraphId('shader');
|
||||
return this.selectedGraphId('shader') ?? this.selectedGraphId('shaderfunction');
|
||||
}
|
||||
|
||||
/** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */
|
||||
@@ -810,7 +810,7 @@ export class EditorState {
|
||||
return this.selectedGraphId('particles');
|
||||
}
|
||||
|
||||
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null {
|
||||
private selectedGraphId(type: GraphAsset['type']): string | null {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind === 'frame') return null;
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import type { ShaderAsset } from '../core/model/Asset';
|
||||
import type { ShaderAsset, ShaderFunctionAsset } from '../core/model/Asset';
|
||||
import { shaderFunctionsOf } from '../core/model/graphLookup';
|
||||
import { findAssetOf, type Project } from '../core/model/Project';
|
||||
import { graphFingerprint } from '../core/graph/Graph';
|
||||
import { compileShader, type ShaderBuild } from '../core/shader/compile';
|
||||
import {
|
||||
checkShaderFunction,
|
||||
compileShader,
|
||||
type FunctionCheck,
|
||||
type ShaderBuild,
|
||||
} from '../core/shader/compile';
|
||||
import { functionsFingerprint } from '../core/shader/functions';
|
||||
import { gameClock } from '../core/time';
|
||||
import type { ShaderSource } from './shaders';
|
||||
|
||||
/**
|
||||
* Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is
|
||||
* edited in place, so the cache keys on a fingerprint of what the fragment depends on.
|
||||
* Compiled shader graphs, one per asset, recompiled when the graph's content changes – or that of
|
||||
* a function it calls (#303). A graph is edited in place, so the cache keys on a fingerprint of what
|
||||
* the fragment depends on.
|
||||
*/
|
||||
class ShaderLibrary {
|
||||
private readonly builds = new Map<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. */
|
||||
compiles = 0;
|
||||
/** Set from the test hook: the clock stands at this second until released. */
|
||||
@@ -25,16 +34,28 @@ class ShaderLibrary {
|
||||
this.frozenAt = at;
|
||||
}
|
||||
|
||||
buildFor(asset: ShaderAsset): ShaderBuild {
|
||||
const fingerprint = graphFingerprint(asset.graph);
|
||||
buildFor(asset: ShaderAsset, project: Project): ShaderBuild {
|
||||
const functions = shaderFunctionsOf(project);
|
||||
const fingerprint = `${graphFingerprint(asset.graph)}\n${functionsFingerprint(asset.graph, functions)}`;
|
||||
const cached = this.builds.get(asset.id);
|
||||
if (cached && cached.fingerprint === fingerprint) return cached.build;
|
||||
this.compiles += 1;
|
||||
const build = compileShader(asset.graph);
|
||||
const build = compileShader(asset.graph, functions);
|
||||
this.builds.set(asset.id, { fingerprint, build });
|
||||
return build;
|
||||
}
|
||||
|
||||
/** A function on its own: what its tab and its nodes report. */
|
||||
checkFor(asset: ShaderFunctionAsset, project: Project): FunctionCheck {
|
||||
const functions = shaderFunctionsOf(project);
|
||||
const fingerprint = `${graphFingerprint(asset.graph)}\n${functionsFingerprint(asset.graph, functions)}`;
|
||||
const cached = this.checks.get(asset.id);
|
||||
if (cached && cached.fingerprint === fingerprint) return cached.check;
|
||||
const check = checkShaderFunction(asset.graph, functions, asset.id);
|
||||
this.checks.set(asset.id, { fingerprint, check });
|
||||
return check;
|
||||
}
|
||||
|
||||
/** For one redraw: each asset is looked up and fingerprinted once, however many sprites wear it. */
|
||||
source(project: Project): ShaderSource {
|
||||
const seen = new Map<string, ShaderBuild | null>();
|
||||
@@ -44,7 +65,7 @@ class ShaderLibrary {
|
||||
let build = seen.get(assetId);
|
||||
if (build === undefined) {
|
||||
const asset = findAssetOf(project, assetId, 'shader');
|
||||
build = asset ? this.buildFor(asset) : null;
|
||||
build = asset ? this.buildFor(asset, project) : null;
|
||||
seen.set(assetId, build);
|
||||
}
|
||||
return build?.compiled ?? null;
|
||||
|
||||
@@ -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,
|
||||
{ chained },
|
||||
);
|
||||
|
||||
// 28l-viii. A shader function (#303): Grayscale as an asset of its own, called from the shader,
|
||||
// draws what the same nodes draw in place. A change to the function reaches the sprite
|
||||
// without the shader being touched, and a double-click on the call opens the function.
|
||||
const FN = 'fn28-gray';
|
||||
const fnEdge = (id, from, fromPort, to, toPort) => ({
|
||||
id,
|
||||
from: { node: from, port: fromPort },
|
||||
to: { node: to, port: toPort },
|
||||
});
|
||||
await mutate(`
|
||||
__gm.state.project.assets.push({
|
||||
id: '${FN}',
|
||||
type: 'shaderfunction',
|
||||
name: 'Grayscale 28',
|
||||
props: {},
|
||||
graph: {
|
||||
nodes: ${JSON.stringify([
|
||||
{ ...node('in', 'ShaderFunctionInput', {}, 0, 0), ports: [{ key: 'color', kind: 'color' }] },
|
||||
node('lum', 'ShaderLuminance', {}, 260, 0),
|
||||
{ ...node('out', 'ShaderFunctionOutput', {}, 520, 0), ports: [{ key: 'gray', kind: 'color' }] },
|
||||
])},
|
||||
edges: ${JSON.stringify([fnEdge('e1', 'in', 'color', 'lum', 'color'), fnEdge('e2', 'lum', 'value', 'out', 'gray')])},
|
||||
},
|
||||
})`);
|
||||
const ends = [node('own', 'ShaderTextureColor', {}, 0, 0), node('out', 'ShaderOutput', {}, 520, 0)];
|
||||
await graph(
|
||||
[...ends, node('lum', 'ShaderLuminance', {}, 260, 0)],
|
||||
[edge('own', 'color', 'lum', 'color'), edge('lum', 'value', 'out', 'color')],
|
||||
);
|
||||
const inPlace = [await texel(4, 6), await texel(27, 6)];
|
||||
await graph(
|
||||
[...ends, node('call', 'ShaderCall', { function: FN }, 260, 0)],
|
||||
[edge('own', 'color', 'call', 'color'), edge('call', 'gray', 'out', 'color')],
|
||||
);
|
||||
const called = [await texel(4, 6), await texel(27, 6)];
|
||||
const isGrey = (hex) => new Set(rgb(hex)).size === 1;
|
||||
check(
|
||||
'a function called from the shader draws what its nodes draw in place, byte for byte',
|
||||
called.join() === inPlace.join() && called.every(isGrey) && called[0] !== called[1],
|
||||
{ inPlace, called },
|
||||
);
|
||||
await mutate(`
|
||||
const g = __gm.state.project.assets.find((a) => a.id === '${FN}').graph;
|
||||
g.nodes.push(${JSON.stringify(node('half', 'ShaderMultiply', { b: 0.5 }, 390, 140))});
|
||||
g.edges = g.edges.filter((e) => e.id !== 'e2');
|
||||
g.edges.push(...${JSON.stringify([fnEdge('e3', 'lum', 'value', 'half', 'a'), fnEdge('e4', 'half', 'result', 'out', 'gray')])})`);
|
||||
const fnHalved = [await texel(4, 6), await texel(27, 6)];
|
||||
check(
|
||||
'a change to the function reaches the sprite, its shader untouched',
|
||||
fnHalved.every((hex, k) => isGrey(hex) && Math.abs(rgb(hex)[0] - rgb(called[k])[0] / 2) <= 2),
|
||||
{ called, fnHalved },
|
||||
);
|
||||
await select({ kind: 'asset', assetId: made.id });
|
||||
await showTab('shader');
|
||||
await clickText('Fit');
|
||||
const callNode = await json("__gm.shader.nodeAt('call')");
|
||||
const callLabel = await json("__gm.shader.nodeLabel('call')");
|
||||
if (callNode) await doubleClick(callNode.x, callNode.y);
|
||||
await sleep(200);
|
||||
const openedFunction = {
|
||||
current: await json('__gm.shader.current()'),
|
||||
info: await json('__gm.shader.info()'),
|
||||
subject: (await json('__gm.shader.preview()')).subject,
|
||||
};
|
||||
await mutate(`
|
||||
__gm.state.project.assets.find((a) => a.id === '${FN}').graph.nodes.push(
|
||||
${JSON.stringify(node('self', 'ShaderCall', { function: FN }, 260, 260))})`);
|
||||
const selfProblem = await json("__gm.shader.nodeProblem('self')");
|
||||
check(
|
||||
'the call is named after the function, a double-click opens it, and a call of itself has a problem',
|
||||
callLabel === 'Grayscale 28' &&
|
||||
openedFunction.current === FN &&
|
||||
openedFunction.info === '1 input, 1 output' &&
|
||||
openedFunction.subject === 'A function shows in the shaders calling it' &&
|
||||
selfProblem === 'A function cannot call itself',
|
||||
JSON.stringify({ callNode, callLabel, openedFunction, selfProblem }),
|
||||
);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => a.id !== '${FN}')`);
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await showTab('scene');
|
||||
await assetProps({ texture: picture?.id ?? '' });
|
||||
await filterOf(picture?.id, 'linear');
|
||||
|
||||
|
||||
Reference in new issue
Block a user