Simstim

Houdini

Gather, don't scatter: reading data from other points in VEX

In a Houdini VEX wrangle, @P reads the current point and point() reads any point from a read-only snapshot of the input. Blend a grid toward a displaced copy of itself, build tangents with one gather, and see why writing to other points is the slower path.

· 9 min read · houdini · vex · tutorial · vex fundamentals

The gather wrangle's VEX code in a dark editor panel beside a Karma render of the grid blended halfway toward its Mountain-displaced copy.

TL;DR Houdini's VEX runs one small function per point, in parallel, like a GPU kernel. @P reads the current point's own data. point() reads any point's data, an indexed gather from the node's input, and that input is a read-only snapshot that never sees what the node writes. Writing into other points (a scatter) works too, but it's slower and needs care. This post blends a grid toward a displaced copy of itself and builds a per-point tangent with one gather.

Houdini in 60 seconds

The first post in this series read node parameters, which are the same for every point. This one reads attributes, which vary per point, including attributes on points other than the one the code is running on. It follows the "Reading attributes" section of Juraj Tomori's VEX tutorial. First, the minimum Houdini you need.

Houdini builds geometry with a graph of nodes. The geometry nodes are called SOPs. Each SOP takes geometry in and passes new geometry out. That geometry is made of points (positions), primitives (polygons, curves and so on), vertices (a primitive's reference to a point) and one detail (the whole object). Any of these can carry named, typed data called attributes, which work like columns in a table. P is the position column, Cd is color and N is the normal.

A wrangle is a node where you write VEX, Houdini's C-like shading and geometry language. Its Run Over setting picks which table the code runs over. On Points (the default), your snippet runs once per row in parallel, and the loop is implicit. If you've written a CUDA kernel or a NumPy ufunc, you already know the shape of this.

The core idea: @ is "my row", point() is "any row"

Inside a wrangle, @P means "this point's position". The letter before the @ gives the type: v@ vector, f@ float, i@ int, s@ string, p@ vector4, u@ vector2, 2@/3@/4@ matrices and d@ dict. A list of well-known names (P, Cd, N, v, id, ptnum and a few more) get their type automatically. Everything else defaults to float.

An Attribute Wrangle has up to four inputs. There are two ways to reach into them:

  1. v@opinput1_P: the P attribute from input 1, at the same element number as the current point. It's quick and readable, but it quietly assumes both inputs have the same point count and order.
  2. point(geo, "name", index): an explicit read of any attribute, from any input, at any index. geo is usually an input number (0 is the first input), but it can also be a path string such as "op:/obj/geo1/some_node".

In GPU terms, reading another element's data by index is a gather. Writing into another element is a scatter. Here's a gather that blends a flat grid toward a displaced copy of itself and writes out a per-point tangent:

// Attribute Wrangle, Run Over: Points
// Input 0: Grid SOP.  Input 1: same grid -> Mountain SOP (same count + order)
float t = chf("blend");                     // a slider, read with chf

vector a = v@P;                             // my row, input 0
vector b = point(1, "P", @ptnum);           // same row, input 1
// shorthand for the line above: vector b = v@opinput1_P;

// Gather from a *different* row: the next point, clamped at the end
int next    = min(@ptnum + 1, @numpt - 1);
vector ahd  = point(0, "P", next);

// A missing attribute doesn't throw an error; you get a zero value back
float h = point(1, "height", @ptnum);       // 0.0 if "height" isn't there

v@P = lerp(a, b, t);
v@tangent = normalize(ahd - a);             // last point: zero vector

To run it: drop a Grid, branch it into a Mountain SOP, and wire both into an Attribute Wrangle, the grid into input 0 and the mountain into input 1. Leave Run Over on Points, paste the code, and click the Create spare parameters button to the right of the code editor to get the blend slider. As in the first post, the new slider starts at 0, which outputs the flat grid unchanged, so drag it toward 1. The Mountain's default displacement is small on the default 10 × 10 grid, so raise its Height to see the blend clearly.

Three renders of the same grid side by side: flat at blend 0, gently displaced at blend 0.5 and fully displaced like the Mountain at blend 1, colored by height.
The wrangle's output at blend = 0, 0.5 and 1, rendered with Karma from the cooked network (Mountain Height 2, grid at 90 × 90 rows and columns, colored by height for display).
A flat 10 by 10 grid of points, each with an arrow to the next point number: teal arrows run along each row, and orange arrows at the row ends point back across the grid toward the start of the next row.
The tangent attribute the gather wrote, drawn as arrows on the default 10 × 10 grid. At the end of each row the next point number is the start of the following row, so those tangents (orange) point back across the grid. The last point has none.

The same thing in NumPy makes the memory model obvious:

import numpy as np

def gather(P0, P1, t=0.5):
    """P0, P1: (N, 3) arrays of positions from input 0 and input 1."""
    out = P0 + (P1 - P0) * t                          # @opinput1_P: same index, input 1
    nxt = np.minimum(np.arange(len(P0)) + 1, len(P0) - 1)
    d = P0[nxt] - P0                                  # point(0, "P", next): a gather
    n = np.linalg.norm(d, axis=1, keepdims=True)
    tangent = np.divide(d, n, out=np.zeros_like(d), where=n > 0)   # normalize()
    return out, tangent

point() is fancy indexing. @opinput1_P is elementwise alignment. That's the whole mental model.

Why gather, not scatter

VEX can scatter with setpointattrib, which writes an attribute on any point you name. Here's the same tangent as a scatter, with each point pushing a value into the point before it:

// Attribute Wrangle, Run Over: Points
// The same tangent as a scatter: each point writes into the point before it
if (@ptnum > 0) {
    int prev = @ptnum - 1;
    setpointattrib(0, "tangent", prev, normalize(v@P - point(0, "P", prev)));
}

It gives the same tangents, and it isn't a data race: Houdini queues every setpointattrib write and applies them after all points have run, so the result is deterministic. It still costs you in three ways:

  • It's slower. SideFX's docs say setting the current element through @ is faster than the setattrib family. On a million-point grid in our test, the scatter version took roughly 20 times as long to cook as the gather.
  • It overrides your @ writes. The queued writes land last, so setpointattrib beats any @tangent = ... on the same attribute.
  • Collisions need a mode. When several points write to the same point with the default "set" mode, only one value survives. Pass "add" (or "min", "max", "mult") to combine them.

So gather whenever each output point can say where its inputs are, which covers most per-point work. Keep scatter for jobs that really are one-to-many, such as splatting values onto neighbors, and choose the mode on purpose.

What trips you up, and what clicks

  1. point(0, "P", next) doesn't see the node's own edits. Input 0 is the geometry as it came into the node. Every invocation reads the same frozen buffer, and @ writes go only to the current point's output row. Add v@P.y += 1; to a wrangle and point(0, "P", @ptnum) still reads the old height. It's double-buffering, the same ping-pong pattern you'd use in a compute shader so that thread 7 doesn't read half-updated data from thread 6, and it's why there are no race conditions to think about. If you need the updated values, read them in a second wrangle downstream.
  2. @opinput1_ fails silently when counts don't match. Feed input 1 a grid with fewer points and nothing errors or warns. The extra points read (0, 0, 0), so the blend pulls them toward the origin, all the way there at blend = 1. A misspelled attribute name in point() behaves the same way: you get a zero. Coming from Python, where a bad key raises KeyError, this takes adjusting. haspointattrib(1, "height") returns 1 or 0 if you want to check first. One more wrinkle: the wrangle's Attribute to Match parameter defaults to id, so if both inputs carry an id attribute, @opinput1_ pairs points by id instead of by number. point() always uses the index you give it.
  3. The type prefix is not decoration. Write @tangent = normalize(ahd - a); without the v and Houdini creates tangent as a float, keeping only the x component of your vector. The node shows a warning ("Implicit cast from vector to float") but still cooks. Prefix everything you create, even when Houdini could infer the type; Tomori's tutorial does the same ("I prefer explicit declaration"). Do it on opinput reads too: the SideFX docs say the automatic typing doesn't cover them. (In our Houdini 22 test @opinput1_P came through as a vector anyway, but the docs don't promise it.)
  4. The op: path trick works, but hides a dependency. point("op:../mountain1", "P", @ptnum) reads from a node that isn't wired in, with an absolute or a relative path. Houdini does track it for cooking: change the mountain and the wrangle recooks. But there's no wire in the network, so someone reading the graph can't see where the data comes from. Tomori's tutorial adds that the network editor's dependency-link display doesn't show these references; that note dates from early 2018, so check it on your build. We'd rather bring the geometry in with an Object Merge and a real wire. Explicit dataflow beats spooky action at a distance, same as with global variables.

Try it yourself

  1. Install Houdini Apprentice, the free, non-commercial edition from SideFX. It has everything in this post.
  2. Rebuild the grid and mountain setup above and keyframe blend from 0 to 1.
  3. Change next to read the previous point instead, clamped at 0 with max(@ptnum - 1, 0). What happens to the tangents at the start of each row? (Hint: the Grid numbers its points row by row, so the previous point isn't always a neighbor.)
  4. Stretch goal: replace @ptnum in the input-1 read with @numpt - 1 - @ptnum. You've just reversed an array with a gather and no loop.

No Houdini? Do step 4 in NumPy with P1[::-1], or in Blender's geometry nodes with the Sample Index node, which reads a value from another geometry at any index, much like point().

Functions used: chf, point, min, max, lerp, normalize, setpointattrib, haspointattrib. Each one links to its entry in the Simstim VEX reference.

Sources