The Expression language

Write one snippet and it runs on every element of the input, point, pixel, voxel or vertex, on the GPU.

The Expression node replaces a chain of nodes nudging an attribute with one short snippet that runs across the whole input at once. The language is close to VEX in Houdini.

The model

Read some attributes, do maths with them, write attributes back. That is the whole language.

@P.z = luminance(@Cd) * 100.0;

@P is the position attribute, @Cd is the colour. The .z on the left restricts the write to the Z component, so each point rises by its own brightness.

Reads, writes and globals

@name reads or writes a per-element attribute: @P, @Cd, @N, @pscale and the rest of the built-in set on Expression attributes. A name outside that set has to be assigned before it can be read, so a snippet can make its own attribute and read it back further down, but a bare @my_thing that nothing in the snippet wrote is a compile error. $NAME reads a global, one value for the whole frame, such as $T (time in seconds), $F (frame number) or $BPM (tempo). Globals & parameters carries the full list.

// Wobble every point with time-driven noise along its normal
@P += @N * noise(@P * 0.1 + $T) * 5.0;

// Pick a colour based on height
@Cd = @P.y > 0 ? {1, 0, 0, 1} : {0, 0, 1, 1};

Curly braces {...} build a vector or colour. ? : is if/else.

Square brackets build a list of constants, and a subscript reads one back.

// a three-step ramp, picked per element
let steps = [0.0, 0.4, 1.0];
@pscale = steps[@ptnum];

The values in the list have to be plain numbers rather than anything worked out at cook time, and the index has to be a whole number. Nothing checks the range, so keep the index inside it.

What counts as an element

The input decides.

A collection is a table of rows, and the kind of row sets what one element is. Points, meshes, analytic shapes and splats are all collections; the element is a point, a vertex, a shape, a splat.

Collection ofRuns once perAttributes
Pointspoint@P, @Cd, @N, @pscale
Geometry (meshes) (a mesh)vertex@P, @N, @uv
Analytic shapes shapesshape@P, @analytic, @dims0, @dims1
Gaussian splatssplat@P, @scale, @rot, @opacity

Images, volumes and distance fields are not collections. They still run per element, and the element is the one their kind implies.

InputRuns once perAttributes
A Rasters (images) imagepixel@Cd (pixel colour), @P and @uv (both the pixel's UV)
A Distance fields & volumes volumevoxel@P, @density

The snippet that displaces points displaces a mesh's vertices once the input is rewired. Nothing on the node changes.

An image on the input cooks as an image, and a volume cooks as a volume. The kinds that resolve to points are the ones with no grid of their own: a distance field or a morph field iterates over a sampling grid and comes out as points.

Exposed sliders

param("name", default) adds a live slider to the node.

@P.z = luminance(@Cd) * param("height", 100.0);

A height slider appears under the node's parameters, and dragging it updates the result without a recompile. That slider has a pin, so an MIDI Input knob, an OSC Input stream or an envelope can drive it live.

What comes out

By default the node hands back the kind it was given: points in, points out. Two switches change that.

output_field returns an analytic field, evaluated per position rather than baked, with the kind following what the snippet writes: a float gives a scalar field, a vec3 a vector field, a vec4 a colour one.

output_scatter returns an image built by depositing into a grid. scatter_width and scatter_height size it, 256 by 256 to start, scatter_channels sets how many values each cell accumulates, and the body deposits with atomic_add(x, y, channel, value) instead of assigning to an attribute. One line, run across every element of the input, adding into whichever cell it lands in: that is a histogram, a density map or a splat accumulation written as a snippet. atomic_add is refused outside this mode.

Working across a whole collection

map, filter, reduce, scan and fold operate on the collection rather than one element: sum every particle's mass, drop the dead ones, build a running total.

// Total up every point's mass, then normalise so it sums to 1
@_total = reduce(@pscale, 0.0, |acc, m| acc + m);
@pscale = map(@pscale, |p| p / @_total);

Built-in functions has the full set.

The loop forms are for (int i = 0; i < 4; i++), while, and foreach (int i : neighbours(radius)) for a spatial query over nearby elements. Branch with if or a ternary (cond ? a : b). while_converged and recursive_split cover an iteration with a stopping rule, and the For Start loop nodes iterate over the graph itself. Matrices are functions (mat4_mul_vec, mat_solve3) rather than a type you declare.

Building analytic shapes

An Expression does not only change what comes in. It can author analytic shapes outright, because an analytic shape is a row of numbers: a shape kind and its parameters.

crc_stamp places one shape. crc_compose combines shapes with one of the operators listed below. The _instanced pair does the same thing once per row of a source collection, with each shape's kind, position and parameters read from that row's attributes.

// One sphere per scattered point, its radius driven by the point's scale
crc_stamp_instanced(input, "sphere", @P, {0,0,0}, {@pscale,0,0,0}, {0,0,0,0}, @Cd);

input is the collection wired into the node, and input1 is the one on its second input. Those two are all an Expression binds: a higher number still compiles, and reads the first input again. The shapes you name directly are sphere, cube, cylinder, cone, torus, plane and superquadric. The operators are boolean, offset, displaced, repeat, deformed, extruded and revolved.

Because the parameters are ordinary numbers, everything else in the language reaches them: noise drives a radius, an envelope drives a corner, a neighbour count picks a boolean operator. This is the same vocabulary Evolve draws from when it grows a shape from a seed.

Text and nodes are equals

Anything written as an expression can be built by wiring nodes, and anything wired can be written. Text reads better when the subject is a pipeline ("for each particle, halve its mass, drop the dead ones, sum the rest"); nodes read better when the structure is the point. Convert to Expression turns an existing node graph into editable code.

Where to go next

See also