Skip to content

Build an Operator Graph

std/visuals has ready-made operators — gfx.Noise, gfx.Blur, gfx.Displace, gfx.Composite and more — that you wire into a graph instead of writing GLSL. Every operator, with its uniforms and inputs, is in the std/visuals reference.

Each constructor takes a name. Set uniforms with <<, and chain with >>, which feeds the left node into the right one’s main input:

use "std/visuals" as gfx;
let field = gfx.Noise("field");
field.parameter("period") << 2.0;
let soft = gfx.Blur("soft");
soft.parameter("size") << 16.0;
field >> soft >> screen;
PLAY;

A uniform takes a number, a signal, a meter feed, an array of 2–4 numbers for a vector, or a table of rows, such as the keys of a ramp:

edge.parameter("strength") << Sine(8).range(0.5, 2.0);
blur.parameter("size") << drums.rms.smooth(80).range(1, 40);
move.parameter("scale") << #[1.5, 1.5];
ramp.parameter("keys") << #[#[0, 0.1, 0.0, 0.2, 1], #[1, 1, 1, 1, 1]];

An operator with two inputs names its second one. Bind a node to it with <<:

comp.parameter("b") << other; // Composite's input B
disp.parameter("by") << field; // what Displace displaces by
look.parameter("table") << ramp; // Lookup's lookup table

Both inputs must be wired. An operator with an empty input is not drawn: putting its graph on screen is an error that names the operator and the input, and the picture on screen stays.

Keep the node in a variable and wire it wherever it is needed. It is drawn once per frame, and every consumer reads that result:

use "std/visuals" as gfx;
let field = gfx.Noise("field");
field.parameter("period") << 2.0;
let soft = gfx.Blur("soft");
soft.parameter("size") << 12.0;
let softer = gfx.Blur("softer");
softer.parameter("size") << 48.0;
let comp = gfx.Composite("comp");
comp.parameter("operand") << gfx.Operand.subtract; // A - B
field >> soft >> comp; // A
comp.parameter("b") << (field >> softer); // B
comp >> screen;
PLAY;

The name is the operator: the same name gives back the same node. A helper that builds its nodes with fixed names hands every caller the same ones, so take the name as an argument:

use "std/visuals" as gfx;
fn glow(name, src) {
let b = gfx.Blur(name);
b.parameter("size") << 24.0;
return src >> b;
}
glow("halo", gfx.Noise("field")) >> screen;
PLAY;

To read what the graph drew in the previous frame, build it inside gfx.feedback. The function gets prev, last frame’s output; wire it into an input, and return the node that is the output. The first argument is the seed: prev is the seed’s frame until the output has drawn one of its own:

use "std/visuals" as gfx;
use "std/signals" { Time };
let field = gfx.Noise("field");
field.parameter("period") << 3.0;
field.parameter("tz") << Time(0.2).set_timebase(seconds(1));
let look = gfx.feedback(field, fn(prev) {
let disp = gfx.Displace("trail");
disp.parameter("displace_weight_x") << 0.1;
disp.parameter("displace_weight_y") << 0.1;
disp.parameter("by") << prev;
return field >> disp;
});
look >> screen;
PLAY;

The noise moves through its third axis, tz, by 0.2 each second. Each frame displaces it by the frame before, which leaves a flowing trail. The noise is also the seed, so on the first frame the noise displaces itself.

The trail survives your edits. To start it again from the seed, reset the node the function returned:

look.reset();

examples/05_visuals/04_voronoi_feedback.non is a whole patch built this way: a TouchDesigner feedback patch whose cells flow and warp, using fan-out and feedback together.