graphModifier.ts
examples/fxedit/fx/graphModifier.ts · raw · open the example · all examples · site home
Exports: FxInputsGraphModifierbuildGraphModifiersetParamgetParam
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
// A graph built into a Radix SplatModifier. Modifiers are program cache
// keys, so each graph structure (graphKey) is built once and kept: every
// unlinked input reads its own uniform, and FxInputs holds the uniforms of
// time, the click and the camera, so changing values, time, the click or
// the camera writes uniforms and compiles nothing. A new structure (links,
// options, absent inputs) is a new modifier, compiled at its first render.
import * as THREE from "three";
import { GpuSplats, TSL, type SplatModifier, type TslNode } from "@sparkjsdev/radix";
import {
evaluationOrder, flattenGraph, graphKey, graphParams, isAbsent, linkSources, splitPort, validate, valueFits, withGroups, type Graph,
type GraphParam, type NodeLookup,
} from "./graph.js";
import { fxNodes } from "./nodes/index.js";
import type { BuildContext, PortType, Value } from "./types.js";
const { float, uniform, vec2, vec3, vec4 } = TSL;
// Written by the page: seconds, the last click (a point in the modifier's
// space and its time) and the viewing camera in the modifier's space
// (pixelSize: world size of one drawing-buffer pixel at unit depth)
export const FxInputs = {
time: uniform(0),
click: { point: uniform(new THREE.Vector3()), time: uniform(-1e4) },
camera: { position: uniform(new THREE.Vector3(0, 0, 3)), forward: uniform(new THREE.Vector3(0, 0, -1)), pixelSize: uniform(0.0015) },
};
export interface GraphModifier {
// "fx:" + graphKey: the structure, never values
readonly key: string;
readonly modifier: SplatModifier;
// One uniform per param ("node.input"); a bool's is a float (0 or 1)
readonly uniforms: Map<string, TslNode>;
readonly usesTime: boolean;
readonly usesClick: boolean;
readonly usesCamera: boolean;
// The last built graph's knobs: label -> "node.input"
labels: Record<string, string>;
// Every param with its type and current value
readonly params: Map<string, GraphParam>;
}
const cache = new Map<string, GraphModifier>();
// The modifier of `graph`'s structure, its uniforms set to the graph's values
export function buildGraphModifier(graph: Graph, registry: NodeLookup = fxNodes): GraphModifier {
const errors = validate(graph, registry);
if (errors.length > 0) {
throw new Error(`Invalid fx graph${graph.name ? ` ${graph.name}` : ""}:\n${errors.join("\n")}`);
}
const key = `fx:${graphKey(graph, registry)}`;
const built = cache.get(key) ?? compile(structuredClone(graph), registry, key);
cache.set(key, built);
built.labels = structuredClone(graph.params ?? {});
for (const param of graphParams(graph, registry)) {
setParam(built, param.name, param.value);
}
return built;
}
// A param by label or "node.input"
export function setParam(target: GraphModifier, name: string, value: Value) {
const param = paramOf(target, name);
if (!valueFits(param.type, value)) {
throw new Error(`fx param ${name}: ${JSON.stringify(value)} does not fit ${param.type}`);
}
param.value = structuredClone(value);
const u = target.uniforms.get(param.name)!;
if (typeof u.value === "number") {
u.value = Number(value);
} else {
u.value.fromArray(value as number[]);
}
}
export function getParam(target: GraphModifier, name: string): Value {
return paramOf(target, name).value;
}
function paramOf(target: GraphModifier, name: string) {
const param = target.params.get(target.labels[name] ?? name);
if (!param) {
throw new Error(`fx graph has no param ${name}`);
}
return param;
}
function compile(source: Graph, registry: NodeLookup, key: string): GraphModifier {
const graph = flattenGraph(source);
const lookup = withGroups(registry, source.groups);
const order = evaluationOrder(source, registry);
const def = (id: string) => lookup.get(graph.nodes[id].type)!;
const usesClick = order.some((id) => def(id).click);
const usesCamera = order.some((id) => def(id).camera);
const usesTime = usesClick || order.some((id) => def(id).time);
const params = new Map(graphParams(source, registry).map((param) => [param.name, param]));
const uniforms = new Map([...params.values()].map((param) => [param.name, makeUniform(param.type, param.value)]));
const sources = linkSources(graph);
const modifier: SplatModifier = (s) => {
// Reads see the splat as it entered: .toVar() declares lazily, where
// first used, so both vars are declared here by explicit assignments
const entered = GpuSplats.Splat().toVar();
entered.assign(s);
const splat = GpuSplats.Splat().toVar();
splat.assign(s);
const time = usesTime ? FxInputs.time : float(0);
const click = usesClick
? { point: FxInputs.click.point, age: time.sub(FxInputs.click.time) }
: { point: vec3(0), age: float(1e4) };
const camera = usesCamera
? FxInputs.camera
: { position: vec3(0, 0, 3), forward: vec3(0, 0, -1), pixelSize: float(0.0015) };
const outputs = new Map<string, Record<string, TslNode>>();
for (const id of order) {
const node = graph.nodes[id];
const nodeDef = def(id);
const inputs: Record<string, TslNode> = {};
for (const [name, input] of Object.entries(nodeDef.inputs)) {
const from = sources.get(`${id}.${name}`);
if (from) {
const [fromNode, port] = splitPort(from);
inputs[name] = convert(outputs.get(fromNode)![port], def(fromNode).outputs[port], input.type);
} else if (!isAbsent(graph, nodeDef, id, name, sources)) {
const u = uniforms.get(`${id}.${name}`)!;
inputs[name] = input.type === "bool" ? u.greaterThan(0.5) : u;
}
}
const build: BuildContext = {
source: entered,
splat,
index: entered.get("index"),
time,
click,
camera,
option: (name) => node.options?.[name] ?? nodeDef.options![name].default,
};
outputs.set(id, nodeDef.build(inputs, build));
}
return splat;
};
return { key, modifier, uniforms, usesTime, usesClick, usesCamera, labels: {}, params };
}
function makeUniform(type: PortType, value: Value): TslNode {
const v = Array.isArray(value) ? value : [Number(value)];
switch (type) {
case "float":
case "bool": return uniform(v[0]);
case "vec2": return uniform(new THREE.Vector2().fromArray(v));
case "vec3":
case "color": return uniform(new THREE.Vector3().fromArray(v));
case "vec4": return uniform(new THREE.Vector4().fromArray(v));
}
}
// A linked float feeds vectors by broadcast (validate allows nothing else)
function convert(value: TslNode, from: PortType, to: PortType): TslNode {
if (from !== "float" || to === "float") {
return value;
}
return to === "vec2" ? vec2(value) : to === "vec4" ? vec4(value) : vec3(value);
}